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']
DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.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']
ExecutionBaseDialog: typeof import('./src/components/atoms/organisms/ExecutionBaseDialog.vue')['default']
ExecutionsViewComponent: typeof import('./src/components/templates/run/executions/ExecutionsViewComponent.vue')['default']

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

@ -93,17 +93,15 @@ async function submit() {
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 {

@ -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 { 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 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 router = useRouter();
const username = ref("");
const projectName = ref(localStorage.getItem("projectName") || "");
const username = ref<string>("");
const projectName = ref<string>(localStorage.getItem("projectName") || "");
const isAdmin = ref(false);
const adminMode = ref(false);
const lastNonAdminPath = ref("/home");
const isAdmin = ref<boolean>(false);
const adminMode = ref<boolean>(false); // Settings /
const lastNonAdminPath = ref<string>("/home");
function computeIsAdmin() {
/* ================================
* Auth / Role helpers
* ================================ */
function readAuth() {
try {
const raw =
typeof storage?.getAuth === "function"
? storage.getAuth()
: JSON.parse(localStorage.getItem("autoflow-auth") || "null");
return raw ?? null;
} catch {
return null;
}
}
const roles = raw?.userInfo?.roles ?? raw?.roles ?? [];
const authCd = raw?.userInfo?.authCd ?? raw?.authCd ?? raw?.auth;
function computeIsAdmin() {
const auth = readAuth();
const roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
const authCd = auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth;
const inRoles = Array.isArray(roles)
? roles.includes("ROLE_ADMIN")
: roles === "ROLE_ADMIN";
isAdmin.value = inRoles || authCd === "ADMIN";
} catch {
isAdmin.value = false;
}
}
//
function toggleAdmin() {
if (!isAdmin.value) return;
if (adminMode.value) {
adminMode.value = false;
router.push(lastNonAdminPath.value || "/home");
} else {
adminMode.value = true;
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: "Change Password",
@ -61,36 +117,32 @@ const menuItems = [
{ title: "Logout", icon: "mdi-logout", click: () => logOut() },
];
const drawer = ref(null);
const pageTitle = computed(() => route.meta.title);
const pagePath = computed(() => route.path);
// active
const isLinkActive = (link) => route.path.includes(link);
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) {
/* ================================
* Navigation actions
* ================================ */
function goMain() {
adminMode.value = false;
}
router.push("/home");
}
function refreshProjectName() {
const v = localStorage.getItem("projectName");
projectName.value = v ? v : "";
}
function goSelect() {
adminMode.value = false;
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() {
UserManagerService.signOut()
.catch(console.error)
@ -98,50 +150,54 @@ function logOut() {
localStorage.removeItem("autoflow-auth");
localStorage.removeItem("projectName");
localStorage.removeItem("projectId");
sessionStorage.removeItem("initialRedirectDone");
username.value = "";
projectName.value = "";
sessionStorage.removeItem("initialRedirectDone");
adminMode.value = false;
router.push("/login");
});
}
// storage
function onStorage(e) {
if (!e.key || e.key === "projectName") {
refreshProjectName();
adminMode.value = false;
}
if (!e.key || e.key === "autoflow-auth" || e.key === "auth") {
updateUsername();
computeIsAdmin();
}
/* ================================
* Effects
* ================================ */
function refreshProjectName() {
projectName.value = localStorage.getItem("projectName") || "";
}
const goMain = () => {
adminMode.value = false;
router.push("/home");
};
//
//
watch(
() => route.fullPath,
() => {
refreshProjectName();
const isAdminRoute = route.matched.some((r) => r.meta?.requiresAdmin);
if (!isAdminRoute) lastNonAdminPath.value = route.fullPath || "/home";
syncAdminModeWithRoute();
if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
},
{ 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(() => {
updateUsername();
computeIsAdmin();
refreshProjectName();
menu.value = menuItems;
window.addEventListener("storage", onStorage);
syncAdminModeWithRoute();
});
onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage);
});
@ -149,69 +205,97 @@ onBeforeUnmount(() => {
<template>
<v-app>
<!-- 사이드바 -->
<v-navigation-drawer
v-model="drawer"
border="0"
hide-overlay
permanent
v-if="!route.meta.hideSidebar"
:rail="false"
>
<!-- 헤더 -->
<v-card
:ripple="false"
flat
class="bg-shades-transparent d-flex w-100 justify-center text-h5 pa-4 pb-16"
<!-- ===== 상단 탑바 ===== -->
<v-app-bar flat height="75" class="topbar">
<!-- 좌측: (브랜드) 버튼 -->
<v-btn
variant="text"
size="large"
class="brand-btn text-h5 text-primary"
@click="goMain"
aria-label="Home"
>
<SidebarHeader />
</v-card>
AUTOFLOW WEB CONSOLE
</v-btn>
<!-- 기본(일반 사용자) 메뉴 -->
<DrawerComponent v-if="!adminMode" />
<!-- 중앙: 메뉴 그룹 (Settings / 분기) -->
<div class="center-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴: showAdminTabs 조건으로 표시 -->
<template v-if="showAdminTabs">
<template v-for="(m, i) in adminMenus" :key="'am_' + i">
<v-btn
variant="text"
class="nav-btn"
:prepend-icon="m.icon"
:to="m.path"
:color="isLinkActive(m.path) ? 'primary' : undefined"
:class="{ 'nav-active': isLinkActive(m.path) }"
>
{{ m.title }}
</v-btn>
</template>
</template>
<!-- 관리자 메뉴: 유저 메뉴와 동일한 /여백/활성 스타일 -->
<!-- 기본 메뉴 -->
<template v-else>
<v-card flat class="mx-auto">
<v-list nav class="pa-5 pt-0">
<v-list-item
rounded
title="Projects"
value="projects"
to="/project"
prepend-icon="mdi-briefcase"
:active="isLinkActive('/project')"
:active-color="isLinkActive('/project') ? 'primary' : null"
density="compact"
class="pa-2 rounded-lg"
style="padding-inline-start: 10px"
/>
<template v-for="(m, i) in baseMenus" :key="'m_' + i">
<v-menu
v-if="m.depth?.length"
open-on-hover
close-on-content-click
location="bottom"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="nav-btn"
:class="{
'nav-active': m.depth?.some((d: any) =>
isLinkActive(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
rounded
title="Users"
value="users"
to="/users"
prepend-icon="mdi-account-multiple"
:active="isLinkActive('/users')"
:active-color="isLinkActive('/users') ? 'primary' : null"
density="compact"
class="pa-2 rounded-lg"
style="padding-inline-start: 10px"
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-card>
</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>
</v-navigation-drawer>
<v-app-bar class="bg-shades-transparent" flat>
</div>
<v-spacer />
<!-- 우측: 기존 기능 유지 -->
<v-tooltip v-if="isAdmin" location="bottom" text="Settings">
<template #activator="{ props }">
<template #activator="{ props }" v-if="!hideAllMenus">
<v-btn
icon
color="primary"
class="mr-3"
class="mr-2"
v-bind="props"
@click="toggleAdmin"
aria-label="Settings"
@ -221,22 +305,22 @@ onBeforeUnmount(() => {
</template>
</v-tooltip>
<!-- 프로젝트 선택 -->
<v-tooltip location="bottom" text="Project">
<template #activator="{ props }">
<v-btn
icon
color="primary"
class="mr-3"
class="mr-2"
@click="goSelect"
v-bind="props"
aria-label="Project"
>
<v-icon>mdi-home</v-icon>
</v-btn>
</template>
</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="text-subtitle-2">
{{ projectName || "No Project Selected" }}
@ -245,7 +329,7 @@ onBeforeUnmount(() => {
<v-menu location="bottom end">
<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-btn>
</template>
@ -263,16 +347,58 @@ onBeforeUnmount(() => {
</v-menu>
</v-app-bar>
<!-- 본문 -->
<v-main>
<v-container
fluid
class="pa-16 background d-flex justify-center"
style="width: 100%"
>
<slot></slot>
<slot />
</v-container>
</v-main>
</v-app>
</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 = [
{ label: "No", width: "5%", 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 Path", 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: "Modified Data", width: "7%", style: "word-break: keep-all;" },
{ label: "Action", width: "7%", style: "word-break: keep-all;" },
];
@ -500,12 +500,17 @@ watch(
:key="i"
class="text-center"
>
<td>
{{
data.totalElements -
((data.params.pageNum - 1) * data.params.pageSize + i)
}}
</td>
<td>{{ item.title }}</td>
<td>{{ item.fileName }}</td>
<td>{{ item.filePath }}</td>
<td>{{ item.description }}</td>
<td>{{ item.createdData }}</td>
<td>{{ item.modifiedData }}</td>
<td style="white-space: nowrap">
<IconInfoBtn @on-click="openDetailModal(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 { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
import { DataGroupService } from "@/components/service/management/DataGroupService";
import { useRouter } from "vue-router";
type UiStatus = "success" | "failed" | "running" | "pending";
type KfFilter = "SUCCEEDED" | "FAILED" | "RUNNING" | "PENDING" | null;
@ -85,9 +86,15 @@ function avatarIconByUiStatus(status: UiStatus) {
}
const groupSummaries = ref<Array<{ id: number; name: string }>>([]);
const datasetCountByGroup = ref<Record<number, number>>({});
const datasetsByGroup = ref<Record<number, DatasetRow[]>>({});
const groupLoading = 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) {
if (groupLoaded.value[groupId] || groupLoading.value[groupId]) return;
@ -362,9 +369,45 @@ async function loadDatasetActivity() {
}))
.filter((g) => Number.isFinite(g.id));
//
datasetsByGroup.value = {};
groupLoaded.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) {
console.error("[Dashboard] loadDatasetActivity error:", err);
groupSummaries.value = [];
@ -403,7 +446,7 @@ async function loadKubeflowRuns() {
};
})
.sort((a, b) => toEpoch(b.createdAt) - toEpoch(a.createdAt))
.slice(0, 20);
.slice(0, 1000);
} catch (err) {
console.error("[Dashboard] loadKubeflowRuns error:", err);
kubeflowRuns.value = [];
@ -635,8 +678,21 @@ watch(showKubeflowDetails, async () => {
<!-- Recent Run -->
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<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>
<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 style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
@ -704,10 +760,22 @@ watch(showKubeflowDetails, async () => {
<!-- Workflows -->
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<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">
Recently Registered Workflow
</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 style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
@ -746,12 +814,13 @@ watch(showKubeflowDetails, async () => {
<!-- Middle: Kubeflow / Dataset -->
<v-row class="mt-4">
<!-- Kubeflow -->
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #3a3a3a">
<div class="d-flex align-center justify-space-between w-100">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Kubeflow Runs
Run Status Overview
</h3>
<div class="d-flex align-center ga-2">
<v-chip size="small" variant="tonal" color="grey"
@ -778,6 +847,15 @@ watch(showKubeflowDetails, async () => {
Filter: {{ kubeflowStatusFilter }}
</v-chip>
</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>
@ -891,6 +969,15 @@ watch(showKubeflowDetails, async () => {
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Dataset Update Activity
</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>
<v-divider />
@ -918,7 +1005,11 @@ watch(showKubeflowDetails, async () => {
<div class="d-flex align-center ga-2">
<span class="font-weight-bold">{{ g.name }}</span>
<v-chip size="x-small" variant="tonal" color="secondary">
{{ (datasetsByGroup[g.id] || []).length }} datasets
{{
datasetCountByGroup[g.id] ??
(datasetsByGroup[g.id] || []).length
}}
datasets
</v-chip>
</div>
</v-expansion-panel-title>

@ -37,10 +37,7 @@ const selectedLabel = computed(
);
/* ============ 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);
const elMetrics = ref<HTMLDivElement | null>(null);
/* ===== 유틸: 단건 값 꺼내기 ===== */
function metricValue(key: "accuracy" | "precision" | "recall" | "f1_score") {
@ -81,52 +78,60 @@ function getTag(run: any, key: string): string | undefined {
/* ===== 차트 렌더: 선택한 단건만 ===== */
function drawCharts() {
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] },
const mm = selectedMetrics.value
// value
.filter((m) => typeof m.value === "number" && isFinite(m.value));
const x = mm.map((m) => m.key);
const y = mm.map((m) => m.value);
const layout: Partial<any> = {
margin: { t: 40, r: 20, b: 60, l: 50 },
height: 400,
showlegend: false,
});
const config = { displayModeBar: false, responsive: true };
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
if (elAccuracy.value)
Plotly.react(
elAccuracy.value,
[{ x: ["accuracy"], y: [metricValue("accuracy")], type: "bar" }],
baseLayout("accuracy"),
config,
);
font: { color: "#E0E0E0" },
xaxis: {
automargin: true,
if (elF1.value)
Plotly.react(
elF1.value,
[{ x: ["f1_score"], y: [metricValue("f1_score")], type: "bar" }],
baseLayout("f1_score"),
config,
);
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)",
},
};
if (elPrecision.value)
Plotly.react(
elPrecision.value,
[{ x: ["precision"], y: [metricValue("precision")], type: "bar" }],
baseLayout("precision"),
config,
);
const trace: any = {
type: "bar",
x,
y,
text: y.map((v) => (typeof v === "number" ? v.toFixed(4) : "")),
textposition: "auto",
hovertemplate: "%{x}: %{y}<extra></extra>",
};
if (elRecall.value)
Plotly.react(
elRecall.value,
[{ x: ["recall"], y: [metricValue("recall")], type: "bar" }],
baseLayout("recall"),
config,
);
const config = { displayModeBar: false, responsive: true };
if (elMetrics.value) {
if (x.length === 0) {
Plotly.purge(elMetrics.value);
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() {
[elAccuracy.value, elF1.value, elPrecision.value, elRecall.value]
.filter(Boolean)
.forEach((el: any) => Plotly.Plots.resize(el));
if (elMetrics.value) Plotly.Plots.resize(elMetrics.value);
}
/* ===== 응답 정규화 + fallback ===== */
@ -145,10 +150,9 @@ function findFromList(runId: string) {
);
}
/* ============ API: runs 목록 & 단건 run ============ */
async function fetchRuns(expName?: string) {
const parentRunId = pickParentRunId(props.experimentInfo);
if (!expName || !parentRunId) {
if (!expName) {
runs.value = [];
selectedRunId.value = "";
return;
@ -156,6 +160,7 @@ async function fetchRuns(expName?: string) {
loadingRuns.value = true;
try {
// 1) experiment_id
const expRes = await MlflowService.getExperimentByName(expName);
const exp = expRes?.data ?? expRes;
const expId = String(
@ -167,28 +172,38 @@ async function fetchRuns(expName?: string) {
return;
}
// 2) experiment_id runs
const runsRes = await MlflowService.getRuns(expId);
const body = runsRes?.data ?? runsRes;
const list =
body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []);
// runId MLflow tag(kubeflow_run_id)
const filtered = (Array.isArray(list) ? list : []).filter(
(r) => getTag(r, "kubeflow_run_id") === parentRunId,
// 3) runId ( )
const matched = (Array.isArray(list) ? list : []).filter(
(r: any) => getTag(r, "kubeflow_run_id") === parentRunId,
);
const final = matched.length > 0 ? matched : list;
runs.value = filtered;
// : 1
const first = [...filtered].sort(
// +
const sorted = [...final].sort(
(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 {
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) {
if (!runId) {
runDetail.value = null;
@ -445,7 +460,7 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
<v-row align="center" class="py-2">
<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-row>
@ -550,23 +565,33 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
부모 runId와 일치하는 MLflow run이 없습니다.
</v-alert>
</v-col>
<v-col
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">
Runs (matched):
Runs loaded:
<strong>{{ runs.length.toLocaleString() }}</strong>
</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"
/>
</div>
<div class="text-body-2">
Selected: <strong>{{ selectedLabel }}</strong>
</div>
</v-col>
</v-row>
@ -737,30 +762,17 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
</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 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-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.createdID }}</td>
<td style="white-space: nowrap">
<!-- 클릭 중복 방지 -->
<IconInfoBtn @on-click.stop="openInfoModal(item)" />
<span @click.stop @keydown.stop>
<IconInfoBtn @on-click="openInfoModal(item)" />
</span>
<span @click.stop @keydown.stop>
<IconDeleteBtn
@on-click.stop="
@on-click="
removeData([{ deviceKey: item.deviceKey }])
"
/>
</span>
</td>
</tr>
</tbody>

@ -1,10 +1,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { MlflowService } from "@/components/service/mlflow/MlflowService";
import DetailComponent from "@/components/templates/run/experiment/DetailComponent.vue";
const props = defineProps<{ experimentInfo: any }>();
const emit = defineEmits<{ (e: "close"): void }>();
const showDetail = ref(false);
const detailProps = ref<{ expName: string; runId: string } | null>(null);
//
const header = computed(() => ({
experimentName: props.experimentInfo?.name ?? "",
@ -24,27 +28,44 @@ const loading = ref(false);
const experimentId = ref<string>("");
const runs = ref<any[]>([]);
//
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");
function toMillis(v: unknown): number | null {
if (v === null || v === undefined || v === "") return null;
// ( ) => /
const n = typeof v === "number" ? v : Number(v as any);
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(
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)}`;
};
}
function fmtDurationAny(start?: unknown, end?: unknown): string {
const s = toMillis(start);
const e = toMillis(end);
if (s == null || e == null || e < s) return "-";
const diff = e - s;
const sec = Math.floor(diff / 1000);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s2 = sec % 60;
const pad = (x: number) => String(x).padStart(2, "0");
return `${h}:${pad(m)}:${pad(s2)}`;
}
const toUiStatus = (status?: string) => {
switch ((status || "").toUpperCase()) {
case "FINISHED":
@ -61,16 +82,14 @@ const toUiStatus = (status?: string) => {
}
};
// 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;
// pipeline mlflow.source.name runName
// pipeline
const sourceName = tag("mlflow.source.name") || "";
//
const pipeline =
sourceName
.split("/")
@ -83,9 +102,10 @@ const runRows = computed(() =>
return {
runName: info?.run_name || tag("mlflow.runName") || "-",
status: toUiStatus(info?.status),
duration: fmtDuration(info?.start_time, info?.end_time),
//
duration: fmtDurationAny(info?.start_time, info?.end_time),
pipeline,
startTime: fmtTs(info?.start_time),
startTime: fmtTsAny(info?.start_time),
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(() => {
fetchRunsByExperimentName(header.value.experimentName);
@ -145,6 +175,8 @@ watch(
</script>
<template>
<!-- 목록 화면 -->
<div v-if="!showDetail" class="w-100">
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card
flat
@ -158,7 +190,7 @@ watch(
</v-card-item>
</v-card>
<!-- Experiment Information -->
<!-- Experiment Information -->
<v-card
flat
class="bordered-box mb-6 w-100 rounded-lg pa-8 position-relative"
@ -185,7 +217,9 @@ watch(
<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="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
@ -195,7 +229,9 @@ watch(
<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="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>
@ -210,7 +246,7 @@ watch(
</v-overlay>
</v-card>
<!-- Runs -->
<!-- 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>
@ -222,18 +258,6 @@ watch(
{{ 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">
@ -247,7 +271,6 @@ watch(
<thead>
<tr>
<!-- 가독성 위해 Run Name / Pipeline은 좌측, 나머지는 중앙 -->
<th class="text-left">Run Name</th>
<th class="text-center">Status</th>
<th class="text-center">Duration</th>
@ -263,12 +286,19 @@ watch(
</td>
</tr>
<tr v-for="(r, i) in pagedRows" :key="r.raw?.info?.run_id || i">
<!-- 셀에 정렬 클래스 지정 -->
<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
@ -286,18 +316,11 @@ watch(
<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-center">{{ r.duration }}</td>
<td class="text-left text-truncate" :title="r.pipeline">
{{ r.pipeline }}
</td>
<td class="text-center">
{{ r.startTime }}
</td>
<td class="text-center">{{ r.startTime }}</td>
</tr>
</tbody>
</v-table>
@ -319,4 +342,29 @@ watch(
</v-card>
</v-card>
</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>
<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 = [
{ label: "No", width: "5%", 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 Path", 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: "Modified Data", width: "7%", style: "word-break: keep-all;" },
{ label: "Action", width: "7%", style: "word-break: keep-all;" },
];
@ -499,12 +499,18 @@ watch(
:key="i"
class="text-center"
>
<td>
{{
data.totalElements -
((data.params.pageNum - 1) * data.params.pageSize + i)
}}
</td>
<td>{{ item.title }}</td>
<td>{{ item.fileName }}</td>
<td>{{ item.filePath }}</td>
<td>{{ item.description }}</td>
<td>{{ item.createdData }}</td>
<td>{{ item.modifiedData }}</td>
<td style="white-space: nowrap">
<IconInfoBtn @on-click="openDetailModal(item)" />
<IconModifyBtn @on-click="openModifyModal(item)" />

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

Loading…
Cancel
Save