Executions 상세페이지 데이터 바인딩 및 차트 추가, home 수정

main
jschoi 8 months ago
parent 8114ca58c5
commit 96d6d13f61

5
components.d.ts vendored

@ -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']

27
package-lock.json generated

@ -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",

@ -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": {

@ -7,3 +7,17 @@
<script setup>
//
</script>
<!-- <template>
<v-app>
<TopNav />
<v-main>
<router-view />
</v-main>
</v-app>
</template>
<script setup>
import TopNav from "@/layouts/TopNav.vue";
</script>
-->

@ -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);

@ -0,0 +1,212 @@
<script setup lang="ts">
import IconArrowDown from "@/components/atoms/button/IconArrowDown.vue";
import IconArrowUp from "@/components/atoms/button/IconArrowUp.vue";
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
import { computed, onBeforeUnmount, onMounted, watch, ref } from "vue";
import { DataGroupService } from "@/components/service/management/DataGroupService";
import { storage } from "@/utils/storage";
import { storeToRefs } from "pinia";
import { useAutoflowStore } from "@/stores/autoflowStore";
const { projectId } = storeToRefs(useAutoflowStore());
const props = defineProps<{ editData: any; mode: "create" | "edit" }>();
const emit = defineEmits<{
(e: "close-modal"): void;
(e: "saved", v: any): void;
}>();
const isEdit = computed(() => props.mode === "edit");
const saving = ref(false);
const errorMsg = ref("");
const form = ref({ name: "", description: "" });
//
function hydrateFormFromEdit(data: any) {
if (!data) return;
form.value.name = data.workflowName ?? data.dsNm ?? data.name ?? "";
form.value.description =
data.workflowDescription ?? data.dsDesc ?? data.description ?? "";
}
onMounted(() => {
if (isEdit.value) hydrateFormFromEdit(props.editData);
});
watch(
() => props.editData,
(v) => {
if (isEdit.value) hydrateFormFromEdit(v);
},
);
// /
function getAuthUser() {
const authObj =
(typeof storage?.getAuth === "function" ? storage.getAuth() : null) ??
JSON.parse(localStorage.getItem("autoflow-auth") || "{}");
const ui = authObj?.userInfo ?? authObj?.userinfo ?? authObj ?? {};
return { id: Number(ui.id), username: String(ui.username ?? "").trim() };
}
function cleanUndefined<T extends Record<string, any>>(obj: T): T {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined),
) as T;
}
async function submit() {
errorMsg.value = "";
const name = (form.value.name ?? "").trim(); // (/ OK)
if (!name) {
errorMsg.value = "이름을 입력하세요.";
return;
}
const { id: userId, username } = getAuthUser();
if (!userId || !username) {
errorMsg.value = "로그인 사용자 정보를 찾을 수 없습니다.";
return;
}
if (!projectId.value) {
errorMsg.value = "프로젝트가 선택되지 않았습니다.";
return;
}
try {
saving.value = true;
if (isEdit.value) {
const rawId = props.editData?.id ?? props.editData?.deviceKey;
const id = Number(rawId);
if (!id) {
errorMsg.value = "수정할 ID가 없습니다.";
return;
}
const viewRes = await DataGroupService.view(id);
const current = (viewRes?.data ?? viewRes) || {};
const updatePayload = {
id,
dsNm: name,
dsDesc: form.value.description ?? "",
projectId: current.projectId,
regUserId: current.regUserId,
regUserNm: current.regUserNm,
modUserId: userId,
modUserNm: username,
refType: "TRAINING_SCRIPT",
};
const { data } = await DataGroupService.update(id, updatePayload);
emit("saved", data);
emit("close-modal");
} else {
const createPayload = {
dsNm: name,
dsDesc: form.value.description ?? "",
regUserId: userId,
regUserNm: username,
projectId: projectId.value!,
refType: "TRAINING_SCRIPT",
};
const { data } = await DataGroupService.add(createPayload);
emit("saved", data);
emit("close-modal");
}
} catch (e: any) {
console.error("데이터그룹 저장 실패:", e);
const status = e?.response?.status;
const raw =
(typeof e?.response?.data === "string"
? e?.response?.data
: e?.response?.data?.message || e?.response?.data?.error) ||
e?.message ||
"";
if (status === 409)
errorMsg.value = "같은 이름의 데이터그룹이 이미 존재합니다.";
else if (status === 400) errorMsg.value = "요청 형식이 올바르지 않습니다.";
else if (status === 401 || status === 403)
errorMsg.value = "권한이 없거나 로그인 정보가 만료되었습니다.";
else errorMsg.value = raw || `요청 실패 (HTTP ${status ?? "Error"})`;
} finally {
saving.value = false;
}
}
function onEsc(e: KeyboardEvent) {
if (e.key === "Escape") emit("close-modal");
}
onMounted(() => window.addEventListener("keydown", onEsc));
onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
</script>
<template>
<v-card>
<v-card-title
class="text-white font-weight-bold text-h6"
style="background-color: #1976d2"
>
{{ isEdit ? "Edit TrainingGroup" : "Create TrainingGroup" }}
</v-card-title>
<v-card-text class="pa-6">
<div class="text-subtitle-1 font-weight-medium mb-4">
TrainingGroup Information
</div>
<v-form @submit.prevent="submit">
<!-- Name: 제한 없음 -->
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
>TrainingGroup Name</label
>
<v-text-field
v-model="form.name"
variant="outlined"
:disabled="saving"
dense
hide-details="auto"
required
/>
</div>
<!-- Description: 제한 없음 -->
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
>Description</label
>
<v-textarea
v-model="form.description"
variant="outlined"
:disabled="saving"
rows="3"
dense
hide-details="auto"
/>
</div>
<div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
</v-form>
</v-card-text>
<v-card-actions class="justify-end" style="padding: 16px 24px">
<v-btn color="success" :loading="saving" @click="submit">
{{ isEdit ? "Update" : "Save" }}
</v-btn>
<v-btn
text
class="white--text"
:disabled="saving"
@click="$emit('close-modal')"
>Close</v-btn
>
</v-card-actions>
</v-card>
</template>

