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/experiment/DetailComponent.vue

384 lines
12 KiB

<script setup lang="ts">
import {
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import Plotly from "plotly.js-dist-min";
import { MlflowService } from "@/components/service/mlflow/MlflowService";
/** 부모에서 받는 값: 제목 라벨은 그대로 쓰고, 데이터는 runId 하나로 조회 */
const props = defineProps<{
expName: string; // 상단 라벨 유지용 (표시만)
runId?: string; // ★ 이 값으로만 MLflow 단건 조회
}>();
const emit = defineEmits<{ (e: "close"): void }>();
/* ---------- 상태 ---------- */
const runs = ref<any[]>([]); // 드롭다운/카운트용: 단건이면 길이=1
const loadingRuns = ref(false);
const selectedRunId = ref<string>(props.runId ?? "");
const loadingRunDetail = ref(false);
const runDetail = ref<any | null>(null);
/* 표시용: runs가 비어도 runDetail이 있으면 1로 보이도록 */
const runsLoadedCount = computed(() =>
runs.value.length > 0 ? runs.value.length : runDetail.value ? 1 : 0,
);
/* ---------- 렌더링용 ---------- */
const runItems = computed(() => {
// runs(단건) 우선, 없으면 runDetail로 fallback, 그것도 없으면 selectedRunId로 임시 항목
const source = runs.value?.length
? runs.value
: runDetail.value
? [runDetail.value]
: [];
const items = source.map((r: any) => {
const id = r?.info?.run_id || r?.info?.run_uuid || "";
const name = r?.info?.run_name || id || "(no name)";
return { value: id, label: name };
});
if (items.length === 0 && selectedRunId.value) {
items.push({ value: selectedRunId.value, label: selectedRunId.value });
}
return items;
});
const selectedMetrics = computed(() => {
const mm: Array<{ key: string; value: number }> =
runDetail.value?.data?.metrics ?? [];
return mm.map((m) => ({ key: m.key, value: m.value }));
});
/* ---------- Plotly ---------- */
const elMetrics = ref<HTMLDivElement | null>(null);
function drawCharts() {
const mm = selectedMetrics.value.filter(
(m) => typeof m.value === "number" && isFinite(m.value),
);
const x = mm.map((m) => m.key);
const y = mm.map((m) => m.value);
if (!elMetrics.value) return;
if (x.length === 0) {
Plotly.purge(elMetrics.value);
elMetrics.value.innerHTML =
'<div style="padding:16px;color:#999;text-align:center;">No Data</div>';
return;
}
const trace: Partial<Plotly.PlotData> = {
type: "bar",
x,
y,
text: y.map((v) => (typeof v === "number" ? v.toFixed(4) : "")),
textposition: "auto",
hovertemplate: "%{x}: %{y}<extra></extra>",
};
const layout: Partial<Plotly.Layout> = {
title: { text: "Metrics" },
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)",
},
};
Plotly.react(elMetrics.value, [trace], layout, {
displayModeBar: false,
responsive: true,
});
}
function resizeCharts() {
if (elMetrics.value) Plotly.Plots.resize(elMetrics.value);
}
/* ---------- MLflow helpers ---------- */
function normalizeRun(res: any) {
const v = res?.data?.run ?? res?.run ?? res?.data ?? res;
return v?.info && v?.data ? v : null;
}
/* ---------- API (runId 단건만 조회) ---------- */
async function fetchRunDetailById(runId: string) {
loadingRuns.value = true; // 상단 로딩 아이콘 유지
loadingRunDetail.value = true;
try {
if (!runId) {
runDetail.value = null;
runs.value = [];
await nextTick();
drawCharts();
return;
}
const res = await MlflowService.getExperimentRun(runId);
const one = normalizeRun(res);
runDetail.value = one;
// 드롭다운/카운트 유지용으로 단건을 배열로 보관
runs.value = one ? [one] : [];
// 드롭다운 선택값 보정
if (!selectedRunId.value) selectedRunId.value = runId;
await nextTick();
drawCharts();
} catch (e) {
runDetail.value = null;
runs.value = [];
await nextTick();
drawCharts();
} finally {
loadingRunDetail.value = false;
loadingRuns.value = false;
}
}
/* ---------- watch & lifecycle ---------- */
// 부모가 준 runId가 바뀌면 재조회
watch(
() => props.runId,
(nv) => {
selectedRunId.value = nv || "";
fetchRunDetailById(selectedRunId.value);
},
{ immediate: true },
);
// 드롭다운 변경 시에도 동일하게 단건 조회(템플릿 유지 목적)
watch(selectedRunId, (id, prev) => {
if (id && id !== prev) fetchRunDetailById(id);
});
// 최초 마운트 시(부모 runId가 이미 세팅된 케이스 포함) 한 번 더 안전하게
onMounted(() => {
if (selectedRunId.value) fetchRunDetailById(selectedRunId.value);
window.addEventListener("resize", resizeCharts);
});
onBeforeUnmount(() => window.removeEventListener("resize", resizeCharts));
</script>
<template>
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card flat class="w-100 rounded-lg pa-8">
<v-card-title class="grey lighten-4 py-2 px-4">
<span class="font-weight-bold">Metrics</span>
</v-card-title>
<v-card-text class="px-6 pb-2 pt-4">
<v-row class="mb-4" align="center">
<v-col
cols="12"
class="d-flex align-center justify-between flex-wrap ga-3"
>
<div class="text-body-2">
Runs loaded:
<strong>{{ runsLoadedCount.toLocaleString() }}</strong>
</div>
<v-select
v-model="selectedRunId"
:items="runItems"
item-title="label"
item-value="value"
density="comfortable"
hide-details
:clearable="false"
:disabled="runItems.length <= 1"
style="min-width: 280px; max-width: 440px"
/>
<v-progress-circular
v-if="loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
/>
</v-col>
</v-row>
<!-- 선택 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>
<!-- 요약 테이블 -->
<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-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-container>
</template>