You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
autoflow-web-console/src/components/templates/run/executions/ViewComponent.vue

849 lines
28 KiB

<script setup lang="ts">
import { MlflowService } from "@/components/service/mlflow/MlflowService";
import {
computed,
watch,
ref,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import Plotly from "plotly.js-dist-min";
11 months ago
const props = defineProps<{ experimentInfo: any }>();
const emit = defineEmits<{ (e: "close"): void }>();
11 months ago
/* ============ Tabs ============ */
const mainTab = ref<"details" | "viz">("details");
const vizTab = ref<"metrics" | "scatter" | "box" | "contour">("metrics");
11 months ago
/* ============ expName pick ============ */
function pickExpName(v: any): string {
return v?.expName || v?.experiment || v?.name || v?.raw?.displayName || "";
}
/* ============ MLflow state ============ */
const runs = ref<any[]>([]);
const loadingRuns = ref(false);
/* --- 단건 조회 state --- */
const selectedRunId = ref<string>("");
const loadingRunDetail = ref(false);
const runDetail = ref<any | null>(null);
/* 표시용 라벨 */
const selectedLabel = computed(
() => runDetail.value?.info?.run_name ?? runDetail.value?.info?.run_id ?? "-",
);
/* ============ Plotly refs ============ */
const elMetrics = ref<HTMLDivElement | null>(null);
/* ===== 유틸: 단건 값 꺼내기 ===== */
function metricValue(key: "accuracy" | "precision" | "recall" | "f1_score") {
const m = (runDetail.value?.data?.metrics ?? []).find(
(x: any) => x.key === key,
);
return m?.value ?? null;
}
/* 선택한 run의 메트릭 표 데이터 */
const selectedMetrics = computed(() => {
const mm: Array<{ key: string; value: number }> =
runDetail.value?.data?.metrics ?? [];
return mm.map((m) => ({ key: m.key, value: m.value }));
});
/* ===== 부모에서 내려온 runId 픽 ===== */
function pickParentRunId(v: any): string {
const cand = [
v?.run_id,
v?.runId,
v?.raw?.run_id,
v?.raw?.runId,
v?.raw?.kfp_run_id,
v?.raw?.kubeflow_run_id,
v?.raw?.id,
].filter(Boolean);
return String(cand[0] ?? "");
}
/* ===== MLflow 태그 읽기 (array or object 모두 대응) ===== */
function getTag(run: any, key: string): string | undefined {
const tags = run?.data?.tags;
if (Array.isArray(tags)) return tags.find((t: any) => t?.key === key)?.value;
if (tags && typeof tags === "object") return tags[key];
return undefined;
}
/* ===== 차트 렌더: 선택한 단건만 ===== */
function drawCharts() {
const mm = selectedMetrics.value
// value가 숫자인 것만 필터링
.filter((m) => typeof m.value === "number" && isFinite(m.value));
const x = mm.map((m) => m.key);
const y = mm.map((m) => m.value);
const layout: Partial<any> = {
margin: { t: 40, r: 20, b: 60, l: 50 },
height: 400,
showlegend: false,
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
font: { color: "#E0E0E0" },
xaxis: {
automargin: true,
gridcolor: "rgba(255,255,255,0.08)",
zerolinecolor: "rgba(255,255,255,0.18)",
linecolor: "rgba(255,255,255,0.18)",
},
yaxis: {
rangemode: "tozero",
gridcolor: "rgba(255,255,255,0.08)",
zerolinecolor: "rgba(255,255,255,0.18)",
linecolor: "rgba(255,255,255,0.18)",
},
};
const trace: any = {
type: "bar",
x,
y,
text: y.map((v) => (typeof v === "number" ? v.toFixed(4) : "")),
textposition: "auto",
hovertemplate: "%{x}: %{y}<extra></extra>",
};
const config = { displayModeBar: false, responsive: true };
if (elMetrics.value) {
if (x.length === 0) {
Plotly.purge(elMetrics.value);
elMetrics.value.innerHTML =
'<div style="padding:16px;color:#999;text-align:center;">No Data</div>';
} else {
Plotly.react(elMetrics.value, [trace], layout, config);
}
}
}
function resizeCharts() {
if (elMetrics.value) Plotly.Plots.resize(elMetrics.value);
}
/* ===== 응답 정규화 + fallback ===== */
function normalizeRun(res: any) {
const v = res?.data?.run ?? res?.run ?? res?.data ?? res;
return v?.info && v?.data ? v : null;
}
function findFromList(runId: string) {
return (
runs.value.find(
(r) =>
r?.info?.run_id === runId ||
r?.info?.run_uuid === runId ||
r?.info?.run_name === runId,
) ?? null
);
}
async function fetchRuns(expName?: string) {
const parentRunId = pickParentRunId(props.experimentInfo);
if (!expName) {
runs.value = [];
selectedRunId.value = "";
return;
}
loadingRuns.value = true;
try {
// 1) 실험명으로 experiment_id 조회
const expRes = await MlflowService.getExperimentByName(expName);
const exp = expRes?.data ?? expRes;
const expId = String(
exp?.experiment_id ?? exp?.experimentId ?? exp?.id ?? "",
);
if (!expId) {
runs.value = [];
selectedRunId.value = "";
return;
}
// 2) experiment_id로 runs 전체 조회
const runsRes = await MlflowService.getRuns(expId);
const body = runsRes?.data ?? runsRes;
const list =
body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []);
// 3) 부모 runId 태그와 매칭 → 없으면 전체 리스트 사용(이전 동작처럼)
const matched = (Array.isArray(list) ? list : []).filter(
(r: any) => getTag(r, "kubeflow_run_id") === parentRunId,
);
const final = matched.length > 0 ? matched : list;
// 최신순 정렬 + 기본 선택
const sorted = [...final].sort(
(a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0),
);
runs.value = sorted;
selectedRunId.value =
sorted[0]?.info?.run_id || sorted[0]?.info?.run_uuid || "";
} finally {
loadingRuns.value = false;
}
}
const runItems = computed(() =>
(runs.value || []).map((r: any) => {
const id = r?.info?.run_id || r?.info?.run_uuid || "";
const name = r?.info?.run_name || id; // ✅ 시간 제거, 이름만 표시
return { value: id, label: name };
}),
);
async function fetchRunDetail(runId: string) {
if (!runId) {
runDetail.value = null;
await nextTick();
drawCharts();
return;
}
loadingRunDetail.value = true;
try {
const res = await MlflowService.getExperimentRun(runId);
runDetail.value = normalizeRun(res) || findFromList(runId);
await nextTick();
drawCharts();
} finally {
loadingRunDetail.value = false;
}
}
/* ============ 트리거 ============ */
// 탭 전환 시 viz 탭이면 로드
watch(
() => mainTab.value,
async (t) => {
if (t === "viz") {
await fetchRuns(pickExpName(props.experimentInfo));
await fetchRunDetail(selectedRunId.value);
}
},
{ immediate: true },
);
// 부모 execution 바뀌면 viz 탭일 때만 재조회
watch(
() => props.experimentInfo,
async () => {
if (mainTab.value === "viz") {
await fetchRuns(pickExpName(props.experimentInfo));
await fetchRunDetail(selectedRunId.value);
}
},
);
// 선택된 runId가 바뀌면 단건 상세 갱신
watch(selectedRunId, (id) => fetchRunDetail(id));
// metrics 서브탭일 때 리렌더
watch(vizTab, async (t) => {
if (mainTab.value === "viz" && t === "metrics") {
await nextTick();
drawCharts();
resizeCharts();
}
});
function onResize() {
if (mainTab.value === "viz" && vizTab.value === "metrics") {
resizeCharts();
}
}
onMounted(() => window.addEventListener("resize", onResize));
onBeforeUnmount(() => window.removeEventListener("resize", onResize));
/* ============ State History (Details 탭) ============ */
const rawHistory = computed<any[]>(() => {
const h =
props.experimentInfo?.raw?.state_history ??
props.experimentInfo?.raw?.stateHistory ??
props.experimentInfo?.state_history ??
props.experimentInfo?.stateHistory ??
[];
if (Array.isArray(h) && h.length > 0) return h;
const startIso =
props.experimentInfo?.startTime &&
!isNaN(new Date(props.experimentInfo.startTime).getTime())
? new Date(props.experimentInfo.startTime).toISOString()
: undefined;
const latest = [...(runs.value ?? [])].sort(
(a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0),
)[0];
const endIso =
latest?.info?.end_time && isFinite(Number(latest.info.end_time))
? new Date(Number(latest.info.end_time)).toISOString()
: undefined;
const status = String(props.experimentInfo?.status ?? "").toUpperCase();
const out: Array<{ state: string; update_time?: string }> = [];
if (startIso) out.push({ state: "PENDING", update_time: startIso });
if (startIso) out.push({ state: "RUNNING", update_time: startIso });
if (status === "SUCCEEDED" || status === "FAILED") {
out.push({ state: status, update_time: endIso ?? startIso });
}
return out;
});
9 months ago
const history = computed(() =>
rawHistory.value
9 months ago
.slice()
.filter((h) => h && h.state)
9 months ago
.sort(
(a: any, b: any) =>
new Date(a.update_time || 0).getTime() -
new Date(b.update_time || 0).getTime(),
9 months ago
),
);
9 months ago
const hPending = computed(() =>
history.value.find((h) => (h.state || "").toUpperCase() === "PENDING"),
);
const hRunning = computed(() =>
history.value.find((h) => (h.state || "").toUpperCase() === "RUNNING"),
);
const hTerminal = computed(() => {
const t = history.value
.slice()
.reverse()
.find((h) =>
["SUCCEEDED", "FAILED", "COMPLETED"].includes(
(h.state || "").toUpperCase(),
),
9 months ago
);
return t ?? null;
});
9 months ago
const steps = computed(() => {
const lastLabel = (
hTerminal.value?.state ||
props.experimentInfo?.status ||
"COMPLETED"
)
.toString()
.toUpperCase();
9 months ago
return [
{
9 months ago
key: "PENDING",
label: "PENDING",
active: !!(hPending.value || hRunning.value || hTerminal.value),
color: "primary",
icon: "mdi-clock-outline",
ts: hPending.value?.update_time,
},
{
9 months ago
key: "RUNNING",
label: "RUNNING",
active: !!(hRunning.value || hTerminal.value),
color: "info",
icon: "mdi-progress-clock",
ts: hRunning.value?.update_time,
},
{
9 months ago
key: "TERMINAL",
label: ["SUCCEEDED", "FAILED"].includes(lastLabel)
? lastLabel
: "COMPLETED",
active: !!hTerminal.value || ["SUCCEEDED", "FAILED"].includes(lastLabel),
9 months ago
color:
lastLabel === "FAILED"
? "error"
: lastLabel === "SUCCEEDED"
? "success"
: "surface-variant",
icon:
lastLabel === "FAILED"
? "mdi-close"
: lastLabel === "SUCCEEDED"
? "mdi-check"
: "mdi-dots-horizontal",
ts: hTerminal.value?.update_time,
},
];
9 months ago
});
const nSteps = 3;
const activeIndex = computed(() =>
steps.value.map((s) => s.active).lastIndexOf(true),
9 months ago
);
const leftPct = (i: number) => (i / (nSteps - 1)) * 100;
const segWidthPct = () => 100 / (nSteps - 1);
const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
</script>
<template>
11 months ago
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card
flat
class="bg-shades-transparent d-flex flex-column justify-center w-100"
>
<v-card flat class="bg-shades-transparent w-100">
<v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
<div class="d-flex flex-row justify-start align-center">
<div class="text-primary">Compare Executions</div>
</div>
</v-card-item>
</v-card>
<!-- Main Tabs -->
<v-tabs v-model="mainTab" density="comfortable" color="primary">
<v-tab value="details">Details</v-tab>
<v-tab value="viz">Metrics</v-tab>
</v-tabs>
<v-window v-model="mainTab">
<!-- ========= Details ========= -->
<v-window-item value="details">
<v-card flat class="bordered-box mb-6 w-100 rounded-lg pa-8">
<v-card-title class="grey lighten-4 py-2 px-4">
<span class="font-weight-bold">Execution Information</span>
</v-card-title>
<v-card-text class="px-6 pb-6 pt-4">
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Name</v-col>
<v-col cols="9">{{ props.experimentInfo.name }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Status</v-col>
<v-col cols="9">
<v-icon
v-if="props.experimentInfo.status === 'Succeeded'"
color="green"
>mdi-check-circle</v-icon
9 months ago
>
<v-icon
v-else-if="props.experimentInfo.status === 'Failed'"
color="red"
>mdi-close-circle</v-icon
>
{{ props.experimentInfo.status }}
</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Duration</v-col
>
<v-col cols="9">{{ props.experimentInfo.duration }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Experiment Name</v-col
>
<v-col cols="9">{{ props.experimentInfo.expName }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Workflow Name</v-col
>
<v-col cols="9">{{ props.experimentInfo.workflow }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Start Time</v-col
>
<v-col cols="9">{{ props.experimentInfo.startTime }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Registry Status</v-col
>
<v-col cols="9">{{
props.experimentInfo.registryStatus
}}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- State History -->
<v-row align="center" class="py-6">
<v-col cols="3" class="text-h6 font-weight-bold"
>State History</v-col
>
<v-col cols="9">
<div class="history-rail">
<div class="history-rail__line" />
<template v-for="i in 2" :key="'seg-' + i">
<div
class="history-rail__seg"
:style="{
left: `calc(${leftPct(i - 1)}% + var(--dot-size)/2)`,
width: `calc(${segWidthPct()}% - var(--dot-size))`,
background:
i - 1 < activeIndex
? 'var(--color-active)'
: 'var(--color-idle)',
}"
/>
</template>
<template v-for="(s, i) in steps" :key="'dot-' + s.key">
<div
class="history-rail__dot"
:style="{ left: leftPct(i) + '%' }"
>
<v-avatar
:color="s.active ? s.color : 'surface-variant'"
size="28"
class="elev-1"
>
<v-icon size="18">{{ s.icon }}</v-icon>
</v-avatar>
</div>
<div
class="history-rail__label"
:style="{ left: leftPct(i) + '%' }"
>
<v-chip
size="small"
:color="s.active ? s.color : undefined"
variant="tonal"
class="mb-1 text-uppercase font-weight-medium"
>
{{ s.label }}
</v-chip>
<div class="text-caption text-medium-emphasis">
{{ fmt(s.ts) }}
</div>
</div>
</template>
9 months ago
</div>
</v-col>
</v-row>
</v-card-text>
9 months ago
<v-sheet class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</v-sheet>
</v-card>
</v-window-item>
<!-- ========= Visualizations ========= -->
<v-window-item value="viz">
<v-card class="rounded-lg pa-8 w-100">
<v-window v-model="vizTab">
<v-window-item value="metrics">
<v-card-text>
<v-row class="mb-4" align="center">
<v-col cols="12" class="pa-0">
<v-alert
v-if="!loadingRuns && runs.length === 0"
type="info"
variant="tonal"
class="mx-3"
density="comfortable"
>
부모 runId와 일치하는 MLflow run이 없습니다.
</v-alert>
</v-col>
<v-col
cols="12"
class="d-flex align-center justify-between flex-wrap ga-3"
9 months ago
>
<div class="text-body-2">
Runs loaded:
<strong>{{ runs.length.toLocaleString() }}</strong>
</div>
<v-select
v-model="selectedRunId"
:items="runItems"
item-title="label"
item-value="value"
density="comfortable"
hide-details
:clearable="false"
clear-icon=""
style="min-width: 280px; max-width: 440px"
/>
<v-progress-circular
v-if="loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
/>
</v-col>
</v-row>
<!-- (A) 선택한 단건 run 상세 -->
<v-card class="mb-6" variant="tonal">
<v-card-title class="py-2 px-4">Selected Run</v-card-title>
<v-card-text class="px-4 pb-4">
<v-row>
<v-col cols="12" md="6">
<v-table density="comfortable">
<tbody>
<tr>
<td style="width: 40%">Run ID</td>
<td>{{ runDetail?.info?.run_id || "—" }}</td>
</tr>
<tr>
<td>Run Name</td>
<td>{{ runDetail?.info?.run_name || "—" }}</td>
</tr>
<tr>
<td>Status</td>
<td>{{ runDetail?.info?.status || "—" }}</td>
</tr>
<tr>
<td>Start</td>
<td>
{{
runDetail?.info?.start_time
? new Date(
runDetail.info.start_time,
).toLocaleString()
: "—"
}}
</td>
</tr>
<tr>
<td>End</td>
<td>
{{
runDetail?.info?.end_time
? new Date(
runDetail.info.end_time,
).toLocaleString()
: "—"
}}
</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col cols="12" md="6">
<div class="text-subtitle-2 mb-2">Parameters</div>
<v-table density="compact">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!runDetail?.data?.params?.length">
<td colspan="2" class="text-medium-emphasis">
No params
</td>
</tr>
<tr
v-for="p in runDetail?.data?.params || []"
:key="p.key"
>
<td>{{ p.key }}</td>
<td>{{ p.value }}</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col cols="12" md="6">
<div class="text-subtitle-2 mb-2">Metrics</div>
<v-table density="compact">
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!runDetail?.data?.metrics?.length">
<td colspan="2" class="text-medium-emphasis">
No metrics
</td>
</tr>
<tr
v-for="m in runDetail?.data?.metrics || []"
:key="m.key"
>
<td>{{ m.key }}</td>
<td>{{ m.value }}</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col cols="12" md="6">
<div class="text-subtitle-2 mb-2">Tags</div>
<v-table density="compact">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!runDetail?.data?.tags?.length">
<td colspan="2" class="text-medium-emphasis">
No tags
</td>
</tr>
<tr
v-for="t in runDetail?.data?.tags || []"
:key="t.key"
>
<td>{{ t.key }}</td>
<td
class="text-truncate"
style="max-width: 420px"
>
{{ t.value }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- (B) 선택한 run 기준의 간단 메트릭 -->
<v-card flat class="mb-6">
<v-card-title
class="py-2 px-0 text-button text-medium-emphasis"
>
Model Metrics (selected run)
</v-card-title>
<v-table density="comfortable">
<thead>
<tr>
<th class="text-left" style="width: 50%">Metric</th>
<th class="text-left">Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!selectedMetrics.length">
<td
colspan="2"
class="text-center py-6 text-medium-emphasis"
>
No Data
</td>
</tr>
<tr v-for="m in selectedMetrics" :key="m.key">
<td>{{ m.key }}</td>
<td>{{ m.value }}</td>
</tr>
</tbody>
</v-table>
</v-card>
<v-card flat class="mb-6">
<v-card-title
class="py-2 px-0 text-button text-medium-emphasis"
>
Metrics (bar chart)
</v-card-title>
<div
ref="elMetrics"
style="width: 100%; height: 400px"
></div>
</v-card>
</v-card-text>
</v-window-item>
<!-- placeholders -->
<v-window-item value="scatter">
<v-card-text class="px-6 py-10 text-medium-emphasis"
>(준비중) X/Y 선택 산점도 표시</v-card-text
>
</v-window-item>
<v-window-item value="box">
<v-card-text class="px-6 py-10 text-medium-emphasis"
>(준비중) 메트릭 분포 Box Plot</v-card-text
>
</v-window-item>
<v-window-item value="contour">
<v-card-text class="px-6 py-10 text-medium-emphasis"
>(준비중) 2D/3D Contour Plot</v-card-text
>
</v-window-item>
</v-window>
<v-sheet class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</v-sheet>
</v-card>
</v-window-item>
</v-window>
</v-card>
</v-container>
</template>
<style scoped>
9 months ago
:root {
--dot-size: 28px;
11 months ago
}
9 months ago
.history-rail {
position: relative;
width: 40%;
padding: 12px 0 34px;
9 months ago
--color-rail: rgba(255, 255, 255, 0.12);
--color-idle: rgba(255, 255, 255, 0.12);
--color-active: rgb(98, 0, 238);
11 months ago
}
9 months ago
.history-rail__line {
position: absolute;
left: 0;
right: 0;
top: 14px;
9 months ago
height: 4px;
background: var(--color-rail);
border-radius: 2px;
11 months ago
}
9 months ago
.history-rail__seg {
position: absolute;
top: 14px;
height: 4px;
border-radius: 2px;
transition:
width 0.24s ease,
background 0.24s ease;
9 months ago
}
.history-rail__dot {
position: absolute;
top: 0;
9 months ago
transform: translateX(-50%);
}
.history-rail__label {
position: absolute;
top: 28px;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
}
</style>