|
|
|
@ -9,14 +9,14 @@ import {
|
|
|
|
} from "vue";
|
|
|
|
} from "vue";
|
|
|
|
import Plotly from "plotly.js-dist-min";
|
|
|
|
import Plotly from "plotly.js-dist-min";
|
|
|
|
|
|
|
|
|
|
|
|
/** ===== Types ===== */
|
|
|
|
/* ===== Types ===== */
|
|
|
|
type MetricKV = { key: string; value: number };
|
|
|
|
type MetricKV = { key: string; value: number };
|
|
|
|
type RunDetailType = {
|
|
|
|
type RunDetailType = {
|
|
|
|
info: any;
|
|
|
|
info: any;
|
|
|
|
data: { metrics: MetricKV[]; params?: any[]; tags?: any[] };
|
|
|
|
data: { metrics: MetricKV[]; params?: any[]; tags?: any[] };
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** ===== Props / Emits ===== */
|
|
|
|
/* ===== Props / Emits ===== */
|
|
|
|
const props = withDefaults(
|
|
|
|
const props = withDefaults(
|
|
|
|
defineProps<{
|
|
|
|
defineProps<{
|
|
|
|
modelValue: boolean;
|
|
|
|
modelValue: boolean;
|
|
|
|
@ -46,7 +46,7 @@ const emit = defineEmits<{
|
|
|
|
(e: "update:selectedMetricKeys", v: string[]): void;
|
|
|
|
(e: "update:selectedMetricKeys", v: string[]): void;
|
|
|
|
}>();
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
/** ===== local refs ===== */
|
|
|
|
/* ===== local refs ===== */
|
|
|
|
const dialogOpen = computed({
|
|
|
|
const dialogOpen = computed({
|
|
|
|
get: () => props.modelValue,
|
|
|
|
get: () => props.modelValue,
|
|
|
|
set: (v: boolean) => emit("update:modelValue", v),
|
|
|
|
set: (v: boolean) => emit("update:modelValue", v),
|
|
|
|
@ -63,7 +63,7 @@ const selectedMetricKeysProxy = computed({
|
|
|
|
const elCompare = ref<HTMLDivElement | null>(null);
|
|
|
|
const elCompare = ref<HTMLDivElement | null>(null);
|
|
|
|
const loading = ref(false);
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
|
|
|
|
|
|
/** ===== helpers ===== */
|
|
|
|
/* ===== helpers ===== */
|
|
|
|
const fmtNumber = (v: number | null, digits = 3) => {
|
|
|
|
const fmtNumber = (v: number | null, digits = 3) => {
|
|
|
|
if (v == null || !Number.isFinite(v)) return "";
|
|
|
|
if (v == null || !Number.isFinite(v)) return "";
|
|
|
|
const abs = Math.abs(v);
|
|
|
|
const abs = Math.abs(v);
|
|
|
|
@ -75,8 +75,8 @@ const fmtNumber = (v: number | null, digits = 3) => {
|
|
|
|
const normalizeArray = (vals: (number | null)[]) => {
|
|
|
|
const normalizeArray = (vals: (number | null)[]) => {
|
|
|
|
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
|
|
|
|
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
|
|
|
|
if (xs.length === 0) return vals;
|
|
|
|
if (xs.length === 0) return vals;
|
|
|
|
const min = Math.min(...xs),
|
|
|
|
const min = Math.min(...xs);
|
|
|
|
max = Math.max(...xs);
|
|
|
|
const max = Math.max(...xs);
|
|
|
|
if (max === min) return vals.map((v) => (v == null ? v : 1));
|
|
|
|
if (max === min) return vals.map((v) => (v == null ? v : 1));
|
|
|
|
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
|
|
|
|
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
|
|
|
|
};
|
|
|
|
};
|
|
|
|
@ -86,7 +86,7 @@ const valueOf = (run: RunDetailType, key: string): number | null => {
|
|
|
|
return Number.isFinite(m as number) ? Number(m) : null;
|
|
|
|
return Number.isFinite(m as number) ? Number(m) : null;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** ===== derived ===== */
|
|
|
|
/* ===== derived ===== */
|
|
|
|
const compareRuns = computed<RunDetailType[]>(
|
|
|
|
const compareRuns = computed<RunDetailType[]>(
|
|
|
|
() =>
|
|
|
|
() =>
|
|
|
|
selectedRunIdsProxy.value
|
|
|
|
selectedRunIdsProxy.value
|
|
|
|
@ -109,7 +109,25 @@ const activeMetricKeys = computed<string[]>(() =>
|
|
|
|
: commonMetricKeys.value,
|
|
|
|
: commonMetricKeys.value,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/** ===== chart ===== */
|
|
|
|
/* ===== responsive widths for horizontal scroll =====
|
|
|
|
|
|
|
|
- 그래프: 막대 개수에 비례해 내부 너비를 크게 잡아 가로 스크롤 허용
|
|
|
|
|
|
|
|
- 테이블: (메트릭 수 + Run 컬럼 1개) * 160px 정도로 최소 너비 설정
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
const chartInnerWidth = computed(() => {
|
|
|
|
|
|
|
|
// byMetric: X축 = metric 개수, byRun: X축 = run 개수
|
|
|
|
|
|
|
|
const xCount =
|
|
|
|
|
|
|
|
props.compareChartMode === "byMetric"
|
|
|
|
|
|
|
|
? activeMetricKeys.value.length
|
|
|
|
|
|
|
|
: compareRuns.value.length;
|
|
|
|
|
|
|
|
// 막대 간격 여유를 위해 140px씩, 최소 900px 확보
|
|
|
|
|
|
|
|
return Math.max(900, xCount * 140 + 240);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
const tableInnerWidth = computed(() => {
|
|
|
|
|
|
|
|
const cols = 1 + activeMetricKeys.value.length; // Run 컬럼 + metric 수
|
|
|
|
|
|
|
|
return Math.max(900, cols * 160);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ===== chart ===== */
|
|
|
|
const commonLayout: Partial<any> = {
|
|
|
|
const commonLayout: Partial<any> = {
|
|
|
|
autosize: true,
|
|
|
|
autosize: true,
|
|
|
|
margin: { t: 28, r: 12, b: 56, l: 56 },
|
|
|
|
margin: { t: 28, r: 12, b: 56, l: 56 },
|
|
|
|
@ -181,6 +199,7 @@ function drawCompareChart() {
|
|
|
|
? createTracesByMetric(metricKeys, runsData)
|
|
|
|
? createTracesByMetric(metricKeys, runsData)
|
|
|
|
: createTracesByRun(metricKeys, runsData);
|
|
|
|
: createTracesByRun(metricKeys, runsData);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// byMetric 모드에서 분산 큰 순으로 정렬(가독성)
|
|
|
|
if (props.compareChartMode === "byMetric") {
|
|
|
|
if (props.compareChartMode === "byMetric") {
|
|
|
|
const varianceOrder = metricKeys
|
|
|
|
const varianceOrder = metricKeys
|
|
|
|
.map((k, idx) => {
|
|
|
|
.map((k, idx) => {
|
|
|
|
@ -216,7 +235,7 @@ function drawCompareChart() {
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** ===== load & watch ===== */
|
|
|
|
/* ===== load & watch ===== */
|
|
|
|
async function loadCompareData() {
|
|
|
|
async function loadCompareData() {
|
|
|
|
if (!dialogOpen.value) return;
|
|
|
|
if (!dialogOpen.value) return;
|
|
|
|
loading.value = true;
|
|
|
|
loading.value = true;
|
|
|
|
@ -250,9 +269,9 @@ onBeforeUnmount(() => window.removeEventListener("resize", onResize));
|
|
|
|
</script>
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
<template>
|
|
|
|
<v-dialog v-model="dialogOpen" max-width="1000">
|
|
|
|
<!-- 가로도 넉넉히, 세로는 90vh 제한 -->
|
|
|
|
|
|
|
|
<v-dialog v-model="dialogOpen" max-width="80vw">
|
|
|
|
<v-card class="rounded-lg overflow-hidden">
|
|
|
|
<v-card class="rounded-lg overflow-hidden">
|
|
|
|
<!-- 헤더: Deploy Model 스타일 적용 -->
|
|
|
|
|
|
|
|
<v-card-title
|
|
|
|
<v-card-title
|
|
|
|
class="text-white font-weight-bold text-h6"
|
|
|
|
class="text-white font-weight-bold text-h6"
|
|
|
|
style="background-color: #1976d2"
|
|
|
|
style="background-color: #1976d2"
|
|
|
|
@ -260,128 +279,154 @@ onBeforeUnmount(() => window.removeEventListener("resize", onResize));
|
|
|
|
Compare Runs
|
|
|
|
Compare Runs
|
|
|
|
</v-card-title>
|
|
|
|
</v-card-title>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 상단: 선택 섹션 -->
|
|
|
|
<!-- 본문 전체에 세로 스크롤 -->
|
|
|
|
<v-card-text class="pa-6">
|
|
|
|
<div class="scroll-y">
|
|
|
|
<v-row dense class="mb-2">
|
|
|
|
<!-- 상단 선택 섹션 -->
|
|
|
|
<v-col cols="12">
|
|
|
|
<v-card-text class="pa-6">
|
|
|
|
<v-subheader class="font-weight-medium mb-2"
|
|
|
|
<v-row dense class="mb-2">
|
|
|
|
>Select Runs</v-subheader
|
|
|
|
<v-col cols="12">
|
|
|
|
>
|
|
|
|
<v-subheader class="font-weight-medium mb-2"
|
|
|
|
<v-autocomplete
|
|
|
|
>Select Runs</v-subheader
|
|
|
|
v-model="selectedRunIdsProxy"
|
|
|
|
>
|
|
|
|
:items="items"
|
|
|
|
<v-autocomplete
|
|
|
|
item-title="label"
|
|
|
|
v-model="selectedRunIdsProxy"
|
|
|
|
item-value="value"
|
|
|
|
:items="items"
|
|
|
|
chips
|
|
|
|
item-title="label"
|
|
|
|
multiple
|
|
|
|
item-value="value"
|
|
|
|
closable-chips
|
|
|
|
chips
|
|
|
|
variant="outlined"
|
|
|
|
multiple
|
|
|
|
density="comfortable"
|
|
|
|
closable-chips
|
|
|
|
hide-details
|
|
|
|
variant="outlined"
|
|
|
|
:disabled="loading"
|
|
|
|
density="comfortable"
|
|
|
|
placeholder="Select runs to compare"
|
|
|
|
hide-details
|
|
|
|
/>
|
|
|
|
:disabled="loading"
|
|
|
|
</v-col>
|
|
|
|
placeholder="Select runs to compare"
|
|
|
|
</v-row>
|
|
|
|
/>
|
|
|
|
|
|
|
|
</v-col>
|
|
|
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
|
|
<v-expand-transition>
|
|
|
|
<v-expand-transition>
|
|
|
|
<v-alert
|
|
|
|
<v-alert
|
|
|
|
v-if="!loading && selectedRunIdsProxy.length < 2"
|
|
|
|
v-if="!loading && selectedRunIdsProxy.length < 2"
|
|
|
|
type="info"
|
|
|
|
type="info"
|
|
|
|
variant="tonal"
|
|
|
|
variant="tonal"
|
|
|
|
density="compact"
|
|
|
|
density="compact"
|
|
|
|
class="mt-2"
|
|
|
|
class="mt-2"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
2개 이상의 Run을 선택해 주세요.
|
|
|
|
2개 이상의 Run을 선택해 주세요.
|
|
|
|
</v-alert>
|
|
|
|
</v-alert>
|
|
|
|
</v-expand-transition>
|
|
|
|
</v-expand-transition>
|
|
|
|
|
|
|
|
|
|
|
|
<v-row dense class="mt-4">
|
|
|
|
<v-row dense class="mt-4">
|
|
|
|
<v-col cols="12">
|
|
|
|
<v-col cols="12">
|
|
|
|
<v-subheader class="font-weight-medium mb-2">Metrics</v-subheader>
|
|
|
|
<v-subheader class="font-weight-medium mb-2">Metrics</v-subheader>
|
|
|
|
<v-autocomplete
|
|
|
|
<v-autocomplete
|
|
|
|
v-model="selectedMetricKeysProxy"
|
|
|
|
v-model="selectedMetricKeysProxy"
|
|
|
|
:items="activeMetricKeys.length ? activeMetricKeys : []"
|
|
|
|
:items="activeMetricKeys.length ? activeMetricKeys : []"
|
|
|
|
:menu-props="{ maxHeight: 360 }"
|
|
|
|
:menu-props="{ maxHeight: 360 }"
|
|
|
|
chips
|
|
|
|
chips
|
|
|
|
multiple
|
|
|
|
multiple
|
|
|
|
closable-chips
|
|
|
|
closable-chips
|
|
|
|
variant="outlined"
|
|
|
|
variant="outlined"
|
|
|
|
density="comfortable"
|
|
|
|
density="comfortable"
|
|
|
|
hide-details
|
|
|
|
hide-details
|
|
|
|
:disabled="loading || compareRuns.length === 0"
|
|
|
|
:disabled="loading || compareRuns.length === 0"
|
|
|
|
placeholder="Select metrics (empty = all common)"
|
|
|
|
placeholder="Select metrics (empty = all common)"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
<div class="d-flex align-center mt-2 ga-2">
|
|
|
|
<div class="d-flex align-center mt-2 ga-2">
|
|
|
|
<v-chip size="small" color="primary" variant="tonal">
|
|
|
|
<v-chip size="small" color="primary" variant="tonal">
|
|
|
|
Mode:
|
|
|
|
Mode:
|
|
|
|
{{
|
|
|
|
{{
|
|
|
|
props.compareChartMode === "byMetric" ? "By Metric" : "By Run"
|
|
|
|
props.compareChartMode === "byMetric"
|
|
|
|
}}
|
|
|
|
? "By Metric"
|
|
|
|
</v-chip>
|
|
|
|
: "By Run"
|
|
|
|
<v-chip size="small" color="secondary" variant="tonal">
|
|
|
|
}}
|
|
|
|
{{ props.normalizeValues ? "Normalized" : "Raw values" }}
|
|
|
|
</v-chip>
|
|
|
|
</v-chip>
|
|
|
|
<v-chip size="small" color="secondary" variant="tonal">
|
|
|
|
<v-chip
|
|
|
|
{{ props.normalizeValues ? "Normalized" : "Raw values" }}
|
|
|
|
v-if="props.baselineRunId"
|
|
|
|
</v-chip>
|
|
|
|
size="small"
|
|
|
|
<v-chip
|
|
|
|
color="info"
|
|
|
|
v-if="props.baselineRunId"
|
|
|
|
variant="tonal"
|
|
|
|
size="small"
|
|
|
|
>
|
|
|
|
color="info"
|
|
|
|
Baseline: {{ props.baselineRunId }}
|
|
|
|
variant="tonal"
|
|
|
|
</v-chip>
|
|
|
|
>
|
|
|
|
</div>
|
|
|
|
Baseline: {{ props.baselineRunId }}
|
|
|
|
</v-col>
|
|
|
|
</v-chip>
|
|
|
|
</v-row>
|
|
|
|
</div>
|
|
|
|
</v-card-text>
|
|
|
|
</v-col>
|
|
|
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 차트 & 테이블 섹션 (톤 카드로 감싸기) -->
|
|
|
|
<!-- 차트 (가로 스크롤) -->
|
|
|
|
<v-card-text class="pt-0 px-6 pb-2">
|
|
|
|
<v-card-text class="pt-0 px-6 pb-2">
|
|
|
|
<v-card variant="tonal" class="mb-4">
|
|
|
|
<v-card variant="tonal" class="mb-4">
|
|
|
|
<v-card-title class="py-2 px-4">Metrics (grouped bar)</v-card-title>
|
|
|
|
<v-card-title class="py-2 px-4">Metrics (grouped bar)</v-card-title>
|
|
|
|
<v-divider />
|
|
|
|
<v-divider />
|
|
|
|
<v-card-text class="px-4">
|
|
|
|
<v-card-text class="px-4">
|
|
|
|
<div v-if="loading" class="my-2">
|
|
|
|
<div v-if="loading" class="my-2">
|
|
|
|
<v-progress-linear indeterminate />
|
|
|
|
<v-progress-linear indeterminate />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div ref="elCompare" style="width: 100%; height: 420px"></div>
|
|
|
|
<div class="scroll-x">
|
|
|
|
</v-card-text>
|
|
|
|
<div
|
|
|
|
</v-card>
|
|
|
|
ref="elCompare"
|
|
|
|
|
|
|
|
:style="{ width: chartInnerWidth + 'px', height: '420px' }"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
|
|
</v-card>
|
|
|
|
|
|
|
|
|
|
|
|
<v-card variant="tonal">
|
|
|
|
<!-- 테이블 (가로 스크롤) -->
|
|
|
|
<v-card-title class="py-2 px-4">Comparison Table</v-card-title>
|
|
|
|
<v-card variant="tonal">
|
|
|
|
<v-divider />
|
|
|
|
<v-card-title class="py-2 px-4">Comparison Table</v-card-title>
|
|
|
|
<v-card-text class="px-0">
|
|
|
|
<v-divider />
|
|
|
|
<v-table density="comfortable">
|
|
|
|
<v-card-text class="px-0">
|
|
|
|
<thead>
|
|
|
|
<div class="scroll-x">
|
|
|
|
<tr>
|
|
|
|
<div :style="{ minWidth: tableInnerWidth + 'px' }">
|
|
|
|
<th style="width: 28%">Run</th>
|
|
|
|
<v-table density="comfortable">
|
|
|
|
<th v-for="k in activeMetricKeys" :key="k">{{ k }}</th>
|
|
|
|
<thead>
|
|
|
|
</tr>
|
|
|
|
<tr>
|
|
|
|
</thead>
|
|
|
|
<th style="width: 28%">Run</th>
|
|
|
|
<tbody>
|
|
|
|
<th v-for="k in activeMetricKeys" :key="k">{{ k }}</th>
|
|
|
|
<tr v-for="r in compareRuns" :key="r.info.run_id">
|
|
|
|
</tr>
|
|
|
|
<td class="text-no-wrap">
|
|
|
|
</thead>
|
|
|
|
{{ r.info.run_name || r.info.run_id }}
|
|
|
|
<tbody>
|
|
|
|
</td>
|
|
|
|
<tr v-for="r in compareRuns" :key="r.info.run_id">
|
|
|
|
<td v-for="k in activeMetricKeys" :key="k">
|
|
|
|
<td class="text-no-wrap">
|
|
|
|
{{ r.data.metrics.find((m) => m.key === k)?.value ?? "—" }}
|
|
|
|
{{ r.info.run_name || r.info.run_id }}
|
|
|
|
</td>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
<td v-for="k in activeMetricKeys" :key="k">
|
|
|
|
</tbody>
|
|
|
|
{{
|
|
|
|
</v-table>
|
|
|
|
r.data.metrics.find((m) => m.key === k)?.value ??
|
|
|
|
</v-card-text>
|
|
|
|
"—"
|
|
|
|
</v-card>
|
|
|
|
}}
|
|
|
|
</v-card-text>
|
|
|
|
</td>
|
|
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
|
|
</v-table>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
|
|
</v-card>
|
|
|
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 액션 -->
|
|
|
|
<!-- 액션 -->
|
|
|
|
<v-card-actions class="justify-end">
|
|
|
|
<v-card-actions class="justify-end">
|
|
|
|
<v-btn variant="text" class="text-white" @click="dialogOpen = false">
|
|
|
|
<v-btn variant="text" class="text-white" @click="dialogOpen = false"
|
|
|
|
CLOSE
|
|
|
|
>CLOSE</v-btn
|
|
|
|
</v-btn>
|
|
|
|
>
|
|
|
|
</v-card-actions>
|
|
|
|
</v-card-actions>
|
|
|
|
</v-card>
|
|
|
|
</v-card>
|
|
|
|
</v-dialog>
|
|
|
|
</v-dialog>
|
|
|
|
</template>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped></style>
|
|
|
|
<style scoped>
|
|
|
|
|
|
|
|
.scroll-y {
|
|
|
|
|
|
|
|
max-height: 90vh; /* 다이얼로그 본문 세로 스크롤 */
|
|
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
.scroll-x {
|
|
|
|
|
|
|
|
overflow-x: auto; /* 가로 스크롤 */
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
</style>
|
|
|
|
|