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.
384 lines
12 KiB
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>
|