feat: Artifact 다운로드 추가 및 Deployment 업로드 기능 추가

main
jschoi 8 months ago
parent 85e53a9d52
commit bf3359de5c

@ -1,3 +1,3 @@
NODE_ENV = "dev"
VITE_APP_API_SERVER_URL = "http://localhost:80"
VITE_APP_API_SERVER_URL = "http://localhost:8080"
VITE_ROOT_PATH = ""

8
components.d.ts vendored

@ -10,15 +10,20 @@ declare module 'vue' {
export interface GlobalComponents {
AppFooter: typeof import('./src/components/AppFooter.vue')['default']
CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default']
CompareRunDialog: typeof import('./src/components/atoms/organisms/CompareRunDialog.vue')['default']
DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default']
DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default']
DeploymentDialog: typeof import('./src/components/atoms/organisms/DeploymentDialog.vue')['default']
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']
ExperimentCreateDialog: typeof import('./src/components/atoms/organisms/ExperimentCreateDialog.vue')['default']
ExternalDatasetDialog: typeof import('./src/components/atoms/organisms/ExternalDatasetDialog.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IconArrowDown: typeof import('./src/components/atoms/button/IconArrowDown.vue')['default']
IconArrowUp: typeof import('./src/components/atoms/button/IconArrowUp.vue')['default']
IconDeleteBtn: typeof import('./src/components/atoms/button/IconDeleteBtn.vue')['default']
IconDeployBtn: typeof import('./src/components/atoms/button/IconDeployBtn.vue')['default']
IconDeployment: typeof import('./src/components/atoms/button/IconDeployment.vue')['default']
IconDownloadBtn: typeof import('./src/components/atoms/button/IconDownloadBtn.vue')['default']
IconInfoBtn: typeof import('./src/components/atoms/button/IconInfoBtn.vue')['default']
@ -26,10 +31,11 @@ declare module 'vue' {
IconRunBtn: typeof import('./src/components/atoms/button/IconRunBtn.vue')['default']
IconSettingBtn: typeof import('./src/components/atoms/button/IconSettingBtn.vue')['default']
LayoutComponent: typeof import('./src/components/common/LayoutComponent.vue')['default']
ListComponent: typeof import('./src/components/templates/Datasets/ListComponent.vue')['default']
ListComponent: typeof import('./src/components/templates/datagroup/ListComponent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default']
TrainingGroupBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingGroupBaseDoalog.vue')['default']
TrainingScriptBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingScriptBaseDoalog.vue')['default']
ViewComponent: typeof import('./src/components/templates/Datasets/ViewComponent.vue')['default']
WorkflowDialog: typeof import('./src/components/atoms/organisms/WorkflowDialog.vue')['default']

@ -1,16 +1,26 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ExternalAuthControllerService } from "@/components/service/management/ExternalAuthControllerService";
import { EdgePkgInfoVOModel } from "@/components/models/management/ExternalAuthController";
import {
AddFileParamsSwagger,
AddMinioParamsSwagger,
EdgePkgInfoVOModel,
} from "@/components/models/management/ExternalAuthController";
type PackageOption = { label: string; value: string; raw: any };
type MinioRegisterModel = EdgePkgInfoVOModel & {
objectName: string; // props.artifactPath
type: "type1" | "type2";
localPath: string; // (: downloads/temp)
};
const props = defineProps<{
packages: PackageOption[];
packagesLoading?: boolean;
packagesError?: string;
artifactPath?: string;
token: string; //
token: string;
}>();
const emit = defineEmits<{
@ -18,6 +28,7 @@ const emit = defineEmits<{
(e: "handle-data", value: any): void;
}>();
/* ========================= 폼/상태 ========================= */
const form = ref({
package_id: "",
sw_id: "",
@ -38,28 +49,133 @@ const form = ref({
const saving = ref(false);
const errorMsg = ref("");
const successOpen = ref(false);
const successDialog = ref(false);
const pendingPayload = ref<any>(null);
// ---- ----
/* ========================= 파일 업로드 ========================= */
const file = ref<File | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
const onFileChange = (e: Event) => {
const input = e.target as HTMLInputElement;
const f = input.files?.[0] ?? null;
file.value = f;
file.value = input.files?.[0] ?? null;
};
const clearFile = () => {
if (fileInput.value) fileInput.value.value = "";
file.value = null;
};
// ------------------------
const isWin = computed(() => form.value.os === "Windows");
const isLinux = computed(() => form.value.os === "Linux");
/* ========================= SW/EDGE 선택 + 목록 조회 ========================= */
const sourceType = ref<"" | "sw" | "edge">(""); // SW or EDGE
const remotePackages = ref<PackageOption[]>([]);
const remoteLoading = ref(false);
const remoteError = ref("");
const pkgOptions = computed<PackageOption[]>(() =>
remotePackages.value.length ? remotePackages.value : (props.packages ?? []),
);
function getCurrentUserId(): string {
// id ,
try {
const raw = localStorage.getItem("external-auth");
const obj = raw ? JSON.parse(raw) : null;
return obj?.userInfo?.username ?? obj?.userInfo?.id ?? obj?.id ?? "admin";
} catch {
return "admin";
}
}
async function fetchPackages() {
remoteError.value = "";
remotePackages.value = [];
form.value.package_id = "";
if (!sourceType.value) {
remoteError.value = "SW 또는 EDGE를 먼저 선택하세요.";
return;
}
if (!props.token) {
remoteError.value = "토큰이 없습니다. 다시 로그인하세요.";
return;
}
const id = getCurrentUserId(); // ex) 'admin'
try {
remoteLoading.value = true;
const res =
sourceType.value === "edge"
? await ExternalAuthControllerService.edgeSearch(id, props.token)
: await ExternalAuthControllerService.swSearch(id, props.token);
//
const payload = (res as any)?.data ?? res; // 1
const maybeList =
(Array.isArray(payload?.data) ? payload?.data : null) ??
(Array.isArray(payload?.list) ? payload?.list : null) ??
(Array.isArray(payload) ? payload : null);
const list: any[] = Array.isArray(maybeList)
? maybeList
: Array.isArray((payload?.data as any)?.data) // 2 (data.data)
? (payload.data as any).data
: [];
remotePackages.value = list.map((it: any) => {
// value ed_pkg_serial( )
const val = String(
it?.ed_pkg_serial ??
it?.edPkgSerial ??
it?.package_id ??
it?.id ??
it?.pkg_id ??
it?.value ??
"",
);
// label package_name > pkg_name > sw_name > name
const name =
it?.package_name ??
it?.pkg_name ??
it?.sw_name ??
it?.name ??
"unknown";
return {
label: `${name}`,
value: val,
raw: it,
};
});
if (!remotePackages.value.length) {
remoteError.value = "해당 대상의 패키지 목록이 없습니다.";
}
} catch (e: any) {
remoteError.value =
e?.response?.data?.message ||
e?.message ||
"목록 조회 중 오류가 발생했습니다.";
} finally {
remoteLoading.value = false;
}
}
// +
watch(sourceType, async (v) => {
remotePackages.value = [];
form.value.package_id = "";
form.value.win_exe_name = "";
form.value.win_root_path = "";
form.value.linux_exe_name = "";
form.value.linux_root_path = "";
form.value.os = "";
if (v) await fetchPackages();
});
/* ========================= 선택 패키지 → 읽기전용/OS ========================= */
const selectedOption = computed(() =>
props.packages?.find((p) => p.value === form.value.package_id),
pkgOptions.value?.find((p) => p.value === form.value.package_id),
);
const selectedRaw = computed(() => selectedOption.value?.raw ?? null);
@ -68,15 +184,9 @@ const readonlyFields = computed(() => ({
swGroupName: selectedRaw.value?.sw_group_name ?? "",
manufacturer: selectedRaw.value?.sw_manufacturer ?? "",
packageId: selectedRaw.value?.package_id ?? "",
packageName: selectedRaw.value?.package_name ?? "",
}));
function confirmSuccess() {
if (pendingPayload.value) {
emit("handle-data", pendingPayload.value); //
}
emit("close-modal"); //
successDialog.value = false;
}
function onPickPackage() {
form.value.win_exe_name = selectedRaw.value?.window_exe_name ?? "";
form.value.win_root_path = selectedRaw.value?.window_root_location ?? "";
@ -89,87 +199,95 @@ function onPickPackage() {
form.value.os = "Linux";
}
watch(
() => props.packages,
() => {
if (!props.packages?.some((p) => p.value === form.value.package_id)) {
form.value.package_id = "";
form.value.win_exe_name = "";
form.value.win_root_path = "";
form.value.linux_exe_name = "";
form.value.linux_root_path = "";
form.value.os = "";
}
},
);
const installOsCode = computed(() => {
if (form.value.os === "Windows") return 0;
if (form.value.os === "Linux") return 1;
return null;
});
/* ========================= 기타 계산값 ========================= */
const isWin = computed(() => form.value.os === "Windows");
const isLinux = computed(() => form.value.os === "Linux");
const archiveTypeCode = computed(() =>
form.value.file_type === "bundle" ? 1 : 0,
);
const swTypeCode = computed(() => (sourceType.value === "edge" ? 1 : 0));
function getCurrentUserId(): string {
function getCurrentUserIdForSubmit(): string {
try {
const raw = localStorage.getItem("autoflow-auth");
const raw = localStorage.getItem("external-auth");
const obj = raw ? JSON.parse(raw) : null;
return obj?.userInfo?.username ? String(obj.userInfo.username) : "";
return obj?.userInfo?.username ? String(obj.userInfo.username) : "admin";
} catch {
return "";
return "admin";
}
}
const toInt = (v: unknown, fallback = 1) => {
const n = parseInt(String(v ?? "").trim(), 10);
return Number.isFinite(n) ? n : fallback;
};
const minioType = ref<"type1" | "type2">("type2");
async function submit() {
errorMsg.value = "";
if (!file.value) return (errorMsg.value = "업로드할 파일을 선택하세요.");
if (!sourceType.value) return (errorMsg.value = "SW/EDGE를 선택하세요.");
if (!form.value.sw_id?.trim())
return (errorMsg.value = "SW ID를 입력하세요.");
if (!form.value.software_name?.trim())
return (errorMsg.value = "SW 명칭을 입력하세요.");
if (!selectedRaw.value?.ed_pkg_serial)
return (errorMsg.value = "SW 패키지를 선택하세요.");
if (!form.value.install_location?.trim())
return (errorMsg.value = "설치 위치를 입력하세요.");
if (!props.token)
return (errorMsg.value = "토큰이 없습니다. 다시 로그인하세요.");
if (!props.token) return (errorMsg.value = "토큰이 없습니다.");
if (!selectedRaw.value) return (errorMsg.value = "패키지를 선택하세요.");
const adSerial =
selectedRaw.value?.ad_pkg_serial ?? selectedRaw.value?.adPkgSerial;
const edSerial =
selectedRaw.value?.ed_pkg_serial ?? selectedRaw.value?.edPkgSerial;
const rawSerial = sourceType.value === "sw" ? adSerial : edSerial;
const pkgSerial = Number.parseInt(String(rawSerial ?? "").trim(), 10);
if (!Number.isFinite(pkgSerial))
return (errorMsg.value = "패키지 시리얼이 없습니다.");
const common: Omit<AddFileParamsSwagger, "pkg_serial"> & {
pkg_serial: number;
} = {
sw_id: (form.value.sw_id || "").trim(),
sw_version: Number.parseInt(String(form.value.sw_version || "1"), 10) || 1,
sw_name: (form.value.software_name || "").trim(),
authId: props.token,
pkg_serial: pkgSerial,
archiveType: form.value.file_type === "bundle" ? 1 : 0,
execYn: form.value.executed ? 1 : 0,
secretAt: !!form.value.private_only,
downloadLocation: (form.value.install_location || "").trim(),
user_id: getCurrentUserIdForSubmit() || "admin",
sw_type: sourceType.value === "edge" ? 1 : 0,
creation_datetime: new Date().toISOString(),
};
try {
saving.value = true;
let res: any;
if (file.value) {
const params: AddFileParamsSwagger = { ...common };
res = await ExternalAuthControllerService.add(params, file.value);
} else {
const params: AddMinioParamsSwagger = {
...common,
objectName: props.artifactPath || "",
type: minioType.value,
localPath: (form.value.install_location || "").trim(),
};
res = await ExternalAuthControllerService.addMinio(params);
}
const params: EdgePkgInfoVOModel = {
sw_id: (form.value.sw_id || "").trim(),
sw_version: toInt(form.value.sw_version, 1), //
sw_name: (form.value.software_name || "").trim(),
auth_id: props.token,
edPkgSerial: toInt(selectedRaw.value?.ed_pkg_serial, 0),
archiveType: form.value.file_type === "bundle" ? 1 : 0,
execYn: form.value.executed ? 1 : 0,
secretAt: !!form.value.private_only,
downloadLocation: (form.value.install_location || "").trim(),
user_id: getCurrentUserId() || "admin",
creation_datetime: new Date().toISOString(),
};
const res = await ExternalAuthControllerService.add(params, file.value!);
// success ( )
const ok =
res?.success === true || (res?.data && res?.data?.success === true);
res?.status === 200 ||
res?.success === true ||
res?.data?.success === true;
if (!ok) {
const msg =
res?.errorMessage || res?.data?.errorMessage || "등록에 실패했습니다.";
res?.data?.message ||
res?.data?.errorMessage ||
res?.errorMessage ||
"등록에 실패했습니다.";
throw new Error(msg);
}
successOpen.value = true; //
pendingPayload.value = {
...form.value,
artifact_path: props.artifactPath ?? "",
@ -186,6 +304,13 @@ async function submit() {
}
}
function confirmSuccess() {
if (pendingPayload.value) emit("handle-data", pendingPayload.value);
emit("close-modal");
successDialog.value = false;
}
/* ========================= ESC 닫기 ========================= */
function onEsc(e: KeyboardEvent) {
if (e.key === "Escape") emit("close-modal");
}
@ -215,33 +340,40 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
VAlert: { density: 'compact' },
}"
>
<!-- 패키지 선택 & 자동 표기 -->
<!-- 1) 등록 대상 선택 -->
<v-card-text class="pa-4">
<v-row dense class="mb-2">
<v-col cols="12" md="5">
<div class="text-body-2 font-weight-medium mb-1">등록 대상</div>
<v-radio-group v-model="sourceType" inline>
<v-radio class="mr-8" label="SW" value="sw" />
<v-radio label="EDGE" value="edge" />
</v-radio-group>
</v-col>
</v-row>
<!-- 2) 패키지 (라디오 변경 자동 조회됨) -->
<v-row dense class="mb-2">
<v-col cols="12">
<div class="text-body-2 font-weight-medium mb-1">SW 패키지</div>
<div class="text-body-2 font-weight-medium mb-1">패키지</div>
<v-select
v-model="form.package_id"
:items="packages"
:items="pkgOptions"
item-title="label"
item-value="value"
:loading="packagesLoading"
:disabled="packagesLoading"
placeholder="선택해주세요."
:loading="remoteLoading || props.packagesLoading"
:disabled="remoteLoading || !sourceType"
:placeholder="
sourceType
? '패키지를 선택하세요.'
: '등록 대상을 먼저 선택하세요.'
"
@update:model-value="onPickPackage"
/>
<v-alert
v-if="packagesError"
type="error"
variant="tonal"
class="mt-2"
>
{{ packagesError }}
</v-alert>
</v-col>
</v-row>
<!-- 자동 표기 (읽기 전용) -->
<!-- 자동 표기 -->
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<v-text-field
@ -279,9 +411,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
<!-- 사용자 입력 -->
<v-card-text class="pa-4 pt-3">
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<v-text-field label="SW ID" v-model="form.sw_id" />
</v-col>
<v-col cols="12" md="6"
><v-text-field label="SW ID" v-model="form.sw_id"
/></v-col>
<v-col cols="12" md="6">
<v-text-field
label="SW 버전"
@ -290,12 +422,11 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
min="0"
/>
</v-col>
<v-col cols="12">
<v-text-field
<v-col cols="12"
><v-text-field
label="SW 명칭 (Software Name)"
v-model="form.software_name"
/>
</v-col>
/></v-col>
</v-row>
<v-row dense class="mb-1">
@ -336,20 +467,18 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
Windows
</div></v-col
>
<v-col cols="12" md="6">
<v-text-field
<v-col cols="12" md="6"
><v-text-field
label="윈도우 실행 파일명"
v-model="form.win_exe_name"
disabled
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
/></v-col>
<v-col cols="12" md="6"
><v-text-field
label="윈도우 경로"
v-model="form.win_root_path"
disabled
/>
</v-col>
/></v-col>
</v-row>
<!-- Linux -->
@ -357,20 +486,18 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
<v-col cols="12"
><div class="text-body-2 font-weight-medium mb-1">Linux</div></v-col
>
<v-col cols="12" md="6">
<v-text-field
<v-col cols="12" md="6"
><v-text-field
label="리눅스 실행 파일명"
v-model="form.linux_exe_name"
disabled
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
/></v-col>
<v-col cols="12" md="6"
><v-text-field
label="리눅스 경로"
v-model="form.linux_root_path"
disabled
/>
</v-col>
/></v-col>
</v-row>
<!-- Artifact Path -->
@ -384,7 +511,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
</v-col>
</v-row>
<!-- 파일 업로드 (추가) -->
<!-- 파일 업로드 -->
<v-row dense class="mb-1">
<v-col cols="12">
<div class="text-body-2 font-weight-medium mb-1">업로드 파일</div>
@ -392,18 +519,17 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
<v-btn size="small" color="primary" @click="fileInput?.click()"
>파일 선택</v-btn
>
<span v-if="file" class="text-body-2">
{{ file.name }} ({{ file.size.toLocaleString() }} bytes)
</span>
<span v-if="file" class="text-body-2"
>{{ file.name }} ({{ file.size.toLocaleString() }} bytes)</span
>
<v-btn
v-if="file"
size="x-small"
variant="text"
class="ml-1"
@click="clearFile"
>지우기</v-btn
>
지우기
</v-btn>
</div>
<input
ref="fileInput"
@ -416,9 +542,9 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
<!-- 설치 위치 & 등록인만 접근 -->
<v-row dense>
<v-col cols="12" md="8">
<v-text-field label="설치 위치" v-model="form.install_location" />
</v-col>
<v-col cols="12" md="8"
><v-text-field label="설치 위치" v-model="form.install_location"
/></v-col>
<v-col cols="12" md="4" class="d-flex align-center">
<v-checkbox
v-model="form.private_only"
@ -448,6 +574,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
>
</v-card-actions>
</v-defaults-provider>
<v-dialog v-model="successDialog" width="360" persistent>
<v-card rounded="lg">
<v-card-title class="text-h6" style="background: #1976d2"

@ -5,7 +5,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storage } from "@/utils/storage.js";
import { UserManagerService } from "@/components/service/management/UserManagerService";
import { UserManagerService } from "@/components/service/management/userManagerService";
import { menuUtils } from "@/utils/menuUtils";
/* ================================
@ -20,7 +20,7 @@ type MenuItem = {
};
/* ================================
* Router / Reactive base state
* Router / Base states
* ================================ */
const route = useRoute();
const router = useRouter();
@ -29,12 +29,9 @@ const username = ref<string>("");
const projectName = ref<string>(localStorage.getItem("projectName") || "");
const isAdmin = ref<boolean>(false);
const adminMode = ref<boolean>(false); // Settings /
const adminMode = ref<boolean>(false);
const lastNonAdminPath = ref<string>("/home");
/* ================================
* Auth / Role helpers
* ================================ */
function readAuth() {
try {
const raw =
@ -46,7 +43,6 @@ function readAuth() {
return null;
}
}
function computeIsAdmin() {
const auth = readAuth();
const roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
@ -56,7 +52,6 @@ function computeIsAdmin() {
: roles === "ROLE_ADMIN";
isAdmin.value = inRoles || authCd === "ADMIN";
}
function updateUsername() {
const auth = readAuth();
username.value = auth?.userInfo?.username ?? auth?.username ?? "";
@ -64,8 +59,6 @@ function updateUsername() {
/* ================================
* Derived route state
* - /select
* - 관리자 표시 조건: adminMode ON || 관리자 라우트
* ================================ */
const hideAllMenus = computed<boolean>(() => route.path.startsWith("/select"));
@ -78,18 +71,16 @@ const isAdminRoute = computed<boolean>(() => {
const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin);
return hitPath || hitMeta;
});
const showAdminTabs = computed<boolean>(
() => adminMode.value || isAdminRoute.value,
);
/* ================================
* Menus (기본/관리자)
* Menus
* ================================ */
const baseMenus = computed<MenuItem[]>(
() => (menuUtils?.menuItem ?? []) as MenuItem[],
);
const adminMenus = computed<MenuItem[]>(() => {
const fromUtil = (menuUtils?.adminMenuItem ?? []) as MenuItem[];
return fromUtil.length
@ -99,50 +90,75 @@ const adminMenus = computed<MenuItem[]>(() => {
{ 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",
click: () => {
/* open modal */
},
},
{ title: "Change Password", click: () => {} },
{ title: "Logout", icon: "mdi-logout", click: () => logOut() },
];
/* ================================
* Navigation actions
* 상단 Hover 하위 메뉴 스트립
* ================================ */
type DepthItem = { title: string; path: string };
const hoverBar = ref<{
open: boolean;
items: DepthItem[];
}>({ open: false, items: [] });
let hideTimer: number | null = null;
function showHoverStrip(m: MenuItem) {
if (!m.depth?.length) return;
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
hoverBar.value = {
open: true,
items: m.depth,
};
}
function scheduleHideStrip() {
if (hideTimer) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
hoverBar.value.open = false;
}, 140);
}
function keepStrip() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
/* ================================
* Navigation
* ================================ */
function goMain() {
adminMode.value = false;
router.push("/home");
}
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)
@ -165,18 +181,16 @@ function logOut() {
function refreshProjectName() {
projectName.value = localStorage.getItem("projectName") || "";
}
//
watch(
() => route.fullPath,
() => {
refreshProjectName();
//
hoverBar.value.open = false;
if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
},
{ immediate: true },
);
// storage
function onStorage(e: StorageEvent) {
if (!e.key || e.key === "projectName") {
refreshProjectName();
@ -198,9 +212,9 @@ onMounted(() => {
menu.value = menuItems;
window.addEventListener("storage", onStorage);
});
onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage);
if (hideTimer) window.clearTimeout(hideTimer);
});
</script>
@ -218,54 +232,24 @@ onBeforeUnmount(() => {
AUTOFLOW WEB CONSOLE
</div>
<!-- 중앙: 메뉴 그룹 (Settings / 분기) -->
<div class="center-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴: showAdminTabs 조건으로 표시 -->
<!-- 관리자 메뉴바: 기본 메뉴바와 1:1 동일 구조 -->
<!-- 여기 스페이서를 '브랜드 다음' 둬서 오른쪽으로 밀기 -->
<v-spacer />
<!-- 메뉴는 우측 정렬 -->
<div class="right-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴 -->
<template v-if="showAdminTabs">
<template v-for="(m, i) in adminMenus" :key="'am_' + 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
v-for="(d, j) in m.depth"
:key="'amd_' + j"
:title="d.title"
:to="d.path"
:active="isLinkActive(d.path)"
active-class="nav-active"
/>
</v-list>
</v-menu>
<!-- 드롭다운 없는 단일 항목 -->
<v-btn
v-else
variant="text"
class="nav-btn"
:class="{ 'nav-active': isLinkActive(m.path) }"
:class="{
'nav-active':
m.depth?.some((d: any) => isLinkActive(d.path)) ||
isLinkActive(m.path),
}"
@mouseenter="showHoverStrip(m)"
@mouseleave="scheduleHideStrip"
@click="m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
@ -277,87 +261,17 @@ onBeforeUnmount(() => {
<!-- 기본 메뉴 -->
<template v-else>
<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"
transition="scale-transition"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="nav-btn text-white"
: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>
<!-- 여기부터: Run 전용 디자인 -->
<template
v-if="
(m.title && m.title.toLowerCase() === 'run') ||
(m.path && m.path.startsWith('/run'))
"
>
<v-card
rounded="lg"
elevation="12"
color="surface"
class="px-2 py-2"
>
<v-list density="comfortable" lines="one" class="min-w-48">
<template v-for="(d, j) in m.depth" :key="'run_' + j">
<v-hover v-slot="{ isHovering, props: liProps }">
<v-list-item
v-bind="liProps"
:title="d.title"
:to="d.path"
:active="isLinkActive(d.path)"
color="primary"
:rounded="'lg'"
:variant="
isHovering || isLinkActive(d.path)
? 'tonal'
: 'text'
"
class="mx-2 my-1 text-white"
/>
</v-hover>
</template>
</v-list>
</v-card>
</template>
<!-- 기본 하위메뉴 (Run 이외는 기존 그대로) -->
<template v-else>
<v-list density="compact" class="min-w-48 subnav-list">
<v-list-item
v-for="(d, j) in m.depth"
:key="'d_' + j"
:title="d.title"
:to="d.path"
class="submenu-item text-white"
:class="{ 'submenu-active': isLinkActive(d.path) }"
/>
</v-list>
</template>
</v-menu>
<v-btn
v-else
variant="text"
class="nav-btn"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click="m.path && router.push(m.path)"
class="nav-btn text-white"
:class="{
'nav-active':
m.depth?.some((d: any) => isLinkActive(d.path)) ||
isLinkActive(m.path),
}"
@mouseenter="showHoverStrip(m)"
@mouseleave="scheduleHideStrip"
@click="!m.depth?.length && m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
@ -365,10 +279,7 @@ onBeforeUnmount(() => {
</template>
</template>
</div>
<v-spacer />
<!-- 우측: 기존 기능 유지 -->
<!-- 우측 아이콘들 -->
<v-tooltip v-if="isAdmin" location="bottom" text="Settings">
<template #activator="{ props }" v-if="!hideAllMenus">
<v-btn
@ -426,6 +337,37 @@ onBeforeUnmount(() => {
</v-menu>
</v-app-bar>
<!-- ===== 상단 하위 메뉴 스트립 (호버 표시) ===== -->
<v-slide-y-transition>
<v-sheet
v-if="hoverBar.open"
class="hover-strip"
elevation="8"
color="surface"
@mouseenter="keepStrip"
@mouseleave="scheduleHideStrip"
>
<v-container class="py-2" :fluid="true">
<!-- 중앙 정렬 -->
<v-row class="g-2" no-gutters justify="center" align="center">
<v-col class="d-flex flex-wrap justify-center" cols="12">
<v-btn
v-for="d in hoverBar.items"
:key="d.path"
size="small"
class="mx-1 my-1 strip-chip"
:variant="isLinkActive(d.path) ? 'tonal' : 'text'"
:color="isLinkActive(d.path) ? 'primary' : undefined"
@click="router.push(d.path)"
>
{{ d.title }}
</v-btn>
</v-col>
</v-row>
</v-container>
</v-sheet>
</v-slide-y-transition>
<!-- 본문 -->
<v-main>
<v-container
@ -446,64 +388,70 @@ onBeforeUnmount(() => {
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;
.right-nav {
display: flex;
align-items: center;
gap: 8px; /* 버튼 간격 */
justify-content: flex-end;
}
.nav-btn {
text-transform: none;
border-radius: 10px;
padding: 0 16px;
font-size: 14px;
color: #fff !important; /* 흰색 텍스트 통일 */
color: #fff !important;
}
.nav-btn:hover {
background: rgba(59, 130, 246, 0.08);
}
.nav-active {
background: rgba(59, 130, 246, 0.22);
height: 46px;
color: #fff !important;
}
/* 드롭다운(하위 메뉴)도 동일 룩으로 */
.subnav-list {
background: transparent; /* 탑바 느낌 유지 */
.userbox {
min-width: 180px;
}
.submenu-item {
color: #fff !important;
border-radius: 10px;
margin: 2px 8px;
/* ===== 호버 스트립 (상단 바로 아래, 이미지 스타일) ===== */
.hover-strip {
position: fixed;
top: var(--v-layout-top, 64px); /* app-bar 바로 아래 */
left: 0;
right: 0;
z-index: 2500;
/* 다크 배경 + 살짝 투명 + 경계 */
background: rgba(32, 32, 32, 0.96);
border-bottom: 1px solid rgb(145, 61, 61);
backdrop-filter: blur(6px);
}
.submenu-item:hover {
background: rgba(59, 130, 246, 0.08);
/* 버튼(알약) 다크에서도 비활성 글자/테두리 선명 */
.strip-chip {
border-radius: 9999px !important;
text-transform: none;
font-weight: 600;
letter-spacing: 0;
height: 30px;
padding: 0 14px;
color: #e5e7eb !important; /* 비활성도 흐려 보이지 않게 */
}
.submenu-active {
background: rgba(59, 130, 246, 0.22);
color: #fff !important;
}
.min-w-48 {
min-width: 12rem;
.strip-chip.v-btn--variant-text {
/* text 변형일 때도 흐릿하지 않게 약한 테두리 */
border: 1px solid rgba(255, 255, 255, 0.14) !important;
background: transparent !important;
}
.userbox {
min-width: 180px;
.strip-chip:hover {
background: rgba(255, 255, 255, 0.06) !important;
}
</style>

@ -10,4 +10,26 @@ export type EdgePkgInfoVOModel = {
downloadLocation: string;
user_id: string;
creation_datetime: string;
sw_type: number;
};
export type AddFileParamsSwagger = {
sw_id: string;
sw_version: number;
sw_name: string;
authId: string;
pkg_serial: number;
archiveType: 0 | 1;
execYn: 0 | 1;
secretAt: boolean;
downloadLocation: string;
user_id: string;
sw_type: number;
creation_datetime: string;
};
export type AddMinioParamsSwagger = AddFileParamsSwagger & {
objectName: string;
type: "type1" | "type2";
localPath: string;
};

@ -1,4 +1,7 @@
import { EdgePkgInfoVOModel } from "@/components/models/management/ExternalAuthController";
import {
AddFileParamsSwagger,
AddMinioParamsSwagger,
} from "@/components/models/management/ExternalAuthController";
import { request } from "@/components/service/index";
export const ExternalAuthControllerService = {
@ -6,15 +9,31 @@ export const ExternalAuthControllerService = {
return request.post("/api/external-auth/signin", { id, password });
},
add: (params: EdgePkgInfoVOModel, file: File | Blob) => {
add: (params: AddFileParamsSwagger, file: File | Blob) => {
const fd = new FormData();
fd.append("file", file);
const { authId, ...rest } = params as any;
const snakeParams = { ...rest, auth_id: authId };
return request.postWithConfig("/api/external-auth/register-with-file", fd, {
params,
params: snakeParams,
});
},
search: (id: string, token: string) => {
addMinio: (params: AddMinioParamsSwagger) => {
return request.postWithConfig(
"/api/external-auth/register-with-minio-file",
{},
{ params },
);
},
swSearch: (id: string, token: string) => {
return request.get("/api/external-auth/sw-search", {
id,
token,
});
},
edgeSearch: (id: string, token: string) => {
return request.get("/api/external-auth/edge-search", {
id,
token,

@ -1,9 +1,18 @@
import { request } from "@/components/service/index";
import { saveBlob, filenameFromContentDisposition } from "@/utils/download";
export const MinioService = {
download(objectName: string) {
return request.get("/api/minio/download", {
params: { objectName, type: "type2" },
responseType: "blob",
async download(objectName: string) {
const res = await request.getFile("/api/minio/download", {
objectName,
type: "type2",
});
const blob: Blob = res.data;
const cd = res.headers?.["content-disposition"];
const fallback = objectName.split("/").pop() || "download.bin";
const filename = filenameFromContentDisposition(cd, fallback);
saveBlob(blob, filename);
},
};

@ -17,4 +17,22 @@ export const MlflowService = {
runId,
});
},
artifact: (runId: string, path?: string) => {
const params: Record<string, any> = {
runId,
run_id: runId,
};
if (path !== undefined) params.path = path;
// 1차: /list 시도 → 404면 구(舊) 경로로 폴백
return request
.get("/api/mlflow/artifacts/list", params)
.catch((err: any) => {
if (err?.response?.status === 404) {
return request.get("/api/mlflow/artifacts", params);
}
throw err;
});
},
};

@ -220,7 +220,7 @@ const changePageSize = (size: number) => {
fetchList();
};
// / ( )
// /
const removeData = (value?: Array<{ deviceKey: number }>) => {
const removeList = value ?? data.value.selected;
if (!removeList || removeList.length === 0) return;

@ -14,19 +14,31 @@ import Plotly from "plotly.js-dist-min";
import CompareRunsDialog from "@/components/atoms/organisms/CompareRunDialog.vue";
import DeploymentDialog from "@/components/atoms/organisms/DeploymentDialog.vue";
import { MinioService } from "@/components/service/management/MinioService";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
import IconDeployBtn from "@/components/atoms/button/IconDeployBtn.vue";
/* ========= Constants & Types ========= */
const AUTH_KEY = "external-auth";
const externalToken = computed(() => externalAuth.value?.token ?? "");
type ExternalAuth = { id: string; name: string; token: string };
type PackageOption = { label: string; value: string; raw: any };
type MlflowArtifactList = {
root_uri: string;
files: { path: string; is_dir: boolean; file_size?: number }[];
};
type ArtifactRow = {
path: string;
is_dir?: boolean;
file_size?: number;
depth?: number;
};
type MetricKV = { key: string; value: number };
type RunDetailType = {
info: any;
data: { metrics: MetricKV[]; params?: any[]; tags?: any[] };
};
type ArtifactRow = { path: string; is_dir?: boolean; file_size?: number };
type ArtifactGroup = { base: string; items: ArtifactRow[] };
type ExternalAuth = { id: string; name: string; token: string };
type PackageOption = { label: string; value: string; raw: any };
type ArtifactGroup = { dir: string; files: ArtifactRow[] };
const FILE_ICON = "mdi-file-document-outline";
/* ========= Props/Emits ========= */
const props = defineProps<{ experimentInfo: any }>();
const emit = defineEmits<{ (e: "close"): void }>();
@ -38,12 +50,12 @@ const loginForm = ref({ id: "", password: "" });
const loginError = ref("");
const isAuthenticated = ref(false);
const externalAuth = ref<ExternalAuth | null>(null);
const externalToken = computed(() => externalAuth.value?.token ?? "");
/* ========= Deployment State ========= */
const isEditVisible = ref(false);
const lastArtifactUri = ref<string>(""); // URI
const pendingArtifactPath = ref<string | null>(null); //
const lastArtifactUri = ref<string>("");
const pendingArtifactPath = ref<string | null>(null);
const packageOptions = ref<PackageOption[]>([]);
const packagesLoading = ref(false);
const packagesError = ref("");
@ -72,6 +84,109 @@ const baselineRunId = ref<string | null>(null);
const elMetrics = ref<HTMLDivElement | null>(null);
const elCompare = ref<HTMLDivElement | null>(null);
/* ========= Artifacts: Two-step(flat) ========= */
const artifactGroups = computed<ArtifactGroup[]>(() => {
const rootFiles = (lvl1.value || []).filter((x) => !x.is_dir);
const dirs = (lvl1.value || []).filter((x) => x.is_dir).map((x) => x.path);
const groups: ArtifactGroup[] = [];
for (const d of dirs) {
const children = (lvl2.value || [])
.filter((f) => f.path.startsWith(`${d}/`))
.map((f) => {
const rel = f.path.slice(d.length + 1);
const depth = Math.max(rel.split("/").length - 1, 0);
return { ...f, depth };
})
.sort((a, b) => {
if (!!b.is_dir - !!a.is_dir) return !!b.is_dir - !!a.is_dir;
return a.path.localeCompare(b.path);
});
groups.push({ dir: d, files: children });
}
if (rootFiles.length > 0) {
groups.unshift({
dir: "(root)",
files: rootFiles.map((f) => ({ ...f, depth: 0 })),
});
}
return groups;
});
const lvl1 = ref<ArtifactRow[]>([]);
const lvl2 = ref<ArtifactRow[]>([]);
const twoStepLoading = ref(false);
const twoStepError = ref("");
async function fetchArtifactsTwoStep(runId: string) {
lvl1.value = [];
lvl2.value = [];
twoStepError.value = "";
if (!runId) return;
twoStepLoading.value = true;
try {
// 1) (=runId) 1
const res1 = await MlflowService.artifact(runId);
const body1 = unwrapAxiosLike(res1) as MlflowArtifactList;
const firstFiles = (body1?.files ?? []).map((f) => ({
path: f.path,
is_dir: !!f.is_dir,
file_size: f.file_size,
})) as ArtifactRow[];
lvl1.value = firstFiles;
// 2) 1 "" (=2)
const dirPaths = firstFiles.filter((x) => x.is_dir).map((x) => x.path);
let merged: ArtifactRow[] = [];
if (dirPaths.length > 0) {
const results = await Promise.all(
dirPaths.map(async (p) => {
const r = await MlflowService.artifact(runId, p);
return unwrapAxiosLike(r) as MlflowArtifactList;
}),
);
for (const r of results) {
const files2 = (r?.files ?? []).map((f) => ({
path: f.path, // ex) "sklearn-model/MLmodel" "sklearn-model/sub"
is_dir: !!f.is_dir,
file_size: f.file_size,
})) as ArtifactRow[];
merged.push(...files2);
}
// 3) 2 " " (=3) (lazy 1)
const subDirs = merged.filter((x) => x.is_dir);
if (subDirs.length > 0) {
const results3 = await Promise.all(
subDirs.map(async (sd) => {
const r3 = await MlflowService.artifact(runId, sd.path);
return unwrapAxiosLike(r3) as MlflowArtifactList;
}),
);
for (const r3 of results3) {
const files3 = (r3?.files ?? []).map((f) => ({
path: f.path, // ex) "sklearn-model/sub/deeper.txt"
is_dir: !!f.is_dir,
file_size: f.file_size,
})) as ArtifactRow[];
merged.push(...files3);
}
}
}
lvl2.value = merged;
} catch (e: any) {
twoStepError.value =
e?.response?.data?.message || e?.message || "artifact 조회 실패";
console.error("[Artifacts][TwoStep] ERROR", e);
} finally {
twoStepLoading.value = false;
}
}
/* ========= Helpers ========= */
const safeParse = <T = any,>(v: any): T | null => {
try {
@ -123,62 +238,28 @@ const fmtNumber = (v: number | null, digits = 3) => {
return v.toExponential(2);
};
const normalizeArray = (vals: (number | null)[]) => {
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
if (xs.length === 0) return vals;
const min = Math.min(...xs),
max = Math.max(...xs);
if (max === min) return vals.map((v) => (v == null ? v : 1));
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
};
const labelOfRun = (r: RunDetailType) => r.info.run_name || r.info.run_id;
const valueOf = (run: RunDetailType, key: string): number | null => {
const m = run.data.metrics.find((x) => x.key === key)?.value;
return Number.isFinite(m as number) ? Number(m) : null;
};
/* === File icon helpers (빨간 밑줄 원인: 누락/중복 정의 방지) === */
const fileExt = (name: string) => {
const n = (name || "").toLowerCase().trim();
const idx = n.lastIndexOf(".");
return idx >= 0 ? n.slice(idx + 1) : "";
};
const fileIconByName = (name: string) => {
const n = (name || "").toLowerCase().trim();
// exact filename first
if (n === "mlmodel") return "mdi-file-cog-outline";
if (n === "conda.yaml" || n === "conda.yml")
return "mdi-file-settings-outline";
if (n === "requirements.txt") return "mdi-file-code-outline";
// folder-ish (no dot or slash)
if (!n.includes(".") && !n.includes("/")) return "mdi-file-outline";
// by extension
switch (fileExt(n)) {
case "json":
return "mdi-code-json";
case "yml":
case "yaml":
return "mdi-code-braces";
case "txt":
return "mdi-file-document-outline";
case "pkl":
case "pickle":
return "mdi-cube-outline";
case "onnx":
return "mdi-robot-outline";
case "pt":
case "pth":
return "mdi-chip";
case "bin":
return "mdi-database-outline";
case "joblib":
case "pmdarima":
return "mdi-archive-outline";
default:
return "mdi-file-outline";
}
};
const bytes = (n?: number) =>
typeof n === "number" && isFinite(n)
? n < 1024
? `${n} B`
: n < 1024 ** 2
? `${(n / 1024).toFixed(1)} KB`
: n < 1024 ** 3
? `${(n / 1024 ** 2).toFixed(1)} MB`
: `${(n / 1024 ** 3).toFixed(2)} GB`
: "—";
/* ========= Derived (computed) ========= */
const runItems = computed(() =>
@ -195,59 +276,7 @@ const selectedMetrics = computed<MetricKV[]>(() =>
})),
);
/* ===== Artifacts (from tag: mlflow.log-model.history) ===== */
const historyArtifacts = computed<any[]>(() => {
const tags = runDetail.value?.data?.tags;
let raw: string | undefined;
if (Array.isArray(tags))
raw = tags.find((t: any) => t?.key === "mlflow.log-model.history")?.value;
else if (tags && typeof tags === "object")
raw = tags["mlflow.log-model.history"];
const parsed = safeParse<any>(raw);
return parsed ? (Array.isArray(parsed) ? parsed : [parsed]) : [];
});
const artifactGroups = computed<ArtifactGroup[]>(() => {
const groups: ArtifactGroup[] = [];
for (const meta of historyArtifacts.value) {
const base = String(meta?.artifact_path ?? "");
if (!base) continue;
const flavors = meta.flavors ?? {};
const pf = flavors?.python_function ?? {};
const sk = flavors?.sklearn ?? {};
const files = new Set<string>();
files.add(`${base}/MLmodel`);
if (pf?.model_path) files.add(`${base}/${pf.model_path}`);
if (pf?.env?.conda) files.add(`${base}/${pf.env.conda}`);
if (pf?.env?.virtualenv) files.add(`${base}/${pf.env.virtualenv}`);
if (pf?.env?.requirements) files.add(`${base}/${pf.env.requirements}`);
if (sk?.pickled_model) files.add(`${base}/${sk.pickled_model}`);
const items: ArtifactRow[] = Array.from(files)
.sort((a, b) =>
a.endsWith("MLmodel")
? -1
: b.endsWith("MLmodel")
? 1
: a.localeCompare(b),
)
.map((p) => ({ path: p, is_dir: false }));
groups.push({ base, items });
}
return groups;
});
const historyArtifactMeta = computed(
() => historyArtifacts.value.at(-1) ?? null,
);
const historyArtifactPath = computed(() =>
String(historyArtifactMeta.value?.artifact_path ?? ""),
);
const artifactItems = computed<ArtifactRow[]>(() => {
const base = historyArtifactPath.value;
return artifactGroups.value.find((x) => x.base === base)?.items ?? [];
});
/* ========= URI 조합기 & 클릭 핸들러 ========= */
/* ========= URI 조합 & 다운로드/배포 ========= */
function buildArtifactUri(fullPath: string) {
const expId =
currentExperimentId.value ||
@ -257,12 +286,12 @@ function buildArtifactUri(fullPath: string) {
const runId = runDetail.value?.info?.run_id || selectedRunId.value || "";
return `${expId}/${runId}/artifacts/${fullPath}`;
}
async function onClickArtifact(fullPath: string) {
const objectName = buildArtifactUri(fullPath); // expId/runId/artifacts/...
const objectName = buildArtifactUri(fullPath);
try {
artifactsLoading.value = true;
const res = await MinioService.download(objectName);
// blob ...
await MinioService.download(objectName);
} finally {
artifactsLoading.value = false;
}
@ -316,6 +345,7 @@ async function fetchRuns(expName?: string) {
exp?.experiment_id ?? exp?.experimentId ?? exp?.id ?? "",
);
currentExperimentId.value = expId;
if (!expId) {
runs.value = [];
selectedRunId.value = "";
@ -325,10 +355,8 @@ async function fetchRuns(expName?: string) {
const body = unwrapAxiosLike(await MlflowService.getRuns(expId));
const list =
body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []);
console.log("bodybody", body);
const matched = (Array.isArray(list) ? list : []).filter(
(r: any) => getTag(r, "kubeflow_run_id") === parentRunId,
(r: any) => getTag(r, "experiment_id") === parentRunId,
);
const final = matched.length > 0 ? matched : list;
const sorted = [...final].sort(
@ -470,6 +498,14 @@ function createTracesByRun(metricKeys: string[], runsData: RunDetailType[]) {
};
});
}
const normalizeArray = (vals: (number | null)[]) => {
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
if (xs.length === 0) return vals;
const min = Math.min(...xs),
max = Math.max(...xs);
if (max === min) return vals.map((v) => (v == null ? v : 1));
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
};
function drawCompareChart() {
if (!elCompare.value) return;
const metricKeys = activeMetricKeys.value;
@ -484,33 +520,6 @@ function drawCompareChart() {
? createTracesByMetric(metricKeys, runsData)
: createTracesByRun(metricKeys, runsData);
if (compareChartMode.value === "byMetric") {
const varianceOrder = metricKeys
.map((k, idx) => {
const vals = traces
.map((t) => t.y[idx])
.filter((v: any) => v != null) as number[];
const min = Math.min(...vals),
max = Math.max(...vals);
return { k, spread: max - min };
})
.sort((a, b) => b.spread - a.spread)
.map((v) => v.k);
traces.forEach((t) => {
t.x = varianceOrder;
t.y = varianceOrder.map((mk: string) => t.y[metricKeys.indexOf(mk)]);
if (t.text)
t.text = varianceOrder.map(
(mk: string) => t.text![metricKeys.indexOf(mk)],
);
if (t.customdata)
t.customdata = varianceOrder.map(
(mk: string) => t.customdata![metricKeys.indexOf(mk)],
);
});
}
Plotly.react(
elCompare.value,
traces,
@ -519,30 +528,7 @@ function drawCompareChart() {
);
}
/* ========= Compare Actions ========= */
function openCompareDialog() {
compareSelectedRunIds.value = Array.from(
new Set([selectedRunId.value].filter(Boolean)),
);
compareSelectedMetricKeys.value = [];
compareDialog.value = true;
}
async function loadCompareData() {
if (!compareDialog.value) return;
compareLoading.value = true;
try {
await Promise.all(compareSelectedRunIds.value.map(ensureRunDetail));
if (compareSelectedMetricKeys.value.length === 0) {
compareSelectedMetricKeys.value = commonMetricKeys.value.slice(0, 6);
}
await nextTick();
drawCompareChart();
} finally {
compareLoading.value = false;
}
}
/* ========= Derived for Compare ========= */
/* ========= Compare Derived/Actions ========= */
const compareRuns = computed<RunDetailType[]>(
() =>
compareSelectedRunIds.value
@ -565,6 +551,28 @@ const activeMetricKeys = computed(() =>
: commonMetricKeys.value,
);
function openCompareDialog() {
compareSelectedRunIds.value = Array.from(
new Set([selectedRunId.value].filter(Boolean)),
);
compareSelectedMetricKeys.value = [];
compareDialog.value = true;
}
async function loadCompareData() {
if (!compareDialog.value) return;
compareLoading.value = true;
try {
await Promise.all(compareSelectedRunIds.value.map(ensureRunDetail));
if (compareSelectedMetricKeys.value.length === 0) {
compareSelectedMetricKeys.value = commonMetricKeys.value.slice(0, 6);
}
await nextTick();
drawCompareChart();
} finally {
compareLoading.value = false;
}
}
/* ========= Auth ========= */
function restoreAuthFromStorage() {
const raw = localStorage.getItem(AUTH_KEY);
@ -607,7 +615,6 @@ const handleLogin = async () => {
loginError.value = "로그인에 실패했습니다. 아이디/비밀번호를 확인하세요.";
return;
}
const toSave: ExternalAuth = {
id: payload.id ?? id,
name: payload.name ?? id,
@ -631,22 +638,18 @@ const handleLogin = async () => {
/* ========= Deployment Modal ========= */
const openDeploymentModal = async (fullPath?: string) => {
// 1) URI /
if (fullPath) {
const uri = buildArtifactUri(fullPath);
lastArtifactUri.value = uri;
pendingArtifactPath.value = uri;
}
// 2)
if (!isAuthenticated.value) {
loginDialog.value = true;
return;
}
// 3) pending
if (!fullPath && pendingArtifactPath.value)
lastArtifactUri.value = pendingArtifactPath.value;
// 4)
isEditVisible.value = true;
packagesError.value = "";
packageOptions.value = [];
@ -668,7 +671,6 @@ const openDeploymentModal = async (fullPath?: string) => {
try {
packagesLoading.value = true;
const res = await ExternalAuthControllerService.search(auth.id, auth.token);
const body = res?.data ?? res;
const list = body?.data?.data ?? body?.data ?? body?.result ?? body ?? [];
const arr = Array.isArray(list) ? list : [];
@ -688,11 +690,10 @@ const closeCreateModal = () => {
isEditVisible.value = false;
};
const saveData = (payload: any) => {
// /
console.log("[DeploymentDialog payload]", payload);
//
isEditVisible.value = false;
};
/* ========= Timeline (fallback) ========= */
const rawHistory = computed<any[]>(() => {
const h =
@ -746,10 +747,12 @@ watch(
() => mainTab.value,
async (t) => {
if (t === "viz" || t === "artifacts") await refreshIfActive();
if (t === "artifacts" && selectedRunId.value) {
await fetchArtifactsTwoStep(selectedRunId.value);
}
},
{ immediate: true },
);
watch(
() => props.experimentInfo,
async () => {
@ -757,7 +760,12 @@ watch(
await refreshIfActive();
},
);
watch(selectedRunId, (id) => fetchRunDetail(id));
watch(selectedRunId, async (id) => {
fetchRunDetail(id);
if (mainTab.value === "artifacts" && id) {
await fetchArtifactsTwoStep(id);
}
});
watch(vizTab, async (t) => {
if (mainTab.value === "viz" && t === "metrics") {
await nextTick();
@ -968,7 +976,6 @@ const artifactsLoading = ref(false);
clear-icon=""
style="min-width: 280px; max-width: 440px"
/>
<!-- Compare button -->
<v-btn
color="primary"
variant="elevated"
@ -1130,9 +1137,8 @@ const artifactsLoading = ref(false);
<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
>
Model Metrics (selected run)
</v-card-title>
<v-table density="comfortable">
<thead>
<tr>
@ -1161,9 +1167,8 @@ const artifactsLoading = ref(false);
<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
>
Metrics (bar chart)
</v-card-title>
<div
ref="elMetrics"
style="width: 100%; height: 400px"
@ -1173,19 +1178,19 @@ const artifactsLoading = ref(false);
</v-window-item>
<v-window-item value="scatter">
<v-card-text class="px-6 py-10 text-medium-emphasis">
(준비중) X/Y 선택 산점도 표시
</v-card-text>
<v-card-text class="px-6 py-10 text-medium-emphasis"
>(준비중) X/Y 선택 산점도 표시</v-card-text
>
</v-window-item>
<v-window-item value="box">
<v-card-text class="px-6 py-10 text-medium-emphasis">
(준비중) 메트릭 분포 Box Plot
</v-card-text>
<v-card-text class="px-6 py-10 text-medium-emphasis"
>(준비중) 메트릭 분포 Box Plot</v-card-text
>
</v-window-item>
<v-window-item value="contour">
<v-card-text class="px-6 py-10 text-medium-emphasis">
(준비중) 2D/3D Contour Plot
</v-card-text>
<v-card-text class="px-6 py-10 text-medium-emphasis"
>(준비중) 2D/3D Contour Plot</v-card-text
>
</v-window-item>
</v-window>
@ -1195,7 +1200,7 @@ const artifactsLoading = ref(false);
</v-card>
</v-window-item>
<!-- ========= Artifacts ========= -->
<!-- ========= Artifacts (Two-step, Flat) ========= -->
<v-window-item value="artifacts">
<v-card class="rounded-lg pa-8 w-100">
<v-card-text>
@ -1221,8 +1226,16 @@ const artifactsLoading = ref(false);
style="min-width: 280px; max-width: 440px"
/>
<!-- 로그인 상태 표시 -->
<div class="d-flex align-center ga-2 ml-auto">
<v-btn
size="small"
variant="tonal"
:loading="twoStepLoading"
@click="fetchArtifactsTwoStep(selectedRunId)"
>
Refresh
</v-btn>
<v-chip
v-if="isAuthenticated"
color="success"
@ -1233,7 +1246,6 @@ const artifactsLoading = ref(false);
<v-icon start size="16">mdi-check-decagram</v-icon>
{{ externalAuth?.name || externalAuth?.id }}
</v-chip>
<v-btn
v-if="isAuthenticated"
size="small"
@ -1254,7 +1266,7 @@ const artifactsLoading = ref(false);
</v-btn>
<v-progress-circular
v-if="artifactsLoading || loadingRuns || loadingRunDetail"
v-if="twoStepLoading || loadingRuns || loadingRunDetail"
indeterminate
size="16"
class="ml-2"
@ -1264,157 +1276,114 @@ const artifactsLoading = ref(false);
</v-row>
<v-alert
v-if="!historyArtifactPath"
type="info"
v-if="twoStepError"
type="error"
variant="tonal"
class="mb-4"
class="mb-3"
>
실행에서 <code>mlflow.log-model.history</code> 태그를 찾을
없어요.
{{ twoStepError }}
</v-alert>
<template v-else>
<div class="text-body-2 mb-2">
Path:
<v-chip size="small" variant="tonal" class="ml-1">{{
historyArtifactPath
}}</v-chip>
</div>
<v-card variant="tonal">
<v-card-title class="py-2 px-4">Artifacts</v-card-title>
<v-divider />
<v-card-text class="px-0">
<!-- 히스토리에 아무것도 없을 -->
<v-alert
v-if="!loadingRunDetail && artifactGroups.length === 0"
type="info"
variant="tonal"
class="ma-3"
density="comfortable"
>
No files
</v-alert>
<!-- 여러 폴더 -->
<v-list
v-else-if="artifactGroups.length > 1"
lines="one"
density="comfortable"
class="file-list"
>
<template v-for="(g, gi) in artifactGroups" :key="g.base">
<v-list-subheader class="d-flex align-center">
<v-icon class="mr-2">mdi-folder</v-icon>
{{ g.base }}
</v-list-subheader>
<v-list-item
v-for="(f, i) in g.items"
:key="f.path"
:ripple="false"
class="file-row pl-8"
<v-card variant="tonal">
<v-card-title class="py-2 px-4">Artifacts</v-card-title>
<v-divider />
<v-card-text class="px-0">
<v-table density="comfortable">
<thead>
<tr>
<th style="width: 48px"></th>
<th>Path</th>
<th style="width: 160px" class="text-right">Size</th>
<th style="width: 180px" class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="twoStepLoading">
<td
colspan="4"
class="text-center py-6 text-medium-emphasis"
>
<template #prepend>
<v-icon
:icon="
fileIconByName(f.path.split('/').pop() || '')
"
/>
</template>
<v-list-item-title class="file-name">
{{ f.path.replace(g.base + "/", "") }}
</v-list-item-title>
<v-list-item-subtitle class="file-size">
{{ (f.file_size ?? 0).toLocaleString() }} bytes
</v-list-item-subtitle>
<template #append>
<IconDownloadBtn
@onClick="onClickArtifact(f.path)"
/>
<IconDeployBtn
class="ml-2"
:tooltip="
isAuthenticated ? 'Deploy' : 'Login required'
"
@onClick="openDeploymentModal(f.path)"
/>
</template>
</v-list-item>
<v-divider
v-if="gi < artifactGroups.length - 1"
class="my-2"
/>
</template>
</v-list>
Loading
</td>
</tr>
<!-- 폴더 1 -->
<v-list
v-else
lines="one"
density="comfortable"
class="file-list"
>
<v-list-subheader class="d-flex align-center">
<v-icon class="mr-2">mdi-folder</v-icon>
{{ historyArtifactPath }}
</v-list-subheader>
<template v-for="(f, i) in artifactItems" :key="f.path">
<v-list-item :ripple="false" class="file-row pl-8">
<template #prepend>
<v-icon
:icon="
fileIconByName(
historyArtifactPath &&
f.path.startsWith(historyArtifactPath + '/')
? f.path.slice(
historyArtifactPath.length + 1,
)
: f.path,
)
"
/>
</template>
<v-list-item-title class="file-name">
{{
historyArtifactPath &&
f.path.startsWith(historyArtifactPath + "/")
? f.path.slice(historyArtifactPath.length + 1)
: f.path
}}
</v-list-item-title>
<v-list-item-subtitle class="file-size">
{{ (f.file_size ?? 0).toLocaleString() }} bytes
</v-list-item-subtitle>
<template #append>
<IconDownloadBtn
@onClick="onClickArtifact(f.path)"
/>
<IconDeployBtn
class="ml-2"
:tooltip="
isAuthenticated ? 'Deploy' : 'Login required'
"
@onClick="openDeploymentModal(f.path)"
/>
</template>
</v-list-item>
<v-divider v-if="i < artifactItems.length - 1" />
<template v-else>
<tr v-if="!artifactGroups.length">
<td
colspan="4"
class="text-center py-6 text-medium-emphasis"
>
No Artifacts
</td>
</tr>
<!-- 디렉터리 헤더 -->
<template v-for="grp in artifactGroups" :key="grp.dir">
<tr class="group-row">
<td class="text-center">
<v-icon>mdi-folder</v-icon>
</td>
<td>
<strong>{{ grp.dir }}</strong>
</td>
<td class="text-right"></td>
<td />
</tr>
<!-- 하위(폴더/파일) -->
<tr
v-for="(it, idx) in grp.files"
:key="grp.dir + '-' + idx"
>
<!-- 아이콘 전용 제거, 경로 칸이 아이콘 칸까지 흡수 -->
<td colspan="2">
<div
class="path-cell"
:style="{
paddingLeft: `${18 * (1 + (it.depth ?? 0))}px`,
}"
>
<v-icon
:icon="it.is_dir ? 'mdi-folder' : FILE_ICON"
size="18"
class="mr-2"
/>
<code class="truncate">{{ it.path }}</code>
</div>
</td>
<td class="text-right">
{{ bytes(it.file_size) }}
</td>
<td class="text-right">
<template v-if="!it.is_dir">
<IconDownloadBtn
@onClick="onClickArtifact(it.path)"
/>
<IconDeployBtn
class="ml-2"
:tooltip="
isAuthenticated
? 'Deploy'
: 'Login required'
"
@onClick="openDeploymentModal(it.path)"
/>
</template>
<template v-else>
<v-btn size="small" variant="text" @click="">
Open
</v-btn>
</template>
</td>
</tr>
</template>
</template>
</v-list>
</v-card-text>
</v-card>
</template>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-card-text>
<v-sheet class="d-flex justify-end mb-2">
@ -1438,6 +1407,7 @@ const artifactsLoading = ref(false);
/>
</v-container>
<!-- 배포 다이얼로그 -->
<v-dialog v-model="isEditVisible" max-width="800" persistent>
<DeploymentDialog
:edit-data="null"
@ -1452,6 +1422,7 @@ const artifactsLoading = ref(false);
:user-option="[]"
/>
</v-dialog>
<!-- 로그인 모달 -->
<v-dialog v-model="loginDialog" max-width="450" persistent>
<v-card>
@ -1494,9 +1465,7 @@ const artifactsLoading = ref(false);
@keyup.enter.prevent="handleLogin"
/>
</div>
<div v-if="loginError" class="mt-3 text-error">
{{ loginError }}
</div>
<div v-if="loginError" class="mt-3 text-error">{{ loginError }}</div>
</v-form>
</v-card-text>
<v-card-actions class="justify-end" style="padding: 16px 24px">
@ -1515,4 +1484,10 @@ const artifactsLoading = ref(false);
:root {
--dot-size: 28px;
}
.group-row {
background: rgba(255, 255, 255, 0.04);
}
.child-path {
padding-left: 18px; /* 들여쓰기로 디렉터리 소속임을 표시 */
}
</style>

@ -199,7 +199,7 @@ const changePageSize = (size: number) => {
fetchList();
};
// / ( )
// /
const removeData = (value?: Array<{ deviceKey: number }>) => {
const removeList = value ?? data.value.selected;
if (!removeList || removeList.length === 0) return;
@ -264,7 +264,7 @@ const removeData = (value?: Array<{ deviceKey: number }>) => {
}
};
// ===== & ( ) =====
// ===== & =====
const closeView = () => {
openView.value = false;
};

@ -219,7 +219,7 @@ const changePageSize = (size: number) => {
fetchList();
};
// / ( )
// /
const removeData = (value?: Array<{ deviceKey: number }>) => {
const removeList = value ?? data.value.selected;
if (!removeList || removeList.length === 0) return;

@ -5,15 +5,15 @@
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import "@mdi/font/css/materialdesignicons.css";
import "vuetify/styles";
// Composables
import { createVuetify } from 'vuetify'
import { createVuetify } from "vuetify";
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
defaultTheme: 'dark',
defaultTheme: "dark",
},
})
});

@ -0,0 +1,24 @@
export function saveBlob(blob: Blob, filename = "download.bin") {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export function filenameFromContentDisposition(
cd?: string,
fallback = "download.bin",
) {
if (!cd) return fallback;
const m = cd.match(/filename\*=UTF-8''([^;]+)|filename="?([^"]+)"?/i);
const name = decodeURIComponent(m?.[1] || m?.[2] || "");
return name || fallback;
}
export function lastSegment(path: string, fallback = "download.bin") {
return path?.split("/").pop() || fallback;
}

@ -48,16 +48,58 @@ const form = ref({
prjDesc: "",
selectedUsers: [] as string[],
});
const roles = ref<string[]>([]);
const isAdmin = computed(() => roles.value.includes("ROLE_ADMIN"));
const currentUser = ref<{ id?: string | number; username?: string }>({});
const readAuth = () => {
try {
//
return (
storage.getAuth?.() ??
JSON.parse(localStorage.getItem("autoflow-auth") || "null")
);
} catch {
return null;
}
};
/** ===== 롤 ===== */
const roles = ref<string[]>([]);
const refreshRoles = () => {
const auth = storage.getAuth?.() ?? storage.get?.("vpp-Auth") ?? null;
const refreshAuth = () => {
const auth = readAuth();
const r = auth?.userInfo?.roles ?? auth?.roles ?? [];
roles.value = Array.isArray(r) ? r : [];
currentUser.value = {
id: auth?.userInfo?.id ?? auth?.id ?? auth?.userId,
username: auth?.userInfo?.username ?? auth?.username ?? auth?.userName,
};
};
const isAdmin = computed(() => roles.value.includes("ROLE_ADMIN"));
const splitCSV = (v?: string) =>
(v || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// (/ OK)
const canViewProjectRaw = (p: ProjectSearchResponseItem) => {
if (isAdmin.value) return true;
const idStr =
currentUser.value.id != null ? String(currentUser.value.id) : "";
const uname = currentUser.value.username ?? "";
const allowIds = new Set([
...splitCSV(p.regUserId),
...splitCSV(p.modUserId),
]);
const allowNms = new Set([
...splitCSV(p.regUserNm),
...splitCSV(p.modUserNm),
]);
return (idStr && allowIds.has(idStr)) || (uname && allowNms.has(uname));
};
/** ===== 페이지네이션 상태 ===== */
const pager = ref({ pageNum: 1, pageSize: 8, total: 0, pageLength: 1 });
@ -211,24 +253,28 @@ const loadProjects = async () => {
const sorted = [...rawList].sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
// 🔐
const visible = sorted.filter(canViewProjectRaw);
projectRegById.value = {};
projects.value = sorted.map((p) => {
// reg ( reg* )
projects.value = visible.map((p) => {
projectRegById.value[p.id] = { regId: p.regUserId, regNm: p.regUserNm };
// / mod_user_nm , reg_user_nm
const displayNm =
p.modUserNm && p.modUserNm.length > 0 ? p.modUserNm : p.regUserNm || "";
// : reg/mod
const usersDisplay = Array.from(
new Set([...splitCSV(p.regUserNm), ...splitCSV(p.modUserNm)]),
).join(",");
return {
id: p.id,
title: p.prjNm,
creator: displayNm, // v-select v-model
date: p.prjStartDt, // fallback
creator: usersDisplay,
date: p.prjStartDt,
description: p.prjDesc,
};
});
//
if (
pager.value.pageNum >
Math.max(1, Math.ceil(projects.value.length / pager.value.pageSize))
@ -309,12 +355,12 @@ const saveProject = async () => {
try {
let projectId: number;
if (modalMode.value === "create") {
const createPayload = buildCreatePayload(); // mod*
const createRes = await ProjectService.add(createPayload); //
const createPayload = buildCreatePayload();
const createRes = await ProjectService.add(createPayload);
projectId = createRes.data.id;
} else {
const updatePayload = buildUpdatePayload(); // reg* , mod*
await ProjectService.update(editingProjectId.value!, updatePayload); // non-null
const updatePayload = buildUpdatePayload();
await ProjectService.update(editingProjectId.value!, updatePayload);
projectId = editingProjectId.value!;
}
await grantDefaultPermissions(projectId, form.value.selectedUsers);
@ -373,11 +419,14 @@ const modifyProject = () => {
/** ===== 라이프사이클 ===== */
const onStorage = (e: StorageEvent) => {
if (!e.key || /auth|vpp-Auth/i.test(e.key)) refreshRoles();
if (!e.key || /autoflow-auth/i.test(e.key)) {
refreshAuth();
loadProjects();
}
};
onMounted(async () => {
refreshRoles();
refreshAuth();
await Promise.all([loadProjects(), loadUsers()]);
window.addEventListener("storage", onStorage);
});

2
typed-router.d.ts vendored

@ -19,6 +19,7 @@ declare module 'vue-router/auto-routes' {
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/DatagroupView': RouteRecordInfo<'/DatagroupView', '/DatagroupView', Record<never, never>, Record<never, never>>,
'/DatasetView': RouteRecordInfo<'/DatasetView', '/DatasetView', Record<never, never>, Record<never, never>>,
'/DeploymentView': RouteRecordInfo<'/DeploymentView', '/DeploymentView', Record<never, never>, Record<never, never>>,
'/ExecutionsView': RouteRecordInfo<'/ExecutionsView', '/ExecutionsView', Record<never, never>, Record<never, never>>,
@ -28,6 +29,7 @@ declare module 'vue-router/auto-routes' {
'/MainView': RouteRecordInfo<'/MainView', '/MainView', Record<never, never>, Record<never, never>>,
'/ProjectView': RouteRecordInfo<'/ProjectView', '/ProjectView', Record<never, never>, Record<never, never>>,
'/SignupView': RouteRecordInfo<'/SignupView', '/SignupView', Record<never, never>, Record<never, never>>,
'/TrainingscriptgroupView': RouteRecordInfo<'/TrainingscriptgroupView', '/TrainingscriptgroupView', Record<never, never>, Record<never, never>>,
'/TrainingScriptView': RouteRecordInfo<'/TrainingScriptView', '/TrainingScriptView', Record<never, never>, Record<never, never>>,
'/UsersView': RouteRecordInfo<'/UsersView', '/UsersView', Record<never, never>, Record<never, never>>,
'/WorkflowStepConfigView': RouteRecordInfo<'/WorkflowStepConfigView', '/WorkflowStepConfigView', Record<never, never>, Record<never, never>>,

Loading…
Cancel
Save