@ -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"
></v-list-item>

@ -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: "에러가 발생하였습니다.",

@ -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}`, {});
},
};

@ -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,
});
},
};

@ -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
>
<IconInfoBtn @on-click="openDetailModal(row)" />
<IconModifyBtn @on-click="openModifyModal(row)" />
<IconDeleteBtn
@on-click="removeData([{ deviceKey: row.deviceKey }])"

@ -264,41 +264,40 @@ const kubeflowRunsForList = computed(() => {
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++;
}
// Kubeflow Run N Recent Run
const res = await KubeflowRunService.getAll();
const list: any[] = Array.isArray(res?.data) ? res.data : [];
// 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),
//
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<RecentRunRow>((run) => ({
name: run.name,
status: mapRawStateToUiStatus(run.state),
time: fmtYmdHm(run.createdAt),
}));
} catch (err) {
console.error("[Dashboard] loadRecentRuns error:", err);
@ -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<Plotly.PlotData> = 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})<extra></extra>",
}
: {

@ -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<any>(null);
const username = ref<string>("");
const experimentNameMap = ref<Record<string, string>>({});
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<string | number>) {
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":
const toUiStatus = (state?: string, finishedAt?: string) => {
const s = String(state || "").toUpperCase();
// : 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";
case "SKIPPED":
return "Skipped";
default:
return state || "-";
}
};
const { pageNum, pageSize } = data.value.params;
//
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(() => {
<v-icon v-else color="grey">mdi-help-circle</v-icon>
</td>
<td>{{ item.duration }}</td>
<td>{{ item.experiment }}</td>
<td>{{ item.expName }}</td>
<td>{{ item.workflow }}</td>
<td>{{ item.startTime }}</td>
<td>{{ item.registryStatus }}</td>
<td style="white-space: nowrap">
<IconInfoBtn @on-click="openInfoModal(item)" />
<IconDownloadBtn />
<IconDeleteBtn
@on-click="
removeData([

@ -275,7 +275,6 @@ const openComparePage = () => {
};
const openInfoModal = (item: any) => {
execSelected.value = item;
console.log("[Parent] 선택된 실행:", item);
openView.value = true;
openCompare.value = false;
};

@ -1,27 +1,277 @@
<script setup lang="ts">
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<any[]>([]);
const loadingRuns = ref(false);
/* --- 단건 조회 state --- */
const selectedRunId = ref<string>("");
const loadingRunDetail = ref(false);
const runDetail = ref<any | null>(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<HTMLDivElement | null>(null);
const elF1 = ref<HTMLDivElement | null>(null);
const elPrecision = ref<HTMLDivElement | null>(null);
const elRecall = ref<HTMLDivElement | null>(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<any> => ({
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<any[]>(() => {
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() : "—");
</script>
@ -103,11 +356,20 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
<v-card flat class="bg-shades-transparent w-100">
<v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
<div class="d-flex flex-row justify-start align-center">
<div class="text-primary">View Details</div>
<div class="text-primary">Compare Executions</div>
</div>
</v-card-item>
</v-card>
<!-- Main Tabs -->
<v-tabs v-model="mainTab" density="comfortable" color="primary">
<v-tab value="details">Details</v-tab>
<v-tab value="viz">Metrics</v-tab>
</v-tabs>
<v-window v-model="mainTab">
<!-- ========= Details ========= -->
<v-window-item value="details">
<v-card flat class="bordered-box mb-6 w-100 rounded-lg pa-8">
<v-card-title class="grey lighten-4 py-2 px-4">
<span class="font-weight-bold">Execution Information</span>
@ -116,13 +378,13 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
<v-card-text class="px-6 pb-6 pt-4">
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Name</v-col>
<v-col cols="9" class="pa-2">{{ props.experimentInfo.name }}</v-col>
<v-col cols="9">{{ props.experimentInfo.name }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Status</v-col>
<v-col cols="9" class="pa-2">
<v-col cols="9">
<v-icon
v-if="props.experimentInfo.status === 'Succeeded'"
color="green"
@ -133,43 +395,40 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
color="red"
>mdi-close-circle</v-icon
>
<v-icon v-else color="grey">mdi-loading</v-icon>
{{ props.experimentInfo.status }}
</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Duration</v-col>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.duration
}}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold"
>Duration</v-col
>
<v-col cols="9">{{ props.experimentInfo.duration }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Experiment ID</v-col
>Experiment Name</v-col
>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.experiment
}}</v-col>
<v-col cols="9">{{ props.experimentInfo.expName }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Workflow</v-col>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.workflow
}}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold"
>Workflow</v-col
>
<v-col cols="9">{{ props.experimentInfo.workflow }}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Start Time</v-col>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.startTime
}}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold"
>Start Time</v-col
>
<v-col cols="9">{{ props.experimentInfo.startTime }}</v-col>
</v-row>
<VDivider class="my-2" />
@ -177,25 +436,20 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
<v-col cols="3" class="text-h6 font-weight-bold"
>Registry Status</v-col
>
<v-col cols="9" class="pa-2">{{
<v-col cols="9">{{
props.experimentInfo.registryStatus
}}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- 🔹 state_history 출력 -->
<!-- 🔹 State History -->
<!-- State History -->
<v-row align="center" class="py-6">
<v-col cols="3" class="text-h6 font-weight-bold"
>State History</v-col
>
<v-col cols="9" class="pa-2">
<v-col cols="9">
<div class="history-rail">
<!-- 기본(회색) 레일 -->
<div class="history-rail__line" />
<!-- 진행 세그먼트 2 고정 -->
<template v-for="i in 2" :key="'seg-' + i">
<div
class="history-rail__seg"
@ -210,7 +464,6 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
/>
</template>
<!-- /아이콘 + 라벨/시간 : 3 고정 -->
<template v-for="(s, i) in steps" :key="'dot-' + s.key">
<div
class="history-rail__dot"
@ -224,7 +477,6 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
<v-icon size="18">{{ s.icon }}</v-icon>
</v-avatar>
</div>
<div
class="history-rail__label"
:style="{ left: leftPct(i) + '%' }"
@ -251,58 +503,298 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</v-sheet>
</v-card>
</v-window-item>
<!-- ========= Visualizations ========= -->
<v-window-item value="viz">
<v-card class="rounded-lg pa-8 w-100">
<v-window v-model="vizTab">
<v-window-item value="metrics">
<v-card-text>
<v-row class="mb-4" align="center">
<v-col cols="12" md="6">
<v-select
v-model="selectedRunId"
:items="runOptions"
item-title="title"
item-value="value"
label="Select run (run_uuid)"
density="comfortable"
:loading="loadingRuns"
clearable
/>
</v-col>
<v-col
cols="12"
md="6"
class="d-flex align-center justify-end"
>
<div class="text-body-2">
Runs:
<strong>{{ runs.length.toLocaleString() }}</strong>
<v-progress-circular
v-if="loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
/>
</div>
</v-col>
</v-row>
<!-- (A) 선택한 단건 run 상세 -->
<v-card class="mb-6" variant="tonal">
<v-card-title class="py-2 px-4">Selected Run</v-card-title>
<v-card-text class="px-4 pb-4">
<v-row>
<v-col cols="12" md="6">
<v-table density="comfortable">
<tbody>
<tr>
<td style="width: 40%">Run ID</td>
<td>{{ runDetail?.info?.run_id || "—" }}</td>
</tr>
<tr>
<td>Run Name</td>
<td>{{ runDetail?.info?.run_name || "—" }}</td>
</tr>
<tr>
<td>Status</td>
<td>{{ runDetail?.info?.status || "—" }}</td>
</tr>
<tr>
<td>Start</td>
<td>
{{
runDetail?.info?.start_time
? new Date(
runDetail.info.start_time,
).toLocaleString()
: "—"
}}
</td>
</tr>
<tr>
<td>End</td>
<td>
{{
runDetail?.info?.end_time
? new Date(
runDetail.info.end_time,
).toLocaleString()
: "—"
}}
</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col cols="12" md="6">
<div class="text-subtitle-2 mb-2">Parameters</div>
<v-table density="compact">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!runDetail?.data?.params?.length">
<td colspan="2" class="text-medium-emphasis">
No params
</td>
</tr>
<tr
v-for="p in runDetail?.data?.params || []"
:key="p.key"
>
<td>{{ p.key }}</td>
<td>{{ p.value }}</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col cols="12" md="6">
<div class="text-subtitle-2 mb-2">Metrics</div>
<v-table density="compact">
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!runDetail?.data?.metrics?.length">
<td colspan="2" class="text-medium-emphasis">
No metrics
</td>
</tr>
<tr
v-for="m in runDetail?.data?.metrics || []"
:key="m.key"
>
<td>{{ m.key }}</td>
<td>{{ m.value }}</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col cols="12" md="6">
<div class="text-subtitle-2 mb-2">Tags</div>
<v-table density="compact">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!runDetail?.data?.tags?.length">
<td colspan="2" class="text-medium-emphasis">
No tags
</td>
</tr>
<tr
v-for="t in runDetail?.data?.tags || []"
:key="t.key"
>
<td>{{ t.key }}</td>
<td
class="text-truncate"
style="max-width: 420px"
>
{{ t.value }}
</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- (B) 선택한 run 기준의 간단 메트릭 -->
<v-card flat class="mb-6">
<v-card-title
class="py-2 px-0 text-button text-medium-emphasis"
>
Model Metrics (selected run)
</v-card-title>
<v-table density="comfortable">
<thead>
<tr>
<th class="text-left" style="width: 50%">Metric</th>
<th class="text-left">Value</th>
</tr>
</thead>
<tbody>
<tr v-if="!selectedMetrics.length">
<td
colspan="2"
class="text-center py-6 text-medium-emphasis"
>
No Data
</td>
</tr>
<tr v-for="m in selectedMetrics" :key="m.key">
<td>{{ m.key }}</td>
<td>{{ m.value }}</td>
</tr>
</tbody>
</v-table>
</v-card>
<!-- (C) 2×2 단건 바차트 -->
<v-row>
<v-col cols="12" md="6">
<div
ref="elAccuracy"
style="width: 100%; height: 290px"
></div>
</v-col>
<v-col cols="12" md="6">
<div ref="elF1" style="width: 100%; height: 290px"></div>
</v-col>
<v-col cols="12" md="6">
<div
ref="elPrecision"
style="width: 100%; height: 290px"
></div>
</v-col>
<v-col cols="12" md="6">
<div
ref="elRecall"
style="width: 100%; height: 290px"
></div>
</v-col>
</v-row>
</v-card-text>
</v-window-item>
<!-- placeholders -->
<v-window-item value="scatter">
<v-card-text class="px-6 py-10 text-medium-emphasis">
(준비중) X/Y 선택 산점도 표시
</v-card-text>
</v-window-item>
<v-window-item value="box">
<v-card-text class="px-6 py-10 text-medium-emphasis">
(준비중) 메트릭 분포 Box Plot
</v-card-text>
</v-window-item>
<v-window-item value="contour">
<v-card-text class="px-6 py-10 text-medium-emphasis">
(준비중) 2D/3D Contour Plot
</v-card-text>
</v-window-item>
</v-window>
</v-card>
</v-window-item>
</v-window>
</v-card>
</v-container>
</template>
<style scoped>
:root {
--dot-size: 28px;
}
/* 레일 컨테이너: 한 줄 정렬 + 여백 */
.history-rail {
position: relative;
width: 40%;
padding: 12px 0 34px; /* 위: 점 여유 / 아래: 라벨 공간 */
padding: 12px 0 34px;
--color-rail: rgba(255, 255, 255, 0.12);
--color-idle: rgba(255, 255, 255, 0.12);
--color-active: rgb(
98,
0,
238
); /* theme primary(보라) 느낌, 필요시 바꿔도 OK */
--color-active: rgb(98, 0, 238);
}
/* 가로 기준선 */
.history-rail__line {
position: absolute;
left: 0;
right: 0;
top: 14px; /* 점 중앙과 자연스럽게 맞춤 */
top: 14px;
height: 4px;
background: var(--color-rail);
border-radius: 2px;
}
/* 진행 세그먼트(점~점 사이) */
.history-rail__seg {
position: absolute;
top: 14px;
height: 4px;
border-radius: 2px;
transition:
width 240ms ease,
background 240ms ease;
width 0.24s ease,
background 0.24s ease;
}
/* 점 아이콘 */
.history-rail__dot {
position: absolute;
top: 0; /* 컨테이너 안에서 수직 정렬 */
top: 0;
transform: translateX(-50%);
}
/* 라벨+시간(점 아래) */
.history-rail__label {
position: absolute;
top: 28px;

@ -9,7 +9,7 @@ import { ExperimentService } from "@/components/service/management/ExperimentSer
import { commonStore } from "@/stores/commonStore";
const store = commonStore();
const detailDialog = ref(false);
const execSelected = ref<any>(null);
const openView = ref(false);
const username = ref<string>("");
const selectedExperiment = ref<{
@ -265,17 +265,12 @@ const removeData = (value?: Array<{ deviceKey: number }>) => {
};
// ===== & ( ) =====
const closeDetail = () => {
const closeView = () => {
openView.value = false;
selectedExperiment.value = null;
};
const openDetailModal = (selectedItem: any) => {
console.log("[Experiment/List] row clicked:", selectedItem);
if (!selectedItem?.deviceKey) {
console.warn("[Experiment/List] deviceKey 없음!", selectedItem);
}
data.value.selectedData = selectedItem;
const openInfoModal = (item: any) => {
execSelected.value = item;
openView.value = true;
};
const openCreateModal = () => {
@ -427,16 +422,22 @@ onMounted(() => {
<tr
v-for="(item, i) in data.results"
:key="i"
class="text-center"
class="text-center clickable-row"
role="button"
tabindex="0"
@click="openInfoModal(item)"
@keydown.enter.prevent="openInfoModal(item)"
@keydown.space.prevent="openInfoModal(item)"
>
<td>{{ item.name }}</td>
<td>{{ item.description }}</td>
<td>{{ item.createdDate }}</td>
<td>{{ item.createdID }}</td>
<td style="white-space: nowrap">
<IconInfoBtn @on-click="openDetailModal(item)" />
<!-- 클릭 중복 방지 -->
<IconInfoBtn @on-click.stop="openInfoModal(item)" />
<IconDeleteBtn
@on-click="
@on-click.stop="
removeData([{ deviceKey: item.deviceKey }])
"
/>
@ -476,11 +477,22 @@ onMounted(() => {
<div class="w-100" v-else>
<ViewComponent
v-if="data.selectedData"
:id="data.selectedData.deviceKey"
@close="closeDetail"
v-if="openView"
:experiment-info="execSelected"
@close="closeView"
/>
</div>
</template>
<style scoped></style>
<style scoped>
.clickable-row {
cursor: pointer;
}
.clickable-row:hover {
background: rgba(255, 255, 255, 0.06);
}
.clickable-row:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
</style>

@ -1,138 +1,146 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { ExperimentService } from "@/components/service/management/ExperimentService";
import { ProjectService } from "@/components/service/project/projectService";
import { MlflowService } from "@/components/service/mlflow/MlflowService";
const props = defineProps<{ experimentInfo: any }>();
const emit = defineEmits<{ (e: "close"): void }>();
const loading = ref(false);
const detailRaw = ref<any | null>(null);
const experimentInfo = ref({
experimentName: "-",
projectName: "-",
createdDate: "-",
createdId: "-",
description: "-",
kubeFlowId: "-",
mlFlowId: "-",
});
function formatIso(s?: string) {
return s ? String(s).replace("T", " ").slice(0, 19) : "-";
}
//
const header = computed(() => ({
experimentName: props.experimentInfo?.name ?? "",
projectName:
localStorage.getItem("projectName") ||
props.experimentInfo?.projectName ||
"",
createdDate: props.experimentInfo?.createdDate ?? "",
createdId: props.experimentInfo?.createdID ?? "",
description: props.experimentInfo?.description ?? "",
kubeFlowId: props.experimentInfo?.deviceKey ?? "",
mlFlowId: props.experimentInfo?.mlFlowId ?? "",
}));
function mapToViewModel(raw: any) {
const hasRaw = !!raw;
const created = hasRaw && (raw.createdAt || raw.lastUpdateTime);
const createdId =
(hasRaw && (raw.regUserId || raw.createdBy || raw.serviceAccount)) || "-";
// /
const loading = ref(false);
const experimentId = ref<string>("");
const runs = ref<any[]>([]);
return {
experimentName: (hasRaw && (raw.displayName || raw.name)) || "-",
projectName: "-",
createdDate: formatIso(created as string | undefined),
createdId: createdId,
description: (hasRaw && raw.description) || "-",
kubeFlowId: (hasRaw && (raw.runId || raw.run_id || raw.id)) || "-",
mlFlowId: (hasRaw && raw.mlFlowId) || "-",
//
const fmtTs = (ms?: number | string) => {
if (ms === undefined || ms === null || ms === "") return "-";
const d = new Date(Number(ms));
if (isNaN(d.getTime())) return "-";
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())}:${pad(d.getSeconds())}`;
};
const fmtDuration = (start?: number | string, end?: number | string) => {
if (start == null || end == null) return "-";
const ms = Number(end) - Number(start);
if (!isFinite(ms) || ms < 0) return "-";
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return `${h}:${pad(m)}:${pad(sec)}`;
};
const toUiStatus = (status?: string) => {
switch ((status || "").toUpperCase()) {
case "FINISHED":
case "SUCCEEDED":
return "Succeeded";
case "FAILED":
return "Failed";
case "RUNNING":
return "Running";
case "PENDING":
return "Pending";
default:
return status || "-";
}
};
const info = computed(() => mapToViewModel(detailRaw.value || {}));
function bindFromProp() {
const hasProp =
props.experimentInfo !== null && props.experimentInfo !== undefined;
if (!hasProp) return;
const raw =
hasProp && props.experimentInfo.raw
? props.experimentInfo.raw
: props.experimentInfo;
if (!raw) return;
detailRaw.value = raw;
const vm = mapToViewModel(detailRaw.value);
experimentInfo.value = { ...experimentInfo.value, ...vm };
// Runs
const runRows = computed(() =>
runs.value.map((r: any) => {
const info = r?.info ?? {};
const tags: Array<{ key: string; value: string }> = r?.data?.tags ?? [];
const tag = (k: string) => tags.find((t) => t.key === k)?.value;
const prjName = localStorage.getItem("projectName");
if (prjName) {
experimentInfo.value.projectName = prjName;
}
// pipeline mlflow.source.name runName
const sourceName = tag("mlflow.source.name") || "";
//
const pipeline =
sourceName
.split("/")
.pop()
?.replace(/\.[^/.]+$/, "") ||
tag("mlflow.runName") ||
info?.run_name ||
"-";
if (
detailRaw.value &&
detailRaw.value.projectId !== undefined &&
detailRaw.value.projectId !== null
) {
fetchProjectName(Number(detailRaw.value.projectId)).catch(function () {});
}
}
async function fetchProjectName(projectId?: number) {
if (projectId === undefined || projectId === null) return;
try {
const res = await ProjectService.fetchProjectById(projectId as number);
const prj = res && res.data ? res.data : res;
const name = prj && (prj.prjNm || prj.name) ? prj.prjNm || prj.name : "-";
experimentInfo.value.projectName = name;
} catch (e) {
console.warn("[Experiment/View] project fetch fail:", e);
}
}
return {
runName: info?.run_name || tag("mlflow.runName") || "-",
status: toUiStatus(info?.status),
duration: fmtDuration(info?.start_time, info?.end_time),
pipeline,
startTime: fmtTs(info?.start_time),
raw: r,
};
}),
);
async function fetchDetail(id: number | string) {
const idNum = typeof id === "string" ? Number(id) : id;
if (!Number.isFinite(idNum as number)) return;
//
const page = ref(1);
const pageSize = ref(10);
const pageSizeOptions = [
{ text: "10", value: 10 },
{ text: "25", value: 25 },
{ text: "50", value: 50 },
];
const totalPages = computed(() =>
Math.max(1, Math.ceil(runRows.value.length / pageSize.value)),
);
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize.value;
return runRows.value.slice(start, start + pageSize.value);
});
// experiment_id id runs
async function fetchRunsByExperimentName(expName: string) {
if (!expName) return;
loading.value = true;
try {
const res = await ExperimentService.view(idNum as number);
const payload = res && res.data ? res.data : res;
detailRaw.value = payload;
// 1) Experiment
const expRes = await MlflowService.getExperimentByName(expName);
const exp = expRes?.data ?? expRes;
const id = String(exp?.experiment_id ?? "");
experimentId.value = id;
const vm = mapToViewModel(detailRaw.value);
experimentInfo.value = { ...experimentInfo.value, ...vm };
const hasProjectId =
detailRaw.value &&
detailRaw.value.projectId !== undefined &&
detailRaw.value.projectId !== null;
if (hasProjectId) {
await fetchProjectName(Number(detailRaw.value.projectId));
// 2) id Runs
if (id) {
const runsRes = await MlflowService.getRuns(id);
const list = runsRes?.data?.runs ?? runsRes?.runs ?? [];
runs.value = Array.isArray(list) ? list : [];
} else {
runs.value = [];
}
} catch (e) {
console.error("[Experiment/View] fetch detail error:", e);
console.error("[MLflow] fetch error:", e);
runs.value = [];
} finally {
loading.value = false;
}
}
onMounted(function () {
const hasObj =
props.experimentInfo && typeof props.experimentInfo === "object";
if (hasObj) {
bindFromProp();
} else {
fetchDetail(props.experimentInfo as any);
}
// /
onMounted(() => {
fetchRunsByExperimentName(header.value.experimentName);
});
watch(
() => props.experimentInfo,
function (nv) {
if (nv === null || nv === undefined || nv === "") return;
if (typeof nv === "object") {
bindFromProp();
} else {
fetchDetail(nv as any);
}
},
() => header.value.experimentName,
(nv) => fetchRunsByExperimentName(nv),
);
</script>
@ -150,6 +158,7 @@ watch(
</v-card-item>
</v-card>
<!-- Experiment Information -->
<v-card
flat
class="bordered-box mb-6 w-100 rounded-lg pa-8 position-relative"
@ -159,56 +168,35 @@ watch(
</v-card-title>
<v-card-text class="px-6 pb-6 pt-4">
<!-- Experiment Name -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Experiment Name</v-col
>
<v-col cols="9" class="pa-2">{{
experimentInfo.experimentName
}}</v-col>
<v-col cols="9" class="pa-2">{{ header.experimentName }}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- Project Name -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Project Name</v-col
>
<v-col cols="9" class="pa-2">{{
experimentInfo.projectName
}}</v-col>
<v-col cols="9" class="pa-2">{{ header.projectName }}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- Created Date / ID -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Created ID</v-col>
<v-col cols="3" class="pa-2">{{ experimentInfo.createdId }}</v-col>
<v-col cols="3" class="pa-2">{{ header.createdId }}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold"
>Created Date</v-col
>
<v-col cols="3" class="pa-2">{{
experimentInfo.createdDate
}}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- Kubeflow / MLflow ID -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Kubeflow ID</v-col>
<v-col cols="3" class="pa-2">{{ experimentInfo.kubeFlowId }}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold">MLflow ID</v-col>
<v-col cols="3" class="pa-2">{{ experimentInfo.mlFlowId }}</v-col>
<v-col cols="3" class="pa-2">{{ header.createdDate }}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- Description -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Description</v-col>
<v-col cols="9" class="pa-2">{{
experimentInfo.description
}}</v-col>
<v-col cols="9" class="pa-2">{{ header.description }}</v-col>
</v-row>
</v-card-text>
@ -220,6 +208,110 @@ watch(
>
<v-progress-circular indeterminate size="48" />
</v-overlay>
</v-card>
<!-- Runs -->
<v-card class="rounded-lg pa-8 w-100">
<v-card-title class="grey lighten-4 py-2 px-4">
<span class="font-weight-bold">Runs</span>
</v-card-title>
<v-card-text class="px-6 pb-2 pt-4">
<v-sheet class="d-flex align-center justify-between mb-4">
<div class="text-body-2">
{{ runRows.length.toLocaleString() }} · Experiment ID:
<strong>{{ experimentId || "-" }}</strong>
</div>
<v-responsive max-width="120">
<v-select
v-model="pageSize"
:items="pageSizeOptions"
item-title="text"
item-value="value"
density="compact"
hide-details
label="Rows"
@update:model-value="page = 1"
/>
</v-responsive>
</v-sheet>
<v-table density="comfortable" fixed-header height="420">
<colgroup>
<col style="width: 30%" />
<col style="width: 12%" />
<col style="width: 14%" />
<col style="width: 24%" />
<col style="width: 20%" />
</colgroup>
<thead>
<tr>
<!-- 가독성 위해 Run Name / Pipeline은 좌측, 나머지는 중앙 -->
<th class="text-left">Run Name</th>
<th class="text-center">Status</th>
<th class="text-center">Duration</th>
<th class="text-left">Pipeline</th>
<th class="text-center">Start Time</th>
</tr>
</thead>
<tbody class="text-body-2">
<tr v-if="pagedRows.length === 0">
<td colspan="5" class="text-center py-6 text-medium-emphasis">
No runs
</td>
</tr>
<tr v-for="(r, i) in pagedRows" :key="r.raw?.info?.run_id || i">
<!-- 셀에 정렬 클래스 지정 -->
<td class="text-left text-truncate" :title="r.runName">
{{ r.runName }}
</td>
<td class="text-center">
<v-icon v-if="r.status === 'Succeeded'" color="green"
>mdi-check-circle</v-icon
>
<v-icon v-else-if="r.status === 'Failed'" color="red"
>mdi-close-circle</v-icon
>
<v-progress-circular
v-else-if="r.status === 'Running'"
indeterminate
size="18"
width="2"
color="info"
/>
<v-icon v-else color="grey">mdi-help-circle</v-icon>
<span class="ml-1">{{ r.status }}</span>
</td>
<td class="text-center">
{{ r.duration }}
</td>
<td class="text-left text-truncate" :title="r.pipeline">
{{ r.pipeline }}
</td>
<td class="text-center">
{{ r.startTime }}
</td>
</tr>
</tbody>
</v-table>
<v-card-actions class="text-center mt-6 justify-center">
<v-pagination
v-model="page"
:length="totalPages"
:total-visible="10"
color="primary"
rounded="circle"
/>
</v-card-actions>
</v-card-text>
<v-sheet class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>

@ -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);
</v-sheet>
<v-sheet class="justify-end mb-2">
<v-btn color="info" @click="openCreateModal"
>Create DataGroup</v-btn
>Create TrainingGroup</v-btn
>
</v-sheet>
</v-sheet>
@ -508,7 +509,6 @@ onMounted(fetchList);
@click.stop
@mousedown.stop
>
<IconInfoBtn @on-click="openDetailModal(row)" />
<IconModifyBtn @on-click="openModifyModal(row)" />
<IconDeleteBtn
@on-click="removeData([{ deviceKey: row.deviceKey }])"
@ -537,7 +537,7 @@ onMounted(fetchList);
<!-- Create/Edit Dialog -->
<v-dialog v-model="data.isCreateVisible" max-width="800" persistent>
<DatagroupBaseDoalog
<TrainingGroupBaseDoalog
:key="data.modalMode + String(data.selectedData?.deviceKey ?? '')"
:edit-data="data.selectedData"
:mode="data.modalMode"

@ -125,7 +125,6 @@ const fetchList = () => {
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(() => {

@ -0,0 +1,162 @@
<!-- src/components/layout/TopNav.vue -->
<script setup lang="ts">
import { ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { menuUtils } from "@/utils/menuUtils";
import { storage } from "@/utils/storage";
const route = useRoute();
const router = useRouter();
const drawer = ref(false);
// ===== / =====
function readRolesFromStorage(): string[] {
try {
const raw =
storage.get?.("autoflow-auth") ??
localStorage.getItem("autoflow-auth") ??
null;
const auth = typeof raw === "string" ? JSON.parse(raw) : raw;
let roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
if (typeof roles === "string")
roles = roles.split(",").map((s: string) => s.trim());
return Array.isArray(roles) ? roles : [];
} catch {
return [];
}
}
const isAdmin = computed(() => {
const roles = readRolesFromStorage();
return roles.some((r) => r === "ROLE_ADMIN" || r === "ADMIN");
});
const menus = computed(() => [
...menuUtils.menuItem,
...(isAdmin.value ? menuUtils.adminMenuItem : []),
]);
const isActive = (path?: string) => !!path && route.path.startsWith(path);
const go = (path: string) => {
if (path && path !== route.path) router.push(path);
};
</script>
<template>
<!-- 상단 앱바 -->
<v-app-bar flat height="64" class="topbar">
<!-- 모바일 햄버거 -->
<v-app-bar-nav-icon class="d-md-none" @click="drawer = true" />
<!-- 좌측 로고/타이틀 -->
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
Autoflow Web Console
</v-toolbar-title>
<!-- 가로 메뉴(데스크탑) -->
<div class="d-none d-md-flex align-center ml-4 ga-1">
<template v-for="(m, i) in menus" :key="`m_${i}`">
<!-- depth가 있는 메뉴: 드롭다운 -->
<v-menu v-if="m.depth?.length" open-on-hover close-on-content-click>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="nav-btn"
:class="{
'nav-active': m.depth?.some((d: any) => isActive(d.path)),
}"
append-icon="mdi-chevron-down"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
<v-list density="compact" class="min-w-48">
<v-list-item
v-for="(d, j) in m.depth"
:key="`d_${j}`"
:title="d.title"
:to="d.path"
:active="isActive(d.path)"
:color="isActive(d.path) ? 'primary' : undefined"
/>
</v-list>
</v-menu>
<!-- 단일 링크 -->
<v-btn
v-else
variant="text"
class="nav-btn"
:class="{ 'nav-active': isActive(m.path) }"
@click="go(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
</div>
<v-spacer />
<!-- 우측 액션 예시 -->
<div class="d-none d-md-flex align-center ga-2">
<v-btn icon variant="text" :to="'/home'"><v-icon>mdi-home</v-icon></v-btn>
<v-btn icon variant="text"><v-icon>mdi-cog</v-icon></v-btn>
</div>
</v-app-bar>
<!-- 모바일 드로어 -->
<v-navigation-drawer v-model="drawer" temporary class="d-md-none" width="280">
<v-list nav density="comfortable" class="pa-2">
<template v-for="(m, i) in menus" :key="`sm_${i}`">
<v-list-group v-if="m.depth?.length">
<template #activator="{ props }">
<v-list-item
v-bind="props"
:prepend-icon="m.icon"
:title="m.title"
/>
</template>
<v-list-item
v-for="(d, j) in m.depth"
:key="`smd_${j}`"
:title="d.title"
:to="d.path"
:active="isActive(d.path)"
@click="drawer = false"
/>
</v-list-group>
<v-list-item
v-else
:prepend-icon="m.icon"
:title="m.title"
:to="m.path"
:active="isActive(m.path)"
@click="drawer = false"
/>
</template>
</v-list>
</v-navigation-drawer>
</template>
<style scoped>
.topbar {
background: rgba(18, 18, 18, 0.7) !important;
backdrop-filter: saturate(140%) blur(8px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.nav-btn {
height: 40px;
text-transform: none;
border-radius: 10px;
padding: 0 10px;
}
.nav-active {
background: rgba(59, 130, 246, 0.15);
}
.min-w-48 {
min-width: 12rem;
}
</style>

@ -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(),

Loading…
Cancel
Save