fix: 수정

- 메뉴바 상단으로 변경
- 대시보드 datagroup 데이터 오류 수정
- Experiment run 상세페이지 추가
- dataset, trainingscript no 추가
main
jschoi 8 months ago
parent 07f63adc00
commit 5e5dffcde9

1
components.d.ts vendored

@ -14,6 +14,7 @@ declare module 'vue' {
DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default'] DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default']
DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default'] DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default']
DeploymentDialog: typeof import('./src/components/atoms/organisms/DeploymentDialog.vue')['default'] DeploymentDialog: typeof import('./src/components/atoms/organisms/DeploymentDialog.vue')['default']
DetailComponent: typeof import('./src/components/templates/run/experiment/DetailComponent.vue')['default']
DrawerComponent: typeof import('./src/components/common/DrawerComponent.vue')['default'] DrawerComponent: typeof import('./src/components/common/DrawerComponent.vue')['default']
ExecutionBaseDialog: typeof import('./src/components/atoms/organisms/ExecutionBaseDialog.vue')['default'] ExecutionBaseDialog: typeof import('./src/components/atoms/organisms/ExecutionBaseDialog.vue')['default']
ExecutionsViewComponent: typeof import('./src/components/templates/run/executions/ExecutionsViewComponent.vue')['default'] ExecutionsViewComponent: typeof import('./src/components/templates/run/executions/ExecutionsViewComponent.vue')['default']

@ -19,5 +19,4 @@
<script setup> <script setup>
import TopNav from "@/layouts/TopNav.vue"; import TopNav from "@/layouts/TopNav.vue";
</script> </script> -->
-->

