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

806 lines
27 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);
/* 목록 옵션 (run_name 표시, 값은 run_id / run_uuid) */
const runOptions = computed(() =>
runs.value.map((r) => ({
title: r?.info?.run_name || r?.info?.run_id || "—",
value: r?.info?.run_id || r?.info?.run_uuid || "",
})),
);
/* ============ Plotly refs ============ */
const elAccuracy = ref<HTMLDivElement | null>(null);
const elF1 = ref<HTMLDivElement | null>(null);
const elPrecision = ref<HTMLDivElement | null>(null);
const elRecall = 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;
}
const selectedLabel = computed(
() => runDetail.value?.info?.run_name ?? runDetail.value?.info?.run_id ?? "-",
);
/* 선택한 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 }));
});
/* ===== 차트 렌더: 선택한 단건만 ===== */
function drawCharts() {
// Plotly Layout을 타입 안전하게 만들기
const baseLayout = (titleText: string, xlabel: string): Partial<any> => ({
title: { text: titleText }, // ← 문자열 대신 객체
margin: { t: 40, r: 20, b: 40, l: 40 },
height: 290,
yaxis: { rangemode: "tozero" },
xaxis: {
tickmode: "array",
tickvals: [xlabel],
ticktext: [xlabel],
},
showlegend: false,
});
const config = { displayModeBar: false, responsive: true };
if (elAccuracy.value) {
const x = ["accuracy"];
Plotly.react(
elAccuracy.value,
[{ x, y: [metricValue("accuracy")], type: "bar" }],
baseLayout("accuracy", x[0]),
config,
);
}
if (elF1.value) {
const x = ["f1_score"];
Plotly.react(
elF1.value,
[{ x, y: [metricValue("f1_score")], type: "bar" }],
baseLayout("f1_score", x[0]),
config,
);
}
if (elPrecision.value) {
const x = ["precision"];
Plotly.react(
elPrecision.value,
[{ x, y: [metricValue("precision")], type: "bar" }],
baseLayout("precision", x[0]),
config,
);
}
if (elRecall.value) {
const x = ["recall"];
Plotly.react(
elRecall.value,
[{ x, y: [metricValue("recall")], type: "bar" }],
baseLayout("recall", x[0]),
config,
);
}
}
function resizeCharts() {
[elAccuracy.value, elF1.value, elPrecision.value, elRecall.value]
.filter(Boolean)
.forEach((el: any) => Plotly.Plots.resize(el));
}
/* ===== 응답 정규화 + 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
);
}
/* ============ API: runs 목록 & 단건 run ============ */
async function fetchRunsOnce(expName?: string) {
if (!expName || runs.value.length > 0) return;
loadingRuns.value = true;
try {
const expRes = await MlflowService.getExperimentByName(expName);
const exp = expRes?.data ?? exp;
const expId = String(
exp?.experiment_id ?? exp?.experimentId ?? exp?.id ?? "",
);
if (!expId) return;
const runsRes = await MlflowService.getRuns(expId);
const body = runsRes?.data ?? runsRes;
const list =
body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []);
runs.value = Array.isArray(list) ? list : [];
// 기본 선택: 최신 run
const first = [...runs.value].sort(
(a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0),
)[0];
selectedRunId.value =
first?.info?.run_id || first?.info?.run_uuid || selectedRunId.value || "";
} finally {
loadingRuns.value = false;
}
}
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;
}
}
/* ============ 트리거 ============ */
// Visualizations 탭 진입 시 목록 로드 + 기본 단건 로드
watch(
() => mainTab.value,
async (t) => {
if (t === "viz") {
await fetchRunsOnce(pickExpName(props.experimentInfo));
if (selectedRunId.value) await fetchRunDetail(selectedRunId.value);
}
},
{ immediate: true },
);
// 드롭다운에서 Run 변경 시 단건 조회
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;
// fallback 합성
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</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" md="6">
<v-select
v-model="selectedRunId"
:items="runOptions"
item-title="title"
item-value="value"
label="Select run (run_uuid)"
density="comfortable"
:loading="loadingRuns"
clearable
/>
</v-col>
<v-col
cols="12"
md="6"
class="d-flex align-center justify-end"
9 months ago
>
<div class="text-body-2">
Runs:
<strong>{{ runs.length.toLocaleString() }}</strong>
<v-progress-circular
v-if="loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
/>
</div>
</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>
<!-- (C) 2×2 단건 바차트 -->
<v-row>
<v-col cols="12" md="6">
<div
ref="elAccuracy"
style="width: 100%; height: 290px"
></div>
</v-col>
<v-col cols="12" md="6">
<div ref="elF1" style="width: 100%; height: 290px"></div>
</v-col>
<v-col cols="12" md="6">
<div
ref="elPrecision"
style="width: 100%; height: 290px"
></div>
</v-col>
<v-col cols="12" md="6">
<div
ref="elRecall"
style="width: 100%; height: 290px"
></div>
</v-col>
</v-row>
</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-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>