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

main
jschoi 8 months ago
parent 85e53a9d52
commit bf3359de5c

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

8
components.d.ts vendored

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

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

@ -5,7 +5,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue"; import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { storage } from "@/utils/storage.js"; import { storage } from "@/utils/storage.js";
import { UserManagerService } from "@/components/service/management/UserManagerService"; import { UserManagerService } from "@/components/service/management/userManagerService";
import { menuUtils } from "@/utils/menuUtils"; import { menuUtils } from "@/utils/menuUtils";
/* ================================ /* ================================
@ -20,7 +20,7 @@ type MenuItem = {
}; };
/* ================================ /* ================================
* Router / Reactive base state * Router / Base states
* ================================ */ * ================================ */
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -29,12 +29,9 @@ const username = ref<string>("");
const projectName = ref<string>(localStorage.getItem("projectName") || ""); const projectName = ref<string>(localStorage.getItem("projectName") || "");
const isAdmin = ref<boolean>(false); const isAdmin = ref<boolean>(false);
const adminMode = ref<boolean>(false); // Settings / const adminMode = ref<boolean>(false);
const lastNonAdminPath = ref<string>("/home"); const lastNonAdminPath = ref<string>("/home");
/* ================================
* Auth / Role helpers
* ================================ */
function readAuth() { function readAuth() {
try { try {
const raw = const raw =
@ -46,7 +43,6 @@ function readAuth() {
return null; return null;
} }
} }
function computeIsAdmin() { function computeIsAdmin() {
const auth = readAuth(); const auth = readAuth();
const roles = auth?.userInfo?.roles ?? auth?.roles ?? []; const roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
@ -56,7 +52,6 @@ function computeIsAdmin() {
: roles === "ROLE_ADMIN"; : roles === "ROLE_ADMIN";
isAdmin.value = inRoles || authCd === "ADMIN"; isAdmin.value = inRoles || authCd === "ADMIN";
} }
function updateUsername() { function updateUsername() {
const auth = readAuth(); const auth = readAuth();
username.value = auth?.userInfo?.username ?? auth?.username ?? ""; username.value = auth?.userInfo?.username ?? auth?.username ?? "";
@ -64,8 +59,6 @@ function updateUsername() {
/* ================================ /* ================================
* Derived route state * Derived route state
* - /select
* - 관리자 표시 조건: adminMode ON || 관리자 라우트
* ================================ */ * ================================ */
const hideAllMenus = computed<boolean>(() => route.path.startsWith("/select")); 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); const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin);
return hitPath || hitMeta; return hitPath || hitMeta;
}); });
const showAdminTabs = computed<boolean>( const showAdminTabs = computed<boolean>(
() => adminMode.value || isAdminRoute.value, () => adminMode.value || isAdminRoute.value,
); );
/* ================================ /* ================================
* Menus (기본/관리자) * Menus
* ================================ */ * ================================ */
const baseMenus = computed<MenuItem[]>( const baseMenus = computed<MenuItem[]>(
() => (menuUtils?.menuItem ?? []) as MenuItem[], () => (menuUtils?.menuItem ?? []) as MenuItem[],
); );
const adminMenus = computed<MenuItem[]>(() => { const adminMenus = computed<MenuItem[]>(() => {
const fromUtil = (menuUtils?.adminMenuItem ?? []) as MenuItem[]; const fromUtil = (menuUtils?.adminMenuItem ?? []) as MenuItem[];
return fromUtil.length return fromUtil.length
@ -99,50 +90,75 @@ const adminMenus = computed<MenuItem[]>(() => {
{ title: "Users", icon: "mdi-account-multiple", path: "/users" }, { title: "Users", icon: "mdi-account-multiple", path: "/users" },
]; ];
}); });
const isLinkActive = (path?: string) => !!path && route.path.startsWith(path); const isLinkActive = (path?: string) => !!path && route.path.startsWith(path);
/* ================================ /* ================================
* Header dropdown menu * 사용자 메뉴 (우측)
* ================================ */ * ================================ */
const menu = ref<MenuItem[]>([]); const menu = ref<MenuItem[]>([]);
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ title: "Select Project", click: () => goSelect() }, { title: "Select Project", click: () => goSelect() },
{ { title: "Change Password", click: () => {} },
title: "Change Password",
click: () => {
/* open modal */
},
},
{ title: "Logout", icon: "mdi-logout", click: () => logOut() }, { 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() { function goMain() {
adminMode.value = false; adminMode.value = false;
router.push("/home"); router.push("/home");
} }
function goSelect() { function goSelect() {
adminMode.value = false; adminMode.value = false;
router.push("/select"); router.push("/select");
} }
function toggleAdmin() { function toggleAdmin() {
if (!isAdmin.value) return; if (!isAdmin.value) return;
if (adminMode.value) { if (adminMode.value) {
//
adminMode.value = false; adminMode.value = false;
router.push("/home"); router.push("/home");
} else { } else {
adminMode.value = true; adminMode.value = true;
//
if (!isAdminRoute.value) router.push("/project"); if (!isAdminRoute.value) router.push("/project");
} }
} }
function logOut() { function logOut() {
UserManagerService.signOut() UserManagerService.signOut()
.catch(console.error) .catch(console.error)
@ -165,18 +181,16 @@ function logOut() {
function refreshProjectName() { function refreshProjectName() {
projectName.value = localStorage.getItem("projectName") || ""; projectName.value = localStorage.getItem("projectName") || "";
} }
//
watch( watch(
() => route.fullPath, () => route.fullPath,
() => { () => {
refreshProjectName(); refreshProjectName();
//
hoverBar.value.open = false;
if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home"; if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
}, },
{ immediate: true }, { immediate: true },
); );
// storage
function onStorage(e: StorageEvent) { function onStorage(e: StorageEvent) {
if (!e.key || e.key === "projectName") { if (!e.key || e.key === "projectName") {
refreshProjectName(); refreshProjectName();
@ -198,9 +212,9 @@ onMounted(() => {
menu.value = menuItems; menu.value = menuItems;
window.addEventListener("storage", onStorage); window.addEventListener("storage", onStorage);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage); window.removeEventListener("storage", onStorage);
if (hideTimer) window.clearTimeout(hideTimer);
}); });
</script> </script>
@ -218,54 +232,24 @@ onBeforeUnmount(() => {
AUTOFLOW WEB CONSOLE AUTOFLOW WEB CONSOLE
</div> </div>
<!-- 중앙: 메뉴 그룹 (Settings / 분기) --> <!-- 여기 스페이서를 '브랜드 다음' 둬서 오른쪽으로 밀기 -->
<div class="center-nav d-none d-md-flex" v-if="!hideAllMenus"> <v-spacer />
<!-- 관리자 메뉴: showAdminTabs 조건으로 표시 -->
<!-- 관리자 메뉴바: 기본 메뉴바와 1:1 동일 구조 --> <!-- 메뉴는 우측 정렬 -->
<div class="right-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴 -->
<template v-if="showAdminTabs"> <template v-if="showAdminTabs">
<template v-for="(m, i) in adminMenus" :key="'am_' + i"> <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-btn
v-bind="props"
variant="text" variant="text"
class="nav-btn" class="nav-btn"
:class="{ :class="{
'nav-active': m.depth?.some((d: any) => 'nav-active':
isLinkActive(d.path), m.depth?.some((d: any) => isLinkActive(d.path)) ||
), isLinkActive(m.path),
}" }"
append-icon="mdi-chevron-down" @mouseenter="showHoverStrip(m)"
> @mouseleave="scheduleHideStrip"
<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) }"
@click="m.path && router.push(m.path)" @click="m.path && router.push(m.path)"
> >
<v-icon start :icon="m.icon" class="mr-1" /> <v-icon start :icon="m.icon" class="mr-1" />
@ -277,87 +261,17 @@ onBeforeUnmount(() => {
<!-- 기본 메뉴 --> <!-- 기본 메뉴 -->
<template v-else> <template v-else>
<template v-for="(m, i) in baseMenus" :key="'m_' + i"> <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-btn
v-bind="props"
variant="text" variant="text"
class="nav-btn text-white" class="nav-btn text-white"
:class="{ :class="{
'nav-active': m.depth?.some((d: any) => 'nav-active':
isLinkActive(d.path), m.depth?.some((d: any) => isLinkActive(d.path)) ||
), isLinkActive(m.path),
}" }"
append-icon="mdi-chevron-down" @mouseenter="showHoverStrip(m)"
> @mouseleave="scheduleHideStrip"
<v-icon start :icon="m.icon" class="mr-1" /> @click="!m.depth?.length && m.path && router.push(m.path)"
{{ 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)"
> >
<v-icon start :icon="m.icon" class="mr-1" /> <v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }} {{ m.title }}
@ -365,10 +279,7 @@ onBeforeUnmount(() => {
</template> </template>
</template> </template>
</div> </div>
<!-- 우측 아이콘들 -->
<v-spacer />
<!-- 우측: 기존 기능 유지 -->
<v-tooltip v-if="isAdmin" location="bottom" text="Settings"> <v-tooltip v-if="isAdmin" location="bottom" text="Settings">
<template #activator="{ props }" v-if="!hideAllMenus"> <template #activator="{ props }" v-if="!hideAllMenus">
<v-btn <v-btn
@ -426,6 +337,37 @@ onBeforeUnmount(() => {
</v-menu> </v-menu>
</v-app-bar> </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-main>
<v-container <v-container
@ -446,64 +388,70 @@ onBeforeUnmount(() => {
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid rgba(255, 255, 255, 0.06);
} }
/* 더 커진 홈(브랜드) 버튼 */ /* 브랜드 */
.brand-btn { .brand-btn {
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
padding: 0 14px; padding: 0 14px;
} }
/* 중앙 고정 네비게이션 */ .right-nav {
.center-nav { display: flex;
position: absolute;
left: 50%;
transform: translateX(-50%);
gap: 8px;
align-items: center; align-items: center;
gap: 8px; /* 버튼 간격 */
justify-content: flex-end;
} }
.nav-btn { .nav-btn {
text-transform: none; text-transform: none;
border-radius: 10px; border-radius: 10px;
padding: 0 16px; padding: 0 16px;
font-size: 14px; font-size: 14px;
color: #fff !important; /* 흰색 텍스트 통일 */ color: #fff !important;
} }
.nav-btn:hover { .nav-btn:hover {
background: rgba(59, 130, 246, 0.08); background: rgba(59, 130, 246, 0.08);
} }
.nav-active { .nav-active {
background: rgba(59, 130, 246, 0.22); background: rgba(59, 130, 246, 0.22);
height: 46px; height: 46px;
color: #fff !important; color: #fff !important;
} }
/* 드롭다운(하위 메뉴)도 동일 룩으로 */ .userbox {
.subnav-list { min-width: 180px;
background: transparent; /* 탑바 느낌 유지 */
} }
.submenu-item { /* ===== 호버 스트립 (상단 바로 아래, 이미지 스타일) ===== */
color: #fff !important; .hover-strip {
border-radius: 10px; position: fixed;
margin: 2px 8px; 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 { .strip-chip.v-btn--variant-text {
background: rgba(59, 130, 246, 0.22); /* text 변형일 때도 흐릿하지 않게 약한 테두리 */
color: #fff !important; border: 1px solid rgba(255, 255, 255, 0.14) !important;
} background: transparent !important;
.min-w-48 {
min-width: 12rem;
} }
.userbox { .strip-chip:hover {
min-width: 180px; background: rgba(255, 255, 255, 0.06) !important;
} }
</style> </style>

@ -10,4 +10,26 @@ export type EdgePkgInfoVOModel = {
downloadLocation: string; downloadLocation: string;
user_id: string; user_id: string;
creation_datetime: 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"; import { request } from "@/components/service/index";
export const ExternalAuthControllerService = { export const ExternalAuthControllerService = {
@ -6,15 +9,31 @@ export const ExternalAuthControllerService = {
return request.post("/api/external-auth/signin", { id, password }); 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(); const fd = new FormData();
fd.append("file", file); 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, { 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", { return request.get("/api/external-auth/edge-search", {
id, id,
token, token,

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

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

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

@ -5,15 +5,15 @@
*/ */
// Styles // Styles
import '@mdi/font/css/materialdesignicons.css' import "@mdi/font/css/materialdesignicons.css";
import 'vuetify/styles' import "vuetify/styles";
// Composables // Composables
import { createVuetify } from 'vuetify' import { createVuetify } from "vuetify";
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({ export default createVuetify({
theme: { 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: "", prjDesc: "",
selectedUsers: [] as string[], 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 refreshAuth = () => {
const auth = storage.getAuth?.() ?? storage.get?.("vpp-Auth") ?? null; const auth = readAuth();
const r = auth?.userInfo?.roles ?? auth?.roles ?? []; const r = auth?.userInfo?.roles ?? auth?.roles ?? [];
roles.value = Array.isArray(r) ? r : []; 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 }); 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 sorted = [...rawList].sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
// 🔐
const visible = sorted.filter(canViewProjectRaw);
projectRegById.value = {}; projectRegById.value = {};
projects.value = sorted.map((p) => { projects.value = visible.map((p) => {
// reg ( reg* )
projectRegById.value[p.id] = { regId: p.regUserId, regNm: p.regUserNm }; projectRegById.value[p.id] = { regId: p.regUserId, regNm: p.regUserNm };
// / mod_user_nm , reg_user_nm // : reg/mod
const displayNm = const usersDisplay = Array.from(
p.modUserNm && p.modUserNm.length > 0 ? p.modUserNm : p.regUserNm || ""; new Set([...splitCSV(p.regUserNm), ...splitCSV(p.modUserNm)]),
).join(",");
return { return {
id: p.id, id: p.id,
title: p.prjNm, title: p.prjNm,
creator: displayNm, // v-select v-model creator: usersDisplay,
date: p.prjStartDt, // fallback date: p.prjStartDt,
description: p.prjDesc, description: p.prjDesc,
}; };
}); });
//
if ( if (
pager.value.pageNum > pager.value.pageNum >
Math.max(1, Math.ceil(projects.value.length / pager.value.pageSize)) Math.max(1, Math.ceil(projects.value.length / pager.value.pageSize))
@ -309,12 +355,12 @@ const saveProject = async () => {
try { try {
let projectId: number; let projectId: number;
if (modalMode.value === "create") { if (modalMode.value === "create") {
const createPayload = buildCreatePayload(); // mod* const createPayload = buildCreatePayload();
const createRes = await ProjectService.add(createPayload); // const createRes = await ProjectService.add(createPayload);
projectId = createRes.data.id; projectId = createRes.data.id;
} else { } else {
const updatePayload = buildUpdatePayload(); // reg* , mod* const updatePayload = buildUpdatePayload();
await ProjectService.update(editingProjectId.value!, updatePayload); // non-null await ProjectService.update(editingProjectId.value!, updatePayload);
projectId = editingProjectId.value!; projectId = editingProjectId.value!;
} }
await grantDefaultPermissions(projectId, form.value.selectedUsers); await grantDefaultPermissions(projectId, form.value.selectedUsers);
@ -373,11 +419,14 @@ const modifyProject = () => {
/** ===== 라이프사이클 ===== */ /** ===== 라이프사이클 ===== */
const onStorage = (e: StorageEvent) => { 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 () => { onMounted(async () => {
refreshRoles(); refreshAuth();
await Promise.all([loadProjects(), loadUsers()]); await Promise.all([loadProjects(), loadUsers()]);
window.addEventListener("storage", onStorage); window.addEventListener("storage", onStorage);
}); });

2
typed-router.d.ts vendored

@ -19,6 +19,7 @@ declare module 'vue-router/auto-routes' {
*/ */
export interface RouteNamedMap { export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>, '/': 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>>, '/DatasetView': RouteRecordInfo<'/DatasetView', '/DatasetView', Record<never, never>, Record<never, never>>,
'/DeploymentView': RouteRecordInfo<'/DeploymentView', '/DeploymentView', 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>>, '/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>>, '/MainView': RouteRecordInfo<'/MainView', '/MainView', Record<never, never>, Record<never, never>>,
'/ProjectView': RouteRecordInfo<'/ProjectView', '/ProjectView', 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>>, '/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>>, '/TrainingScriptView': RouteRecordInfo<'/TrainingScriptView', '/TrainingScriptView', Record<never, never>, Record<never, never>>,
'/UsersView': RouteRecordInfo<'/UsersView', '/UsersView', 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>>, '/WorkflowStepConfigView': RouteRecordInfo<'/WorkflowStepConfigView', '/WorkflowStepConfigView', Record<never, never>, Record<never, never>>,

Loading…
Cancel
Save