diff --git a/.env.dev b/.env.dev
index 759edf0..b0c7266 100644
--- a/.env.dev
+++ b/.env.dev
@@ -1,3 +1,3 @@
NODE_ENV = "dev"
-VITE_APP_API_SERVER_URL = "http://localhost:80"
+VITE_APP_API_SERVER_URL = "http://localhost:8080"
VITE_ROOT_PATH = ""
\ No newline at end of file
diff --git a/components.d.ts b/components.d.ts
index f638129..b483b82 100644
--- a/components.d.ts
+++ b/components.d.ts
@@ -10,15 +10,20 @@ declare module 'vue' {
export interface GlobalComponents {
AppFooter: typeof import('./src/components/AppFooter.vue')['default']
CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default']
+ CompareRunDialog: typeof import('./src/components/atoms/organisms/CompareRunDialog.vue')['default']
+ DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default']
DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default']
DeploymentDialog: typeof import('./src/components/atoms/organisms/DeploymentDialog.vue')['default']
+ DetailComponent: typeof import('./src/components/templates/run/experiment/DetailComponent.vue')['default']
DrawerComponent: typeof import('./src/components/common/DrawerComponent.vue')['default']
ExecutionBaseDialog: typeof import('./src/components/atoms/organisms/ExecutionBaseDialog.vue')['default']
ExperimentCreateDialog: typeof import('./src/components/atoms/organisms/ExperimentCreateDialog.vue')['default']
+ ExternalDatasetDialog: typeof import('./src/components/atoms/organisms/ExternalDatasetDialog.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IconArrowDown: typeof import('./src/components/atoms/button/IconArrowDown.vue')['default']
IconArrowUp: typeof import('./src/components/atoms/button/IconArrowUp.vue')['default']
IconDeleteBtn: typeof import('./src/components/atoms/button/IconDeleteBtn.vue')['default']
+ IconDeployBtn: typeof import('./src/components/atoms/button/IconDeployBtn.vue')['default']
IconDeployment: typeof import('./src/components/atoms/button/IconDeployment.vue')['default']
IconDownloadBtn: typeof import('./src/components/atoms/button/IconDownloadBtn.vue')['default']
IconInfoBtn: typeof import('./src/components/atoms/button/IconInfoBtn.vue')['default']
@@ -26,10 +31,11 @@ declare module 'vue' {
IconRunBtn: typeof import('./src/components/atoms/button/IconRunBtn.vue')['default']
IconSettingBtn: typeof import('./src/components/atoms/button/IconSettingBtn.vue')['default']
LayoutComponent: typeof import('./src/components/common/LayoutComponent.vue')['default']
- ListComponent: typeof import('./src/components/templates/Datasets/ListComponent.vue')['default']
+ ListComponent: typeof import('./src/components/templates/datagroup/ListComponent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default']
+ TrainingGroupBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingGroupBaseDoalog.vue')['default']
TrainingScriptBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingScriptBaseDoalog.vue')['default']
ViewComponent: typeof import('./src/components/templates/Datasets/ViewComponent.vue')['default']
WorkflowDialog: typeof import('./src/components/atoms/organisms/WorkflowDialog.vue')['default']
diff --git a/src/components/atoms/organisms/DeploymentDialog.vue b/src/components/atoms/organisms/DeploymentDialog.vue
index f64ab52..aba3aab 100644
--- a/src/components/atoms/organisms/DeploymentDialog.vue
+++ b/src/components/atoms/organisms/DeploymentDialog.vue
@@ -1,16 +1,26 @@
@@ -218,54 +232,24 @@ onBeforeUnmount(() => {
AUTOFLOW WEB CONSOLE
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
- {{ m.title }}
-
-
-
-
-
-
-
-
-
@@ -277,87 +261,17 @@ onBeforeUnmount(() => {
-
-
-
-
- {{ m.title }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ m.title }}
@@ -365,10 +279,7 @@ onBeforeUnmount(() => {
-
-
-
-
+
{
+
+
+
+
+
+
+
+
+ {{ d.title }}
+
+
+
+
+
+
+
{
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
-/* 더 커진 홈(브랜드) 버튼 */
+/* 브랜드 */
.brand-btn {
font-weight: 800;
letter-spacing: 0.08em;
padding: 0 14px;
}
-/* 중앙 고정 네비게이션 */
-.center-nav {
- position: absolute;
- left: 50%;
- transform: translateX(-50%);
- gap: 8px;
+.right-nav {
+ display: flex;
align-items: center;
+ gap: 8px; /* 버튼 간격 */
+ justify-content: flex-end;
}
-
.nav-btn {
text-transform: none;
border-radius: 10px;
padding: 0 16px;
font-size: 14px;
- color: #fff !important; /* 흰색 텍스트 통일 */
+ color: #fff !important;
}
-
.nav-btn:hover {
background: rgba(59, 130, 246, 0.08);
}
-
.nav-active {
background: rgba(59, 130, 246, 0.22);
height: 46px;
color: #fff !important;
}
-/* 드롭다운(하위 메뉴)도 동일 룩으로 */
-.subnav-list {
- background: transparent; /* 탑바 느낌 유지 */
+.userbox {
+ min-width: 180px;
}
-.submenu-item {
- color: #fff !important;
- border-radius: 10px;
- margin: 2px 8px;
+/* ===== 호버 스트립 (상단 바로 아래, 이미지 스타일) ===== */
+.hover-strip {
+ position: fixed;
+ top: var(--v-layout-top, 64px); /* app-bar 바로 아래 */
+ left: 0;
+ right: 0;
+ z-index: 2500;
+ /* 다크 배경 + 살짝 투명 + 경계 */
+ background: rgba(32, 32, 32, 0.96);
+ border-bottom: 1px solid rgb(145, 61, 61);
+ backdrop-filter: blur(6px);
}
-.submenu-item:hover {
- background: rgba(59, 130, 246, 0.08);
+/* 버튼(알약) – 다크에서도 비활성 글자/테두리 선명 */
+.strip-chip {
+ border-radius: 9999px !important;
+ text-transform: none;
+ font-weight: 600;
+ letter-spacing: 0;
+ height: 30px;
+ padding: 0 14px;
+ color: #e5e7eb !important; /* 비활성도 흐려 보이지 않게 */
}
-.submenu-active {
- background: rgba(59, 130, 246, 0.22);
- color: #fff !important;
-}
-.min-w-48 {
- min-width: 12rem;
+.strip-chip.v-btn--variant-text {
+ /* text 변형일 때도 흐릿하지 않게 약한 테두리 */
+ border: 1px solid rgba(255, 255, 255, 0.14) !important;
+ background: transparent !important;
}
-.userbox {
- min-width: 180px;
+.strip-chip:hover {
+ background: rgba(255, 255, 255, 0.06) !important;
}
diff --git a/src/components/models/management/ExternalAuthController.ts b/src/components/models/management/ExternalAuthController.ts
index 8541eb2..39a1ca9 100644
--- a/src/components/models/management/ExternalAuthController.ts
+++ b/src/components/models/management/ExternalAuthController.ts
@@ -10,4 +10,26 @@ export type EdgePkgInfoVOModel = {
downloadLocation: string;
user_id: string;
creation_datetime: string;
+ sw_type: number;
+};
+
+export type AddFileParamsSwagger = {
+ sw_id: string;
+ sw_version: number;
+ sw_name: string;
+ authId: string;
+ pkg_serial: number;
+ archiveType: 0 | 1;
+ execYn: 0 | 1;
+ secretAt: boolean;
+ downloadLocation: string;
+ user_id: string;
+ sw_type: number;
+ creation_datetime: string;
+};
+
+export type AddMinioParamsSwagger = AddFileParamsSwagger & {
+ objectName: string;
+ type: "type1" | "type2";
+ localPath: string;
};
diff --git a/src/components/service/management/ExternalAuthControllerService.ts b/src/components/service/management/ExternalAuthControllerService.ts
index 3dce57f..7662932 100644
--- a/src/components/service/management/ExternalAuthControllerService.ts
+++ b/src/components/service/management/ExternalAuthControllerService.ts
@@ -1,4 +1,7 @@
-import { EdgePkgInfoVOModel } from "@/components/models/management/ExternalAuthController";
+import {
+ AddFileParamsSwagger,
+ AddMinioParamsSwagger,
+} from "@/components/models/management/ExternalAuthController";
import { request } from "@/components/service/index";
export const ExternalAuthControllerService = {
@@ -6,15 +9,31 @@ export const ExternalAuthControllerService = {
return request.post("/api/external-auth/signin", { id, password });
},
- add: (params: EdgePkgInfoVOModel, file: File | Blob) => {
+ add: (params: AddFileParamsSwagger, file: File | Blob) => {
const fd = new FormData();
fd.append("file", file);
+ const { authId, ...rest } = params as any;
+ const snakeParams = { ...rest, auth_id: authId };
+
return request.postWithConfig("/api/external-auth/register-with-file", fd, {
- params,
+ params: snakeParams,
});
},
- search: (id: string, token: string) => {
+ addMinio: (params: AddMinioParamsSwagger) => {
+ return request.postWithConfig(
+ "/api/external-auth/register-with-minio-file",
+ {},
+ { params },
+ );
+ },
+ swSearch: (id: string, token: string) => {
+ return request.get("/api/external-auth/sw-search", {
+ id,
+ token,
+ });
+ },
+ edgeSearch: (id: string, token: string) => {
return request.get("/api/external-auth/edge-search", {
id,
token,
diff --git a/src/components/service/management/MinioService.ts b/src/components/service/management/MinioService.ts
index e34f9e0..7c9fb3f 100644
--- a/src/components/service/management/MinioService.ts
+++ b/src/components/service/management/MinioService.ts
@@ -1,9 +1,18 @@
import { request } from "@/components/service/index";
+
+import { saveBlob, filenameFromContentDisposition } from "@/utils/download";
export const MinioService = {
- download(objectName: string) {
- return request.get("/api/minio/download", {
- params: { objectName, type: "type2" },
- responseType: "blob",
+ async download(objectName: string) {
+ const res = await request.getFile("/api/minio/download", {
+ objectName,
+ type: "type2",
});
+
+ const blob: Blob = res.data;
+ const cd = res.headers?.["content-disposition"];
+ const fallback = objectName.split("/").pop() || "download.bin";
+ const filename = filenameFromContentDisposition(cd, fallback);
+
+ saveBlob(blob, filename);
},
};
diff --git a/src/components/service/mlflow/MlflowService.ts b/src/components/service/mlflow/MlflowService.ts
index 05bc9d2..191a79f 100644
--- a/src/components/service/mlflow/MlflowService.ts
+++ b/src/components/service/mlflow/MlflowService.ts
@@ -17,4 +17,22 @@ export const MlflowService = {
runId,
});
},
+
+ artifact: (runId: string, path?: string) => {
+ const params: Record = {
+ 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;
+ });
+ },
};
diff --git a/src/components/templates/Datasets/ListComponent.vue b/src/components/templates/Datasets/ListComponent.vue
index 265d180..0230459 100644
--- a/src/components/templates/Datasets/ListComponent.vue
+++ b/src/components/templates/Datasets/ListComponent.vue
@@ -220,7 +220,7 @@ const changePageSize = (size: number) => {
fetchList();
};
-// 삭제/수정 버튼 등(기존 로직 유지)
+// 삭제/수정 버튼 등
const removeData = (value?: Array<{ deviceKey: number }>) => {
const removeList = value ?? data.value.selected;
if (!removeList || removeList.length === 0) return;
diff --git a/src/components/templates/run/executions/ViewComponent.vue b/src/components/templates/run/executions/ViewComponent.vue
index 0c98cb6..612540d 100644
--- a/src/components/templates/run/executions/ViewComponent.vue
+++ b/src/components/templates/run/executions/ViewComponent.vue
@@ -14,19 +14,31 @@ import Plotly from "plotly.js-dist-min";
import CompareRunsDialog from "@/components/atoms/organisms/CompareRunDialog.vue";
import DeploymentDialog from "@/components/atoms/organisms/DeploymentDialog.vue";
import { MinioService } from "@/components/service/management/MinioService";
+import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
+import IconDeployBtn from "@/components/atoms/button/IconDeployBtn.vue";
+
/* ========= Constants & Types ========= */
const AUTH_KEY = "external-auth";
-const externalToken = computed(() => externalAuth.value?.token ?? "");
-type ExternalAuth = { id: string; name: string; token: string };
-type PackageOption = { label: string; value: string; raw: any };
+
+type MlflowArtifactList = {
+ root_uri: string;
+ files: { path: string; is_dir: boolean; file_size?: number }[];
+};
+type ArtifactRow = {
+ path: string;
+ is_dir?: boolean;
+ file_size?: number;
+ depth?: number;
+};
type MetricKV = { key: string; value: number };
type RunDetailType = {
info: any;
data: { metrics: MetricKV[]; params?: any[]; tags?: any[] };
};
-type ArtifactRow = { path: string; is_dir?: boolean; file_size?: number };
-type ArtifactGroup = { base: string; items: ArtifactRow[] };
-
+type ExternalAuth = { id: string; name: string; token: string };
+type PackageOption = { label: string; value: string; raw: any };
+type ArtifactGroup = { dir: string; files: ArtifactRow[] };
+const FILE_ICON = "mdi-file-document-outline";
/* ========= Props/Emits ========= */
const props = defineProps<{ experimentInfo: any }>();
const emit = defineEmits<{ (e: "close"): void }>();
@@ -38,12 +50,12 @@ const loginForm = ref({ id: "", password: "" });
const loginError = ref("");
const isAuthenticated = ref(false);
const externalAuth = ref(null);
+const externalToken = computed(() => externalAuth.value?.token ?? "");
/* ========= Deployment State ========= */
const isEditVisible = ref(false);
-const lastArtifactUri = ref(""); // 배포 모달에 표시될 최종 URI
-const pendingArtifactPath = ref(null); // 로그인 전 임시 저장
-
+const lastArtifactUri = ref("");
+const pendingArtifactPath = ref(null);
const packageOptions = ref([]);
const packagesLoading = ref(false);
const packagesError = ref("");
@@ -72,6 +84,109 @@ const baselineRunId = ref(null);
const elMetrics = ref(null);
const elCompare = ref(null);
+/* ========= Artifacts: Two-step(flat) ========= */
+const artifactGroups = computed(() => {
+ 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([]);
+const lvl2 = ref([]);
+const twoStepLoading = ref(false);
+const twoStepError = ref("");
+
+async function fetchArtifactsTwoStep(runId: string) {
+ lvl1.value = [];
+ lvl2.value = [];
+ twoStepError.value = "";
+ if (!runId) return;
+
+ twoStepLoading.value = true;
+ try {
+ // 1) 루트(=runId) 기준 1단계
+ const res1 = await MlflowService.artifact(runId);
+ const body1 = unwrapAxiosLike(res1) as MlflowArtifactList;
+ const firstFiles = (body1?.files ?? []).map((f) => ({
+ path: f.path,
+ is_dir: !!f.is_dir,
+ file_size: f.file_size,
+ })) as ArtifactRow[];
+ lvl1.value = firstFiles;
+
+ // 2) 1단계의 "디렉터리"에 대해 하위(=2단계) 조회
+ const dirPaths = firstFiles.filter((x) => x.is_dir).map((x) => x.path);
+ let merged: ArtifactRow[] = [];
+ if (dirPaths.length > 0) {
+ const results = await Promise.all(
+ dirPaths.map(async (p) => {
+ const r = await MlflowService.artifact(runId, p);
+ return unwrapAxiosLike(r) as MlflowArtifactList;
+ }),
+ );
+ for (const r of results) {
+ const files2 = (r?.files ?? []).map((f) => ({
+ path: f.path, // ex) "sklearn-model/MLmodel" 또는 "sklearn-model/sub"
+ is_dir: !!f.is_dir,
+ file_size: f.file_size,
+ })) as ArtifactRow[];
+ merged.push(...files2);
+ }
+
+ // 3) 2단계 결과 중 "하위 폴더"가 있으면 그 하위(=3단계)도 한 번 더 조회 (lazy 없이 자동 1번만)
+ const subDirs = merged.filter((x) => x.is_dir);
+ if (subDirs.length > 0) {
+ const results3 = await Promise.all(
+ subDirs.map(async (sd) => {
+ const r3 = await MlflowService.artifact(runId, sd.path);
+ return unwrapAxiosLike(r3) as MlflowArtifactList;
+ }),
+ );
+ for (const r3 of results3) {
+ const files3 = (r3?.files ?? []).map((f) => ({
+ path: f.path, // ex) "sklearn-model/sub/deeper.txt"
+ is_dir: !!f.is_dir,
+ file_size: f.file_size,
+ })) as ArtifactRow[];
+ merged.push(...files3);
+ }
+ }
+ }
+ lvl2.value = merged;
+ } catch (e: any) {
+ twoStepError.value =
+ e?.response?.data?.message || e?.message || "artifact 조회 실패";
+ console.error("[Artifacts][TwoStep] ERROR", e);
+ } finally {
+ twoStepLoading.value = false;
+ }
+}
+
/* ========= Helpers ========= */
const safeParse = (v: any): T | null => {
try {
@@ -123,62 +238,28 @@ const fmtNumber = (v: number | null, digits = 3) => {
return v.toExponential(2);
};
-const normalizeArray = (vals: (number | null)[]) => {
- const xs = vals.filter((v): v is number => Number.isFinite(v as number));
- if (xs.length === 0) return vals;
- const min = Math.min(...xs),
- max = Math.max(...xs);
- if (max === min) return vals.map((v) => (v == null ? v : 1));
- return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
-};
-
const labelOfRun = (r: RunDetailType) => r.info.run_name || r.info.run_id;
const valueOf = (run: RunDetailType, key: string): number | null => {
const m = run.data.metrics.find((x) => x.key === key)?.value;
return Number.isFinite(m as number) ? Number(m) : null;
};
-/* === File icon helpers (빨간 밑줄 원인: 누락/중복 정의 방지) === */
const fileExt = (name: string) => {
const n = (name || "").toLowerCase().trim();
const idx = n.lastIndexOf(".");
return idx >= 0 ? n.slice(idx + 1) : "";
};
-const fileIconByName = (name: string) => {
- const n = (name || "").toLowerCase().trim();
- // exact filename first
- if (n === "mlmodel") return "mdi-file-cog-outline";
- if (n === "conda.yaml" || n === "conda.yml")
- return "mdi-file-settings-outline";
- if (n === "requirements.txt") return "mdi-file-code-outline";
- // folder-ish (no dot or slash)
- if (!n.includes(".") && !n.includes("/")) return "mdi-file-outline";
- // by extension
- switch (fileExt(n)) {
- case "json":
- return "mdi-code-json";
- case "yml":
- case "yaml":
- return "mdi-code-braces";
- case "txt":
- return "mdi-file-document-outline";
- case "pkl":
- case "pickle":
- return "mdi-cube-outline";
- case "onnx":
- return "mdi-robot-outline";
- case "pt":
- case "pth":
- return "mdi-chip";
- case "bin":
- return "mdi-database-outline";
- case "joblib":
- case "pmdarima":
- return "mdi-archive-outline";
- default:
- return "mdi-file-outline";
- }
-};
+
+const bytes = (n?: number) =>
+ typeof n === "number" && isFinite(n)
+ ? n < 1024
+ ? `${n} B`
+ : n < 1024 ** 2
+ ? `${(n / 1024).toFixed(1)} KB`
+ : n < 1024 ** 3
+ ? `${(n / 1024 ** 2).toFixed(1)} MB`
+ : `${(n / 1024 ** 3).toFixed(2)} GB`
+ : "—";
/* ========= Derived (computed) ========= */
const runItems = computed(() =>
@@ -195,59 +276,7 @@ const selectedMetrics = computed(() =>
})),
);
-/* ===== Artifacts (from tag: mlflow.log-model.history) ===== */
-const historyArtifacts = computed(() => {
- 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(raw);
- return parsed ? (Array.isArray(parsed) ? parsed : [parsed]) : [];
-});
-
-const artifactGroups = computed(() => {
- 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();
- 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(() => {
- const base = historyArtifactPath.value;
- return artifactGroups.value.find((x) => x.base === base)?.items ?? [];
-});
-
-/* ========= URI 조합기 & 클릭 핸들러 ========= */
+/* ========= URI 조합 & 다운로드/배포 ========= */
function buildArtifactUri(fullPath: string) {
const expId =
currentExperimentId.value ||
@@ -257,12 +286,12 @@ function buildArtifactUri(fullPath: string) {
const runId = runDetail.value?.info?.run_id || selectedRunId.value || "";
return `${expId}/${runId}/artifacts/${fullPath}`;
}
+
async function onClickArtifact(fullPath: string) {
- const objectName = buildArtifactUri(fullPath); // expId/runId/artifacts/...
+ const objectName = buildArtifactUri(fullPath);
try {
artifactsLoading.value = true;
- const res = await MinioService.download(objectName);
- // blob 다운로드 처리...
+ await MinioService.download(objectName);
} finally {
artifactsLoading.value = false;
}
@@ -316,6 +345,7 @@ async function fetchRuns(expName?: string) {
exp?.experiment_id ?? exp?.experimentId ?? exp?.id ?? "",
);
currentExperimentId.value = expId;
+
if (!expId) {
runs.value = [];
selectedRunId.value = "";
@@ -325,10 +355,8 @@ async function fetchRuns(expName?: string) {
const body = unwrapAxiosLike(await MlflowService.getRuns(expId));
const list =
body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []);
- console.log("bodybody", body);
-
const matched = (Array.isArray(list) ? list : []).filter(
- (r: any) => getTag(r, "kubeflow_run_id") === parentRunId,
+ (r: any) => getTag(r, "experiment_id") === parentRunId,
);
const final = matched.length > 0 ? matched : list;
const sorted = [...final].sort(
@@ -470,6 +498,14 @@ function createTracesByRun(metricKeys: string[], runsData: RunDetailType[]) {
};
});
}
+const normalizeArray = (vals: (number | null)[]) => {
+ const xs = vals.filter((v): v is number => Number.isFinite(v as number));
+ if (xs.length === 0) return vals;
+ const min = Math.min(...xs),
+ max = Math.max(...xs);
+ if (max === min) return vals.map((v) => (v == null ? v : 1));
+ return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
+};
function drawCompareChart() {
if (!elCompare.value) return;
const metricKeys = activeMetricKeys.value;
@@ -484,33 +520,6 @@ function drawCompareChart() {
? createTracesByMetric(metricKeys, runsData)
: createTracesByRun(metricKeys, runsData);
- if (compareChartMode.value === "byMetric") {
- const varianceOrder = metricKeys
- .map((k, idx) => {
- const vals = traces
- .map((t) => t.y[idx])
- .filter((v: any) => v != null) as number[];
- const min = Math.min(...vals),
- max = Math.max(...vals);
- return { k, spread: max - min };
- })
- .sort((a, b) => b.spread - a.spread)
- .map((v) => v.k);
-
- traces.forEach((t) => {
- t.x = varianceOrder;
- t.y = varianceOrder.map((mk: string) => t.y[metricKeys.indexOf(mk)]);
- if (t.text)
- t.text = varianceOrder.map(
- (mk: string) => t.text![metricKeys.indexOf(mk)],
- );
- if (t.customdata)
- t.customdata = varianceOrder.map(
- (mk: string) => t.customdata![metricKeys.indexOf(mk)],
- );
- });
- }
-
Plotly.react(
elCompare.value,
traces,
@@ -519,30 +528,7 @@ function drawCompareChart() {
);
}
-/* ========= Compare Actions ========= */
-function openCompareDialog() {
- compareSelectedRunIds.value = Array.from(
- new Set([selectedRunId.value].filter(Boolean)),
- );
- compareSelectedMetricKeys.value = [];
- compareDialog.value = true;
-}
-async function loadCompareData() {
- if (!compareDialog.value) return;
- compareLoading.value = true;
- try {
- await Promise.all(compareSelectedRunIds.value.map(ensureRunDetail));
- if (compareSelectedMetricKeys.value.length === 0) {
- compareSelectedMetricKeys.value = commonMetricKeys.value.slice(0, 6);
- }
- await nextTick();
- drawCompareChart();
- } finally {
- compareLoading.value = false;
- }
-}
-
-/* ========= Derived for Compare ========= */
+/* ========= Compare Derived/Actions ========= */
const compareRuns = computed(
() =>
compareSelectedRunIds.value
@@ -565,6 +551,28 @@ const activeMetricKeys = computed(() =>
: commonMetricKeys.value,
);
+function openCompareDialog() {
+ compareSelectedRunIds.value = Array.from(
+ new Set([selectedRunId.value].filter(Boolean)),
+ );
+ compareSelectedMetricKeys.value = [];
+ compareDialog.value = true;
+}
+async function loadCompareData() {
+ if (!compareDialog.value) return;
+ compareLoading.value = true;
+ try {
+ await Promise.all(compareSelectedRunIds.value.map(ensureRunDetail));
+ if (compareSelectedMetricKeys.value.length === 0) {
+ compareSelectedMetricKeys.value = commonMetricKeys.value.slice(0, 6);
+ }
+ await nextTick();
+ drawCompareChart();
+ } finally {
+ compareLoading.value = false;
+ }
+}
+
/* ========= Auth ========= */
function restoreAuthFromStorage() {
const raw = localStorage.getItem(AUTH_KEY);
@@ -607,7 +615,6 @@ const handleLogin = async () => {
loginError.value = "로그인에 실패했습니다. 아이디/비밀번호를 확인하세요.";
return;
}
-
const toSave: ExternalAuth = {
id: payload.id ?? id,
name: payload.name ?? id,
@@ -631,22 +638,18 @@ const handleLogin = async () => {
/* ========= Deployment Modal ========= */
const openDeploymentModal = async (fullPath?: string) => {
- // 1) 경로가 들어오면 즉시 URI 조합/저장
if (fullPath) {
const uri = buildArtifactUri(fullPath);
lastArtifactUri.value = uri;
pendingArtifactPath.value = uri;
}
- // 2) 비로그인 → 로그인 우선
if (!isAuthenticated.value) {
loginDialog.value = true;
return;
}
- // 3) 로그인 후 pending 값 복원
if (!fullPath && pendingArtifactPath.value)
lastArtifactUri.value = pendingArtifactPath.value;
- // 4) 모달 열고 패키지 로딩
isEditVisible.value = true;
packagesError.value = "";
packageOptions.value = [];
@@ -668,7 +671,6 @@ const openDeploymentModal = async (fullPath?: string) => {
try {
packagesLoading.value = true;
const res = await ExternalAuthControllerService.search(auth.id, auth.token);
-
const body = res?.data ?? res;
const list = body?.data?.data ?? body?.data ?? body?.result ?? body ?? [];
const arr = Array.isArray(list) ? list : [];
@@ -688,11 +690,10 @@ const closeCreateModal = () => {
isEditVisible.value = false;
};
const saveData = (payload: any) => {
- // 필요 시 서버에 저장/리프레시 등 처리
console.log("[DeploymentDialog payload]", payload);
- // 완료 후 모달 닫기
isEditVisible.value = false;
};
+
/* ========= Timeline (fallback) ========= */
const rawHistory = computed(() => {
const h =
@@ -746,10 +747,12 @@ watch(
() => mainTab.value,
async (t) => {
if (t === "viz" || t === "artifacts") await refreshIfActive();
+ if (t === "artifacts" && selectedRunId.value) {
+ await fetchArtifactsTwoStep(selectedRunId.value);
+ }
},
{ immediate: true },
);
-
watch(
() => props.experimentInfo,
async () => {
@@ -757,7 +760,12 @@ watch(
await refreshIfActive();
},
);
-watch(selectedRunId, (id) => fetchRunDetail(id));
+watch(selectedRunId, async (id) => {
+ fetchRunDetail(id);
+ if (mainTab.value === "artifacts" && id) {
+ await fetchArtifactsTwoStep(id);
+ }
+});
watch(vizTab, async (t) => {
if (mainTab.value === "viz" && t === "metrics") {
await nextTick();
@@ -968,7 +976,6 @@ const artifactsLoading = ref(false);
clear-icon=""
style="min-width: 280px; max-width: 440px"
/>
-
Model Metrics (selected run)
- Model Metrics (selected run)
-
@@ -1161,9 +1167,8 @@ const artifactsLoading = ref(false);
Metrics (bar chart)
- Metrics (bar chart)
-
-
- (준비중) X/Y 축 선택 후 산점도 표시
-
+ (준비중) X/Y 축 선택 후 산점도 표시
-
- (준비중) 메트릭 분포 Box Plot
-
+ (준비중) 메트릭 분포 Box Plot
-
- (준비중) 2D/3D Contour Plot
-
+ (준비중) 2D/3D Contour Plot
@@ -1195,7 +1200,7 @@ const artifactsLoading = ref(false);
-
+
@@ -1221,8 +1226,16 @@ const artifactsLoading = ref(false);
style="min-width: 280px; max-width: 440px"
/>
-
+
+ Refresh
+
+
mdi-check-decagram
{{ externalAuth?.name || externalAuth?.id }}
-
- 이 실행에서 mlflow.log-model.history 태그를 찾을 수
- 없어요.
+ {{ twoStepError }}
-
-
- Path:
- {{
- historyArtifactPath
- }}
-
-
-
- Artifacts
-
-
-
-
-
- No files
-
-
-
-
-
-
- mdi-folder
- {{ g.base }}
-
-
-
+ Artifacts
+
+
+
+
+
+ |
+ Path |
+ Size |
+ Actions |
+
+
+
+
+ |
-
-
-
-
-
- {{ f.path.replace(g.base + "/", "") }}
-
-
-
- {{ (f.file_size ?? 0).toLocaleString() }} bytes
-
-
-
-
-
-
-
-
-
-
-
+ Loading…
+ |
+
-
-
-
- mdi-folder
- {{ historyArtifactPath }}
-
-
-
-
-
-
-
-
-
- {{
- historyArtifactPath &&
- f.path.startsWith(historyArtifactPath + "/")
- ? f.path.slice(historyArtifactPath.length + 1)
- : f.path
- }}
-
-
-
- {{ (f.file_size ?? 0).toLocaleString() }} bytes
-
-
-
-
-
-
-
-
-
+
+
+ |
+ No Artifacts
+ |
+
+
+
+
+
+ |
+ mdi-folder
+ |
+
+ {{ grp.dir }}
+ |
+ |
+ |
+
+
+
+
+
+ |
+
+
+ {{ it.path }}
+
+ |
+
+
+ {{ bytes(it.file_size) }}
+ |
+
+
+
+
+
+
+
+
+ Open
+
+
+ |
+
+
-
-
-
-
+
+
+
+
@@ -1438,6 +1407,7 @@ const artifactsLoading = ref(false);
/>
+
+
@@ -1494,9 +1465,7 @@ const artifactsLoading = ref(false);
@keyup.enter.prevent="handleLogin"
/>
-
- {{ loginError }}
-
+ {{ loginError }}
@@ -1515,4 +1484,10 @@ const artifactsLoading = ref(false);
:root {
--dot-size: 28px;
}
+.group-row {
+ background: rgba(255, 255, 255, 0.04);
+}
+.child-path {
+ padding-left: 18px; /* 들여쓰기로 디렉터리 소속임을 표시 */
+}
diff --git a/src/components/templates/run/experiment/ListComponent.vue b/src/components/templates/run/experiment/ListComponent.vue
index 38148b3..bf11a34 100644
--- a/src/components/templates/run/experiment/ListComponent.vue
+++ b/src/components/templates/run/experiment/ListComponent.vue
@@ -199,7 +199,7 @@ const changePageSize = (size: number) => {
fetchList();
};
-// 삭제/수정 버튼 등(기존 로직 유지)
+// 삭제/수정 버튼 등
const removeData = (value?: Array<{ deviceKey: number }>) => {
const removeList = value ?? data.value.selected;
if (!removeList || removeList.length === 0) return;
@@ -264,7 +264,7 @@ const removeData = (value?: Array<{ deviceKey: number }>) => {
}
};
-// ===== 상세 & 생성 모달 (기존 그대로) =====
+// ===== 상세 & 생성 모달 =====
const closeView = () => {
openView.value = false;
};
diff --git a/src/components/templates/trainingscript/ListComponent.vue b/src/components/templates/trainingscript/ListComponent.vue
index a941e17..db83db8 100644
--- a/src/components/templates/trainingscript/ListComponent.vue
+++ b/src/components/templates/trainingscript/ListComponent.vue
@@ -219,7 +219,7 @@ const changePageSize = (size: number) => {
fetchList();
};
-// 삭제/수정 버튼 등(기존 로직 유지)
+// 삭제/수정 버튼 등
const removeData = (value?: Array<{ deviceKey: number }>) => {
const removeList = value ?? data.value.selected;
if (!removeList || removeList.length === 0) return;
diff --git a/src/plugins/vuetify.js b/src/plugins/vuetify.js
index 247a149..825a9f5 100644
--- a/src/plugins/vuetify.js
+++ b/src/plugins/vuetify.js
@@ -5,15 +5,15 @@
*/
// Styles
-import '@mdi/font/css/materialdesignicons.css'
-import 'vuetify/styles'
+import "@mdi/font/css/materialdesignicons.css";
+import "vuetify/styles";
// Composables
-import { createVuetify } from 'vuetify'
+import { createVuetify } from "vuetify";
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: {
- defaultTheme: 'dark',
+ defaultTheme: "dark",
},
-})
+});
diff --git a/src/utils/download.ts b/src/utils/download.ts
new file mode 100644
index 0000000..daeb5fb
--- /dev/null
+++ b/src/utils/download.ts
@@ -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;
+}
diff --git a/src/views/Select.vue b/src/views/Select.vue
index 4b3f684..705be7a 100644
--- a/src/views/Select.vue
+++ b/src/views/Select.vue
@@ -48,16 +48,58 @@ const form = ref({
prjDesc: "",
selectedUsers: [] as string[],
});
+const roles = ref([]);
+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([]);
-const refreshRoles = () => {
- const auth = storage.getAuth?.() ?? storage.get?.("vpp-Auth") ?? null;
+
+const refreshAuth = () => {
+ const auth = readAuth();
const r = auth?.userInfo?.roles ?? auth?.roles ?? [];
roles.value = Array.isArray(r) ? r : [];
+ currentUser.value = {
+ id: auth?.userInfo?.id ?? auth?.id ?? auth?.userId,
+ username: auth?.userInfo?.username ?? auth?.username ?? auth?.userName,
+ };
};
-const isAdmin = computed(() => roles.value.includes("ROLE_ADMIN"));
+const splitCSV = (v?: string) =>
+ (v || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+// 열람 가능 여부 (등록/수정 사용자에 포함되면 OK)
+const canViewProjectRaw = (p: ProjectSearchResponseItem) => {
+ if (isAdmin.value) return true;
+ const idStr =
+ currentUser.value.id != null ? String(currentUser.value.id) : "";
+ const uname = currentUser.value.username ?? "";
+
+ const allowIds = new Set([
+ ...splitCSV(p.regUserId),
+ ...splitCSV(p.modUserId),
+ ]);
+ const allowNms = new Set([
+ ...splitCSV(p.regUserNm),
+ ...splitCSV(p.modUserNm),
+ ]);
+
+ return (idStr && allowIds.has(idStr)) || (uname && allowNms.has(uname));
+};
/** ===== 페이지네이션 상태 ===== */
const pager = ref({ pageNum: 1, pageSize: 8, total: 0, pageLength: 1 });
@@ -211,24 +253,28 @@ const loadProjects = async () => {
const sorted = [...rawList].sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
+ // 🔐 현재 사용자 기준으로 필터
+ const visible = sorted.filter(canViewProjectRaw);
+
projectRegById.value = {};
- projects.value = sorted.map((p) => {
- // 원본 reg 값은 따로 보관(수정 시 reg* 유지용)
+ projects.value = visible.map((p) => {
projectRegById.value[p.id] = { regId: p.regUserId, regNm: p.regUserNm };
- // ★ 화면/모달에 보여줄 선택값은 mod_user_nm이 있으면 그걸로, 없으면 reg_user_nm
- const displayNm =
- p.modUserNm && p.modUserNm.length > 0 ? p.modUserNm : p.regUserNm || "";
+ // 카드 표시용 사용자명: reg/mod 합쳐서 중복 제거
+ const usersDisplay = Array.from(
+ new Set([...splitCSV(p.regUserNm), ...splitCSV(p.modUserNm)]),
+ ).join(",");
return {
id: p.id,
title: p.prjNm,
- creator: displayNm, // ← 이 값이 모달 v-select v-model에 들어감
- date: p.prjStartDt, // fallback 없이 그대로
+ creator: usersDisplay,
+ date: p.prjStartDt,
description: p.prjDesc,
};
});
+ // 페이지 보정
if (
pager.value.pageNum >
Math.max(1, Math.ceil(projects.value.length / pager.value.pageSize))
@@ -309,12 +355,12 @@ const saveProject = async () => {
try {
let projectId: number;
if (modalMode.value === "create") {
- const createPayload = buildCreatePayload(); // mod* 없음
- const createRes = await ProjectService.add(createPayload); // ← 오타 수정
+ const createPayload = buildCreatePayload();
+ const createRes = await ProjectService.add(createPayload);
projectId = createRes.data.id;
} else {
- const updatePayload = buildUpdatePayload(); // reg* 유지, mod* 반영
- await ProjectService.update(editingProjectId.value!, updatePayload); // ← non-null 보장
+ const updatePayload = buildUpdatePayload();
+ await ProjectService.update(editingProjectId.value!, updatePayload);
projectId = editingProjectId.value!;
}
await grantDefaultPermissions(projectId, form.value.selectedUsers);
@@ -373,11 +419,14 @@ const modifyProject = () => {
/** ===== 라이프사이클 ===== */
const onStorage = (e: StorageEvent) => {
- if (!e.key || /auth|vpp-Auth/i.test(e.key)) refreshRoles();
+ if (!e.key || /autoflow-auth/i.test(e.key)) {
+ refreshAuth();
+ loadProjects();
+ }
};
onMounted(async () => {
- refreshRoles();
+ refreshAuth();
await Promise.all([loadProjects(), loadUsers()]);
window.addEventListener("storage", onStorage);
});
diff --git a/typed-router.d.ts b/typed-router.d.ts
index e75a5c5..116a2e4 100644
--- a/typed-router.d.ts
+++ b/typed-router.d.ts
@@ -19,6 +19,7 @@ declare module 'vue-router/auto-routes' {
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record, Record>,
+ '/DatagroupView': RouteRecordInfo<'/DatagroupView', '/DatagroupView', Record, Record>,
'/DatasetView': RouteRecordInfo<'/DatasetView', '/DatasetView', Record, Record>,
'/DeploymentView': RouteRecordInfo<'/DeploymentView', '/DeploymentView', Record, Record>,
'/ExecutionsView': RouteRecordInfo<'/ExecutionsView', '/ExecutionsView', Record, Record>,
@@ -28,6 +29,7 @@ declare module 'vue-router/auto-routes' {
'/MainView': RouteRecordInfo<'/MainView', '/MainView', Record, Record>,
'/ProjectView': RouteRecordInfo<'/ProjectView', '/ProjectView', Record, Record>,
'/SignupView': RouteRecordInfo<'/SignupView', '/SignupView', Record, Record>,
+ '/TrainingscriptgroupView': RouteRecordInfo<'/TrainingscriptgroupView', '/TrainingscriptgroupView', Record, Record>,
'/TrainingScriptView': RouteRecordInfo<'/TrainingScriptView', '/TrainingScriptView', Record, Record>,
'/UsersView': RouteRecordInfo<'/UsersView', '/UsersView', Record, Record>,
'/WorkflowStepConfigView': RouteRecordInfo<'/WorkflowStepConfigView', '/WorkflowStepConfigView', Record, Record>,