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/home/ListComponent.vue

1082 lines
34 KiB

<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch, nextTick } from "vue";
import Plotly from "plotly.js-dist-min";
import { useAutoflowStore } from "@/stores/autoflowStore";
import { WorkflowService } from "@/components/service/management/WorkflowService";
9 months ago
import { ExecutionsService } from "@/components/service/management/ExecutionsService";
import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
import { DataGroupService } from "@/components/service/management/DataGroupService";
type UiStatus = "success" | "failed" | "running" | "pending";
type KfFilter = "SUCCEEDED" | "FAILED" | "RUNNING" | "PENDING" | null;
interface RecentRunRow {
name: string;
status: UiStatus;
time: string;
}
interface DatasetGroup {
groupId: number;
groupName: string;
items: DatasetRow[];
}
interface DatasetRow {
groupId: number;
groupName: string;
9 months ago
name: string;
version: number;
rows: number;
last?: string;
pct: number;
}
interface WorkflowRow {
id: string | number;
name: string;
modDt: string;
}
interface KubeflowRunRow {
runId: string;
name: string;
state: string;
uiStatus: UiStatus;
createdAt?: string;
scheduledAt?: string;
finishedAt?: string;
experimentId?: string;
pipelineName?: string;
serviceAccount?: string;
}
const COLOR = {
SUCCESS: "#2ecc71",
ERROR: "#e74c3c",
INFO: "#3498db",
WARNING: "#f1c40f",
SECONDARY: "#95a5a6",
MUTED: "#7f8c8d",
NODATA: "#4b4f55",
} as const;
const STATUS_LABEL: Record<UiStatus, string> = {
success: "Succeeded",
failed: "Failed",
running: "Running",
pending: "Pending",
};
function colorChipByUiStatus(status: UiStatus) {
if (status === "success") return "success";
if (status === "failed") return "error";
if (status === "running") return "info";
return "grey";
}
function avatarColorByUiStatus(status: UiStatus) {
if (status === "success") return "green-lighten-1";
if (status === "failed") return "red-lighten-1";
if (status === "running") return "blue-lighten-1";
return "grey-darken-1";
}
function avatarIconByUiStatus(status: UiStatus) {
if (status === "success") return "mdi-check";
if (status === "failed") return "mdi-close";
if (status === "running") return "mdi-progress-clock";
return "mdi-clock-outline";
}
const groupSummaries = ref<Array<{ id: number; name: string }>>([]);
const datasetsByGroup = ref<Record<number, DatasetRow[]>>({});
const groupLoading = ref<Record<number, boolean>>({});
const groupLoaded = ref<Record<number, boolean>>({});
async function loadDatasetsForGroup(groupId: number, groupName: string) {
if (groupLoaded.value[groupId] || groupLoading.value[groupId]) return;
groupLoading.value[groupId] = true;
9 months ago
try {
const res = await AttachmentsService.search({
9 months ago
projectId: currentProjectId.value,
page: 0,
size: 200,
9 months ago
refType: "DATASET",
refId: groupId,
9 months ago
sortField: "id",
sortDirection: "DESC",
} as any);
const content: any[] = res?.data?.content ?? res?.data ?? [];
// Map → Record 로 변경 (ES5 호환)
const byName: Record<string, DatasetRow> = {};
for (let i = 0; i < content.length; i++) {
const f = content[i];
const name = String(f?.title ?? f?.originalName ?? "(no name)");
const version = Number(f?.version) || 1;
const last = (f?.modDt ?? f?.regDt ?? f?.createdAt) as string | undefined;
const key = name;
const cur = byName[key] ?? {
groupId,
groupName,
9 months ago
name,
version: 0,
rows: 0,
last: undefined,
pct: 0,
9 months ago
};
cur.rows += 1;
cur.version = Math.max(cur.version, version);
if (
!cur.last ||
(last && new Date(last).getTime() > new Date(cur.last).getTime())
) {
cur.last = last;
}
byName[key] = cur;
9 months ago
}
const rows = Object.values(byName);
const maxVer = Math.max(1, ...rows.map((r) => r.version));
for (let i = 0; i < rows.length; i++)
rows[i].pct = Math.round((rows[i].version / maxVer) * 100);
9 months ago
datasetsByGroup.value[groupId] = rows.sort(
(a, b) =>
new Date(b.last ?? 0).getTime() - new Date(a.last ?? 0).getTime() ||
b.version - a.version,
);
groupLoaded.value[groupId] = true;
9 months ago
} catch (e) {
console.error("[Dashboard] loadDatasetsForGroup error:", e);
datasetsByGroup.value[groupId] = [];
9 months ago
} finally {
groupLoading.value[groupId] = false;
9 months ago
}
}
function mapRawStateToUiStatus(raw?: string): UiStatus {
switch ((raw || "").toUpperCase()) {
9 months ago
case "SUCCEEDED":
return "success";
case "FAILED":
return "failed";
case "RUNNING":
return "running";
case "PENDING":
case "QUEUED":
case "SCHEDULED":
9 months ago
return "pending";
default:
return "pending";
}
}
const toEpoch = (iso?: string) => new Date(iso ?? 0).getTime();
const fmtLocalDateTime = (iso?: string) =>
iso ? new Date(iso).toLocaleString() : "-";
9 months ago
function fmtYmdHm(iso?: string) {
if (!iso) return "-";
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const store = useAutoflowStore();
const currentProjectId = computed(() => store.projectId);
const currentProjectName = computed(
() => store.projectName ?? localStorage.getItem("projectName") ?? "",
);
const workflows = ref<WorkflowRow[]>([]);
const recentRuns = ref<RecentRunRow[]>([]);
const datasetRows = ref<DatasetRow[]>([]);
const kubeflowRuns = ref<KubeflowRunRow[]>([]);
const isRefreshing = ref(false);
const runsLoading = ref(false);
const dsLoading = ref(false);
const kfRunsLoading = ref(false);
const kubeflowDetailsExplicitOpen = ref(false);
const kubeflowStatusFilter = ref<KfFilter>(null);
const showKubeflowDetails = computed(
() =>
kubeflowStatusFilter.value !== null || kubeflowDetailsExplicitOpen.value,
);
const recentLimit = 10;
const workflowStatusPieRef = ref<HTMLElement | null>(null);
const kubeflowPieRef = ref<HTMLElement | null>(null);
const kubeflowPieWrapRef = ref<HTMLElement | null>(null);
let kubeflowResizeObserver: ResizeObserver | null = null;
/* Map → Record 로 변경 */
const datasetGroups = computed<DatasetGroup[]>(() => {
const dict: Record<number, DatasetGroup> = {};
const rows = datasetRows.value || [];
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (!dict[r.groupId])
dict[r.groupId] = {
groupId: r.groupId,
groupName: r.groupName,
items: [],
};
dict[r.groupId].items.push(r);
}
const groups = Object.values(dict);
for (let i = 0; i < groups.length; i++) {
groups[i].items.sort(
(a, b) => toEpoch(b.last) - toEpoch(a.last) || b.version - a.version,
);
}
return groups.sort((a, b) => a.groupName.localeCompare(b.groupName));
});
const kubeflowCounts = computed(() => {
const agg = { SUCCEEDED: 0, FAILED: 0, RUNNING: 0, PENDING: 0 };
for (let i = 0; i < kubeflowRuns.value.length; i++) {
const raw = (kubeflowRuns.value[i].state || "").toUpperCase();
if (raw.includes("SUCCEED")) agg.SUCCEEDED++;
else if (raw.includes("FAIL")) agg.FAILED++;
else if (raw.includes("RUN")) agg.RUNNING++;
else agg.PENDING++;
}
return agg;
});
const kubeflowStats = computed(() => ({
total: kubeflowCounts.value.SUCCEEDED + kubeflowCounts.value.FAILED,
succeeded: kubeflowCounts.value.SUCCEEDED,
failed: kubeflowCounts.value.FAILED,
}));
const kubeflowRunsForList = computed(() => {
const list = kubeflowRuns.value.filter((kfRunRow) => {
const raw = (kfRunRow.state || "").toUpperCase();
if (kubeflowStatusFilter.value === "SUCCEEDED")
return raw.includes("SUCCEED");
if (kubeflowStatusFilter.value === "FAILED") return raw.includes("FAIL");
return raw.includes("SUCCEED") || raw.includes("FAIL");
});
return list.sort((a, b) => toEpoch(b.createdAt) - toEpoch(a.createdAt));
});
9 months ago
async function loadRecentRuns() {
runsLoading.value = true;
try {
const allRuns: any[] = [];
const seenTokens = new Set<string>();
9 months ago
let page = await ExecutionsService.search({ pageSize: 200 });
allRuns.push(...(page?.data?.runs ?? []));
9 months ago
let token = page?.data?.next_page_token ?? page?.data?.nextPageToken;
let guard = 0;
while (token && !seenTokens.has(token) && guard < 20) {
seenTokens.add(token);
9 months ago
page = await ExecutionsService.search({
pageToken: token,
pageSize: 200,
} as any);
allRuns.push(...(page?.data?.runs ?? []));
9 months ago
token = page?.data?.next_page_token ?? page?.data?.nextPageToken;
guard++;
}
// Map → Record (중복 제거)
const dict: Record<string, any> = {};
for (let i = 0; i < allRuns.length; i++) {
const run = allRuns[i];
const key = run?.run_id ?? run?.id ?? run?.name;
if (key) dict[String(key)] = run;
}
const deduped: any[] = Object.values(dict);
9 months ago
recentRuns.value = deduped
.sort((a, b) => toEpoch(b?.created_at) - toEpoch(a?.created_at))
9 months ago
.slice(0, recentLimit)
.map<RecentRunRow>((run) => ({
name: run?.display_name ?? run?.name ?? run?.run_id ?? "(no name)",
status: mapRawStateToUiStatus(run?.state),
time: fmtYmdHm(run?.created_at),
9 months ago
}));
} catch (err) {
console.error("[Dashboard] loadRecentRuns error:", err);
9 months ago
recentRuns.value = [];
} finally {
runsLoading.value = false;
}
}
async function loadWorkflows() {
try {
const payload = {
page: 0,
size: 1000,
projectId: currentProjectId.value,
sortField: "id",
sortDirection: "DESC",
};
const res = await WorkflowService.search(payload);
const list: any[] = Array.isArray(res?.data?.content)
? res.data.content
: Array.isArray(res?.data)
? res.data
: [];
const filtered = list.filter(
(wf: any) =>
String(
wf?.projectId ?? wf?.prjId ?? wf?.project_id ?? wf?.project?.id ?? "",
) === String(currentProjectId.value),
);
workflows.value = filtered.map((wf: any) => ({
id: wf.id,
name: wf.name,
modDt: wf.modDt,
}));
renderWorkflowStatusPie();
} catch (err) {
console.error("[Dashboard] loadWorkflows error:", err);
workflows.value = [];
renderWorkflowStatusPie();
}
}
async function loadDatasetActivity() {
dsLoading.value = true;
try {
const dgRes = await DataGroupService.search({
projectId: currentProjectId.value,
page: 0,
size: 200,
sortField: "id",
sortDirection: "DESC",
} as any);
groupSummaries.value = (dgRes?.data?.content ?? dgRes?.data ?? [])
.map((g: any) => ({
id: Number(g?.id),
name: String(g?.dsNm ?? g?.name ?? `(Group ${g?.id})`),
}))
.filter((g) => Number.isFinite(g.id));
datasetsByGroup.value = {};
groupLoaded.value = {};
groupLoading.value = {};
} catch (err) {
console.error("[Dashboard] loadDatasetActivity error:", err);
groupSummaries.value = [];
} finally {
dsLoading.value = false;
}
}
async function loadKubeflowRuns() {
kfRunsLoading.value = true;
try {
const res = await KubeflowRunService.getAll();
const list: any[] = Array.isArray(res?.data) ? res.data : [];
kubeflowRuns.value = list
.map<KubeflowRunRow>((row: any) => {
const state = String(row?.state ?? row?.status ?? "PENDING");
return {
runId: row?.runId ?? row?.run_id ?? row?.id ?? "",
name:
row?.displayName ??
row?.display_name ??
row?.name ??
row?.run_id ??
"(no name)",
state,
uiStatus: mapRawStateToUiStatus(state),
createdAt: row?.createdAt ?? row?.created_at,
scheduledAt: row?.scheduledAt ?? row?.scheduled_at,
finishedAt: row?.finishedAt ?? row?.finished_at,
experimentId:
row?.experimentId ?? row?.experiment_id ?? row?.experimentName,
pipelineName:
row?.pipelineName ?? row?.pipeline_name ?? row?.pipeline_id,
serviceAccount: row?.serviceAccount ?? row?.service_account,
};
})
.sort((a, b) => toEpoch(b.createdAt) - toEpoch(a.createdAt))
.slice(0, 20);
} catch (err) {
console.error("[Dashboard] loadKubeflowRuns error:", err);
kubeflowRuns.value = [];
} finally {
kfRunsLoading.value = false;
renderKubeflowPie();
}
}
/* Map → Record 로 변경 (반복자 제거) */
function renderWorkflowStatusPie() {
const container = workflowStatusPieRef.value;
if (!container) return;
const counts: Record<string, number> = {};
for (let i = 0; i < workflows.value.length; i++) {
const wfRow = workflows.value[i] as any;
const raw = wfRow?.kubeflowStatus ?? wfRow?.kubeflow_status ?? "UNKNOWN";
const key = String(raw).toUpperCase().trim();
counts[key] = (counts[key] || 0) + 1;
}
const keys = Object.keys(counts);
if (keys.length === 0) {
9 months ago
const trace: Partial<Plotly.PlotData> = {
values: [1],
labels: ["No Data"],
type: "pie",
hole: 0.55,
textinfo: "none",
marker: { colors: [COLOR.NODATA] },
hoverinfo: "skip",
};
const layout: Partial<Plotly.Layout> = {
paper_bgcolor: "#1e1e1e",
plot_bgcolor: "#1e1e1e",
showlegend: false,
margin: { t: 20, b: 40, l: 0, r: 0 },
annotations: [
{
text: "<b>No Data</b>",
showarrow: false,
font: { color: "#fff", size: 16 },
9 months ago
},
],
};
Plotly.react(container, [trace], layout, { displayModeBar: false });
9 months ago
return;
}
const labels = keys;
const values = labels.map((k) => counts[k]);
9 months ago
const colors = labels.map((s) => {
if (s.includes("SUCCEED")) return COLOR.SUCCESS;
if (s.includes("FAIL")) return COLOR.ERROR;
if (s.includes("RUN")) return COLOR.INFO;
if (s.includes("CREATE")) return COLOR.INFO;
if (s.includes("PEND")) return COLOR.WARNING;
if (s.includes("SKIP")) return COLOR.SECONDARY;
return COLOR.MUTED;
9 months ago
});
const trace: Partial<Plotly.PlotData> = {
9 months ago
values,
labels,
type: "pie",
hole: 0.4,
9 months ago
textinfo: "label+percent",
textfont: { color: "#fff", size: 13 },
marker: { colors },
hovertemplate: "%{label}: %{percent}<extra></extra>",
};
const layout: Partial<Plotly.Layout> = {
paper_bgcolor: "#1e1e1e",
plot_bgcolor: "#1e1e1e",
showlegend: true,
legend: {
font: { color: "#ffffff", size: 12 },
orientation: "h",
x: 0.5,
xanchor: "center",
y: -0.2,
},
margin: { t: 20, b: 40, l: 0, r: 0 },
};
Plotly.react(container, [trace], layout, { displayModeBar: false });
}
function renderKubeflowPie() {
const plotEl = kubeflowPieRef.value;
const wrapEl = kubeflowPieWrapRef.value || plotEl;
if (!plotEl || !wrapEl) return;
const succeeded = kubeflowCounts.value.SUCCEEDED || 0;
const failed = kubeflowCounts.value.FAILED || 0;
const total = succeeded + failed;
const hasData = total > 0;
const trace: Partial<Plotly.PlotData> = hasData
? {
type: "pie",
labels: ["SUCCEEDED", "FAILED"],
values: [succeeded, failed],
textinfo: "label+value",
textfont: { color: "#fff", size: 13 },
marker: { colors: [COLOR.SUCCESS, COLOR.ERROR] },
hovertemplate: "%{label}: %{value} (%{percent})<extra></extra>",
}
: {
type: "pie",
labels: ["No Data"],
values: [1],
textinfo: "none",
marker: { colors: [COLOR.NODATA] },
hoverinfo: "skip",
};
const width = wrapEl.clientWidth || 300;
const height = wrapEl.clientHeight || 220;
const layout: Partial<Plotly.Layout> = {
width,
height,
autosize: false,
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
showlegend: false,
margin: { t: 8, b: 8, l: 8, r: 8 },
};
Plotly.react(plotEl, [trace], layout, {
displayModeBar: false,
responsive: true,
});
(plotEl as any).on?.("plotly_click", (ev: any) => {
kubeflowDetailsExplicitOpen.value = true;
const label = (ev?.points?.[0]?.label ?? "").toUpperCase();
if (label === "SUCCEEDED" || label === "FAILED") {
kubeflowStatusFilter.value =
kubeflowStatusFilter.value === label ? null : (label as KfFilter);
}
});
(plotEl as any).on?.("plotly_doubleclick", () => {
kubeflowStatusFilter.value = null;
kubeflowDetailsExplicitOpen.value = false;
return false;
});
if (!kubeflowResizeObserver) {
kubeflowResizeObserver = new ResizeObserver(() => {
if (kubeflowPieRef.value) Plotly.Plots.resize(kubeflowPieRef.value);
});
kubeflowResizeObserver.observe(wrapEl);
window.addEventListener("orientationchange", () => {
if (kubeflowPieRef.value) Plotly.Plots.resize(kubeflowPieRef.value);
});
}
}
async function handleRefresh() {
if (isRefreshing.value) return;
try {
isRefreshing.value = true;
await Promise.all([
loadRecentRuns(),
(async () => {
await loadWorkflows();
renderWorkflowStatusPie();
})(),
loadDatasetActivity(),
loadKubeflowRuns(),
]);
} catch (err) {
console.error("[Dashboard] refresh failed:", err);
} finally {
isRefreshing.value = false;
}
}
onMounted(async () => {
renderWorkflowStatusPie();
9 months ago
await loadRecentRuns();
await loadWorkflows();
9 months ago
await loadDatasetActivity();
await loadKubeflowRuns();
});
onUnmounted(() => {
if (kubeflowResizeObserver && kubeflowPieWrapRef.value) {
kubeflowResizeObserver.unobserve(kubeflowPieWrapRef.value);
}
kubeflowResizeObserver = null;
});
watch(currentProjectId, () => loadWorkflows());
watch(kubeflowRuns, () => renderKubeflowPie());
watch(showKubeflowDetails, async () => {
await nextTick();
renderKubeflowPie();
});
</script>
<template>
<v-container fluid>
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<h2 class="text-h6 font-weight-bold">{{ currentProjectName }}</h2>
<v-btn
color="primary"
prepend-icon="mdi-refresh"
:loading="isRefreshing"
:disabled="isRefreshing"
@click="handleRefresh"
11 months ago
>
Refresh
</v-btn>
</div>
<!-- Top: Recent Runs / Workflows -->
<v-row>
<!-- Recent Run -->
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<h3 class="text-subtitle-1 font-weight-bold mb-0">Recent Run</h3>
</div>
9 months ago
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
9 months ago
<v-skeleton-loader
v-if="runsLoading"
type="list-item-two-line"
class="mb-2"
v-for="i in 4"
:key="i"
/>
<v-list v-else density="comfortable">
<v-list-item
v-for="(runRow, idx) in recentRuns"
:key="idx"
class="py-2"
>
9 months ago
<div class="d-flex align-center justify-space-between w-100">
<!-- left: icon + status -->
9 months ago
<div class="d-flex align-center ga-2">
<v-avatar
size="28"
:color="avatarColorByUiStatus(runRow.status)"
9 months ago
>
<v-icon size="20" color="white">
{{ avatarIconByUiStatus(runRow.status) }}
9 months ago
</v-icon>
</v-avatar>
<v-chip
size="small"
:color="colorChipByUiStatus(runRow.status)"
9 months ago
variant="tonal"
class="text-uppercase"
>
{{ STATUS_LABEL[runRow.status] }}
9 months ago
</v-chip>
</div>
<!-- right: name & time -->
<div
9 months ago
class="d-flex flex-column text-right"
style="min-width: 220px"
>
9 months ago
<span class="font-weight-medium text-body-2 truncate">
{{ runRow.name }}
</span>
<span class="text-caption text-grey-darken-1">
{{ runRow.time }}
</span>
</div>
</div>
</v-list-item>
9 months ago
<v-list-item v-if="!runsLoading && recentRuns.length === 0">
<template #title>
<div class="text-caption text-grey">
최근 실행 데이터가 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-card>
</v-col>
<!-- Workflows -->
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Recently Registered Workflow
</h3>
</div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<v-list density="comfortable" nav>
<v-list-item
v-for="wfRow in workflows
.slice(0, 10)
.sort((a, b) => toEpoch(b.modDt) - toEpoch(a.modDt))"
:key="wfRow.id"
>
<template #title>
<div class="d-flex justify-space-between align-center w-100">
<span class="text-body-2 font-weight-medium">{{
wfRow.name
}}</span>
<span class="text-caption text-grey-lighten-1">{{
fmtYmdHm(wfRow.modDt)
}}</span>
</div>
</template>
</v-list-item>
<v-list-item v-if="workflows.length === 0">
<template #title>
<div class="text-caption text-grey">
최근 등록/수정된 워크플로우가 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-card>
</v-col>
</v-row>
<!-- Middle: Kubeflow / Dataset -->
<v-row class="mt-4">
<!-- Kubeflow -->
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #3a3a3a">
<div class="d-flex align-center justify-space-between w-100">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Kubeflow Runs
</h3>
<div class="d-flex align-center ga-2">
<v-chip size="small" variant="tonal" color="grey"
>Total {{ kubeflowStats.total }}</v-chip
>
<v-chip size="small" variant="tonal" color="success"
>Succeeded {{ kubeflowStats.succeeded }}</v-chip
>
<v-chip size="small" variant="tonal" color="error"
>Failed {{ kubeflowStats.failed }}</v-chip
>
<v-chip
v-if="kubeflowStatusFilter"
size="small"
color="secondary"
variant="tonal"
closable
@click:close="
kubeflowStatusFilter = null;
kubeflowDetailsExplicitOpen = false;
"
>
Filter: {{ kubeflowStatusFilter }}
</v-chip>
</div>
</div>
</div>
<div style="padding: 8px 12px; height: 260px">
<!-- Only Chart -->
<div
v-if="!showKubeflowDetails"
ref="kubeflowPieWrapRef"
style="
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
"
>
<div ref="kubeflowPieRef" style="width: 100%; height: 100%"></div>
</div>
<!-- Split 50/50 (details open) -->
<v-row v-else no-gutters class="h-100">
<v-col cols="6" class="h-100">
<div ref="kubeflowPieWrapRef" style="width: 100%; height: 100%">
<div
ref="kubeflowPieRef"
style="width: 100%; height: 100%"
></div>
</div>
</v-col>
<v-col cols="6" class="h-100">
<div
class="d-flex align-center ga-2"
style="margin-bottom: 8px"
>
<v-btn
v-if="kubeflowStatusFilter"
size="small"
variant="text"
@click="kubeflowStatusFilter = null"
>
Clear
</v-btn>
</div>
<div style="height: calc(100% - 36px); overflow: auto">
<v-list density="comfortable">
<v-list-item
v-for="kfRunRow in kubeflowRunsForList"
:key="kfRunRow.runId"
class="py-2"
>
<div
class="d-flex align-center justify-space-between w-100"
>
<div class="d-flex align-center ga-2">
<v-chip
size="small"
:color="
(kfRunRow.state || '')
.toUpperCase()
.includes('SUCCEED')
? 'success'
: 'error'
"
variant="tonal"
>
{{
(kfRunRow.state || "")
.toUpperCase()
.includes("SUCCEED")
? "Succeeded"
: "Failed"
}}
</v-chip>
<span
class="text-body-2 font-weight-medium truncate"
style="max-width: 220px"
>
{{ kfRunRow.name }}
</span>
</div>
<span class="text-caption text-grey-darken-1">
{{ fmtYmdHm(kfRunRow.createdAt) }}
</span>
</div>
</v-list-item>
<v-list-item v-if="kubeflowRunsForList.length === 0">
<template #title>
<div class="text-caption text-grey">
표시할 Run이 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-col>
</v-row>
</div>
</v-card>
</v-col>
<!-- Dataset (fixed height, inner scroll, compact) -->
<v-col cols="12" md="6">
<v-card class="pa-0 d-flex flex-column" :height="360">
<!-- Header (고정 높이) -->
<div
class="d-flex align-center justify-space-between w-100 pa-4 flex-shrink-0"
>
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Dataset Update Activity
</h3>
</div>
<v-divider />
9 months ago
<!-- Body (남은 영역만 스크롤) -->
<v-sheet class="pa-3 flex-grow-1 overflow-auto">
9 months ago
<v-skeleton-loader
v-if="dsLoading"
type="list-item-two-line"
9 months ago
class="mb-2"
v-for="i in 4"
9 months ago
:key="i"
/>
<v-expansion-panels v-else multiple variant="accordion">
<v-expansion-panel
v-for="g in groupSummaries"
:key="g.id"
@group:selected="
async (e: any) => {
if (e.value) await loadDatasetsForGroup(g.id, g.name);
}
"
>
<v-expansion-panel-title density="compact">
9 months ago
<div class="d-flex align-center ga-2">
<span class="font-weight-bold">{{ g.name }}</span>
<v-chip size="x-small" variant="tonal" color="secondary">
{{ (datasetsByGroup[g.id] || []).length }} datasets
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text density="compact" class="py-1">
<div v-if="groupLoading[g.id]" class="pa-2">
<v-skeleton-loader
type="list-item-three-line"
v-for="i in 2"
:key="i"
class="mb-2"
/>
9 months ago
</div>
<v-list
v-else-if="(datasetsByGroup[g.id] || []).length > 0"
density="compact"
9 months ago
>
<v-list-item
v-for="row in datasetsByGroup[g.id]"
:key="g.id + '-' + row.name"
density="compact"
class="py-0"
>
<div
class="d-flex justify-space-between align-center w-100"
>
<span
class="text-body-2 font-weight-medium d-flex align-center ga-2"
>
<span
class="text-truncate"
style="max-width: 260px"
>{{ row.name }}</span
>
<v-chip size="x-small" color="primary" variant="tonal"
>v{{ row.version }}</v-chip
>
</span>
<span class="text-caption text-medium-emphasis"
>Last: {{ fmtLocalDateTime(row.last) }}</span
>
</div>
<v-progress-linear
:model-value="row.pct"
height="4"
color="primary"
class="mt-1"
/>
<div class="text-caption text-grey mt-1">
{{ row.version }} Update{{ row.version > 1 ? "s" : "" }}
<span v-if="row.rows && row.rows !== row.version">
{{ row.rows }} Rows</span
>
</div>
</v-list-item>
</v-list>
9 months ago
<div v-else class="text-caption text-grey px-2 py-1">
그룹에 표시할 데이터셋이 없습니다.
9 months ago
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<div
v-if="!dsLoading && groupSummaries.length === 0"
class="text-caption text-grey px-2 py-1"
>
표시할 데이터그룹이 없습니다.
</div>
</v-expansion-panels>
</v-sheet>
</v-card>
</v-col>
</v-row>
<!-- Bottom: Model Deployment (demo) -->
<v-card class="rounded-lg pa-4 mt-4">
<div class="d-flex justify-space-between align-center mt-8 mb-2 px-2">
<div class="d-flex align-center">
<span class="text-subtitle-1 font-weight-bold">Model Deployment</span>
</div>
<v-btn
variant="text"
class="text-caption font-weight-bold"
append-icon="mdi-arrow-right"
style="text-transform: none"
>
Go to Model Deploy
</v-btn>
</div>
<v-col cols="12">
<v-sheet>
<v-table density="comfortable" fixed-header height="625">
<colgroup>
<col style="width: 5%" />
<col style="width: 10%" />
<col style="width: 10%" />
<col style="width: 10%" />
<col style="width: 10%" />
<col style="width: 10%" />
</colgroup>
<thead>
<tr>
<th></th>
<th class="font-weight-bold" style="word-break: keep-all">
Model Name
</th>
<th class="font-weight-bold" style="word-break: keep-all">
Version
</th>
<th class="font-weight-bold" style="word-break: keep-all">
Deployed At
</th>
<th class="font-weight-bold" style="word-break: keep-all">
Status
</th>
<th class="font-weight-bold" style="word-break: keep-all">
Download
</th>
</tr>
</thead>
<tbody class="text-body-2">
<tr>
<td></td>
<td>LaneDetectionModel</td>
<td>v1.2.0</td>
<td>2025-05-13 14:32</td>
<td>Active</td>
<td>Finished</td>
</tr>
<tr>
<td></td>
<td>TrafficSignClassifier</td>
<td>v0.9.3</td>
<td>2025-05-13 09:00</td>
<td>Pending</td>
<td>-</td>
</tr>
<tr>
<td></td>
<td>PathPlannerModel</td>
<td>v2.0.1</td>
<td>2025-05-12 17:44</td>
<td>Failed</td>
<td>Failed</td>
</tr>
</tbody>
</v-table>
</v-sheet>
</v-col>
</v-card>
</v-container>
</template>
<style scoped>
ul {
list-style: none;
padding-left: 0;
margin-top: 8px;
}
li {
margin-bottom: 8px;
}
</style>