|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { onMounted, ref, computed, watch } from "vue";
|
|
|
|
|
import Plotly from "plotly.js-dist-min";
|
|
|
|
|
import { WorkflowService } from "@/components/service/management/WorkflowService";
|
|
|
|
|
import { ExecutionsService } from "@/components/service/management/ExecutionsService";
|
|
|
|
|
import { AttachmentsService } from "@/components/service/management/AttachmentsService";
|
|
|
|
|
import { useAutoflowStore } from "@/stores/autoflowStore";
|
|
|
|
|
|
|
|
|
|
const store = useAutoflowStore();
|
|
|
|
|
const currentProjectId = computed(() => store.projectId);
|
|
|
|
|
|
|
|
|
|
const pieChartRef = ref<HTMLElement | null>(null);
|
|
|
|
|
const workflows = ref<any[]>([]);
|
|
|
|
|
const recentLimit = 10;
|
|
|
|
|
const runsLoading = ref(false);
|
|
|
|
|
const recentRuns = ref<
|
|
|
|
|
{
|
|
|
|
|
name: string;
|
|
|
|
|
status: "success" | "failed" | "running" | "pending";
|
|
|
|
|
time: string;
|
|
|
|
|
}[]
|
|
|
|
|
>([]);
|
|
|
|
|
// dataset
|
|
|
|
|
const dsLoading = ref(false);
|
|
|
|
|
type DsItem = {
|
|
|
|
|
name: string;
|
|
|
|
|
version: number;
|
|
|
|
|
rows: number;
|
|
|
|
|
last?: string;
|
|
|
|
|
pct: number;
|
|
|
|
|
};
|
|
|
|
|
const dsItems = ref<DsItem[]>([]);
|
|
|
|
|
const dsWindowDays = ref<7 | 30 | 90>(30);
|
|
|
|
|
|
|
|
|
|
// 날짜 유틸
|
|
|
|
|
const toTime = (x?: string) => new Date(x ?? 0).getTime();
|
|
|
|
|
const fmtYmd = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "-");
|
|
|
|
|
|
|
|
|
|
async function loadDatasetActivity() {
|
|
|
|
|
dsLoading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const payload = {
|
|
|
|
|
projectId: currentProjectId.value,
|
|
|
|
|
page: 0,
|
|
|
|
|
size: 1000,
|
|
|
|
|
refType: "DATASET",
|
|
|
|
|
sortField: "id",
|
|
|
|
|
sortDirection: "DESC",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const res = await AttachmentsService.search(payload as any);
|
|
|
|
|
const list: any[] = res?.data?.content ?? res?.data ?? [];
|
|
|
|
|
|
|
|
|
|
const map = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{ name: string; version: number; rows: number; last?: string }
|
|
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
for (const a of list) {
|
|
|
|
|
const name = String(a?.title ?? a?.originalName ?? "(no name)");
|
|
|
|
|
const v = Number(a?.version) || 1; // null 방어
|
|
|
|
|
const reg = (a?.modDt ?? a?.regDt ?? a?.createdAt) as string | undefined;
|
|
|
|
|
|
|
|
|
|
const cur = map.get(name) ?? {
|
|
|
|
|
name,
|
|
|
|
|
version: 0,
|
|
|
|
|
rows: 0,
|
|
|
|
|
last: undefined,
|
|
|
|
|
};
|
|
|
|
|
cur.rows += 1;
|
|
|
|
|
cur.version = Math.max(cur.version, v); // 최신 버전 반영
|
|
|
|
|
if (!cur.last || (reg && toTime(reg) > toTime(cur.last))) cur.last = reg;
|
|
|
|
|
map.set(name, cur);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const arr = Array.from(map.values());
|
|
|
|
|
const maxVer = Math.max(1, ...arr.map((i) => i.version));
|
|
|
|
|
|
|
|
|
|
dsItems.value = arr
|
|
|
|
|
.map((i) => ({ ...i, pct: Math.round((i.version / maxVer) * 100) }))
|
|
|
|
|
.sort((a, b) => toTime(b.last) - toTime(a.last) || b.version - a.version);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("[Home] loadDatasetActivity error:", e);
|
|
|
|
|
dsItems.value = [];
|
|
|
|
|
} finally {
|
|
|
|
|
dsLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toUiStatus(state?: string) {
|
|
|
|
|
switch ((state || "").toUpperCase()) {
|
|
|
|
|
case "SUCCEEDED":
|
|
|
|
|
return "success";
|
|
|
|
|
case "FAILED":
|
|
|
|
|
return "failed";
|
|
|
|
|
case "RUNNING":
|
|
|
|
|
return "running";
|
|
|
|
|
case "PENDING":
|
|
|
|
|
return "pending";
|
|
|
|
|
default:
|
|
|
|
|
return "pending";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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())}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadRecentRuns() {
|
|
|
|
|
runsLoading.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const all: any[] = [];
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
|
|
|
|
// 첫 페이지
|
|
|
|
|
let page = await ExecutionsService.search({ pageSize: 200 });
|
|
|
|
|
all.push(...(page?.data?.runs ?? []));
|
|
|
|
|
let token = page?.data?.next_page_token ?? page?.data?.nextPageToken;
|
|
|
|
|
|
|
|
|
|
// 다음 페이지들(필요 시)
|
|
|
|
|
let guard = 0;
|
|
|
|
|
while (token && !seen.has(token) && guard < 20) {
|
|
|
|
|
seen.add(token);
|
|
|
|
|
|
|
|
|
|
// snake_case 우선
|
|
|
|
|
page = await ExecutionsService.search({
|
|
|
|
|
pageToken: token,
|
|
|
|
|
pageSize: 200,
|
|
|
|
|
} as any);
|
|
|
|
|
all.push(...(page?.data?.runs ?? []));
|
|
|
|
|
token = page?.data?.next_page_token ?? page?.data?.nextPageToken;
|
|
|
|
|
guard++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 중복 제거
|
|
|
|
|
const dedup = Array.from(
|
|
|
|
|
new Map(all.map((r) => [r?.run_id ?? r?.id ?? r?.name, r])).values(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// (선택) 프로젝트 필터가 필요하면 여기에서 필터
|
|
|
|
|
// const filtered = dedup.filter(r => String(r?.projectId ?? "") === String(currentProjectId.value));
|
|
|
|
|
|
|
|
|
|
const latest = dedup
|
|
|
|
|
.sort(
|
|
|
|
|
(a, b) =>
|
|
|
|
|
new Date(b?.created_at ?? 0).getTime() -
|
|
|
|
|
new Date(a?.created_at ?? 0).getTime(),
|
|
|
|
|
)
|
|
|
|
|
.slice(0, recentLimit)
|
|
|
|
|
.map((r) => ({
|
|
|
|
|
name: r?.display_name ?? r?.name ?? r?.run_id ?? "(no name)",
|
|
|
|
|
status: toUiStatus(r?.state),
|
|
|
|
|
time: fmtYmdHm(r?.created_at),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
recentRuns.value = latest;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error("[Home] loadRecentRuns error:", e);
|
|
|
|
|
recentRuns.value = [];
|
|
|
|
|
} finally {
|
|
|
|
|
runsLoading.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderStatusPie() {
|
|
|
|
|
if (!pieChartRef.value) return;
|
|
|
|
|
|
|
|
|
|
// 1) 상태별 고정 팔레트
|
|
|
|
|
const COLOR = {
|
|
|
|
|
SUCCEEDED: "#2ecc71",
|
|
|
|
|
FAILED: "#e74c3c",
|
|
|
|
|
RUNNING: "#3498db",
|
|
|
|
|
PENDING: "#f1c40f",
|
|
|
|
|
CREATED: "#3498db",
|
|
|
|
|
SKIPPED: "#95a5a6",
|
|
|
|
|
UNKNOWN: "#7f8c8d",
|
|
|
|
|
NODATA: "#4b4f55",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 2) 집계
|
|
|
|
|
const counts = new Map<string, number>();
|
|
|
|
|
for (const wf of workflows.value ?? []) {
|
|
|
|
|
const raw = (wf?.kubeflowStatus ?? wf?.kubeflow_status ?? "UNKNOWN") + "";
|
|
|
|
|
const status = raw.toUpperCase().trim();
|
|
|
|
|
counts.set(status, (counts.get(status) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3) No Data 전용 처리 (중립 색 + 중앙 텍스트 / 범례 숨김)
|
|
|
|
|
if (counts.size === 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: "#ffffff", size: 16 },
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Plotly.react(pieChartRef.value, [trace], layout, { displayModeBar: false });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4) 데이터가 있을 때: 상태별 색 지정
|
|
|
|
|
const labels = Array.from(counts.keys());
|
|
|
|
|
const values = Array.from(counts.values());
|
|
|
|
|
const colors = labels.map((s) => {
|
|
|
|
|
if (s.includes("SUCCEED")) return COLOR.SUCCEEDED;
|
|
|
|
|
if (s.includes("FAIL")) return COLOR.FAILED;
|
|
|
|
|
if (s.includes("RUN")) return COLOR.RUNNING;
|
|
|
|
|
if (s.includes("CREATE")) return COLOR.CREATED;
|
|
|
|
|
if (s.includes("PEND")) return COLOR.PENDING;
|
|
|
|
|
if (s.includes("SKIP")) return COLOR.SKIPPED;
|
|
|
|
|
return COLOR.UNKNOWN;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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(pieChartRef.value, [trace], layout, { displayModeBar: false });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const datasetUpdates = [
|
|
|
|
|
{ name: "DrivingLog2025", count: 7 },
|
|
|
|
|
{ name: "CameraFrames", count: 3 },
|
|
|
|
|
{ name: "LidarScans", count: 2 },
|
|
|
|
|
{ name: "Traffic_log", count: 2 },
|
|
|
|
|
{ name: "Traffic_log2", count: 2 },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const tableHeader = [
|
|
|
|
|
{ label: "Model Name", width: "10%", style: "word-break: keep-all;" },
|
|
|
|
|
{ label: "Version", width: "10%", style: "word-break: keep-all;" },
|
|
|
|
|
{ label: "Deployed At", width: "10%", style: "word-break: keep-all;" },
|
|
|
|
|
{ label: "Status", width: "10%", style: "word-break: keep-all;" },
|
|
|
|
|
{ label: "Download", width: "10%", style: "word-break: keep-all;" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const data = ref({
|
|
|
|
|
results: [
|
|
|
|
|
{
|
|
|
|
|
deviceKey: "1",
|
|
|
|
|
name: "LaneDetectionModel",
|
|
|
|
|
version: "v1.2.0",
|
|
|
|
|
time: "2025-05-13 14:32",
|
|
|
|
|
status: "Active",
|
|
|
|
|
download: "Finished",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
deviceKey: "2",
|
|
|
|
|
name: "TrafficSignClassifier",
|
|
|
|
|
version: "v0.9.3",
|
|
|
|
|
time: "2025-05-13 09:00",
|
|
|
|
|
status: "Pending",
|
|
|
|
|
download: "-",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
deviceKey: "3",
|
|
|
|
|
name: "PathPlannerModel",
|
|
|
|
|
version: "v2.0.1",
|
|
|
|
|
time: "2025-05-12 17:44",
|
|
|
|
|
status: "Failed",
|
|
|
|
|
download: "Failed",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
allSelected: false,
|
|
|
|
|
selected: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleRefresh = () => {
|
|
|
|
|
alert("Refresh 작업 진행중...");
|
|
|
|
|
};
|
|
|
|
|
const getSelectedAllData = () => {
|
|
|
|
|
data.value.selected = data.value.allSelected
|
|
|
|
|
? data.value.results.map(({ deviceKey }) => ({ deviceKey }))
|
|
|
|
|
: [];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getLatestTimestamp = (wf: any): string => wf.modDt;
|
|
|
|
|
|
|
|
|
|
// "YYYY-MM-DD HH:mm" 포맷
|
|
|
|
|
const formatToYmdHm = (isoString: string): string => {
|
|
|
|
|
if (!isoString) return "-";
|
|
|
|
|
const d = new Date(isoString);
|
|
|
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
|
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const recentWorkflowList = computed(() => {
|
|
|
|
|
return (workflows.value ?? [])
|
|
|
|
|
.map((wf: any) => ({
|
|
|
|
|
id: wf.id,
|
|
|
|
|
title: wf.name,
|
|
|
|
|
timestamp: getLatestTimestamp(wf),
|
|
|
|
|
}))
|
|
|
|
|
.filter((item) => item.title && item.timestamp)
|
|
|
|
|
.sort(
|
|
|
|
|
(a, b) =>
|
|
|
|
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
|
|
|
|
)
|
|
|
|
|
.slice(0, recentLimit);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---- 데이터 로드 & 차트 갱신 ----
|
|
|
|
|
async function loadWorkflows() {
|
|
|
|
|
try {
|
|
|
|
|
const payload = {
|
|
|
|
|
page: 0,
|
|
|
|
|
size: 1000,
|
|
|
|
|
projectId: currentProjectId.value,
|
|
|
|
|
sortField: "id",
|
|
|
|
|
sortDirection: "DESC",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const res = await WorkflowService.search(payload);
|
|
|
|
|
const raw = Array.isArray(res?.data?.content)
|
|
|
|
|
? res.data.content
|
|
|
|
|
: Array.isArray(res?.data)
|
|
|
|
|
? res.data
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
workflows.value = raw.filter(
|
|
|
|
|
(wf: any) =>
|
|
|
|
|
String(
|
|
|
|
|
wf?.projectId ?? wf?.prjId ?? wf?.project_id ?? wf?.project?.id ?? "",
|
|
|
|
|
) === String(currentProjectId.value),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
renderStatusPie();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("GET /api/workflows failed:", err);
|
|
|
|
|
workflows.value = [];
|
|
|
|
|
renderStatusPie();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
renderStatusPie();
|
|
|
|
|
await loadRecentRuns();
|
|
|
|
|
await loadWorkflows();
|
|
|
|
|
await loadDatasetActivity();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 프로젝트 변경 시 재조회
|
|
|
|
|
watch(currentProjectId, () => loadWorkflows());
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<v-container fluid>
|
|
|
|
|
<div class="d-flex justify-space-between align-center mb-6">
|
|
|
|
|
<h2 class="text-h6 font-weight-bold">배터리 상태 예측 모델 프로젝트</h2>
|
|
|
|
|
<v-btn color="primary" prepend-icon="mdi-refresh" @click="handleRefresh"
|
|
|
|
|
>Refresh</v-btn
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-row>
|
|
|
|
|
<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">
|
|
|
|
|
Workflow Success Rate
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="overflow-y: auto; padding: 8px 16px">
|
|
|
|
|
<div ref="pieChartRef" style="height: 280px"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-col>
|
|
|
|
|
<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="item in recentWorkflowList" :key="item.id">
|
|
|
|
|
<template #title>
|
|
|
|
|
<div class="d-flex justify-space-between align-center w-100">
|
|
|
|
|
<span class="text-body-2 font-weight-medium">{{
|
|
|
|
|
item.title
|
|
|
|
|
}}</span>
|
|
|
|
|
<span class="text-caption text-grey-lighten-1">
|
|
|
|
|
{{ formatToYmdHm(item.timestamp) }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</v-list-item>
|
|
|
|
|
|
|
|
|
|
<v-list-item v-if="recentWorkflowList.length === 0">
|
|
|
|
|
<template #title>
|
|
|
|
|
<div class="text-caption text-grey">
|
|
|
|
|
최근 등록/수정된 워크플로우가 없습니다.
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</v-list-item>
|
|
|
|
|
</v-list>
|
|
|
|
|
</div>
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
<v-row class="mt-4">
|
|
|
|
|
<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="(run, idx) in recentRuns"
|
|
|
|
|
:key="idx"
|
|
|
|
|
class="py-2"
|
|
|
|
|
>
|
|
|
|
|
<!-- 기존 div 대신 이 블록으로 교체 -->
|
|
|
|
|
<div class="d-flex align-center justify-space-between w-100">
|
|
|
|
|
<!-- 왼쪽: 상태칩 + (선택) 아이콘 -->
|
|
|
|
|
<div class="d-flex align-center ga-2">
|
|
|
|
|
<!-- 아이콘을 계속 쓰고 싶으면 유지, 아니면 이 v-avatar는 삭제해도 됨 -->
|
|
|
|
|
<v-avatar
|
|
|
|
|
size="28"
|
|
|
|
|
:color="
|
|
|
|
|
run.status === 'success'
|
|
|
|
|
? 'green-lighten-1'
|
|
|
|
|
: run.status === 'failed'
|
|
|
|
|
? 'red-lighten-1'
|
|
|
|
|
: run.status === 'running'
|
|
|
|
|
? 'blue-lighten-1'
|
|
|
|
|
: 'grey-darken-1'
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
<v-icon size="20" color="white">
|
|
|
|
|
{{
|
|
|
|
|
run.status === "success"
|
|
|
|
|
? "mdi-check"
|
|
|
|
|
: run.status === "failed"
|
|
|
|
|
? "mdi-close"
|
|
|
|
|
: run.status === "running"
|
|
|
|
|
? "mdi-progress-clock"
|
|
|
|
|
: "mdi-clock-outline"
|
|
|
|
|
}}
|
|
|
|
|
</v-icon>
|
|
|
|
|
</v-avatar>
|
|
|
|
|
<v-chip
|
|
|
|
|
size="small"
|
|
|
|
|
:color="
|
|
|
|
|
run.status === 'success'
|
|
|
|
|
? 'success'
|
|
|
|
|
: run.status === 'failed'
|
|
|
|
|
? 'error'
|
|
|
|
|
: run.status === 'running'
|
|
|
|
|
? 'info'
|
|
|
|
|
: 'grey'
|
|
|
|
|
"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
class="text-uppercase"
|
|
|
|
|
>
|
|
|
|
|
{{ run.status }}
|
|
|
|
|
</v-chip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 오른쪽: 이름/시간(오른쪽 정렬) -->
|
|
|
|
|
<div
|
|
|
|
|
class="d-flex flex-column text-right"
|
|
|
|
|
style="min-width: 220px"
|
|
|
|
|
>
|
|
|
|
|
<span class="font-weight-medium text-body-2 truncate">
|
|
|
|
|
{{ run.name }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="text-caption text-grey-darken-1">
|
|
|
|
|
{{ run.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>
|
|
|
|
|
<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">
|
|
|
|
|
<div class="d-flex align-center justify-space-between w-100">
|
|
|
|
|
<h3 class="text-subtitle-1 font-weight-bold mb-0">
|
|
|
|
|
Dataset Update Activity
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
|
|
|
|
|
<v-skeleton-loader
|
|
|
|
|
v-if="dsLoading"
|
|
|
|
|
type="list-item-three-line"
|
|
|
|
|
class="mb-2"
|
|
|
|
|
v-for="i in 5"
|
|
|
|
|
:key="i"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<v-list v-else density="comfortable">
|
|
|
|
|
<v-list-item v-for="it in dsItems" :key="it.name" class="py-2">
|
|
|
|
|
<div class="d-flex justify-space-between align-center w-100">
|
|
|
|
|
<span class="text-body-2 font-weight-medium"
|
|
|
|
|
>{{ it.name }}
|
|
|
|
|
<v-chip
|
|
|
|
|
size="small"
|
|
|
|
|
color="primary"
|
|
|
|
|
variant="tonal"
|
|
|
|
|
class="py-2"
|
|
|
|
|
>
|
|
|
|
|
v{{ it.version }}
|
|
|
|
|
</v-chip></span
|
|
|
|
|
>
|
|
|
|
|
|
|
|
|
|
<!-- 최신 버전 칩 -->
|
|
|
|
|
<div class="d-flex align-center ga-2">
|
|
|
|
|
<span class="text-caption text-medium-emphasis">
|
|
|
|
|
Last: {{ fmtYmd(it.last) }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<v-progress-linear
|
|
|
|
|
:model-value="it.pct"
|
|
|
|
|
height="8"
|
|
|
|
|
color="primary"
|
|
|
|
|
class="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
<div class="text-caption text-grey mt-1">
|
|
|
|
|
{{ it.version }} Update{{ it.version > 1 ? "s" : "" }}
|
|
|
|
|
<span v-if="it.rows && it.rows !== it.version">
|
|
|
|
|
• {{ it.rows }} Rows</span
|
|
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
</v-list-item>
|
|
|
|
|
|
|
|
|
|
<v-list-item v-if="!dsLoading && dsItems.length === 0">
|
|
|
|
|
<template #title>
|
|
|
|
|
<div class="text-caption text-grey">
|
|
|
|
|
표시할 데이터셋이 없습니다.
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</v-list-item>
|
|
|
|
|
</v-list>
|
|
|
|
|
</div>
|
|
|
|
|
</v-card>
|
|
|
|
|
</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
<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
|
|
|
|
|
v-for="(item, i) in tableHeader"
|
|
|
|
|
:key="i"
|
|
|
|
|
:style="`width:${item.width}`"
|
|
|
|
|
/>
|
|
|
|
|
</colgroup>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>
|
|
|
|
|
<v-checkbox
|
|
|
|
|
v-model="data.allSelected"
|
|
|
|
|
style="min-width: 36px"
|
|
|
|
|
:indeterminate="data.allSelected === true"
|
|
|
|
|
hide-details
|
|
|
|
|
@change="getSelectedAllData"
|
|
|
|
|
></v-checkbox>
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
v-for="(item, i) in tableHeader"
|
|
|
|
|
:key="i"
|
|
|
|
|
class="text-center font-weight-bold"
|
|
|
|
|
:style="item.style"
|
|
|
|
|
>
|
|
|
|
|
{{ item.label }}
|
|
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody class="text-body-2">
|
|
|
|
|
<tr
|
|
|
|
|
v-for="(item, i) in data.results"
|
|
|
|
|
:key="i"
|
|
|
|
|
class="text-center"
|
|
|
|
|
>
|
|
|
|
|
<td>
|
|
|
|
|
<v-checkbox
|
|
|
|
|
v-model="data.selected"
|
|
|
|
|
hide-details
|
|
|
|
|
:value="{ deviceKey: item.deviceKey }"
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{{ item.name }}</td>
|
|
|
|
|
<td>{{ item.version }}</td>
|
|
|
|
|
<td>{{ item.time }}</td>
|
|
|
|
|
<td>{{ item.status }}</td>
|
|
|
|
|
<td>{{ item.download }}</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>
|