|
|
|
|
<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";
|
|
|
|
|
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;
|
|
|
|
|
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;
|
|
|
|
|
try {
|
|
|
|
|
const res = await AttachmentsService.search({
|
|
|
|
|
projectId: currentProjectId.value,
|
|
|
|
|
page: 0,
|
|
|
|
|
size: 200,
|
|
|
|
|
refType: "DATASET",
|
|
|
|
|
refId: groupId,
|
|
|
|
|
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,
|
|
|
|
|
name,
|
|
|
|
|
version: 0,
|
|
|
|
|
rows: 0,
|
|
|
|
|
last: undefined,
|
|
|
|
|
pct: 0,
|
|
|
|
|
};
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("[Dashboard] loadDatasetsForGroup error:", e);
|
|
|
|
|
datasetsByGroup.value[groupId] = [];
|
|
|
|
|
} finally {
|
|
|
|
|
groupLoading.value[groupId] = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mapRawStateToUiStatus(raw?: string): UiStatus {
|
|
|
|
|
switch ((raw || "").toUpperCase()) {
|
|
|
|
|
case "SUCCEEDED":
|
|
|
|
|
return "success";
|
|
|
|
|
case "FAILED":
|
|
|
|
|
return "failed";
|
|
|
|
|
case "RUNNING":
|
|
|
|
|
return "running";
|
|
|
|
|
case "PENDING":
|
|
|
|
|
case "QUEUED":
|
|
|
|
|
case "SCHEDULED":
|
|
|
|
|
return "pending";
|
|
|
|
|
default:
|
|
|
|
|
return "pending";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toEpoch = (iso?: string) => new Date(iso ?? 0).getTime();
|
|
|
|
|
const fmtLocalDateTime = (iso?: string) =>
|
|
|
|
|
iso ? new Date(iso).toLocaleString() : "-";
|
|
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function loadRecentRuns() {
|
|
|
|
|
runsLoading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const allRuns: any[] = [];
|
|
|
|
|
const seenTokens = new Set<string>();
|
|
|
|
|
|
|
|
|
|
let page = await ExecutionsService.search({ pageSize: 200 });
|
|
|
|
|
allRuns.push(...(page?.data?.runs ?? []));
|
|
|
|
|
let token = page?.data?.next_page_token ?? page?.data?.nextPageToken;
|
|
|
|
|
|
|
|
|
|
let guard = 0;
|
|
|
|
|
while (token && !seenTokens.has(token) && guard < 20) {
|
|
|
|
|
seenTokens.add(token);
|
|
|
|
|
page = await ExecutionsService.search({
|
|
|
|
|
pageToken: token,
|
|
|
|
|
pageSize: 200,
|
|
|
|
|
} as any);
|
|
|
|
|
allRuns.push(...(page?.data?.runs ?? []));
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
recentRuns.value = deduped
|
|
|
|
|
.sort((a, b) => toEpoch(b?.created_at) - toEpoch(a?.created_at))
|
|
|
|
|
.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),
|
|
|
|
|
}));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("[Dashboard] loadRecentRuns error:", err);
|
|
|
|
|
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) {
|
|
|
|
|
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 },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
Plotly.react(container, [trace], layout, { displayModeBar: false });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const labels = keys;
|
|
|
|
|
const values = labels.map((k) => counts[k]);
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const trace: Partial<Plotly.PlotData> = {
|
|
|
|
|
values,
|
|
|
|
|
labels,
|
|
|
|
|
type: "pie",
|
|
|
|
|
hole: 0.4,
|
|
|
|
|
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();
|
|
|
|
|
await loadRecentRuns();
|
|
|
|
|
await loadWorkflows();
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
|
|
|
|
|
<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"
|
|
|
|
|
>
|
|
|
|
|
<div class="d-flex align-center justify-space-between w-100">
|
|
|
|
|
<!-- left: icon + status -->
|
|
|
|
|
<div class="d-flex align-center ga-2">
|
|
|
|
|
<v-avatar
|
|
|
|
|
size="28"
|
|
|
|
|
:color="avatarColorByUiStatus(runRow.status)"
|
|
|
|
|
>
|
|
|
|
|
<v-icon size="20" color="white">
|
|
|
|
|
{{ avatarIconByUiStatus(runRow.status) }}
|
|
|
|
|
</v-icon>
|
|
|
|
|
</v-avatar>
|
|
|
|
|
<v-chip
|
|
|
|
|
size="small"
|
|
|
|
|
:color="colorChipByUiStatus(runRow.status)"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
class="text-uppercase"
|
|
|
|
|
>
|
|
|
|
|
{{ STATUS_LABEL[runRow.status] }}
|
|
|
|
|
</v-chip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- right: name & time -->
|
|
|
|
|
<div
|
|
|
|
|
class="d-flex flex-column text-right"
|
|
|
|
|
style="min-width: 220px"
|
|
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<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 />
|
|
|
|
|
|
|
|
|
|
<!-- Body (남은 영역만 스크롤) -->
|
|
|
|
|
<v-sheet class="pa-3 flex-grow-1 overflow-auto">
|
|
|
|
|
<v-skeleton-loader
|
|
|
|
|
v-if="dsLoading"
|
|
|
|
|
type="list-item-two-line"
|
|
|
|
|
class="mb-2"
|
|
|
|
|
v-for="i in 4"
|
|
|
|
|
: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">
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-list
|
|
|
|
|
v-else-if="(datasetsByGroup[g.id] || []).length > 0"
|
|
|
|
|
density="compact"
|
|
|
|
|
>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<div v-else class="text-caption text-grey px-2 py-1">
|
|
|
|
|
이 그룹에 표시할 데이터셋이 없습니다.
|
|
|
|
|
</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>
|