diff --git a/.env.dev b/.env.dev index 759edf0..f8d8bfb 100644 --- a/.env.dev +++ b/.env.dev @@ -1,3 +1,4 @@ NODE_ENV = "dev" -VITE_APP_API_SERVER_URL = "http://localhost:80" +# WSL에서 백엔드 8080 구동 시 +VITE_APP_API_SERVER_URL = "http://localhost:8080" VITE_ROOT_PATH = "" \ No newline at end of file diff --git a/.env.prod b/.env.prod index 014d35c..fa9edf1 100644 --- a/.env.prod +++ b/.env.prod @@ -1,3 +1,3 @@ NODE_ENV = "prod" -VITE_APP_API_SERVER_URL = "/autoflow-server-mgmt" +VITE_APP_API_SERVER_URL = "http://cuuva.com:2481/autoflow-server-mgmt" VITE_ROOT_PATH = "/autoflow" \ No newline at end of file diff --git a/components.d.ts b/components.d.ts index d286d49..995e384 100644 --- a/components.d.ts +++ b/components.d.ts @@ -32,7 +32,7 @@ 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/datagroup/ListComponent.vue')['default'] + ListComponent: typeof import('./src/components/templates/Datasets/ListComponent.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] ScriptCompileDialog: typeof import('./src/components/atoms/organisms/ScriptCompileDialog.vue')['default'] diff --git a/default.conf b/default.conf index f861d9d..a7bf56b 100644 --- a/default.conf +++ b/default.conf @@ -10,8 +10,7 @@ server { # 백엔드 API 프록시 location /autoflow-server-mgmt/ { - proxy_pass http://autoflow-server-mgmt-svc:80; - + proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/package-lock.json b/package-lock.json index dbde56f..8eab477 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2024,7 +2024,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" diff --git a/src/components/atoms/organisms/CompareRunDialog.vue b/src/components/atoms/organisms/CompareRunDialog.vue index d8a7ba2..bdca7df 100644 --- a/src/components/atoms/organisms/CompareRunDialog.vue +++ b/src/components/atoms/organisms/CompareRunDialog.vue @@ -25,6 +25,8 @@ const props = withDefaults( selectedMetricKeys: string[]; ensureRunDetail: (runId: string) => Promise; runDetailCache: Map; + /** runId → 실행 시 입력한 이름(Execution name, 예: auto26run) */ + runIdToExecutionName?: Record; compareChartMode?: "byMetric" | "byRun"; normalizeValues?: boolean; baselineRunId?: string | null; @@ -34,6 +36,7 @@ const props = withDefaults( items: () => [], selectedRunIds: () => [], selectedMetricKeys: () => [], + runIdToExecutionName: () => ({}), compareChartMode: "byMetric", normalizeValues: false, baselineRunId: null, @@ -123,7 +126,7 @@ const chartInnerWidth = computed(() => { return Math.max(900, xCount * 140 + 240); }); const tableInnerWidth = computed(() => { - const cols = 1 + activeMetricKeys.value.length; // Run 컬럼 + metric 수 + const cols = 2 + activeMetricKeys.value.length; // Execution name + Run + metric 수 return Math.max(900, cols * 160); }); @@ -285,9 +288,9 @@ onBeforeUnmount(() => window.removeEventListener("resize", onResize)); - Select Runs +
+ Select Runs +
window.removeEventListener("resize", onResize)); - Metrics +
Metrics
window.removeEventListener("resize", onResize)); - Run + Execution name + Run {{ k }} - + + + {{ + props.runIdToExecutionName?.[ + selectedRunIdsProxy[idx] + ] ?? "—" + }} + - {{ r.info.run_name || r.info.run_id }} + {{ r.info.run_name || r.info.run_id || "—" }} {{ diff --git a/src/components/atoms/organisms/DeploymentDialog.vue b/src/components/atoms/organisms/DeploymentDialog.vue index db01c1e..d2f7baf 100644 --- a/src/components/atoms/organisms/DeploymentDialog.vue +++ b/src/components/atoms/organisms/DeploymentDialog.vue @@ -3,13 +3,13 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { ExternalAuthControllerService } from "@/components/service/management/ExternalAuthControllerService"; import { AddFileParamsSwagger, - AddStorageParamsSwagger, + AddMinioParamsSwagger, EdgePkgInfoVOModel, } from "@/components/models/management/ExternalAuthController"; type PackageOption = { label: string; value: string; raw: any }; -type StorageRegisterModel = EdgePkgInfoVOModel & { +type MinioRegisterModel = EdgePkgInfoVOModel & { objectName: string; type: "type1" | "type2"; localPath: string; @@ -227,7 +227,7 @@ const toInt = (v: unknown, fallback = 1) => { const n = parseInt(String(v ?? "").trim(), 10); return Number.isFinite(n) ? n : fallback; }; -const storageType = ref<"type1" | "type2">("type2"); +const minioType = ref<"type1" | "type2">("type2"); async function submit() { errorMsg.value = ""; @@ -291,10 +291,10 @@ async function submit() { const params: AddMinioParamsSwagger = { ...common, objectName: props.artifactPath || "", - type: storageType.value, + type: minioType.value, localPath: (form.value.install_location || "").trim(), }; - res = await ExternalAuthControllerService.addStorage(params); + res = await ExternalAuthControllerService.addMinio(params); } const ok = diff --git a/src/components/atoms/organisms/ExecutionBaseDialog.vue b/src/components/atoms/organisms/ExecutionBaseDialog.vue index 4e7adbf..22d9e64 100644 --- a/src/components/atoms/organisms/ExecutionBaseDialog.vue +++ b/src/components/atoms/organisms/ExecutionBaseDialog.vue @@ -74,9 +74,9 @@ function onClose() { - +
Select Workflow - +
- +
Execution Type - +
- +
Execution Type - +
- +
Description - +
- +
Experiment - +
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue"; +import { useRouter } from "vue-router"; import { KubeflowService } from "@/components/service/management/KubeflowService"; +const router = useRouter(); + type RunPayload = { display_name: string; description?: string; @@ -94,6 +97,12 @@ async function submitRun() { return; } + const selectedExp = experimentOptions.value.find( + (e) => e.value === form.value.experiment_id, + ); + const experimentName = + (selectedExp?.label ?? "").trim() || (selectedExp?.value ?? "").trim(); + const payload: RunPayload = { display_name: form.value.display_name.trim(), ...(form.value.description.trim() && { @@ -104,6 +113,11 @@ async function submitRun() { ...(form.value.experiment_id && { experiment_id: form.value.experiment_id, }), + ...(experimentName && { + runtime_config: { + parameters: { mlflow_experiment_name: experimentName }, + }, + }), }; try { @@ -111,6 +125,11 @@ async function submitRun() { const { data } = await KubeflowService.run(payload); emit("submitted", data); emit("close-modal"); + const runId = data?.run_id ?? data?.runId ?? data?.id ?? ""; + router.push({ + name: "Executions", + query: runId ? { runId: String(runId) } : undefined, + }); } catch (e: any) { errorMsg.value = e?.response?.data?.message || diff --git a/src/components/common/LayoutComponent.vue b/src/components/common/LayoutComponent.vue index 3212dcf..cc9835c 100644 --- a/src/components/common/LayoutComponent.vue +++ b/src/components/common/LayoutComponent.vue @@ -2,7 +2,7 @@ /* ================================ * Imports * ================================ */ -import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue"; +import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue"; import { useRoute, useRouter } from "vue-router"; import { storage } from "@/utils/storage.js"; import { UserManagerService } from "@/components/service/management/UserManagerService"; @@ -49,12 +49,31 @@ function readAuth() { function computeIsAdmin() { const auth = readAuth(); - const roles = auth?.userInfo?.roles ?? auth?.roles ?? []; - const authCd = auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth; - const inRoles = Array.isArray(roles) - ? roles.includes("ROLE_ADMIN") - : roles === "ROLE_ADMIN"; - isAdmin.value = inRoles || authCd === "ADMIN"; + let roles = auth?.userInfo?.roles ?? auth?.roles ?? []; + if (typeof roles === "string") { + roles = roles.split(",").map((s: string) => String(s).trim()); + } + const rolesArr = Array.isArray(roles) ? roles : []; + const roleStrings = rolesArr.map((r) => + typeof r === "object" && r !== null && "authority" in r + ? String((r as { authority?: string }).authority ?? "") + : typeof r === "object" && r !== null && "name" in r + ? String((r as { name?: string }).name ?? "") + : String(r), + ); + const authCd = String( + auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth ?? "", + ).toUpperCase(); + const username = + auth?.userInfo?.username ?? auth?.username ?? auth?.userName ?? ""; + + const inRoles = roleStrings.some( + (r) => r === "ROLE_ADMIN" || String(r).toUpperCase() === "ADMIN", + ); + isAdmin.value = + inRoles || + authCd === "ADMIN" || + String(username).toLowerCase() === "admin"; } function updateUsername() { @@ -74,6 +93,7 @@ const isAdminRoute = computed(() => { const hitPath = p.startsWith("/project") || p.startsWith("/users") || + p.startsWith("/system-status") || p.startsWith("/select"); const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin); return hitPath || hitMeta; @@ -106,7 +126,7 @@ const isLinkActive = (path?: string) => !!path && route.path.startsWith(path); * Header dropdown menu * ================================ */ const menu = ref([]); -const menuItems: MenuItem[] = [ +const menuItemsBase: MenuItem[] = [ { title: "Select Project", click: () => goSelect() }, { title: "Change Password", @@ -143,6 +163,45 @@ function toggleAdmin() { } } +/** 관리자 메뉴 클릭 시 해당 경로로 이동 (SPA 실패 시 location으로 fallback) */ +function goToAdminMenu(path: string | undefined) { + if (!path) return; + router.push(path).catch((err) => { + if (err?.name !== "NavigationDuplicated") console.warn("admin nav", err); + }); + setTimeout(() => { + if (route.path !== path && path === "/system-status") { + window.location.href = resolveHref(path); + } + }, 200); +} + +/** base 포함 전체 href (클릭 시 네트워크 요청·이동 보장용) */ +function resolveHref(path: string | undefined): string { + if (!path) return "#"; + try { + return router.resolve(path).href; + } catch { + return path.startsWith("/") ? path : "/" + path; + } +} + +/** 우측 드롭다운에서 관리자(경로) 항목인지 */ +function isAdminMenuItem(item: MenuItem): boolean { + return !!item.path && item.path.startsWith("/system-status"); +} + +/** 우측 드롭다운 메뉴 클릭: click 있으면 먼저 실행, 없으면 path로 이동 */ +function onMenuItemClick(item: MenuItem, e?: MouseEvent) { + e?.preventDefault(); + e?.stopPropagation(); + if (item.click) { + item.click(); + } else if (item.path) { + router.push(item.path); + } +} + function logOut() { UserManagerService.signOut() .catch(console.error) @@ -171,6 +230,7 @@ watch( () => route.fullPath, () => { refreshProjectName(); + computeIsAdmin(); if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home"; }, { immediate: true }, @@ -191,11 +251,34 @@ function onStorage(e: StorageEvent) { /* ================================ * Lifecycle * ================================ */ +const menuItems = computed(() => { + const items = [...menuItemsBase]; + if (isAdmin.value) { + items.splice(1, 0, { + title: "사용자 관리", + icon: "mdi-account-multiple", + path: "/users", + click: () => router.push("/users"), + }); + items.splice(2, 0, { + title: "관리자", + icon: "mdi-cog", + path: "/system-status", + click: () => goToAdminMenu("/system-status"), + }); + } + return items; +}); + +watch(menuItems, (v) => { + menu.value = v; +}, { immediate: true }); + onMounted(() => { updateUsername(); computeIsAdmin(); refreshProjectName(); - menu.value = menuItems; + menu.value = menuItems.value; window.addEventListener("storage", onStorage); }); @@ -258,17 +341,23 @@ onBeforeUnmount(() => { - - + - - {{ m.title }} - + + + {{ m.title }} + + @@ -388,6 +477,21 @@ onBeforeUnmount(() => { + + + + +
{{ username || "GUEST" }} @@ -408,8 +512,10 @@ onBeforeUnmount(() => { v-for="(item, index) in menu" :key="index" :value="index" - @click="item.click" + :to="item.path && !isAdminMenuItem(item) ? item.path : undefined" :prepend-icon="item.icon" + :href="isAdminMenuItem(item) ? resolveHref(item.path) : undefined" + @click.stop.prevent="onMenuItemClick(item, $event)" > {{ item.title }} @@ -437,12 +543,13 @@ onBeforeUnmount(() => { border-bottom: 1px solid rgba(255, 255, 255, 0.06); } -/* 더 커진 홈(브랜드) 버튼 */ +/* 더 커진 홈(브랜드) 버튼 - 마우스 오버 시 포인터 */ .brand-btn { font-weight: 800; letter-spacing: 0.08em; padding: 0 14px; color: #fff; + cursor: pointer; } /* 중앙 고정 네비게이션 */ @@ -454,6 +561,13 @@ onBeforeUnmount(() => { align-items: center; } +/* 관리자 메뉴 router-link: 링크 스타일 제거 */ +.admin-nav-link { + text-decoration: none; + color: inherit; + display: inline-flex; +} + .nav-btn { text-transform: none; border-radius: 10px; diff --git a/src/components/models/management/Attachments.ts b/src/components/models/management/Attachments.ts index 8c76a59..a1d0932 100644 --- a/src/components/models/management/Attachments.ts +++ b/src/components/models/management/Attachments.ts @@ -20,5 +20,6 @@ export type AttachmentSearch = { endDate?: string; sortField?: string; sortDirection?: "ASC" | "DESC"; - refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT"; + refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT" | "workflows"; + refId?: number; }; diff --git a/src/components/models/management/ExternalAuthController.ts b/src/components/models/management/ExternalAuthController.ts index b89bbb9..39a1ca9 100644 --- a/src/components/models/management/ExternalAuthController.ts +++ b/src/components/models/management/ExternalAuthController.ts @@ -28,7 +28,7 @@ export type AddFileParamsSwagger = { creation_datetime: string; }; -export type AddStorageParamsSwagger = AddFileParamsSwagger & { +export type AddMinioParamsSwagger = AddFileParamsSwagger & { objectName: string; type: "type1" | "type2"; localPath: string; diff --git a/src/components/service/index.ts b/src/components/service/index.ts index 0e42b69..3e2c827 100644 --- a/src/components/service/index.ts +++ b/src/components/service/index.ts @@ -10,8 +10,8 @@ export const request = { post: (uri: string, param: any): any => { return axios.post(`${API_URL}${uri}`, param); }, - get: (uri: string, param: any): any => { - return axios.get(`${API_URL}${uri}`, { params: param }); + get: (uri: string, param: any, config?: any): any => { + return axios.get(`${API_URL}${uri}`, { params: param, ...config }); }, getsize: (uri: string): any => { return axios.get(`${API_URL}${uri}`); diff --git a/src/components/service/management/ExternalAuthControllerService.ts b/src/components/service/management/ExternalAuthControllerService.ts index f7520bc..7662932 100644 --- a/src/components/service/management/ExternalAuthControllerService.ts +++ b/src/components/service/management/ExternalAuthControllerService.ts @@ -1,6 +1,6 @@ import { AddFileParamsSwagger, - AddStorageParamsSwagger, + AddMinioParamsSwagger, } from "@/components/models/management/ExternalAuthController"; import { request } from "@/components/service/index"; @@ -20,7 +20,7 @@ export const ExternalAuthControllerService = { }); }, - addStorage: (params: AddStorageParamsSwagger) => { + addMinio: (params: AddMinioParamsSwagger) => { return request.postWithConfig( "/api/external-auth/register-with-minio-file", {}, diff --git a/src/components/service/management/KubeflowRunService.ts b/src/components/service/management/KubeflowRunService.ts index 5f4c4fd..0cd8921 100644 --- a/src/components/service/management/KubeflowRunService.ts +++ b/src/components/service/management/KubeflowRunService.ts @@ -15,10 +15,13 @@ export const KubeflowRunService = { getAll: () => { return request.get("/api/kubeflow/runs", {}); }, - singleData: (runId: number) => { + singleData: (runId: string | number) => { return request.get(`/api/kubeflow/runs/${runId}`, {}); }, search: (params?: KubeflowRunSearchParams) => { - return request.get("/api/kubeflow/runs", params); + return request.get("/api/kubeflow/runs/search", params); + }, + delete: (runId: string) => { + return request.delete(`/api/kubeflow/runs/${runId}`, {}); }, }; diff --git a/src/components/service/management/attachmentsService.ts b/src/components/service/management/attachmentsService.ts index f553589..6764989 100644 --- a/src/components/service/management/attachmentsService.ts +++ b/src/components/service/management/attachmentsService.ts @@ -33,4 +33,41 @@ export const AttachmentsService = { search: (payload: AttachmentSearch) => { return request.get("/api/attachments/search", payload); }, + + /** 여러 Training Script를 머지하여 master.py 생성 */ + mergeScripts: (payload: { + scriptIds: number[]; + title?: string; + description?: string; + refId?: number | null; + refType?: string; + regUserId: string; + projectId: number; + }) => { + return request.post("/api/attachments/merge-scripts", payload); + }, + + /** 스크립트 컴파일 요청 */ + compile: (id: number) => { + return request.post(`/api/attachments/${id}/compile`, {}); + }, + + /** 기존 컴파일 결과 정보 조회 (이미 만들어진 YAML 경로 확인) */ + getCompiledInfo: (id: number) => { + return request.get(`/api/attachments/${id}/compiled-info`, {}); + }, + + /** 컴파일된 YAML + 원본 py 스크립트 ZIP 다운로드 */ + downloadCompiledBundle: (attachmentId: number, yamlObjectName: string) => { + return request.getFile( + `/api/attachments/download-compiled-bundle?id=${attachmentId}&yamlObjectName=${encodeURIComponent(yamlObjectName)}`, + {}, + ); + }, + + /** 스크립트 저장 시 YAML에 반영할 MinIO 설정 (백엔드 저장값) */ + getMinioConfig: () => request.get>("/api/attachments/minio-config"), + + /** Auto Script MLflow 사용 시 YAML에 넣을 설정 (백엔드 저장값) */ + getMlflowConfig: () => request.get>("/api/attachments/mlflow-config"), }; diff --git a/src/components/service/mlflow/MlflowService.ts b/src/components/service/mlflow/MlflowService.ts index 191a79f..0e881de 100644 --- a/src/components/service/mlflow/MlflowService.ts +++ b/src/components/service/mlflow/MlflowService.ts @@ -1,4 +1,5 @@ import { request } from "@/components/service/index"; +import { saveBlob, filenameFromContentDisposition } from "@/utils/download"; export const MlflowService = { getRuns: (experimentId: string) => { @@ -7,6 +8,18 @@ export const MlflowService = { }); }, + /** Kubeflow run id 태그로 MLflow run 검색 (전체 experiment 대상, experiment name 무관) */ + getRunsByKubeflowRunId: (kubeflowRunId: string) => { + return request.get("/api/mlflow/runs/by-kubeflow-run-id", { + kubeflowRunId, + }); + }, + + /** 전체 Experiment 목록 조회 (이름 등록 없이 동적 검색용) */ + getExperiments: () => { + return request.get("/api/mlflow/experiments"); + }, + getExperimentByName: (experimentName: string) => { return request.get("/api/mlflow/experiment", { experimentName, @@ -35,4 +48,19 @@ export const MlflowService = { throw err; }); }, + + /** + * MLflow get-artifact API를 통해 artifact 파일 다운로드 (MinIO 직접 경로 대신 사용 시 NoSuchKey 방지). + */ + async downloadArtifact(runId: string, path: string): Promise { + const res = await request.getFile("/api/mlflow/artifacts/download", { + run_id: runId, + path, + }); + const blob: Blob = res.data; + const cd = res.headers?.["content-disposition"]; + const fallback = path.split("/").pop() || "download.bin"; + const filename = filenameFromContentDisposition(cd, fallback); + saveBlob(blob, filename); + }, }; diff --git a/src/components/templates/run/executions/ListComponent.vue b/src/components/templates/run/executions/ListComponent.vue index 504d30c..ab478fd 100644 --- a/src/components/templates/run/executions/ListComponent.vue +++ b/src/components/templates/run/executions/ListComponent.vue @@ -1,21 +1,25 @@ - + diff --git a/src/components/templates/run/executions/ViewComponent.vue b/src/components/templates/run/executions/ViewComponent.vue index e3ef781..adcaeb3 100644 --- a/src/components/templates/run/executions/ViewComponent.vue +++ b/src/components/templates/run/executions/ViewComponent.vue @@ -1,6 +1,9 @@