@ -93,17 +93,15 @@ async function submit() {
id, id,
dsNm: name, dsNm: name,
dsDesc: form.value.description ?? "", dsDesc: form.value.description ?? "",
projectId: current.projectId, projectId: current.projectId,
regUserId: current.regUserId, regUserId: current.regUserId,
regUserNm: current.regUserNm, regUserNm: current.regUserNm,
modUserId: userId, modUserId: userId,
modUserNm: username, modUserNm: username,
refType: "TRAINING_SCRIPT",
}; };
const { data } = await DataGroupService.update(id, updatePayload); const { data } = await DataGroupService.update(id, updatePayload);
emit("saved", data); emit("saved", data);
emit("close-modal"); emit("close-modal");
} else { } else {

@ -1,56 +1,112 @@
<script setup> <script setup lang="ts">
/* ================================
* Imports
* ================================ */
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { storage } from "@/utils/storage.js"; import { storage } from "@/utils/storage.js";
import DrawerComponent from "@/components/common/DrawerComponent.vue";
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { UserManagerService } from "@/components/service/management/UserManagerService"; import { UserManagerService } from "@/components/service/management/UserManagerService";
import SidebarHeader from "@/components/common/SidebarHeader.vue"; import { menuUtils } from "@/utils/menuUtils";
/* ================================
* Types
* ================================ */
type MenuItem = {
title: string;
icon?: string;
path?: string;
depth?: Array<{ title: string; path: string }>;
click?: () => void;
};
/* ================================
* Router / Reactive base state
* ================================ */
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const username = ref(""); const username = ref<string>("");
const projectName = ref(localStorage.getItem("projectName") || ""); const projectName = ref<string>(localStorage.getItem("projectName") || "");
const isAdmin = ref(false); const isAdmin = ref<boolean>(false);
const adminMode = ref(false); const adminMode = ref<boolean>(false); // Settings /
const lastNonAdminPath = ref("/home"); const lastNonAdminPath = ref<string>("/home");
function computeIsAdmin() { /* ================================
* Auth / Role helpers
* ================================ */
function readAuth() {
try { try {
const raw = const raw =
typeof storage?.getAuth === "function" typeof storage?.getAuth === "function"
? storage.getAuth() ? storage.getAuth()
: JSON.parse(localStorage.getItem("autoflow-auth") || "null"); : JSON.parse(localStorage.getItem("autoflow-auth") || "null");
return raw ?? null;
const roles = raw?.userInfo?.roles ?? raw?.roles ?? [];
const authCd = raw?.userInfo?.authCd ?? raw?.authCd ?? raw?.auth;
const inRoles = Array.isArray(roles)
? roles.includes("ROLE_ADMIN")
: roles === "ROLE_ADMIN";
isAdmin.value = inRoles || authCd === "ADMIN";
} catch { } catch {
isAdmin.value = false; return null;
} }
} }
// function computeIsAdmin() {
function toggleAdmin() { const auth = readAuth();
if (!isAdmin.value) return; const roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
if (adminMode.value) { const authCd = auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth;
adminMode.value = false; const inRoles = Array.isArray(roles)
router.push(lastNonAdminPath.value || "/home"); ? roles.includes("ROLE_ADMIN")
} else { : roles === "ROLE_ADMIN";
adminMode.value = true; isAdmin.value = inRoles || authCd === "ADMIN";
if (!route.meta?.requiresAdmin) router.push("/project");
}
} }
// ---------------------- function updateUsername() {
// const auth = readAuth();
// ---------------------- username.value = auth?.userInfo?.username ?? auth?.username ?? "";
const menu = ref([]); }
const menuItems = [
/* ================================
* Derived route state
* - /select
* - 관리자 표시 조건: adminMode ON || 관리자 라우트
* ================================ */
const hideAllMenus = computed<boolean>(() => route.path.startsWith("/select"));
const isAdminRoute = computed<boolean>(() => {
const p = route.path || "";
const hitPath =
p.startsWith("/project") ||
p.startsWith("/users") ||
p.startsWith("/select");
const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin);
return hitPath || hitMeta;
});
const showAdminTabs = computed<boolean>(
() => adminMode.value || isAdminRoute.value,
);
/* ================================
* Menus (기본/관리자)
* ================================ */
const baseMenus = computed<MenuItem[]>(
() => (menuUtils?.menuItem ?? []) as MenuItem[],
);
const adminMenus = computed<MenuItem[]>(() => {
const fromUtil = (menuUtils?.adminMenuItem ?? []) as MenuItem[];
return fromUtil.length
? fromUtil
: [
{ title: "Projects", icon: "mdi-briefcase", path: "/project" },
{ title: "Users", icon: "mdi-account-multiple", path: "/users" },
];
});
const isLinkActive = (path?: string) => !!path && route.path.startsWith(path);
/* ================================
* Header dropdown menu
* ================================ */
const menu = ref<MenuItem[]>([]);
const menuItems: MenuItem[] = [
{ title: "Select Project", click: () => goSelect() }, { title: "Select Project", click: () => goSelect() },
{ {
title: "Change Password", title: "Change Password",
@ -61,36 +117,32 @@ const menuItems = [
{ title: "Logout", icon: "mdi-logout", click: () => logOut() }, { title: "Logout", icon: "mdi-logout", click: () => logOut() },
]; ];
const drawer = ref(null); /* ================================
const pageTitle = computed(() => route.meta.title); * Navigation actions
const pagePath = computed(() => route.path); * ================================ */
function goMain() {
// active adminMode.value = false;
const isLinkActive = (link) => route.path.includes(link); router.push("/home");
const settingsLabel = computed(() =>
adminMode.value ? "Back to Console" : "Settings",
);
function updateUsername() {
const auth = storage.getAuth?.() ?? null;
username.value = auth?.userInfo?.username ?? auth?.username ?? "";
}
function syncAdminModeWithRoute() {
const isAdminRoute = route.matched.some((r) => r.meta?.requiresAdmin);
if (!isAdminRoute && adminMode.value) {
adminMode.value = false;
}
} }
function refreshProjectName() {
const v = localStorage.getItem("projectName");
projectName.value = v ? v : "";
}
function goSelect() { function goSelect() {
adminMode.value = false; adminMode.value = false;
router.push("/select"); router.push("/select");
} }
function toggleAdmin() {
if (!isAdmin.value) return;
if (adminMode.value) {
//
adminMode.value = false;
router.push("/home");
} else {
adminMode.value = true;
//
if (!isAdminRoute.value) router.push("/project");
}
}
function logOut() { function logOut() {
UserManagerService.signOut() UserManagerService.signOut()
.catch(console.error) .catch(console.error)
@ -98,50 +150,54 @@ function logOut() {
localStorage.removeItem("autoflow-auth"); localStorage.removeItem("autoflow-auth");
localStorage.removeItem("projectName"); localStorage.removeItem("projectName");
localStorage.removeItem("projectId"); localStorage.removeItem("projectId");
sessionStorage.removeItem("initialRedirectDone");
username.value = ""; username.value = "";
projectName.value = ""; projectName.value = "";
sessionStorage.removeItem("initialRedirectDone");
adminMode.value = false; adminMode.value = false;
router.push("/login"); router.push("/login");
}); });
} }
// storage /* ================================
function onStorage(e) { * Effects
if (!e.key || e.key === "projectName") { * ================================ */
refreshProjectName(); function refreshProjectName() {
adminMode.value = false; projectName.value = localStorage.getItem("projectName") || "";
}
if (!e.key || e.key === "autoflow-auth" || e.key === "auth") {
updateUsername();
computeIsAdmin();
}
} }
const goMain = () => {
adminMode.value = false;
router.push("/home");
};
// //
watch( watch(
() => route.fullPath, () => route.fullPath,
() => { () => {
refreshProjectName(); refreshProjectName();
const isAdminRoute = route.matched.some((r) => r.meta?.requiresAdmin); if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
if (!isAdminRoute) lastNonAdminPath.value = route.fullPath || "/home";
syncAdminModeWithRoute();
}, },
{ immediate: true }, { immediate: true },
); );
// storage
function onStorage(e: StorageEvent) {
if (!e.key || e.key === "projectName") {
refreshProjectName();
adminMode.value = false;
}
if (!e.key || e.key === "autoflow-auth" || e.key === "auth") {
updateUsername();
computeIsAdmin();
}
}
/* ================================
* Lifecycle
* ================================ */
onMounted(() => { onMounted(() => {
updateUsername(); updateUsername();
computeIsAdmin(); computeIsAdmin();
refreshProjectName(); refreshProjectName();
menu.value = menuItems; menu.value = menuItems;
window.addEventListener("storage", onStorage); window.addEventListener("storage", onStorage);
syncAdminModeWithRoute();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage); window.removeEventListener("storage", onStorage);
}); });
@ -149,69 +205,97 @@ onBeforeUnmount(() => {
<template> <template>
<v-app> <v-app>
<!-- 사이드바 --> <!-- ===== 상단 탑바 ===== -->
<v-navigation-drawer <v-app-bar flat height="75" class="topbar">
v-model="drawer" <!-- 좌측: (브랜드) 버튼 -->
border="0" <v-btn
hide-overlay variant="text"
permanent size="large"
v-if="!route.meta.hideSidebar" class="brand-btn text-h5 text-primary"
:rail="false"
>
<!-- 헤더 -->
<v-card
:ripple="false"
flat
class="bg-shades-transparent d-flex w-100 justify-center text-h5 pa-4 pb-16"
@click="goMain" @click="goMain"
aria-label="Home"
> >
<SidebarHeader /> AUTOFLOW WEB CONSOLE
</v-card> </v-btn>
<!-- 기본(일반 사용자) 메뉴 --> <!-- 중앙: 메뉴 그룹 (Settings / 분기) -->
<DrawerComponent v-if="!adminMode" /> <div class="center-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴: showAdminTabs 조건으로 표시 -->
<!-- 관리자 메뉴: 유저 메뉴와 동일한 /여백/활성 스타일 --> <template v-if="showAdminTabs">
<template v-else> <template v-for="(m, i) in adminMenus" :key="'am_' + i">
<v-card flat class="mx-auto"> <v-btn
<v-list nav class="pa-5 pt-0"> variant="text"
<v-list-item class="nav-btn"
rounded :prepend-icon="m.icon"
title="Projects" :to="m.path"
value="projects" :color="isLinkActive(m.path) ? 'primary' : undefined"
to="/project" :class="{ 'nav-active': isLinkActive(m.path) }"
prepend-icon="mdi-briefcase" >
:active="isLinkActive('/project')" {{ m.title }}
:active-color="isLinkActive('/project') ? 'primary' : null" </v-btn>
density="compact" </template>
class="pa-2 rounded-lg" </template>
style="padding-inline-start: 10px"
/> <!-- 기본 메뉴 -->
<v-list-item <template v-else>
rounded <template v-for="(m, i) in baseMenus" :key="'m_' + i">
title="Users" <v-menu
value="users" v-if="m.depth?.length"
to="/users" open-on-hover
prepend-icon="mdi-account-multiple" close-on-content-click
:active="isLinkActive('/users')" location="bottom"
:active-color="isLinkActive('/users') ? 'primary' : null" >
density="compact" <template #activator="{ props }">
class="pa-2 rounded-lg" <v-btn
style="padding-inline-start: 10px" v-bind="props"
/> variant="text"
</v-list> class="nav-btn"
</v-card> :class="{
</template> 'nav-active': m.depth?.some((d: any) =>
</v-navigation-drawer> isLinkActive(d.path),
<v-app-bar class="bg-shades-transparent" flat> ),
}"
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="isLinkActive(d.path)"
:color="isLinkActive(d.path) ? 'primary' : undefined"
/>
</v-list>
</v-menu>
<v-btn
v-else
variant="text"
class="nav-btn"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click="m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
</template>
</div>
<v-spacer /> <v-spacer />
<!-- 우측: 기존 기능 유지 -->
<v-tooltip v-if="isAdmin" location="bottom" text="Settings"> <v-tooltip v-if="isAdmin" location="bottom" text="Settings">
<template #activator="{ props }"> <template #activator="{ props }" v-if="!hideAllMenus">
<v-btn <v-btn
icon icon
color="primary" color="primary"
class="mr-3" class="mr-2"
v-bind="props" v-bind="props"
@click="toggleAdmin" @click="toggleAdmin"
aria-label="Settings" aria-label="Settings"
@ -221,22 +305,22 @@ onBeforeUnmount(() => {
</template> </template>
</v-tooltip> </v-tooltip>
<!-- 프로젝트 선택 -->
<v-tooltip location="bottom" text="Project"> <v-tooltip location="bottom" text="Project">
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn <v-btn
icon icon
color="primary" color="primary"
class="mr-3" class="mr-2"
@click="goSelect" @click="goSelect"
v-bind="props" v-bind="props"
aria-label="Project"
> >
<v-icon>mdi-home</v-icon> <v-icon>mdi-home</v-icon>
</v-btn> </v-btn>
</template> </template>
</v-tooltip> </v-tooltip>
<div style="min-width: 180px" class="d-flex flex-column align-end"> <div class="d-none d-md-flex flex-column align-end userbox">
<div class="font-weight-black">{{ username || "GUEST" }}</div> <div class="font-weight-black">{{ username || "GUEST" }}</div>
<div class="text-subtitle-2"> <div class="text-subtitle-2">
{{ projectName || "No Project Selected" }} {{ projectName || "No Project Selected" }}
@ -245,7 +329,7 @@ onBeforeUnmount(() => {
<v-menu location="bottom end"> <v-menu location="bottom end">
<template #activator="{ props }"> <template #activator="{ props }">
<v-btn icon color="primary" v-bind="props" class="mr-3"> <v-btn icon color="primary" v-bind="props" class="mr-1">
<v-icon>mdi-arrow-down-drop-circle-outline</v-icon> <v-icon>mdi-arrow-down-drop-circle-outline</v-icon>
</v-btn> </v-btn>
</template> </template>
@ -263,16 +347,58 @@ onBeforeUnmount(() => {
</v-menu> </v-menu>
</v-app-bar> </v-app-bar>
<!-- 본문 -->
<v-main> <v-main>
<v-container <v-container
fluid fluid
class="pa-16 background d-flex justify-center" class="pa-16 background d-flex justify-center"
style="width: 100%" style="width: 100%"
> >
<slot></slot> <slot />
</v-container> </v-container>
</v-main> </v-main>
</v-app> </v-app>
</template> </template>
<style scoped lang="sass"></style> <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);
}
/* 더 커진 홈(브랜드) 버튼 */
.brand-btn {
font-weight: 800;
letter-spacing: 0.08em;
padding: 0 14px;
}
/* 중앙 고정 네비게이션 */
.center-nav {
position: absolute;
left: 50%;
transform: translateX(-50%);
gap: 8px;
align-items: center;
}
/* 네비게이션 버튼 스타일 */
.nav-btn {
height: 40px;
text-transform: none;
border-radius: 10px;
padding: 0 10px;
font-size: 16px;
}
.nav-active {
background: rgba(59, 130, 246, 0.15);
}
.min-w-48 {
min-width: 12rem;
}
.userbox {
min-width: 180px;
}
</style>

@ -44,12 +44,12 @@ const pageSizeOptions = [
// //
const tableHeader = [ const tableHeader = [
{ label: "No", width: "5%", style: "word-break: keep-all;" },
{ label: "Title", width: "7%", style: "word-break: keep-all;" }, { label: "Title", width: "7%", style: "word-break: keep-all;" },
{ label: "File Name", width: "7%", style: "word-break: keep-all;" }, { label: "File Name", width: "7%", style: "word-break: keep-all;" },
{ label: "File Path", width: "7%", style: "word-break: keep-all;" }, { label: "File Path", width: "7%", style: "word-break: keep-all;" },
{ label: "Description", width: "7%", style: "word-break: keep-all;" }, { label: "Description", width: "7%", style: "word-break: keep-all;" },
{ label: "Created Data", width: "7%", style: "word-break: keep-all;" }, { label: "Created Data", width: "7%", style: "word-break: keep-all;" },
{ label: "Modified Data", width: "7%", style: "word-break: keep-all;" },
{ label: "Action", width: "7%", style: "word-break: keep-all;" }, { label: "Action", width: "7%", style: "word-break: keep-all;" },
]; ];
@ -500,12 +500,17 @@ watch(
:key="i" :key="i"
class="text-center" class="text-center"
> >
<td>
{{
data.totalElements -
((data.params.pageNum - 1) * data.params.pageSize + i)
}}
</td>
<td>{{ item.title }}</td> <td>{{ item.title }}</td>
<td>{{ item.fileName }}</td> <td>{{ item.fileName }}</td>
<td>{{ item.filePath }}</td> <td>{{ item.filePath }}</td>
<td>{{ item.description }}</td> <td>{{ item.description }}</td>
<td>{{ item.createdData }}</td> <td>{{ item.createdData }}</td>
<td>{{ item.modifiedData }}</td>
<td style="white-space: nowrap"> <td style="white-space: nowrap">
<IconInfoBtn @on-click="openDetailModal(item)" /> <IconInfoBtn @on-click="openDetailModal(item)" />
<IconModifyBtn @on-click="openModifyModal(item)" /> <IconModifyBtn @on-click="openModifyModal(item)" />

@ -7,6 +7,7 @@ import { ExecutionsService } from "@/components/service/management/ExecutionsSer
import { AttachmentsService } from "@/components/service/management/AttachmentsService"; import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { KubeflowRunService } from "@/components/service/management/KubeflowRunService"; import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
import { DataGroupService } from "@/components/service/management/DataGroupService"; import { DataGroupService } from "@/components/service/management/DataGroupService";
import { useRouter } from "vue-router";
type UiStatus = "success" | "failed" | "running" | "pending"; type UiStatus = "success" | "failed" | "running" | "pending";
type KfFilter = "SUCCEEDED" | "FAILED" | "RUNNING" | "PENDING" | null; type KfFilter = "SUCCEEDED" | "FAILED" | "RUNNING" | "PENDING" | null;
@ -85,9 +86,15 @@ function avatarIconByUiStatus(status: UiStatus) {
} }
const groupSummaries = ref<Array<{ id: number; name: string }>>([]); const groupSummaries = ref<Array<{ id: number; name: string }>>([]);
const datasetCountByGroup = ref<Record<number, number>>({});
const datasetsByGroup = ref<Record<number, DatasetRow[]>>({}); const datasetsByGroup = ref<Record<number, DatasetRow[]>>({});
const groupLoading = ref<Record<number, boolean>>({}); const groupLoading = ref<Record<number, boolean>>({});
const groupLoaded = ref<Record<number, boolean>>({}); const groupLoaded = ref<Record<number, boolean>>({});
const router = useRouter();
const goExecutions = () => router.push("/run/executions");
const goWorkflows = () => router.push("/workflows");
const goDatasets = () => router.push("/DataGroup");
async function loadDatasetsForGroup(groupId: number, groupName: string) { async function loadDatasetsForGroup(groupId: number, groupName: string) {
if (groupLoaded.value[groupId] || groupLoading.value[groupId]) return; if (groupLoaded.value[groupId] || groupLoading.value[groupId]) return;
@ -362,9 +369,45 @@ async function loadDatasetActivity() {
})) }))
.filter((g) => Number.isFinite(g.id)); .filter((g) => Number.isFinite(g.id));
//
datasetsByGroup.value = {}; datasetsByGroup.value = {};
groupLoaded.value = {}; groupLoaded.value = {};
groupLoading.value = {}; groupLoading.value = {};
datasetCountByGroup.value = {};
// ()
const tasks = groupSummaries.value.map((g) =>
AttachmentsService.search({
projectId: currentProjectId.value,
page: 0,
size: 1, // 1,
refType: "DATASET",
refId: g.id,
sortField: "id",
sortDirection: "DESC",
} as any)
.then((res: any) => {
//
const total = Number(
res?.data?.totalElements ??
res?.data?.total ??
res?.data?.page?.totalElements ??
0,
);
datasetCountByGroup.value = {
...datasetCountByGroup.value,
[g.id]: total,
};
})
.catch(() => {
datasetCountByGroup.value = {
...datasetCountByGroup.value,
[g.id]: 0,
};
}),
);
await Promise.allSettled(tasks);
} catch (err) { } catch (err) {
console.error("[Dashboard] loadDatasetActivity error:", err); console.error("[Dashboard] loadDatasetActivity error:", err);
groupSummaries.value = []; groupSummaries.value = [];
@ -403,7 +446,7 @@ async function loadKubeflowRuns() {
}; };
}) })
.sort((a, b) => toEpoch(b.createdAt) - toEpoch(a.createdAt)) .sort((a, b) => toEpoch(b.createdAt) - toEpoch(a.createdAt))
.slice(0, 20); .slice(0, 1000);
} catch (err) { } catch (err) {
console.error("[Dashboard] loadKubeflowRuns error:", err); console.error("[Dashboard] loadKubeflowRuns error:", err);
kubeflowRuns.value = []; kubeflowRuns.value = [];
@ -635,8 +678,21 @@ watch(showKubeflowDetails, async () => {
<!-- Recent Run --> <!-- Recent Run -->
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px"> <v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc"> <div
class="d-flex align-center justify-space-between w-100"
style="padding: 16px; border-bottom: 1px solid #ccc"
>
<h3 class="text-subtitle-1 font-weight-bold mb-0">Recent Run</h3> <h3 class="text-subtitle-1 font-weight-bold mb-0">Recent Run</h3>
<v-btn
variant="text"
class="text-caption font-weight-bold"
append-icon="mdi-arrow-right"
style="text-transform: none"
@click="goExecutions"
>
Go to Executions
</v-btn>
</div> </div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px"> <div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
@ -704,10 +760,22 @@ watch(showKubeflowDetails, async () => {
<!-- Workflows --> <!-- Workflows -->
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px"> <v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc"> <div
class="d-flex align-center justify-space-between w-100"
style="padding: 16px; border-bottom: 1px solid #ccc"
>
<h3 class="text-subtitle-1 font-weight-bold mb-0"> <h3 class="text-subtitle-1 font-weight-bold mb-0">
Recently Registered Workflow Recently Registered Workflow
</h3> </h3>
<v-btn
variant="text"
class="text-caption font-weight-bold"
append-icon="mdi-arrow-right"
style="text-transform: none"
@click="goWorkflows"
>
Go to Workflows
</v-btn>
</div> </div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px"> <div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
@ -746,12 +814,13 @@ watch(showKubeflowDetails, async () => {
<!-- Middle: Kubeflow / Dataset --> <!-- Middle: Kubeflow / Dataset -->
<v-row class="mt-4"> <v-row class="mt-4">
<!-- Kubeflow --> <!-- Kubeflow -->
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px"> <v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #3a3a3a"> <div style="padding: 16px; border-bottom: 1px solid #3a3a3a">
<div class="d-flex align-center justify-space-between w-100"> <div class="d-flex align-center justify-space-between w-100">
<h3 class="text-subtitle-1 font-weight-bold mb-0"> <h3 class="text-subtitle-1 font-weight-bold mb-0">
Kubeflow Runs Run Status Overview
</h3> </h3>
<div class="d-flex align-center ga-2"> <div class="d-flex align-center ga-2">
<v-chip size="small" variant="tonal" color="grey" <v-chip size="small" variant="tonal" color="grey"
@ -778,6 +847,15 @@ watch(showKubeflowDetails, async () => {
Filter: {{ kubeflowStatusFilter }} Filter: {{ kubeflowStatusFilter }}
</v-chip> </v-chip>
</div> </div>
<v-btn
variant="text"
class="text-caption font-weight-bold"
append-icon="mdi-arrow-right"
style="text-transform: none"
@click="goExecutions"
>
Go to Executions
</v-btn>
</div> </div>
</div> </div>
@ -891,6 +969,15 @@ watch(showKubeflowDetails, async () => {
<h3 class="text-subtitle-1 font-weight-bold mb-0"> <h3 class="text-subtitle-1 font-weight-bold mb-0">
Dataset Update Activity Dataset Update Activity
</h3> </h3>
<v-btn
variant="text"
class="text-caption font-weight-bold"
append-icon="mdi-arrow-right"
style="text-transform: none"
@click="goDatasets"
>
Go to DataGroup
</v-btn>
</div> </div>
<v-divider /> <v-divider />
@ -918,7 +1005,11 @@ watch(showKubeflowDetails, async () => {
<div class="d-flex align-center ga-2"> <div class="d-flex align-center ga-2">
<span class="font-weight-bold">{{ g.name }}</span> <span class="font-weight-bold">{{ g.name }}</span>
<v-chip size="x-small" variant="tonal" color="secondary"> <v-chip size="x-small" variant="tonal" color="secondary">
{{ (datasetsByGroup[g.id] || []).length }} datasets {{
datasetCountByGroup[g.id] ??
(datasetsByGroup[g.id] || []).length
}}
datasets
</v-chip> </v-chip>
</div> </div>
</v-expansion-panel-title> </v-expansion-panel-title>

@ -37,10 +37,7 @@ const selectedLabel = computed(
); );
/* ============ Plotly refs ============ */ /* ============ Plotly refs ============ */
const elAccuracy = ref<HTMLDivElement | null>(null); const elMetrics = 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") { function metricValue(key: "accuracy" | "precision" | "recall" | "f1_score") {
@ -81,52 +78,60 @@ function getTag(run: any, key: string): string | undefined {
/* ===== 차트 렌더: 선택한 단건만 ===== */ /* ===== 차트 렌더: 선택한 단건만 ===== */
function drawCharts() { function drawCharts() {
const baseLayout = (titleText: string, xlabel: string): Partial<any> => ({ const mm = selectedMetrics.value
title: { text: titleText }, // value
margin: { t: 40, r: 20, b: 40, l: 40 }, .filter((m) => typeof m.value === "number" && isFinite(m.value));
height: 290,
yaxis: { rangemode: "tozero" }, const x = mm.map((m) => m.key);
xaxis: { tickmode: "array", tickvals: [xlabel], ticktext: [xlabel] }, const y = mm.map((m) => m.value);
const layout: Partial<any> = {
margin: { t: 40, r: 20, b: 60, l: 50 },
height: 400,
showlegend: false, showlegend: false,
}); paper_bgcolor: "rgba(0,0,0,0)",
const config = { displayModeBar: false, responsive: true }; plot_bgcolor: "rgba(0,0,0,0)",
if (elAccuracy.value) font: { color: "#E0E0E0" },
Plotly.react( xaxis: {
elAccuracy.value, automargin: true,
[{ x: ["accuracy"], y: [metricValue("accuracy")], type: "bar" }],
baseLayout("accuracy"),
config,
);
if (elF1.value) gridcolor: "rgba(255,255,255,0.08)",
Plotly.react( zerolinecolor: "rgba(255,255,255,0.18)",
elF1.value, linecolor: "rgba(255,255,255,0.18)",
[{ x: ["f1_score"], y: [metricValue("f1_score")], type: "bar" }], },
baseLayout("f1_score"), yaxis: {
config, rangemode: "tozero",
); gridcolor: "rgba(255,255,255,0.08)",
zerolinecolor: "rgba(255,255,255,0.18)",
linecolor: "rgba(255,255,255,0.18)",
},
};
if (elPrecision.value) const trace: any = {
Plotly.react( type: "bar",
elPrecision.value, x,
[{ x: ["precision"], y: [metricValue("precision")], type: "bar" }], y,
baseLayout("precision"), text: y.map((v) => (typeof v === "number" ? v.toFixed(4) : "")),
config, textposition: "auto",
); hovertemplate: "%{x}: %{y}<extra></extra>",
};
if (elRecall.value) const config = { displayModeBar: false, responsive: true };
Plotly.react(
elRecall.value, if (elMetrics.value) {
[{ x: ["recall"], y: [metricValue("recall")], type: "bar" }], if (x.length === 0) {
baseLayout("recall"), Plotly.purge(elMetrics.value);
config, elMetrics.value.innerHTML =
); '<div style="padding:16px;color:#999;text-align:center;">No Data</div>';
} else {
Plotly.react(elMetrics.value, [trace], layout, config);
}
}
} }
function resizeCharts() { function resizeCharts() {
[elAccuracy.value, elF1.value, elPrecision.value, elRecall.value] if (elMetrics.value) Plotly.Plots.resize(elMetrics.value);
.filter(Boolean)
.forEach((el: any) => Plotly.Plots.resize(el));
} }
/* ===== 응답 정규화 + fallback ===== */ /* ===== 응답 정규화 + fallback ===== */
@ -145,10 +150,9 @@ function findFromList(runId: string) {
); );
} }
/* ============ API: runs 목록 & 단건 run ============ */
async function fetchRuns(expName?: string) { async function fetchRuns(expName?: string) {
const parentRunId = pickParentRunId(props.experimentInfo); const parentRunId = pickParentRunId(props.experimentInfo);
if (!expName || !parentRunId) { if (!expName) {
runs.value = []; runs.value = [];
selectedRunId.value = ""; selectedRunId.value = "";
return; return;
@ -156,6 +160,7 @@ async function fetchRuns(expName?: string) {
loadingRuns.value = true; loadingRuns.value = true;
try { try {
// 1) experiment_id
const expRes = await MlflowService.getExperimentByName(expName); const expRes = await MlflowService.getExperimentByName(expName);
const exp = expRes?.data ?? expRes; const exp = expRes?.data ?? expRes;
const expId = String( const expId = String(
@ -167,28 +172,38 @@ async function fetchRuns(expName?: string) {
return; return;
} }
// 2) experiment_id runs
const runsRes = await MlflowService.getRuns(expId); const runsRes = await MlflowService.getRuns(expId);
const body = runsRes?.data ?? runsRes; const body = runsRes?.data ?? runsRes;
const list = const list =
body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []); body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []);
// runId MLflow tag(kubeflow_run_id) // 3) runId ( )
const filtered = (Array.isArray(list) ? list : []).filter( const matched = (Array.isArray(list) ? list : []).filter(
(r) => getTag(r, "kubeflow_run_id") === parentRunId, (r: any) => getTag(r, "kubeflow_run_id") === parentRunId,
); );
const final = matched.length > 0 ? matched : list;
runs.value = filtered; // +
const sorted = [...final].sort(
// : 1
const first = [...filtered].sort(
(a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0), (a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0),
)[0]; );
selectedRunId.value = first?.info?.run_id || first?.info?.run_uuid || ""; runs.value = sorted;
selectedRunId.value =
sorted[0]?.info?.run_id || sorted[0]?.info?.run_uuid || "";
} finally { } finally {
loadingRuns.value = false; loadingRuns.value = false;
} }
} }
const runItems = computed(() =>
(runs.value || []).map((r: any) => {
const id = r?.info?.run_id || r?.info?.run_uuid || "";
const name = r?.info?.run_name || id; // ,
return { value: id, label: name };
}),
);
async function fetchRunDetail(runId: string) { async function fetchRunDetail(runId: string) {
if (!runId) { if (!runId) {
runDetail.value = null; runDetail.value = null;
@ -445,7 +460,7 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
<v-row align="center" class="py-2"> <v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold" <v-col cols="3" class="text-h6 font-weight-bold"
>Workflow</v-col >Workflow Name</v-col
> >
<v-col cols="9">{{ props.experimentInfo.workflow }}</v-col> <v-col cols="9">{{ props.experimentInfo.workflow }}</v-col>
</v-row> </v-row>
@ -550,23 +565,33 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
부모 runId와 일치하는 MLflow run이 없습니다. 부모 runId와 일치하는 MLflow run이 없습니다.
</v-alert> </v-alert>
</v-col> </v-col>
<v-col <v-col
cols="12" cols="12"
class="d-flex align-center justify-between" class="d-flex align-center justify-between flex-wrap ga-3"
> >
<div class="text-body-2"> <div class="text-body-2">
Runs (matched): Runs loaded:
<strong>{{ runs.length.toLocaleString() }}</strong> <strong>{{ runs.length.toLocaleString() }}</strong>
<v-progress-circular
v-if="loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
/>
</div>
<div class="text-body-2">
Selected: <strong>{{ selectedLabel }}</strong>
</div> </div>
<v-select
v-model="selectedRunId"
:items="runItems"
item-title="label"
item-value="value"
density="comfortable"
hide-details
:clearable="false"
clear-icon=""
style="min-width: 280px; max-width: 440px"
/>
<v-progress-circular
v-if="loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
/>
</v-col> </v-col>
</v-row> </v-row>
@ -737,30 +762,17 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
</v-table> </v-table>
</v-card> </v-card>
<!-- (C) 2×2 단건 바차트 --> <v-card flat class="mb-6">
<v-row> <v-card-title
<v-col cols="12" md="6" class="py-2 px-0 text-button text-medium-emphasis"
><div >
ref="elAccuracy" Metrics (bar chart)
style="width: 100%; height: 290px" </v-card-title>
></div <div
></v-col> ref="elMetrics"
<v-col cols="12" md="6" style="width: 100%; height: 400px"
><div ref="elF1" style="width: 100%; height: 290px"></div ></div>
></v-col> </v-card>
<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-card-text>
</v-window-item> </v-window-item>

@ -0,0 +1,383 @@
<script setup lang="ts">
import {
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import Plotly from "plotly.js-dist-min";
import { MlflowService } from "@/components/service/mlflow/MlflowService";
/** 부모에서 받는 값: 제목 라벨은 그대로 쓰고, 데이터는 runId 하나로 조회 */
const props = defineProps<{
expName: string; // ()
runId?: string; // MLflow
}>();
const emit = defineEmits<{ (e: "close"): void }>();
/* ---------- 상태 ---------- */
const runs = ref<any[]>([]); // /: =1
const loadingRuns = ref(false);
const selectedRunId = ref<string>(props.runId ?? "");
const loadingRunDetail = ref(false);
const runDetail = ref<any | null>(null);
/* 표시용: runs가 비어도 runDetail이 있으면 1로 보이도록 */
const runsLoadedCount = computed(() =>
runs.value.length > 0 ? runs.value.length : runDetail.value ? 1 : 0,
);
/* ---------- 렌더링용 ---------- */
const runItems = computed(() => {
// runs() , runDetail fallback, selectedRunId
const source = runs.value?.length
? runs.value
: runDetail.value
? [runDetail.value]
: [];
const items = source.map((r: any) => {
const id = r?.info?.run_id || r?.info?.run_uuid || "";
const name = r?.info?.run_name || id || "(no name)";
return { value: id, label: name };
});
if (items.length === 0 && selectedRunId.value) {
items.push({ value: selectedRunId.value, label: selectedRunId.value });
}
return items;
});
const selectedMetrics = computed(() => {
const mm: Array<{ key: string; value: number }> =
runDetail.value?.data?.metrics ?? [];
return mm.map((m) => ({ key: m.key, value: m.value }));
});
/* ---------- Plotly ---------- */
const elMetrics = ref<HTMLDivElement | null>(null);
function drawCharts() {
const mm = selectedMetrics.value.filter(
(m) => typeof m.value === "number" && isFinite(m.value),
);
const x = mm.map((m) => m.key);
const y = mm.map((m) => m.value);
if (!elMetrics.value) return;
if (x.length === 0) {
Plotly.purge(elMetrics.value);
elMetrics.value.innerHTML =
'<div style="padding:16px;color:#999;text-align:center;">No Data</div>';
return;
}
const trace: Partial<Plotly.PlotData> = {
type: "bar",
x,
y,
text: y.map((v) => (typeof v === "number" ? v.toFixed(4) : "")),
textposition: "auto",
hovertemplate: "%{x}: %{y}<extra></extra>",
};
const layout: Partial<Plotly.Layout> = {
title: { text: "Metrics" },
margin: { t: 40, r: 20, b: 60, l: 50 },
height: 400,
showlegend: false,
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
font: { color: "#E0E0E0" },
xaxis: {
automargin: true,
gridcolor: "rgba(255,255,255,0.08)",
zerolinecolor: "rgba(255,255,255,0.18)",
linecolor: "rgba(255,255,255,0.18)",
},
yaxis: {
rangemode: "tozero",
gridcolor: "rgba(255,255,255,0.08)",
zerolinecolor: "rgba(255,255,255,0.18)",
linecolor: "rgba(255,255,255,0.18)",
},
};
Plotly.react(elMetrics.value, [trace], layout, {
displayModeBar: false,
responsive: true,
});
}
function resizeCharts() {
if (elMetrics.value) Plotly.Plots.resize(elMetrics.value);
}
/* ---------- MLflow helpers ---------- */
function normalizeRun(res: any) {
const v = res?.data?.run ?? res?.run ?? res?.data ?? res;
return v?.info && v?.data ? v : null;
}
/* ---------- API (runId 단건만 조회) ---------- */
async function fetchRunDetailById(runId: string) {
loadingRuns.value = true; //
loadingRunDetail.value = true;
try {
if (!runId) {
runDetail.value = null;
runs.value = [];
await nextTick();
drawCharts();
return;
}
const res = await MlflowService.getExperimentRun(runId);
const one = normalizeRun(res);
runDetail.value = one;
// /
runs.value = one ? [one] : [];
//
if (!selectedRunId.value) selectedRunId.value = runId;
await nextTick();
drawCharts();
} catch (e) {
runDetail.value = null;
runs.value = [];
await nextTick();
drawCharts();
} finally {
loadingRunDetail.value = false;
loadingRuns.value = false;
}
}
/* ---------- watch & lifecycle ---------- */
// runId
watch(
() => props.runId,
(nv) => {
selectedRunId.value = nv || "";
fetchRunDetailById(selectedRunId.value);
},
{ immediate: true },
);
// (릿 )
watch(selectedRunId, (id, prev) => {
if (id && id !== prev) fetchRunDetailById(id);
});
// ( runId )
onMounted(() => {
if (selectedRunId.value) fetchRunDetailById(selectedRunId.value);
window.addEventListener("resize", resizeCharts);
});
onBeforeUnmount(() => window.removeEventListener("resize", resizeCharts));
</script>
<template>
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card flat class="w-100 rounded-lg pa-8">
<v-card-title class="grey lighten-4 py-2 px-4">
<span class="font-weight-bold">Metrics</span>
</v-card-title>
<v-card-text class="px-6 pb-2 pt-4">
<v-row class="mb-4" align="center">
<v-col
cols="12"
class="d-flex align-center justify-between flex-wrap ga-3"
>
<div class="text-body-2">
Runs loaded:
<strong>{{ runsLoadedCount.toLocaleString() }}</strong>
</div>
<v-select
v-model="selectedRunId"
:items="runItems"
item-title="label"
item-value="value"
density="comfortable"
hide-details
:clearable="false"
:disabled="runItems.length <= 1"
style="min-width: 280px; max-width: 440px"
/>
<v-progress-circular
v-if="loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
/>
</v-col>
</v-row>
<!-- 선택 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>
<!-- 요약 테이블 -->
<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>
<!-- 차트 -->
<v-card flat class="mb-6">
<v-card-title class="py-2 px-0 text-button text-medium-emphasis">
Metrics (bar chart)
</v-card-title>
<div ref="elMetrics" style="width: 100%; height: 400px"></div>
</v-card>
</v-card-text>
<v-sheet class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</v-sheet>
</v-card>
</v-container>
</template>

@ -434,13 +434,16 @@ onMounted(() => {
<td>{{ item.createdDate }}</td> <td>{{ item.createdDate }}</td>
<td>{{ item.createdID }}</td> <td>{{ item.createdID }}</td>
<td style="white-space: nowrap"> <td style="white-space: nowrap">
<!-- 클릭 중복 방지 --> <span @click.stop @keydown.stop>
<IconInfoBtn @on-click.stop="openInfoModal(item)" /> <IconInfoBtn @on-click="openInfoModal(item)" />
<IconDeleteBtn </span>
@on-click.stop=" <span @click.stop @keydown.stop>
removeData([{ deviceKey: item.deviceKey }]) <IconDeleteBtn
" @on-click="
/> removeData([{ deviceKey: item.deviceKey }])
"
/>
</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>

@ -1,10 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { MlflowService } from "@/components/service/mlflow/MlflowService"; import { MlflowService } from "@/components/service/mlflow/MlflowService";
import DetailComponent from "@/components/templates/run/experiment/DetailComponent.vue";
const props = defineProps<{ experimentInfo: any }>(); const props = defineProps<{ experimentInfo: any }>();
const emit = defineEmits<{ (e: "close"): void }>(); const emit = defineEmits<{ (e: "close"): void }>();
const showDetail = ref(false);
const detailProps = ref<{ expName: string; runId: string } | null>(null);
// //
const header = computed(() => ({ const header = computed(() => ({
experimentName: props.experimentInfo?.name ?? "", experimentName: props.experimentInfo?.name ?? "",
@ -24,27 +28,44 @@ const loading = ref(false);
const experimentId = ref<string>(""); const experimentId = ref<string>("");
const runs = ref<any[]>([]); const runs = ref<any[]>([]);
// function toMillis(v: unknown): number | null {
const fmtTs = (ms?: number | string) => { if (v === null || v === undefined || v === "") return null;
if (ms === undefined || ms === null || ms === "") return "-";
const d = new Date(Number(ms)); // ( ) => /
if (isNaN(d.getTime())) return "-"; const n = typeof v === "number" ? v : Number(v as any);
const pad = (n: number) => String(n).padStart(2, "0"); if (!Number.isNaN(n)) {
// 1e12 : (>), (<=)
return n > 1e12 ? Math.trunc(n) : Math.trunc(n * 1000);
}
// ISO
const t = Date.parse(String(v));
return Number.isNaN(t) ? null : t;
}
function fmtTsAny(v?: unknown): string {
const ms = toMillis(v);
if (ms == null) return "-";
const d = new Date(ms);
if (Number.isNaN(d.getTime())) return "-";
const pad = (x: number) => String(x).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad( return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours(), d.getHours(),
)}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; )}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}; }
const fmtDuration = (start?: number | string, end?: number | string) => {
if (start == null || end == null) return "-"; function fmtDurationAny(start?: unknown, end?: unknown): string {
const ms = Number(end) - Number(start); const s = toMillis(start);
if (!isFinite(ms) || ms < 0) return "-"; const e = toMillis(end);
const s = Math.floor(ms / 1000); if (s == null || e == null || e < s) return "-";
const h = Math.floor(s / 3600); const diff = e - s;
const m = Math.floor((s % 3600) / 60); const sec = Math.floor(diff / 1000);
const sec = s % 60; const h = Math.floor(sec / 3600);
const pad = (n: number) => String(n).padStart(2, "0"); const m = Math.floor((sec % 3600) / 60);
return `${h}:${pad(m)}:${pad(sec)}`; const s2 = sec % 60;
}; const pad = (x: number) => String(x).padStart(2, "0");
return `${h}:${pad(m)}:${pad(s2)}`;
}
const toUiStatus = (status?: string) => { const toUiStatus = (status?: string) => {
switch ((status || "").toUpperCase()) { switch ((status || "").toUpperCase()) {
case "FINISHED": case "FINISHED":
@ -61,16 +82,14 @@ const toUiStatus = (status?: string) => {
} }
}; };
// Runs
const runRows = computed(() => const runRows = computed(() =>
runs.value.map((r: any) => { runs.value.map((r: any) => {
const info = r?.info ?? {}; const info = r?.info ?? {};
const tags: Array<{ key: string; value: string }> = r?.data?.tags ?? []; const tags: Array<{ key: string; value: string }> = r?.data?.tags ?? [];
const tag = (k: string) => tags.find((t) => t.key === k)?.value; const tag = (k: string) => tags.find((t) => t.key === k)?.value;
// pipeline mlflow.source.name runName // pipeline
const sourceName = tag("mlflow.source.name") || ""; const sourceName = tag("mlflow.source.name") || "";
//
const pipeline = const pipeline =
sourceName sourceName
.split("/") .split("/")
@ -83,9 +102,10 @@ const runRows = computed(() =>
return { return {
runName: info?.run_name || tag("mlflow.runName") || "-", runName: info?.run_name || tag("mlflow.runName") || "-",
status: toUiStatus(info?.status), status: toUiStatus(info?.status),
duration: fmtDuration(info?.start_time, info?.end_time), //
duration: fmtDurationAny(info?.start_time, info?.end_time),
pipeline, pipeline,
startTime: fmtTs(info?.start_time), startTime: fmtTsAny(info?.start_time),
raw: r, raw: r,
}; };
}), }),
@ -134,6 +154,16 @@ async function fetchRunsByExperimentName(expName: string) {
} }
} }
function onRunRowClick(row: any) {
const runId = row?.raw?.info?.run_id || row?.raw?.info?.run_uuid || "";
const expName = header.value.experimentName;
if (!runId || !expName) return;
//
detailProps.value = { expName, runId };
showDetail.value = true;
}
// / // /
onMounted(() => { onMounted(() => {
fetchRunsByExperimentName(header.value.experimentName); fetchRunsByExperimentName(header.value.experimentName);
@ -145,178 +175,196 @@ watch(
</script> </script>
<template> <template>
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center"> <!-- 목록 화면 -->
<v-card <div v-if="!showDetail" class="w-100">
flat <v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
class="bg-shades-transparent d-flex flex-column justify-center w-100"
>
<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>
</v-card-item>
</v-card>
<!-- Experiment Information -->
<v-card <v-card
flat flat
class="bordered-box mb-6 w-100 rounded-lg pa-8 position-relative" class="bg-shades-transparent d-flex flex-column justify-center w-100"
> >
<v-card-title class="grey lighten-4 py-2 px-4"> <v-card flat class="bg-shades-transparent w-100">
<span class="font-weight-bold">Experiment Information</span> <v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
</v-card-title> <div class="d-flex flex-row justify-start align-center">
<div class="text-primary">View Details</div>
<v-card-text class="px-6 pb-6 pt-4"> </div>
<v-row align="center" class="py-2"> </v-card-item>
<v-col cols="3" class="text-h6 font-weight-bold" </v-card>
>Experiment Name</v-col
> <!-- Experiment Information -->
<v-col cols="9" class="pa-2">{{ header.experimentName }}</v-col> <v-card
</v-row> flat
<VDivider class="my-2" /> class="bordered-box mb-6 w-100 rounded-lg pa-8 position-relative"
<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">{{ header.projectName }}</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">Created ID</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">{{ header.createdDate }}</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">Description</v-col>
<v-col cols="9" class="pa-2">{{ header.description }}</v-col>
</v-row>
</v-card-text>
<v-overlay
:model-value="loading"
contained
persistent
class="align-center justify-center"
> >
<v-progress-circular indeterminate size="48" /> <v-card-title class="grey lighten-4 py-2 px-4">
</v-overlay> <span class="font-weight-bold">Experiment Information</span>
</v-card> </v-card-title>
<!-- Runs --> <v-card-text class="px-6 pb-6 pt-4">
<v-card class="rounded-lg pa-8 w-100"> <v-row align="center" class="py-2">
<v-card-title class="grey lighten-4 py-2 px-4"> <v-col cols="3" class="text-h6 font-weight-bold"
<span class="font-weight-bold">Runs</span> >Experiment Name</v-col
</v-card-title> >
<v-col cols="9" class="pa-2">{{ header.experimentName }}</v-col>
<v-card-text class="px-6 pb-2 pt-4"> </v-row>
<v-sheet class="d-flex align-center justify-between mb-4"> <VDivider class="my-2" />
<div class="text-body-2">
{{ runRows.length.toLocaleString() }} · Experiment ID: <v-row align="center" class="py-2">
<strong>{{ experimentId || "-" }}</strong> <v-col cols="3" class="text-h6 font-weight-bold"
</div> >Project Name</v-col
<v-responsive max-width="120"> >
<v-select <v-col cols="9" class="pa-2">{{ header.projectName }}</v-col>
v-model="pageSize" </v-row>
:items="pageSizeOptions" <VDivider class="my-2" />
item-title="text"
item-value="value" <v-row align="center" class="py-2">
density="compact" <v-col cols="3" class="text-h6 font-weight-bold"
hide-details >Created ID</v-col
label="Rows" >
@update:model-value="page = 1" <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">{{ header.createdDate }}</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"
>Description</v-col
>
<v-col cols="9" class="pa-2">{{ header.description }}</v-col>
</v-row>
</v-card-text>
<v-overlay
:model-value="loading"
contained
persistent
class="align-center justify-center"
>
<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-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>
<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"
class="run-row"
role="button"
tabindex="0"
@click="onRunRowClick(r)"
@keydown.enter.prevent="onRunRowClick(r)"
@keydown.space.prevent="onRunRowClick(r)"
>
<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-responsive> </v-card-actions>
</v-sheet> </v-card-text>
<v-table density="comfortable" fixed-header height="420"> <v-sheet class="d-flex justify-end mb-2">
<colgroup> <v-btn color="primary" @click="emit('close')">Back to List</v-btn>
<col style="width: 30%" /> </v-sheet>
<col style="width: 12%" /> </v-card>
<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>
</v-sheet>
</v-card> </v-card>
</v-card> </v-container>
</v-container> </div>
<!-- 디테일 화면 -->
<div v-else class="w-100">
<DetailComponent
v-if="detailProps"
:exp-name="detailProps.expName"
:run-id="detailProps.runId"
@close="showDetail = false"
/>
</div>
</template> </template>
<style scoped>
.run-row {
cursor: pointer;
transition: background-color 0.12s ease;
}
.run-row:hover {
background: rgba(255, 255, 255, 0.06);
}
.run-row:focus {
outline: 2px solid #3b82f6;
outline-offset: -2px;
}
</style>

@ -43,12 +43,12 @@ const pageSizeOptions = [
// //
const tableHeader = [ const tableHeader = [
{ label: "No", width: "5%", style: "word-break: keep-all;" },
{ label: "Title", width: "7%", style: "word-break: keep-all;" }, { label: "Title", width: "7%", style: "word-break: keep-all;" },
{ label: "File Name", width: "7%", style: "word-break: keep-all;" }, { label: "File Name", width: "7%", style: "word-break: keep-all;" },
{ label: "File Path", width: "7%", style: "word-break: keep-all;" }, { label: "File Path", width: "7%", style: "word-break: keep-all;" },
{ label: "Description", width: "7%", style: "word-break: keep-all;" }, { label: "Description", width: "7%", style: "word-break: keep-all;" },
{ label: "Created Data", width: "7%", style: "word-break: keep-all;" }, { label: "Created Data", width: "7%", style: "word-break: keep-all;" },
{ label: "Modified Data", width: "7%", style: "word-break: keep-all;" },
{ label: "Action", width: "7%", style: "word-break: keep-all;" }, { label: "Action", width: "7%", style: "word-break: keep-all;" },
]; ];
@ -499,12 +499,18 @@ watch(
:key="i" :key="i"
class="text-center" class="text-center"
> >
<td>
{{
data.totalElements -
((data.params.pageNum - 1) * data.params.pageSize + i)
}}
</td>
<td>{{ item.title }}</td> <td>{{ item.title }}</td>
<td>{{ item.fileName }}</td> <td>{{ item.fileName }}</td>
<td>{{ item.filePath }}</td> <td>{{ item.filePath }}</td>
<td>{{ item.description }}</td> <td>{{ item.description }}</td>
<td>{{ item.createdData }}</td> <td>{{ item.createdData }}</td>
<td>{{ item.modifiedData }}</td>
<td style="white-space: nowrap"> <td style="white-space: nowrap">
<IconInfoBtn @on-click="openDetailModal(item)" /> <IconInfoBtn @on-click="openDetailModal(item)" />
<IconModifyBtn @on-click="openModifyModal(item)" /> <IconModifyBtn @on-click="openModifyModal(item)" />

@ -272,7 +272,7 @@ async function saveUser() {
const payload: any = { const payload: any = {
username, username,
email, email,
role: roleOne ? [roleOne] : undefined, // role: roleOne ? [roleOne] : undefined,
}; };
if (password) payload.password = password; // if (password) payload.password = password; //

Loading…
Cancel
Save