diff --git a/components.d.ts b/components.d.ts index e95cea5..3e88451 100644 --- a/components.d.ts +++ b/components.d.ts @@ -10,12 +10,15 @@ declare module 'vue' { export interface GlobalComponents { AppFooter: typeof import('./src/components/AppFooter.vue')['default'] CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default'] + copy: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog copy.vue')['default'] DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default'] DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default'] DeploymentDialog: typeof import('./src/components/atoms/organisms/DeploymentDialog.vue')['default'] DrawerComponent: typeof import('./src/components/common/DrawerComponent.vue')['default'] ExecutionBaseDialog: typeof import('./src/components/atoms/organisms/ExecutionBaseDialog.vue')['default'] + ExecutionsViewComponent: typeof import('./src/components/templates/run/executions/ExecutionsViewComponent.vue')['default'] ExperimentCreateDialog: typeof import('./src/components/atoms/organisms/ExperimentCreateDialog.vue')['default'] + ExperimentViewComponent: typeof import('./src/components/templates/run/experiment/ExperimentViewComponent.vue')['default'] HelloWorld: typeof import('./src/components/HelloWorld.vue')['default'] IconArrowDown: typeof import('./src/components/atoms/button/IconArrowDown.vue')['default'] IconArrowUp: typeof import('./src/components/atoms/button/IconArrowUp.vue')['default'] @@ -32,7 +35,9 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default'] + TrainingGroupBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingGroupBaseDoalog.vue')['default'] TrainingScriptBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingScriptBaseDoalog.vue')['default'] + TrainingScriptgroupBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingScriptgroupBaseDoalog.vue')['default'] ViewComponent: typeof import('./src/components/templates/Datasets/ViewComponent.vue')['default'] WorkflowDialog: typeof import('./src/components/atoms/organisms/WorkflowDialog.vue')['default'] WorkflowsBaseDialog: typeof import('./src/components/atoms/organisms/WorkflowsBaseDialog.vue')['default'] diff --git a/package-lock.json b/package-lock.json index a7f125a..32292c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,10 @@ "axios": "^1.11.0", "dayjs": "^1.11.18", "monaco-editor": "^0.52.2", - "plotly.js-dist-min": "^3.0.1", + "plotly.js-dist-min": "^3.1.1", "prettier": "^3.5.3", "vue": "^3.5.13", + "vue3-plotly": "^0.0.7", "vuetify": "^3.8.1" }, "devDependencies": { @@ -4759,10 +4760,16 @@ "pathe": "^2.0.3" } }, + "node_modules/plotly.js-dist": { + "version": "2.35.3", + "resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.35.3.tgz", + "integrity": "sha512-dqB9+FUyBFZN04xWnZoYwaeeF4Jj9T/m0CHYmoozmPC3R4Dy0TRJsHgbRVLPxgYQqodzniVUj17+2wmJuGaZAg==", + "license": "MIT" + }, "node_modules/plotly.js-dist-min": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-3.0.1.tgz", - "integrity": "sha512-RReOqr6TfoHaTbVAoHR1UbTCOSRDsQ7Hbthd+3XAxOwaKmxCE3oejMhLG7urQSqWC65DAcSKV23kZd8e+7mG7w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-3.1.1.tgz", + "integrity": "sha512-eyuiESylUXW4kaF+v9J2gy9eZ+YT2uSVLILM4w1Afxnuv9u4UX9OnZnHR1OdF9ybq4x7+9chAzWUUbQ6HvBb3g==", "license": "MIT" }, "node_modules/pluralize": { @@ -6452,6 +6459,18 @@ "dev": true, "license": "MIT" }, + "node_modules/vue3-plotly": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/vue3-plotly/-/vue3-plotly-0.0.7.tgz", + "integrity": "sha512-ydNOgbQlmg4Zp1etfUVsCKO4ejp/18oZ1pYr0b+pnhwYyx2BNZ+UyuK2PKKRwNdPSpohUBTDml3mhHiF54zVgQ==", + "license": "ISC", + "dependencies": { + "plotly.js-dist": "^2.24.2" + }, + "peerDependencies": { + "vue": "^3.2.21" + } + }, "node_modules/vuetify": { "version": "3.8.9", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.8.9.tgz", diff --git a/package.json b/package.json index a9dfc4b..3614e14 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "axios": "^1.11.0", "dayjs": "^1.11.18", "monaco-editor": "^0.52.2", - "plotly.js-dist-min": "^3.0.1", + "plotly.js-dist-min": "^3.1.1", "prettier": "^3.5.3", "vue": "^3.5.13", + "vue3-plotly": "^0.0.7", "vuetify": "^3.8.1" }, "devDependencies": { diff --git a/src/App.vue b/src/App.vue index ecbb947..a944bc3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,3 +7,17 @@ + + diff --git a/src/components/atoms/organisms/DatagroupBaseDoalog.vue b/src/components/atoms/organisms/DatagroupBaseDoalog.vue index 7ac1510..00cb59c 100644 --- a/src/components/atoms/organisms/DatagroupBaseDoalog.vue +++ b/src/components/atoms/organisms/DatagroupBaseDoalog.vue @@ -100,8 +100,8 @@ async function submit() { modUserId: userId, modUserNm: username, + refType: "DATASET", }; - console.log(id); const { data } = await DataGroupService.update(id, updatePayload); emit("saved", data); @@ -113,6 +113,7 @@ async function submit() { regUserId: userId, regUserNm: username, projectId: projectId.value!, + refType: "DATASET", }; const { data } = await DataGroupService.add(createPayload); emit("saved", data); diff --git a/src/components/atoms/organisms/TrainingGroupBaseDoalog.vue b/src/components/atoms/organisms/TrainingGroupBaseDoalog.vue new file mode 100644 index 0000000..1245705 --- /dev/null +++ b/src/components/atoms/organisms/TrainingGroupBaseDoalog.vue @@ -0,0 +1,212 @@ + + + diff --git a/src/components/common/DrawerComponent.vue b/src/components/common/DrawerComponent.vue index c026f39..9689230 100644 --- a/src/components/common/DrawerComponent.vue +++ b/src/components/common/DrawerComponent.vue @@ -3,7 +3,6 @@ import { ref, onMounted, computed } from "vue"; import { useRoute, useRouter } from "vue-router"; import { menuUtils } from "@/utils/menuUtils"; import { storage } from "@/utils/storage"; -import SidebarHeader from "@/components/common/SidebarHeader.vue"; const route = useRoute(); const router = useRouter(); @@ -77,7 +76,7 @@ onMounted(() => { :title="t" :value="t" :to="p" - :active-color="isLinkActive(p) ? 'primary' : null" + :color="isLinkActive(p) ? 'primary' : null" :active="isLinkActive(p)" class="pa-2 rounded-lg" > diff --git a/src/components/service/index.ts b/src/components/service/index.ts index 394712d..c9f196d 100644 --- a/src/components/service/index.ts +++ b/src/components/service/index.ts @@ -6,7 +6,6 @@ import router from "@/router"; const loading = loadingStore(); const API_URL = import.meta.env.VITE_APP_API_SERVER_URL; -console.log("API URL:", API_URL); export const request = { post: (uri: string, param: any): any => { return axios.post(`${API_URL}${uri}`, param); @@ -105,9 +104,6 @@ axios.interceptors.request.use( }, (error) => { loading.setLoading(false); - - console.log("request error", error); - const store = commonStore(); store.setSnackbarMsg({ text: "에러가 발생하였습니다.", diff --git a/src/components/service/management/kubeflowService.ts b/src/components/service/management/kubeflowService.ts index d27d7a7..8cbe5d5 100644 --- a/src/components/service/management/kubeflowService.ts +++ b/src/components/service/management/kubeflowService.ts @@ -12,4 +12,7 @@ export const KubeflowService = { pageSize?: number; pageToken?: string; }) => request.get("/api/kubeflow/experiments", params), + experimentData: (experimentId: string) => { + return request.get(`/api/kubeflow/experiments/${experimentId}`, {}); + }, }; diff --git a/src/components/service/mlflow/MlflowService.ts b/src/components/service/mlflow/MlflowService.ts new file mode 100644 index 0000000..05bc9d2 --- /dev/null +++ b/src/components/service/mlflow/MlflowService.ts @@ -0,0 +1,20 @@ +import { request } from "@/components/service/index"; + +export const MlflowService = { + getRuns: (experimentId: string) => { + return request.get("/api/mlflow/runs", { + experimentId, + }); + }, + + getExperimentByName: (experimentName: string) => { + return request.get("/api/mlflow/experiment", { + experimentName, + }); + }, + getExperimentRun: (runId: string) => { + return request.get("/api/mlflow/run", { + runId, + }); + }, +}; diff --git a/src/components/templates/datagroup/ListComponent.vue b/src/components/templates/datagroup/ListComponent.vue index 478ec53..5d00678 100644 --- a/src/components/templates/datagroup/ListComponent.vue +++ b/src/components/templates/datagroup/ListComponent.vue @@ -153,6 +153,7 @@ async function fetchList() { size: reqSize, keyword, searchType: mapped, + refType: "DATASET", }; const res: any = await DataGroupService.search(payload); if (res?.status !== 200) return; @@ -508,7 +509,6 @@ onMounted(fetchList); @click.stop @mousedown.stop > - { async function loadRecentRuns() { runsLoading.value = true; try { - const allRuns: any[] = []; - const seenTokens = new Set(); - - 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++; - } + // Kubeflow에서 전체 Run을 가져와서 최근순 상위 N개를 Recent Run으로 사용 + const res = await KubeflowRunService.getAll(); + const list: any[] = Array.isArray(res?.data) ? res.data : []; - // Map → Record (중복 제거) - const dict: Record = {}; - 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((run) => ({ - name: run?.display_name ?? run?.name ?? run?.run_id ?? "(no name)", - status: mapRawStateToUiStatus(run?.state), - time: fmtYmdHm(run?.created_at), - })); + // 필요한 필드만 정규화 + const normalized = list.map((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, + createdAt: row?.createdAt ?? row?.created_at, + }; + }); + + // createdAt desc 정렬 후 recentLimit 만큼 자르기 + const topN = normalized + .sort( + (a, b) => + new Date(b.createdAt ?? 0).getTime() - + new Date(a.createdAt ?? 0).getTime(), + ) + .slice(0, recentLimit); + + // RecentRunRow로 매핑 + recentRuns.value = topN.map((run) => ({ + name: run.name, + status: mapRawStateToUiStatus(run.state), + time: fmtYmdHm(run.createdAt), + })); } catch (err) { console.error("[Dashboard] loadRecentRuns error:", err); recentRuns.value = []; @@ -353,6 +352,7 @@ async function loadDatasetActivity() { size: 200, sortField: "id", sortDirection: "DESC", + refType: "DATASET", } as any); groupSummaries.value = (dgRes?.data?.content ?? dgRes?.data ?? []) @@ -499,17 +499,24 @@ function renderKubeflowPie() { const succeeded = kubeflowCounts.value.SUCCEEDED || 0; const failed = kubeflowCounts.value.FAILED || 0; - const total = succeeded + failed; - const hasData = total > 0; + // 0인 조각 제거 + const parts: Array<{ label: string; value: number; color: string }> = []; + if (succeeded > 0) + parts.push({ label: "SUCCEEDED", value: succeeded, color: COLOR.SUCCESS }); + if (failed > 0) + parts.push({ label: "FAILED", value: failed, color: COLOR.ERROR }); + + const hasData = parts.length > 0; + const trace: Partial = hasData ? { type: "pie", - labels: ["SUCCEEDED", "FAILED"], - values: [succeeded, failed], + labels: parts.map((p) => p.label), + values: parts.map((p) => p.value), textinfo: "label+value", textfont: { color: "#fff", size: 13 }, - marker: { colors: [COLOR.SUCCESS, COLOR.ERROR] }, + marker: { colors: parts.map((p) => p.color) }, hovertemplate: "%{label}: %{value} (%{percent})", } : { diff --git a/src/components/templates/run/executions/ListComponent.vue b/src/components/templates/run/executions/ListComponent.vue index cfa2f75..973bc24 100644 --- a/src/components/templates/run/executions/ListComponent.vue +++ b/src/components/templates/run/executions/ListComponent.vue @@ -3,8 +3,9 @@ import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue"; import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue"; import { onMounted, ref } from "vue"; import { storage } from "@/utils/storage"; -import ViewComponent from "@/components/templates/run/experiment/ViewComponent.vue"; +import ViewComponent from "@/components/templates/run/executions/ViewComponent.vue"; import ExperimentCreateDialog from "@/components/atoms/organisms/ExperimentCreateDialog.vue"; +import { KubeflowService } from "@/components/service/management/KubeflowService"; import { ExperimentService } from "@/components/service/management/ExperimentService"; import { commonStore } from "@/stores/commonStore"; import { KubeflowRunService } from "@/components/service/management/KubeflowRunService"; @@ -15,6 +16,7 @@ const store = commonStore(); const openView = ref(false); const execSelected = ref(null); const username = ref(""); +const experimentNameMap = ref>({}); const tableHeader = [ { label: "No", width: "5%", style: "word-break: keep-all;" }, @@ -53,6 +55,7 @@ const data = ref({ pageSize: 10, searchType: "전체" as SearchType, searchText: "", + experimentId: "", }, results: [] as any[], totalElements: 0, @@ -62,6 +65,33 @@ const data = ref({ isCreateVisible: false, }); +async function resolveExperimentNamesWithApi(ids: Array) { + const targets = Array.from( + new Set( + (ids || []) + .map((x) => String(x)) + .filter((id) => id && experimentNameMap.value[id] == null), + ), + ); + if (!targets.length) return; + + await Promise.all( + targets.map(async (id) => { + try { + // ✅ 너가 만든 API 사용 (POST /api/kubeflow/experiments/{id}) + const res = await KubeflowService.experimentData(id); + const body = res?.data ?? res ?? {}; + const name = + body.display_name ?? body.name ?? body.experiment_name ?? String(id); + experimentNameMap.value[id] = name; + } catch (e) { + // 실패 시 ID fallback + experimentNameMap.value[id] = String(id); + } + }), + ); +} + function readUsernameFromStorage(): string { try { const raw = @@ -84,7 +114,13 @@ const getProjectId = (): number => { return Number.isFinite(v) ? v : 0; }; -const toRow = (r: any, idx: number) => { +const toRow = (r: any, no: number) => { + const expId = r.experimentId ?? r.experiment_id ?? r.experiment?.id; + const expName = + (expId && experimentNameMap.value[String(expId)]) ?? + r.experiment?.displayName ?? + r.experiment?.name ?? + "-"; const fmtStart = (start?: string) => { if (!start) return "-"; const d = new Date(start); @@ -109,31 +145,32 @@ const toRow = (r: any, idx: number) => { return `${h}:${pad(m)}:${pad(sec)}`; }; - const toUiStatus = (state?: string) => { - switch ((state || "").toUpperCase()) { - case "SUCCEEDED": - return "Succeeded"; - case "FAILED": - return "Failed"; - case "RUNNING": - return "Running"; - case "PENDING": - return "Pending"; - case "SKIPPED": - return "Skipped"; - default: - return state || "-"; - } - }; + const toUiStatus = (state?: string, finishedAt?: string) => { + const s = String(state || "").toUpperCase(); - const { pageNum, pageSize } = data.value.params; + // 완료 판단: SUCCEED* 이거나 상태가 비어있지만 finishedAt이 있으면 완료로 간주 + if (s.includes("SUCCEED") || (!s && finishedAt)) return "Succeeded"; + + // 실패 + if (s.includes("FAIL") || s.includes("ERROR")) return "Failed"; + + // 실행 중 + if (s.includes("RUN")) return "Running"; + + // 대기 + if (s.includes("PEND") || s.includes("QUEUE") || s.includes("SCHED")) + return "Pending"; + + // 모르겠으면 대기로 + return "Pending"; + }; return { - no: (pageNum - 1) * pageSize + (idx + 1), + no, // ← 전달받은 번호 사용 name: r.displayName ?? r.name ?? r.runId ?? "(no name)", - status: toUiStatus(r.state), + status: toUiStatus(r.state, r.finishedAt), duration: fmtDuration(r.createdAt, r.finishedAt), - experiment: r.experimentId ?? "-", + expName, workflow: r.pipelineId ?? r.pipelineVersionId ?? "-", startTime: fmtStart(r.createdAt), registryStatus: r.storageState ?? "-", @@ -149,6 +186,7 @@ async function fetchList() { const payload = { projectId: getProjectId(), + page: pageNum - 1, // 0-based size: pageSize, keyword, @@ -199,7 +237,12 @@ async function fetchList() { const bid = b.id ?? b.runId ?? b.run_id ?? b.name ?? ""; return String(bid).localeCompare(String(aid)); // 안정화 }); + const expIds = list + .map((r) => r.experimentId ?? r.experiment_id ?? r.experiment?.id) + .filter((v) => v != null); + // ✅ 여기서 네가 만든 API로 display_name 미리 채움 + await resolveExperimentNamesWithApi(expIds); if (!isServerPaged) { const total = list.length; const pages = Math.max(1, Math.ceil(total / pageSize)); @@ -207,17 +250,29 @@ async function fetchList() { const start = (safePage - 1) * pageSize; const slice = list.slice(start, start + pageSize); - data.value.results = slice.map((r, i) => toRow(r, i)); + // ↓ 이번 페이지의 시작 번호(내림차순) + const startNo = total - (safePage - 1) * pageSize; + data.value.results = slice.map((r, i) => + toRow(r, Math.max(startNo - i, 1)), + ); + data.value.totalElements = total; data.value.pageLength = pages; } else { - data.value.results = list.map((r, i) => toRow(r, i)); - data.value.totalElements = + const te = typeof totalElements === "number" ? totalElements : list.length; + + // ↓ 서버 페이징일 때도 동일하게 시작 번호 계산 + const startNo = te - (pageNum - 1) * pageSize; + data.value.results = list.map((r, i) => + toRow(r, Math.max(startNo - i, 1)), + ); + + data.value.totalElements = te; data.value.pageLength = typeof totalPages === "number" ? Math.max(1, totalPages) - : Math.max(1, Math.ceil((data.value.totalElements || 0) / pageSize)); + : Math.max(1, Math.ceil((te || 0) / pageSize)); } } catch (err) { console.error("[Executions] 조회 에러:", err); @@ -305,9 +360,9 @@ const openInfoModal = (item: any) => { execSelected.value = item; openView.value = true; }; -function closeView() { +const closeView = () => { openView.value = false; -} +}; const onSaved = () => fetchList(); const openCreateModal = () => { data.value.modalMode = "create"; @@ -483,14 +538,13 @@ onMounted(() => { mdi-help-circle {{ item.duration }} - {{ item.experiment }} + {{ item.expName }} {{ item.workflow }} {{ item.startTime }} {{ item.registryStatus }} - -import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue"; -import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue"; -// import FormComponent from "@/components/device/FormComponent.vue"; -import { computed, onMounted, ref, watch } from "vue"; +import { MlflowService } from "@/components/service/mlflow/MlflowService"; +import { + computed, + watch, + ref, + nextTick, + onMounted, + onBeforeUnmount, +} from "vue"; +import Plotly from "plotly.js-dist-min"; -// const store = commonStore(); +const props = defineProps<{ experimentInfo: any }>(); +const emit = defineEmits<{ (e: "close"): void }>(); -const props = defineProps<{ - experimentInfo: any; -}>(); +/* ============ Tabs ============ */ +const mainTab = ref<"details" | "viz">("details"); +const vizTab = ref<"metrics" | "scatter" | "box" | "contour">("metrics"); -const emit = defineEmits<{ (e: "close"): void }>(); +/* ============ expName pick ============ */ +function pickExpName(v: any): string { + return v?.expName || v?.experiment || v?.name || v?.raw?.displayName || ""; +} + +/* ============ MLflow state ============ */ +const runs = ref([]); +const loadingRuns = ref(false); + +/* --- 단건 조회 state --- */ +const selectedRunId = ref(""); +const loadingRunDetail = ref(false); +const runDetail = ref(null); + +/* 목록 옵션 (run_name 표시, 값은 run_id / run_uuid) */ +const runOptions = computed(() => + runs.value.map((r) => ({ + title: r?.info?.run_name || r?.info?.run_id || "—", + value: r?.info?.run_id || r?.info?.run_uuid || "", + })), +); + +/* ============ Plotly refs ============ */ +const elAccuracy = ref(null); +const elF1 = ref(null); +const elPrecision = ref(null); +const elRecall = ref(null); + +/* ===== 유틸: 단건 값 꺼내기 ===== */ +function metricValue(key: "accuracy" | "precision" | "recall" | "f1_score") { + const m = (runDetail.value?.data?.metrics ?? []).find( + (x: any) => x.key === key, + ); + return m?.value ?? null; +} +const selectedLabel = computed( + () => runDetail.value?.info?.run_name ?? runDetail.value?.info?.run_id ?? "-", +); + +/* 선택한 run의 메트릭 표 데이터 */ +const selectedMetrics = computed(() => { + const mm: Array<{ key: string; value: number }> = + runDetail.value?.data?.metrics ?? []; + return mm.map((m) => ({ key: m.key, value: m.value })); +}); + +/* ===== 차트 렌더: 선택한 단건만 ===== */ +function drawCharts() { + // Plotly Layout을 타입 안전하게 만들기 + const baseLayout = (titleText: string, xlabel: string): Partial => ({ + title: { text: titleText }, // ← 문자열 대신 객체 + margin: { t: 40, r: 20, b: 40, l: 40 }, + height: 290, + yaxis: { rangemode: "tozero" }, + xaxis: { + tickmode: "array", + tickvals: [xlabel], + ticktext: [xlabel], + }, + showlegend: false, + }); + + const config = { displayModeBar: false, responsive: true }; + + if (elAccuracy.value) { + const x = ["accuracy"]; + Plotly.react( + elAccuracy.value, + [{ x, y: [metricValue("accuracy")], type: "bar" }], + baseLayout("accuracy", x[0]), + config, + ); + } + if (elF1.value) { + const x = ["f1_score"]; + Plotly.react( + elF1.value, + [{ x, y: [metricValue("f1_score")], type: "bar" }], + baseLayout("f1_score", x[0]), + config, + ); + } + if (elPrecision.value) { + const x = ["precision"]; + Plotly.react( + elPrecision.value, + [{ x, y: [metricValue("precision")], type: "bar" }], + baseLayout("precision", x[0]), + config, + ); + } + if (elRecall.value) { + const x = ["recall"]; + Plotly.react( + elRecall.value, + [{ x, y: [metricValue("recall")], type: "bar" }], + baseLayout("recall", x[0]), + config, + ); + } +} + +function resizeCharts() { + [elAccuracy.value, elF1.value, elPrecision.value, elRecall.value] + .filter(Boolean) + .forEach((el: any) => Plotly.Plots.resize(el)); +} + +/* ===== 응답 정규화 + fallback ===== */ +function normalizeRun(res: any) { + const v = res?.data?.run ?? res?.run ?? res?.data ?? res; + return v?.info && v?.data ? v : null; +} +function findFromList(runId: string) { + return ( + runs.value.find( + (r) => + r?.info?.run_id === runId || + r?.info?.run_uuid === runId || + r?.info?.run_name === runId, + ) ?? null + ); +} + +/* ============ API: runs 목록 & 단건 run ============ */ +async function fetchRunsOnce(expName?: string) { + if (!expName || runs.value.length > 0) return; + + loadingRuns.value = true; + try { + const expRes = await MlflowService.getExperimentByName(expName); + const exp = expRes?.data ?? exp; + const expId = String( + exp?.experiment_id ?? exp?.experimentId ?? exp?.id ?? "", + ); + if (!expId) return; + + const runsRes = await MlflowService.getRuns(expId); + const body = runsRes?.data ?? runsRes; + const list = + body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []); + runs.value = Array.isArray(list) ? list : []; + + // 기본 선택: 최신 run + const first = [...runs.value].sort( + (a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0), + )[0]; + selectedRunId.value = + first?.info?.run_id || first?.info?.run_uuid || selectedRunId.value || ""; + } finally { + loadingRuns.value = false; + } +} + +async function fetchRunDetail(runId: string) { + if (!runId) { + runDetail.value = null; + // 비움 처리 + await nextTick(); + drawCharts(); + return; + } + loadingRunDetail.value = true; + try { + const res = await MlflowService.getExperimentRun(runId); + runDetail.value = normalizeRun(res) || findFromList(runId); + await nextTick(); + drawCharts(); + } finally { + loadingRunDetail.value = false; + } +} + +/* ============ 트리거 ============ */ +// Visualizations 탭 진입 시 목록 로드 + 기본 단건 로드 +watch( + () => mainTab.value, + async (t) => { + if (t === "viz") { + await fetchRunsOnce(pickExpName(props.experimentInfo)); + if (selectedRunId.value) await fetchRunDetail(selectedRunId.value); + } + }, + { immediate: true }, +); + +// 드롭다운에서 Run 변경 시 단건 조회 +watch(selectedRunId, (id) => fetchRunDetail(id)); + +// metrics 서브탭일 때 리렌더 +watch(vizTab, async (t) => { + if (mainTab.value === "viz" && t === "metrics") { + await nextTick(); + drawCharts(); + resizeCharts(); + } +}); + +function onResize() { + if (mainTab.value === "viz" && vizTab.value === "metrics") { + resizeCharts(); + } +} +onMounted(() => window.addEventListener("resize", onResize)); +onBeforeUnmount(() => window.removeEventListener("resize", onResize)); + +/* ============ State History (Details 탭) ============ */ +const rawHistory = computed(() => { + const h = + props.experimentInfo?.raw?.state_history ?? + props.experimentInfo?.raw?.stateHistory ?? + props.experimentInfo?.state_history ?? + props.experimentInfo?.stateHistory ?? + []; + + if (Array.isArray(h) && h.length > 0) return h; + + // fallback 합성 + const startIso = + props.experimentInfo?.startTime && + !isNaN(new Date(props.experimentInfo.startTime).getTime()) + ? new Date(props.experimentInfo.startTime).toISOString() + : undefined; + + const latest = [...(runs.value ?? [])].sort( + (a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0), + )[0]; + + const endIso = + latest?.info?.end_time && isFinite(Number(latest.info.end_time)) + ? new Date(Number(latest.info.end_time)).toISOString() + : undefined; + + const status = String(props.experimentInfo?.status ?? "").toUpperCase(); + + const out: Array<{ state: string; update_time?: string }> = []; + if (startIso) out.push({ state: "PENDING", update_time: startIso }); + if (startIso) out.push({ state: "RUNNING", update_time: startIso }); + if (status === "SUCCEEDED" || status === "FAILED") { + out.push({ state: status, update_time: endIso ?? startIso }); + } + return out; +}); const history = computed(() => - (props.experimentInfo.raw?.state_history ?? []) + rawHistory.value .slice() + .filter((h) => h && h.state) .sort( (a: any, b: any) => - new Date(a.update_time).getTime() - new Date(b.update_time).getTime(), + new Date(a.update_time || 0).getTime() - + new Date(b.update_time || 0).getTime(), ), ); -// 히스토리에서 각 단계의 기록 찾기 const hPending = computed(() => history.value.find((h) => (h.state || "").toUpperCase() === "PENDING"), ); @@ -33,14 +283,21 @@ const hTerminal = computed(() => { .slice() .reverse() .find((h) => - ["SUCCEEDED", "FAILED"].includes((h.state || "").toUpperCase()), + ["SUCCEEDED", "FAILED", "COMPLETED"].includes( + (h.state || "").toUpperCase(), + ), ); return t ?? null; }); -// 3단계 고정 스텝 정의 const steps = computed(() => { - const lastLabel = (hTerminal.value?.state || "COMPLETED").toUpperCase(); + const lastLabel = ( + hTerminal.value?.state || + props.experimentInfo?.status || + "COMPLETED" + ) + .toString() + .toUpperCase(); return [ { @@ -64,7 +321,7 @@ const steps = computed(() => { label: ["SUCCEEDED", "FAILED"].includes(lastLabel) ? lastLabel : "COMPLETED", - active: !!hTerminal.value, + active: !!hTerminal.value || ["SUCCEEDED", "FAILED"].includes(lastLabel), color: lastLabel === "FAILED" ? "error" @@ -81,16 +338,12 @@ const steps = computed(() => { }, ]; }); - -// 고정 3스텝 기준 위치/세그먼트 const nSteps = 3; -const activeIndex = computed( - () => steps.value.map((s) => s.active).lastIndexOf(true), // -1이면 아무것도 진행X +const activeIndex = computed(() => + steps.value.map((s) => s.active).lastIndexOf(true), ); const leftPct = (i: number) => (i / (nSteps - 1)) * 100; const segWidthPct = () => 100 / (nSteps - 1); - -// 유틸 const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—"); @@ -103,206 +356,445 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
-
View Details
+
Compare Executions
- - - Execution Information - - - - - Name - {{ props.experimentInfo.name }} - - - - - Status - - mdi-check-circle - mdi-close-circle - mdi-loading - {{ props.experimentInfo.status }} - - - - - - Duration - {{ - props.experimentInfo.duration - }} - - - - - Experiment ID - {{ - props.experimentInfo.experiment - }} - - - - - Workflow - {{ - props.experimentInfo.workflow - }} - - - - - Start Time - {{ - props.experimentInfo.startTime - }} - - - - - Registry Status - {{ - props.experimentInfo.registryStatus - }} - - - - - - - State History - - -
- -
- - - - - - -
- - - - - - Back to List - - +
+ Runs: + {{ runs.length.toLocaleString() }} + +
+ + + + + + Selected Run + + + + + + + Run ID + {{ runDetail?.info?.run_id || "—" }} + + + Run Name + {{ runDetail?.info?.run_name || "—" }} + + + Status + {{ runDetail?.info?.status || "—" }} + + + Start + + {{ + runDetail?.info?.start_time + ? new Date( + runDetail.info.start_time, + ).toLocaleString() + : "—" + }} + + + + End + + {{ + runDetail?.info?.end_time + ? new Date( + runDetail.info.end_time, + ).toLocaleString() + : "—" + }} + + + + + + + +
Parameters
+ + + + Key + Value + + + + + + No params + + + + {{ p.key }} + {{ p.value }} + + + +
+
+ + + +
Metrics
+ + + + Metric + Value + + + + + + No metrics + + + + {{ m.key }} + {{ m.value }} + + + +
+ + +
Tags
+ + + + Key + Value + + + + + + No tags + + + + {{ t.key }} + + {{ t.value }} + + + + +
+
+
+
+ + + + + Model Metrics (selected run) + + + + + Metric + Value + + + + + + No Data + + + + {{ m.key }} + {{ m.value }} + + + + + + + + +
+
+ +
+
+ +
+
+ +
+
+
+ + + + + + + (준비중) X/Y 축 선택 후 산점도 표시 + + + + + (준비중) 메트릭 분포 Box Plot + + + + + (준비중) 2D/3D Contour Plot + + + + + + + + diff --git a/src/components/templates/run/experiment/ViewComponent.vue b/src/components/templates/run/experiment/ViewComponent.vue index 08080cc..9d77da3 100644 --- a/src/components/templates/run/experiment/ViewComponent.vue +++ b/src/components/templates/run/experiment/ViewComponent.vue @@ -1,138 +1,146 @@ @@ -150,6 +158,7 @@ watch( + - Experiment Name - {{ - experimentInfo.experimentName - }} + {{ header.experimentName }} - Project Name - {{ - experimentInfo.projectName - }} + {{ header.projectName }} - Created ID - {{ experimentInfo.createdId }} + {{ header.createdId }} Created Date - {{ - experimentInfo.createdDate - }} - - - - - - Kubeflow ID - {{ experimentInfo.kubeFlowId }} - MLflow ID - {{ experimentInfo.mlFlowId }} + {{ header.createdDate }} - Description - {{ - experimentInfo.description - }} + {{ header.description }} @@ -220,6 +208,110 @@ watch( > + + + + + + Runs + + + + +
+ 총 {{ runRows.length.toLocaleString() }}개 · Experiment ID: + {{ experimentId || "-" }} +
+ + + +
+ + + + + + + + + + + + + + Run Name + Status + Duration + Pipeline + Start Time + + + + + + + No runs + + + + + + + {{ r.runName }} + + + + mdi-check-circle + mdi-close-circle + + mdi-help-circle + {{ r.status }} + + + + {{ r.duration }} + + + + {{ r.pipeline }} + + + + {{ r.startTime }} + + + + + + + + +
Back to List diff --git a/src/components/templates/trainingscript/ListComponent.vue b/src/components/templates/trainingscript/ListComponent.vue index f132052..e4f34b0 100644 --- a/src/components/templates/trainingscript/ListComponent.vue +++ b/src/components/templates/trainingscript/ListComponent.vue @@ -355,7 +355,7 @@ watch(
-
Training Script
+
TrainingScript
diff --git a/src/components/templates/trainingscriptgroup/ListComponent.vue b/src/components/templates/trainingscriptgroup/ListComponent.vue index bdaff10..524675f 100644 --- a/src/components/templates/trainingscriptgroup/ListComponent.vue +++ b/src/components/templates/trainingscriptgroup/ListComponent.vue @@ -11,8 +11,8 @@ import { DataGroupService } from "@/components/service/management/DataGroupServi import ViewComponent from "@/components/templates/workflow/ViewComponent.vue"; import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue"; import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue"; -import DatagroupBaseDoalog from "@/components/atoms/organisms/DatagroupBaseDoalog.vue"; import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue"; +import TrainingGroupBaseDoalog from "@/components/atoms/organisms/TrainingGroupBaseDoalog.vue"; /* ------------------------- * Dayjs & Router @@ -153,6 +153,7 @@ async function fetchList() { size: reqSize, keyword, searchType: mapped, + refType: "TRAINING_SCRIPT", }; const res: any = await DataGroupService.search(payload); if (res?.status !== 200) return; @@ -455,7 +456,7 @@ onMounted(fetchList);
Create DataGroupCreate TrainingGroup @@ -508,7 +509,6 @@ onMounted(fetchList); @click.stop @mousedown.stop > - - { if (res.status !== 200) return; const result = res.data; - console.log("Workflows", result); let list = result?.content ?? []; if (needLocalFilter) { @@ -220,8 +219,6 @@ const removeData = (value?: Array<{ deviceKey: number }>) => { data.value.selected = []; data.value.allSelected = false; }; - console.log(ids.length); - if (ids.length === 1) { remove(ids[0]) .then(() => { diff --git a/src/layouts/TopNav.vue b/src/layouts/TopNav.vue new file mode 100644 index 0000000..3ee2b94 --- /dev/null +++ b/src/layouts/TopNav.vue @@ -0,0 +1,162 @@ + + + + + + diff --git a/vite.config.mjs b/vite.config.mjs index f61162d..b3e6abf 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -14,7 +14,8 @@ import { fileURLToPath, URL } from "node:url"; // https://vitejs.dev/config/ export default defineConfig({ - base: process.env.NODE_ENV === "production" ? process.env.VITE_ROOT_PATH : "/", + base: + process.env.NODE_ENV === "production" ? process.env.VITE_ROOT_PATH : "/", plugins: [ VueRouter(), Layouts(),