fix: force merge all remaining patched updates regardless of timestamps

feature/apply-patched-updates
bjkim 4 weeks ago
parent ce5b2d8d14
commit fa21667541

@ -1,3 +1,4 @@
NODE_ENV = "dev" NODE_ENV = "dev"
VITE_APP_API_SERVER_URL = "http://localhost:80" # WSL에서 백엔드 8080 구동 시
VITE_APP_API_SERVER_URL = "http://localhost:8080"
VITE_ROOT_PATH = "" VITE_ROOT_PATH = ""

@ -1,3 +1,3 @@
NODE_ENV = "prod" 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" VITE_ROOT_PATH = "/autoflow"

2
components.d.ts vendored

@ -32,7 +32,7 @@ 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/datagroup/ListComponent.vue')['default'] ListComponent: typeof import('./src/components/templates/Datasets/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']
ScriptCompileDialog: typeof import('./src/components/atoms/organisms/ScriptCompileDialog.vue')['default'] ScriptCompileDialog: typeof import('./src/components/atoms/organisms/ScriptCompileDialog.vue')['default']

@ -10,8 +10,7 @@ server {
# 백엔드 API 프록시 # 백엔드 API 프록시
location /autoflow-server-mgmt/ { 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

2
package-lock.json generated

@ -2024,7 +2024,7 @@
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"

@ -25,6 +25,8 @@ const props = withDefaults(
selectedMetricKeys: string[]; selectedMetricKeys: string[];
ensureRunDetail: (runId: string) => Promise<RunDetailType | null>; ensureRunDetail: (runId: string) => Promise<RunDetailType | null>;
runDetailCache: Map<string, RunDetailType>; runDetailCache: Map<string, RunDetailType>;
/** runId → 실행 시 입력한 이름(Execution name, 예: auto26run) */
runIdToExecutionName?: Record<string, string>;
compareChartMode?: "byMetric" | "byRun"; compareChartMode?: "byMetric" | "byRun";
normalizeValues?: boolean; normalizeValues?: boolean;
baselineRunId?: string | null; baselineRunId?: string | null;
@ -34,6 +36,7 @@ const props = withDefaults(
items: () => [], items: () => [],
selectedRunIds: () => [], selectedRunIds: () => [],
selectedMetricKeys: () => [], selectedMetricKeys: () => [],
runIdToExecutionName: () => ({}),
compareChartMode: "byMetric", compareChartMode: "byMetric",
normalizeValues: false, normalizeValues: false,
baselineRunId: null, baselineRunId: null,
@ -123,7 +126,7 @@ const chartInnerWidth = computed(() => {
return Math.max(900, xCount * 140 + 240); return Math.max(900, xCount * 140 + 240);
}); });
const tableInnerWidth = computed(() => { 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); return Math.max(900, cols * 160);
}); });
@ -285,9 +288,9 @@ onBeforeUnmount(() => window.removeEventListener("resize", onResize));
<v-card-text class="pa-6"> <v-card-text class="pa-6">
<v-row dense class="mb-2"> <v-row dense class="mb-2">
<v-col cols="12"> <v-col cols="12">
<v-subheader class="font-weight-medium mb-2" <div class="text-subtitle-2 font-weight-medium mb-2">
>Select Runs</v-subheader Select Runs
> </div>
<v-autocomplete <v-autocomplete
v-model="selectedRunIdsProxy" v-model="selectedRunIdsProxy"
:items="items" :items="items"
@ -319,7 +322,7 @@ onBeforeUnmount(() => window.removeEventListener("resize", onResize));
<v-row dense class="mt-4"> <v-row dense class="mt-4">
<v-col cols="12"> <v-col cols="12">
<v-subheader class="font-weight-medium mb-2">Metrics</v-subheader> <div class="text-subtitle-2 font-weight-medium mb-2">Metrics</div>
<v-autocomplete <v-autocomplete
v-model="selectedMetricKeysProxy" v-model="selectedMetricKeysProxy"
:items="activeMetricKeys.length ? activeMetricKeys : []" :items="activeMetricKeys.length ? activeMetricKeys : []"
@ -386,14 +389,25 @@ onBeforeUnmount(() => window.removeEventListener("resize", onResize));
<v-table density="comfortable"> <v-table density="comfortable">
<thead> <thead>
<tr> <tr>
<th style="width: 28%">Run</th> <th style="width: 22%">Execution name</th>
<th style="width: 22%">Run</th>
<th v-for="k in activeMetricKeys" :key="k">{{ k }}</th> <th v-for="k in activeMetricKeys" :key="k">{{ k }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="r in compareRuns" :key="r.info.run_id"> <tr
v-for="(r, idx) in compareRuns"
:key="selectedRunIdsProxy[idx] ?? r.info.run_id"
>
<td class="text-no-wrap">
{{
props.runIdToExecutionName?.[
selectedRunIdsProxy[idx]
] ?? "—"
}}
</td>
<td class="text-no-wrap"> <td class="text-no-wrap">
{{ r.info.run_name || r.info.run_id }} {{ r.info.run_name || r.info.run_id || "—" }}
</td> </td>
<td v-for="k in activeMetricKeys" :key="k"> <td v-for="k in activeMetricKeys" :key="k">
{{ {{

@ -3,13 +3,13 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { ExternalAuthControllerService } from "@/components/service/management/ExternalAuthControllerService"; import { ExternalAuthControllerService } from "@/components/service/management/ExternalAuthControllerService";
import { import {
AddFileParamsSwagger, AddFileParamsSwagger,
AddStorageParamsSwagger, AddMinioParamsSwagger,
EdgePkgInfoVOModel, EdgePkgInfoVOModel,
} from "@/components/models/management/ExternalAuthController"; } from "@/components/models/management/ExternalAuthController";
type PackageOption = { label: string; value: string; raw: any }; type PackageOption = { label: string; value: string; raw: any };
type StorageRegisterModel = EdgePkgInfoVOModel & { type MinioRegisterModel = EdgePkgInfoVOModel & {
objectName: string; objectName: string;
type: "type1" | "type2"; type: "type1" | "type2";
localPath: string; localPath: string;
@ -227,7 +227,7 @@ 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 storageType = ref<"type1" | "type2">("type2"); const minioType = ref<"type1" | "type2">("type2");
async function submit() { async function submit() {
errorMsg.value = ""; errorMsg.value = "";
@ -291,10 +291,10 @@ async function submit() {
const params: AddMinioParamsSwagger = { const params: AddMinioParamsSwagger = {
...common, ...common,
objectName: props.artifactPath || "", objectName: props.artifactPath || "",
type: storageType.value, type: minioType.value,
localPath: (form.value.install_location || "").trim(), localPath: (form.value.install_location || "").trim(),
}; };
res = await ExternalAuthControllerService.addStorage(params); res = await ExternalAuthControllerService.addMinio(params);
} }
const ok = const ok =

@ -74,9 +74,9 @@ function onClose() {
<v-row dense class="mb-6"> <v-row dense class="mb-6">
<v-col cols="6"> <v-col cols="6">
<v-subheader class="font-weight-medium white--text mb-2"> <div class="text-subtitle-2 font-weight-medium mb-2 text-white">
Select Workflow Select Workflow
</v-subheader> </div>
<v-select <v-select
v-model="internalWorkflow" v-model="internalWorkflow"
:items="workflowList" :items="workflowList"
@ -87,9 +87,9 @@ function onClose() {
/> />
</v-col> </v-col>
<v-col cols="6"> <v-col cols="6">
<v-subheader class="font-weight-medium white--text mb-2"> <div class="text-subtitle-2 font-weight-medium mb-2 text-white">
Execution Type Execution Type
</v-subheader> </div>
<v-select <v-select
v-model="internalExecType" v-model="internalExecType"
:items="executionTypes" :items="executionTypes"
@ -104,9 +104,9 @@ function onClose() {
<!-- Execution Name --> <!-- Execution Name -->
<v-row dense class="mb-6"> <v-row dense class="mb-6">
<v-col cols="12"> <v-col cols="12">
<v-subheader class="font-weight-medium white--text mb-2"> <div class="text-subtitle-2 font-weight-medium mb-2 text-white">
Execution Type Execution Type
</v-subheader> </div>
<v-text-field <v-text-field
v-model="internalName" v-model="internalName"
@ -120,9 +120,9 @@ function onClose() {
<!-- Description --> <!-- Description -->
<v-row dense class="mb-6"> <v-row dense class="mb-6">
<v-col cols="12"> <v-col cols="12">
<v-subheader class="font-weight-medium white--text mb-2"> <div class="text-subtitle-2 font-weight-medium mb-2 text-white">
Description Description
</v-subheader> </div>
<v-text-field <v-text-field
v-model="internalName" v-model="internalName"
@ -136,9 +136,9 @@ function onClose() {
<!-- Experiment --> <!-- Experiment -->
<v-row dense class="mb-6"> <v-row dense class="mb-6">
<v-col cols="12"> <v-col cols="12">
<v-subheader class="font-weight-medium white--text mb-2"> <div class="text-subtitle-2 font-weight-medium mb-2 text-white">
Experiment Experiment
</v-subheader> </div>
<v-text-field <v-text-field
v-model="internalName" v-model="internalName"

@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue"; import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { KubeflowService } from "@/components/service/management/KubeflowService"; import { KubeflowService } from "@/components/service/management/KubeflowService";
const router = useRouter();
type RunPayload = { type RunPayload = {
display_name: string; display_name: string;
description?: string; description?: string;
@ -94,6 +97,12 @@ async function submitRun() {
return; return;
} }
const selectedExp = experimentOptions.value.find(
(e) => e.value === form.value.experiment_id,
);
const experimentName =
(selectedExp?.label ?? "").trim() || (selectedExp?.value ?? "").trim();
const payload: RunPayload = { const payload: RunPayload = {
display_name: form.value.display_name.trim(), display_name: form.value.display_name.trim(),
...(form.value.description.trim() && { ...(form.value.description.trim() && {
@ -104,6 +113,11 @@ async function submitRun() {
...(form.value.experiment_id && { ...(form.value.experiment_id && {
experiment_id: form.value.experiment_id, experiment_id: form.value.experiment_id,
}), }),
...(experimentName && {
runtime_config: {
parameters: { mlflow_experiment_name: experimentName },
},
}),
}; };
try { try {
@ -111,6 +125,11 @@ async function submitRun() {
const { data } = await KubeflowService.run(payload); const { data } = await KubeflowService.run(payload);
emit("submitted", data); emit("submitted", data);
emit("close-modal"); 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) { } catch (e: any) {
errorMsg.value = errorMsg.value =
e?.response?.data?.message || e?.response?.data?.message ||

@ -2,7 +2,7 @@
/* ================================ /* ================================
* Imports * 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 { 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";
@ -49,12 +49,31 @@ function readAuth() {
function computeIsAdmin() { function computeIsAdmin() {
const auth = readAuth(); const auth = readAuth();
const roles = auth?.userInfo?.roles ?? auth?.roles ?? []; let roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
const authCd = auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth; if (typeof roles === "string") {
const inRoles = Array.isArray(roles) roles = roles.split(",").map((s: string) => String(s).trim());
? roles.includes("ROLE_ADMIN") }
: roles === "ROLE_ADMIN"; const rolesArr = Array.isArray(roles) ? roles : [];
isAdmin.value = inRoles || authCd === "ADMIN"; 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() { function updateUsername() {
@ -74,6 +93,7 @@ const isAdminRoute = computed<boolean>(() => {
const hitPath = const hitPath =
p.startsWith("/project") || p.startsWith("/project") ||
p.startsWith("/users") || p.startsWith("/users") ||
p.startsWith("/system-status") ||
p.startsWith("/select"); p.startsWith("/select");
const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin); const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin);
return hitPath || hitMeta; return hitPath || hitMeta;
@ -106,7 +126,7 @@ const isLinkActive = (path?: string) => !!path && route.path.startsWith(path);
* Header dropdown menu * Header dropdown menu
* ================================ */ * ================================ */
const menu = ref<MenuItem[]>([]); const menu = ref<MenuItem[]>([]);
const menuItems: MenuItem[] = [ const menuItemsBase: MenuItem[] = [
{ title: "Select Project", click: () => goSelect() }, { title: "Select Project", click: () => goSelect() },
{ {
title: "Change Password", 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() { function logOut() {
UserManagerService.signOut() UserManagerService.signOut()
.catch(console.error) .catch(console.error)
@ -171,6 +230,7 @@ watch(
() => route.fullPath, () => route.fullPath,
() => { () => {
refreshProjectName(); refreshProjectName();
computeIsAdmin();
if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home"; if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
}, },
{ immediate: true }, { immediate: true },
@ -191,11 +251,34 @@ function onStorage(e: StorageEvent) {
/* ================================ /* ================================
* Lifecycle * Lifecycle
* ================================ */ * ================================ */
const menuItems = computed<MenuItem[]>(() => {
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(() => { onMounted(() => {
updateUsername(); updateUsername();
computeIsAdmin(); computeIsAdmin();
refreshProjectName(); refreshProjectName();
menu.value = menuItems; menu.value = menuItems.value;
window.addEventListener("storage", onStorage); window.addEventListener("storage", onStorage);
}); });
@ -258,17 +341,23 @@ onBeforeUnmount(() => {
</v-list> </v-list>
</v-menu> </v-menu>
<!-- 드롭다운 없는 단일 항목 --> <!-- 드롭다운 없는 단일 항목: 버튼/링크 클릭 이동 (Project/Users에서도 동작) -->
<v-btn <a
v-else v-else
variant="text" :href="resolveHref(m.path)"
class="nav-btn" class="admin-nav-link"
:class="{ 'nav-active': isLinkActive(m.path) }" @click.prevent="goToAdminMenu(m.path)"
@click="m.path && router.push(m.path)"
> >
<v-icon start :icon="m.icon" class="mr-1" /> <v-btn
{{ m.title }} variant="text"
</v-btn> class="nav-btn"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click.prevent="goToAdminMenu(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</a>
</template> </template>
</template> </template>
@ -388,6 +477,21 @@ onBeforeUnmount(() => {
</template> </template>
</v-tooltip> </v-tooltip>
<!-- 사용자 관리 (관리자만, Project 버튼 ) -->
<v-tooltip v-if="isAdmin && !hideAllMenus" location="bottom" text="사용자 관리">
<template #activator="{ props }">
<v-btn
icon
class="mr-2 text-white flex-shrink-0"
v-bind="props"
@click="router.push('/users')"
aria-label="사용자 관리"
>
<v-icon>mdi-account-multiple</v-icon>
</v-btn>
</template>
</v-tooltip>
<div class="d-none d-md-flex flex-column align-end userbox"> <div class="d-none d-md-flex flex-column align-end userbox">
<div class="font-weight-black text-white"> <div class="font-weight-black text-white">
{{ username || "GUEST" }} {{ username || "GUEST" }}
@ -408,8 +512,10 @@ onBeforeUnmount(() => {
v-for="(item, index) in menu" v-for="(item, index) in menu"
:key="index" :key="index"
:value="index" :value="index"
@click="item.click" :to="item.path && !isAdminMenuItem(item) ? item.path : undefined"
:prepend-icon="item.icon" :prepend-icon="item.icon"
:href="isAdminMenuItem(item) ? resolveHref(item.path) : undefined"
@click.stop.prevent="onMenuItemClick(item, $event)"
> >
<v-list-item-title>{{ item.title }}</v-list-item-title> <v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item> </v-list-item>
@ -437,12 +543,13 @@ 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;
color: #fff; color: #fff;
cursor: pointer;
} }
/* 중앙 고정 네비게이션 */ /* 중앙 고정 네비게이션 */
@ -454,6 +561,13 @@ onBeforeUnmount(() => {
align-items: center; align-items: center;
} }
/* 관리자 메뉴 router-link: 링크 스타일 제거 */
.admin-nav-link {
text-decoration: none;
color: inherit;
display: inline-flex;
}
.nav-btn { .nav-btn {
text-transform: none; text-transform: none;
border-radius: 10px; border-radius: 10px;

@ -20,5 +20,6 @@ export type AttachmentSearch = {
endDate?: string; endDate?: string;
sortField?: string; sortField?: string;
sortDirection?: "ASC" | "DESC"; sortDirection?: "ASC" | "DESC";
refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT"; refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT" | "workflows";
refId?: number;
}; };

@ -28,7 +28,7 @@ export type AddFileParamsSwagger = {
creation_datetime: string; creation_datetime: string;
}; };
export type AddStorageParamsSwagger = AddFileParamsSwagger & { export type AddMinioParamsSwagger = AddFileParamsSwagger & {
objectName: string; objectName: string;
type: "type1" | "type2"; type: "type1" | "type2";
localPath: string; localPath: string;

@ -10,8 +10,8 @@ export const request = {
post: (uri: string, param: any): any => { post: (uri: string, param: any): any => {
return axios.post(`${API_URL}${uri}`, param); return axios.post(`${API_URL}${uri}`, param);
}, },
get: (uri: string, param: any): any => { get: (uri: string, param: any, config?: any): any => {
return axios.get(`${API_URL}${uri}`, { params: param }); return axios.get(`${API_URL}${uri}`, { params: param, ...config });
}, },
getsize: (uri: string): any => { getsize: (uri: string): any => {
return axios.get(`${API_URL}${uri}`); return axios.get(`${API_URL}${uri}`);

@ -1,6 +1,6 @@
import { import {
AddFileParamsSwagger, AddFileParamsSwagger,
AddStorageParamsSwagger, AddMinioParamsSwagger,
} from "@/components/models/management/ExternalAuthController"; } from "@/components/models/management/ExternalAuthController";
import { request } from "@/components/service/index"; import { request } from "@/components/service/index";
@ -20,7 +20,7 @@ export const ExternalAuthControllerService = {
}); });
}, },
addStorage: (params: AddStorageParamsSwagger) => { addMinio: (params: AddMinioParamsSwagger) => {
return request.postWithConfig( return request.postWithConfig(
"/api/external-auth/register-with-minio-file", "/api/external-auth/register-with-minio-file",
{}, {},

@ -15,10 +15,13 @@ export const KubeflowRunService = {
getAll: () => { getAll: () => {
return request.get("/api/kubeflow/runs", {}); return request.get("/api/kubeflow/runs", {});
}, },
singleData: (runId: number) => { singleData: (runId: string | number) => {
return request.get(`/api/kubeflow/runs/${runId}`, {}); return request.get(`/api/kubeflow/runs/${runId}`, {});
}, },
search: (params?: KubeflowRunSearchParams) => { 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}`, {});
}, },
}; };

@ -33,4 +33,41 @@ export const AttachmentsService = {
search: (payload: AttachmentSearch) => { search: (payload: AttachmentSearch) => {
return request.get("/api/attachments/search", payload); 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<Record<string, string>>("/api/attachments/minio-config"),
/** Auto Script MLflow 사용 시 YAML에 넣을 설정 (백엔드 저장값) */
getMlflowConfig: () => request.get<Record<string, string>>("/api/attachments/mlflow-config"),
}; };

@ -1,4 +1,5 @@
import { request } from "@/components/service/index"; import { request } from "@/components/service/index";
import { saveBlob, filenameFromContentDisposition } from "@/utils/download";
export const MlflowService = { export const MlflowService = {
getRuns: (experimentId: string) => { 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) => { getExperimentByName: (experimentName: string) => {
return request.get("/api/mlflow/experiment", { return request.get("/api/mlflow/experiment", {
experimentName, experimentName,
@ -35,4 +48,19 @@ export const MlflowService = {
throw err; throw err;
}); });
}, },
/**
* MLflow get-artifact API artifact (MinIO NoSuchKey ).
*/
async downloadArtifact(runId: string, path: string): Promise<void> {
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);
},
}; };

@ -1,21 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue"; import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue"; import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue";
import { onMounted, ref } from "vue"; import { onMounted, onBeforeUnmount, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storage } from "@/utils/storage"; import { storage } from "@/utils/storage";
import ViewComponent from "@/components/templates/run/executions/ViewComponent.vue"; import ViewComponent from "@/components/templates/run/executions/ViewComponent.vue";
import ExperimentCreateDialog from "@/components/atoms/organisms/ExperimentCreateDialog.vue"; import ExperimentCreateDialog from "@/components/atoms/organisms/ExperimentCreateDialog.vue";
import { KubeflowService } from "@/components/service/management/KubeflowService"; import { KubeflowService } from "@/components/service/management/KubeflowService";
import { ExperimentService } from "@/components/service/management/ExperimentService";
import { commonStore } from "@/stores/commonStore"; import { commonStore } from "@/stores/commonStore";
import { KubeflowRunService } from "@/components/service/management/KubeflowRunService"; import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue"; import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
import { WorkflowService } from "@/components/service/management/WorkflowService"; import { WorkflowService } from "@/components/service/management/WorkflowService";
const store = commonStore(); const store = commonStore();
const route = useRoute();
const router = useRouter();
const openView = ref(false); const openView = ref(false);
const execSelected = ref<any>(null); const execSelected = ref<any>(null);
/** runId 쿼리로 열기 처리 완료 여부 (재처리 방지) */
const openedRunIdFromQuery = ref<string | null>(null);
const username = ref<string>(""); const username = ref<string>("");
const experimentNameMap = ref<Record<string, string>>({}); const experimentNameMap = ref<Record<string, string>>({});
const pipelineNameMap = ref<Record<string, string>>({}); const pipelineNameMap = ref<Record<string, string>>({});
@ -270,7 +274,7 @@ const toRow = (r: any, no: number) => {
workflow: wfName, workflow: wfName,
startTime: fmt(createdIso), startTime: fmt(createdIso),
registryStatus: r.storageState ?? "-", registryStatus: r.storageState ?? "-",
run_id: r.runId, run_id: r.runId ?? r.run_id,
raw: r, raw: r,
}; };
}; };
@ -403,7 +407,7 @@ async function fetchList() {
.filter(Boolean); .filter(Boolean);
await resolvePipelineNamesWithApi(pipeIds); await resolvePipelineNamesWithApi(pipeIds);
// // (No: 1 , )
if (!isServerPaged) { if (!isServerPaged) {
const total = const total =
typeof totalElements === "number" ? totalElements : list.length; typeof totalElements === "number" ? totalElements : list.length;
@ -412,7 +416,8 @@ async function fetchList() {
const start = (safePage - 1) * pageSize; const start = (safePage - 1) * pageSize;
const slice = list.slice(start, start + pageSize); const slice = list.slice(start, start + pageSize);
const startNo = total - (safePage - 1) * pageSize; const rawStartNo = total - (safePage - 1) * pageSize;
const startNo = Math.max(rawStartNo, slice.length);
data.value.results = slice.map((r, i) => data.value.results = slice.map((r, i) =>
toRow(r, Math.max(startNo - i, 1)), toRow(r, Math.max(startNo - i, 1)),
); );
@ -421,7 +426,8 @@ async function fetchList() {
} else { } else {
const te = const te =
typeof totalElements === "number" ? totalElements : list.length; typeof totalElements === "number" ? totalElements : list.length;
const startNo = te - (pageNum - 1) * pageSize; const rawStartNo = te - (pageNum - 1) * pageSize;
const startNo = Math.max(rawStartNo, list.length);
data.value.results = list.map((r, i) => data.value.results = list.map((r, i) =>
toRow(r, Math.max(startNo - i, 1)), toRow(r, Math.max(startNo - i, 1)),
); );
@ -437,6 +443,62 @@ async function fetchList() {
data.value.totalElements = 0; data.value.totalElements = 0;
data.value.pageLength = 1; data.value.pageLength = 1;
} }
// experimentInfo ( )
if (openView.value && execSelected.value) {
const runId =
execSelected.value.run_id ??
execSelected.value.raw?.runId ??
execSelected.value.raw?.id;
if (runId != null) {
const fresh = data.value.results.find(
(row: any) =>
(row.run_id ?? row.raw?.runId ?? row.raw?.id) === runId,
);
if (fresh) execSelected.value = fresh;
}
}
// Workflow Run runId : (Compare )
const runIdFromQuery = route.query.runId as string | undefined;
if (runIdFromQuery && openedRunIdFromQuery.value !== runIdFromQuery) {
void tryOpenRunFromQuery();
}
}
/** 쿼리 runId가 있으면 해당 Run을 목록에서 찾아 상세 뷰로 열거나, 단건 조회로 열기 */
async function tryOpenRunFromQuery() {
const runId = route.query.runId as string | undefined;
if (!runId || openedRunIdFromQuery.value === runId) return;
const row = data.value.results.find(
(r: any) => (r.run_id ?? r.raw?.runId ?? r.raw?.id) === runId,
);
if (row) {
openedRunIdFromQuery.value = runId;
execSelected.value = row;
openView.value = true;
router.replace({ name: "Executions", query: {} });
return;
}
try {
const res = await KubeflowRunService.singleData(runId);
const entity = res?.data ?? res;
if (!entity?.runId && !entity?.run_id) return;
await resolveExperimentNamesWithApi(
[entity.experimentId ?? entity.experiment_id].filter(Boolean),
);
await resolvePipelineNamesWithApi(
[entity.pipelineId ?? entity.pipeline_id].filter(Boolean),
);
const built = toRow(entity, 1);
openedRunIdFromQuery.value = runId;
execSelected.value = built;
openView.value = true;
router.replace({ name: "Executions", query: {} });
} catch {
// Run DB ; 1
}
} }
// / // /
@ -454,27 +516,27 @@ const changePageSize = (size: number) => {
fetchList(); fetchList();
}; };
// // ( DELETE /api/kubeflow/runs/{runId} , Workflows )
const removeData = (value: Array<{ deviceKey: number }>) => { const removeData = (value: Array<{ deviceKey: string | number }>) => {
const ids = (value || []).map((x) => x.deviceKey); const runIds = (value || []).map((x) => String(x.deviceKey));
if (ids.length === 0) return; if (runIds.length === 0) return;
const removeOne = (id: number) => const removeOne = (runId: string) =>
ExperimentService.delete(id).then((res) => { KubeflowRunService.delete(runId).then((res) => {
if (res.status < 200 || res.status >= 300) return Promise.reject(res); if (res.status < 200 || res.status >= 300) return Promise.reject(res);
}); });
const after = () => { const after = () => {
if ( if (
ids.length >= data.value.results.length && runIds.length >= data.value.results.length &&
data.value.params.pageNum > 1 data.value.params.pageNum > 1
) )
data.value.params.pageNum -= 1; data.value.params.pageNum -= 1;
fetchList(); fetchList();
}; };
if (ids.length === 1) { if (runIds.length === 1) {
removeOne(ids[0]) removeOne(runIds[0])
.then(() => .then(() =>
store.setSnackbarMsg({ store.setSnackbarMsg({
color: "success", color: "success",
@ -492,7 +554,7 @@ const removeData = (value: Array<{ deviceKey: number }>) => {
}) })
.finally(after); .finally(after);
} else { } else {
Promise.all(ids.map(removeOne)) Promise.all(runIds.map(removeOne))
.then(() => .then(() =>
store.setSnackbarMsg({ store.setSnackbarMsg({
color: "success", color: "success",
@ -534,10 +596,28 @@ const closeModal = () => {
data.value.selectedData = null; data.value.selectedData = null;
}; };
const POLL_INTERVAL_MS = 10 * 1000;
let listPollTimerId: ReturnType<typeof setInterval> | null = null;
function startListPolling() {
if (listPollTimerId != null) return;
listPollTimerId = setInterval(() => fetchList(), POLL_INTERVAL_MS);
}
function stopListPolling() {
if (listPollTimerId != null) {
clearInterval(listPollTimerId);
listPollTimerId = null;
}
}
onMounted(() => { onMounted(() => {
username.value = readUsernameFromStorage(); username.value = readUsernameFromStorage();
if (route.query.runId) {
data.value.params.pageNum = 1;
}
fetchList(); fetchList();
startListPolling();
}); });
onBeforeUnmount(() => stopListPolling());
</script> </script>
<template> <template>
@ -667,7 +747,12 @@ onMounted(() => {
class="text-center" class="text-center"
> >
<td>{{ item.no }}</td> <td>{{ item.no }}</td>
<td class="text-truncate">{{ item.name }}</td> <td
class="text-truncate text-primary execution-name-link"
@click="openInfoModal(item)"
>
{{ item.name }}
</td>
<td> <td>
<v-icon v-if="item.status === 'Succeeded'" color="green" <v-icon v-if="item.status === 'Succeeded'" color="green"
>mdi-check-circle</v-icon >mdi-check-circle</v-icon
@ -701,7 +786,10 @@ onMounted(() => {
<IconDeleteBtn <IconDeleteBtn
@on-click=" @on-click="
removeData([ removeData([
{ deviceKey: item.raw?.id ?? item.run_id }, {
deviceKey:
item.run_id ?? item.raw?.runId ?? item.raw?.id,
},
]) ])
" "
/> />
@ -748,4 +836,11 @@ onMounted(() => {
</div> </div>
</template> </template>
<style scoped></style> <style scoped>
.execution-name-link {
cursor: pointer;
}
.execution-name-link:hover {
text-decoration: underline;
}
</style>

File diff suppressed because it is too large Load Diff

@ -119,9 +119,9 @@ function normalizeRun(res: any) {
return v?.info && v?.data ? v : null; return v?.info && v?.data ? v : null;
} }
/* ---------- API (runId 단건만 조회) ---------- */ /* ---------- API (runId: KFP run id 또는 MLflow run id) ---------- */
async function fetchRunDetailById(runId: string) { async function fetchRunDetailById(runId: string) {
loadingRuns.value = true; // loadingRuns.value = true;
loadingRunDetail.value = true; loadingRunDetail.value = true;
try { try {
if (!runId) { if (!runId) {
@ -132,16 +132,28 @@ async function fetchRunDetailById(runId: string) {
return; return;
} }
const res = await MlflowService.getExperimentRun(runId); let one: any = null;
const one = normalizeRun(res); // 1) runId KFP run id kubeflow_run_id MLflow run
runDetail.value = one; try {
const byTag = await MlflowService.getRunsByKubeflowRunId(runId);
const raw = byTag?.data ?? byTag;
const list = raw?.runs ?? raw?.data?.runs ?? (Array.isArray(raw) ? raw : []);
const first = Array.isArray(list) && list.length > 0 ? list[0] : null;
if (first?.info?.run_id || first?.info?.run_uuid) {
one = first?.info && first?.data ? first : null;
}
} catch {
// ignore
}
// 2) runId MLflow run id
if (!one) {
const res = await MlflowService.getExperimentRun(runId);
one = normalizeRun(res);
}
// / runDetail.value = one;
runs.value = one ? [one] : []; runs.value = one ? [one] : [];
if (!selectedRunId.value) selectedRunId.value = one?.info?.run_id || one?.info?.run_uuid || runId;
//
if (!selectedRunId.value) selectedRunId.value = runId;
await nextTick(); await nextTick();
drawCharts(); drawCharts();
} catch (e) { } catch (e) {
@ -182,8 +194,11 @@ onBeforeUnmount(() => window.removeEventListener("resize", resizeCharts));
<template> <template>
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center"> <v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card flat class="w-100 rounded-lg pa-8"> <v-card flat class="w-100 rounded-lg pa-8">
<v-card-title class="grey lighten-4 py-2 px-4"> <v-card-title class="grey lighten-4 py-2 px-4 d-flex align-center justify-space-between">
<span class="font-weight-bold">Metrics</span> <span class="font-weight-bold">Metrics</span>
<v-btn color="primary" variant="elevated" @click="emit('close')">
Back to List
</v-btn>
</v-card-title> </v-card-title>
<v-card-text class="px-6 pb-2 pt-4"> <v-card-text class="px-6 pb-2 pt-4">
@ -374,10 +389,6 @@ onBeforeUnmount(() => window.removeEventListener("resize", resizeCharts));
<div ref="elMetrics" style="width: 100%; height: 400px"></div> <div ref="elMetrics" style="width: 100%; height: 400px"></div>
</v-card> </v-card>
</v-card-text> </v-card-text>
<v-sheet class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</v-sheet>
</v-card> </v-card>
</v-container> </v-container>
</template> </template>

@ -104,6 +104,7 @@ const toRow = (e: any) => ({
createdDate: fmtDate(e.lastUpdateTime), createdDate: fmtDate(e.lastUpdateTime),
deviceKey: e.id, deviceKey: e.id,
createdID: e.regUserId, createdID: e.regUserId,
mlFlowId: e.mlFlowId ?? e.mlFlowExperimentId ?? "",
}); });
// ===== ( ) ===== // ===== ( ) =====

@ -1,8 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { MlflowService } from "@/components/service/mlflow/MlflowService"; import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
import { KubeflowService } from "@/components/service/management/kubeflowService";
import DetailComponent from "@/components/templates/run/experiment/DetailComponent.vue"; import DetailComponent from "@/components/templates/run/experiment/DetailComponent.vue";
const getProjectId = (): number => {
const v = Number(localStorage.getItem("projectId"));
return Number.isFinite(v) ? v : 0;
};
const props = defineProps<{ experimentInfo: any }>(); const props = defineProps<{ experimentInfo: any }>();
const emit = defineEmits<{ (e: "close"): void }>(); const emit = defineEmits<{ (e: "close"): void }>();
@ -127,27 +133,80 @@ const pagedRows = computed(() => {
return runRows.value.slice(start, start + pageSize.value); return runRows.value.slice(start, start + pageSize.value);
}); });
// experiment_id id runs // Run() runRows
function backendRunToRunShape(r: any) {
const createdAt = r?.createdAt ?? r?.startTime ?? r?.startedAt ?? r?.start_time;
const finishedAt = r?.finishedAt ?? r?.endTime ?? r?.end_time ?? r?.end_time;
return {
info: {
run_name: r?.displayName ?? r?.name ?? r?.runId ?? "-",
run_id: r?.runId ?? r?.run_id ?? r?.id,
start_time: createdAt,
end_time: finishedAt,
status: r?.state ?? r?.status ?? r?.lifecycle_state,
},
data: { tags: r?.data?.tags ?? [] },
raw: r,
};
}
// (Run) API run , KFP experiment (Execution )
async function fetchRunsByExperimentName(expName: string) { async function fetchRunsByExperimentName(expName: string) {
if (!expName) return; if (!expName) return;
loading.value = true; loading.value = true;
try { try {
// 1) Experiment const projectId = getProjectId();
const expRes = await MlflowService.getExperimentByName(expName); if (!projectId) {
const exp = expRes?.data ?? expRes;
const id = String(exp?.experiment_id ?? "");
experimentId.value = id;
// 2) id Runs
if (id) {
const runsRes = await MlflowService.getRuns(id);
const list = runsRes?.data?.runs ?? runsRes?.runs ?? [];
runs.value = Array.isArray(list) ? list : [];
} else {
runs.value = []; runs.value = [];
return;
} }
// Execution : /api/kubeflow/runs/search (experimentId )
const res = await KubeflowRunService.search({
projectId: projectId as any,
page: 0,
size: 1000,
sortField: "createdAt",
sortDirection: "DESC",
});
const result = res?.data ?? res;
let list: any[] = [];
if (Array.isArray(result)) list = result;
else if (Array.isArray(result?.content)) list = result.content;
else if (Array.isArray(result?.data)) list = result.data;
else if (Array.isArray(result?.runs)) list = result.runs;
if (list.length === 0) {
runs.value = [];
return;
}
// run experimentId KFP experiment (Execution )
const expIds = [...new Set(list.map((r: any) => r.experimentId ?? r.experiment_id ?? r.experiment?.id).filter(Boolean))];
const expNameById: Record<string, string> = {};
await Promise.all(
expIds.map(async (id: string | number) => {
try {
const expRes = await KubeflowService.experimentData(String(id));
const body = expRes?.data ?? expRes ?? {};
expNameById[String(id)] = body.display_name ?? body.name ?? body.experiment_name ?? String(id);
} catch {
expNameById[String(id)] = String(id);
}
}),
);
const expNameLower = String(expName).toLowerCase().trim();
const filtered = list.filter((r: any) => {
const runExpId = r.experimentId ?? r.experiment_id ?? r.experiment?.id;
const runExpName = (expNameById[String(runExpId)] ?? r.experiment?.displayName ?? r.experiment?.name ?? "").toLowerCase().trim();
return runExpName === expNameLower;
});
experimentId.value = "";
runs.value = filtered.map(backendRunToRunShape);
} catch (e) { } catch (e) {
console.error("[MLflow] fetch error:", e); console.error("[Experiment runs] fetch error:", e);
runs.value = []; runs.value = [];
} finally { } finally {
loading.value = false; loading.value = false;
@ -169,8 +228,9 @@ onMounted(() => {
fetchRunsByExperimentName(header.value.experimentName); fetchRunsByExperimentName(header.value.experimentName);
}); });
watch( watch(
() => header.value.experimentName, () => ({ name: header.value.experimentName, mlFlowId: header.value.mlFlowId }),
(nv) => fetchRunsByExperimentName(nv), () => fetchRunsByExperimentName(header.value.experimentName),
{ deep: true },
); );
</script> </script>
@ -184,8 +244,11 @@ watch(
> >
<v-card flat class="bg-shades-transparent w-100"> <v-card flat class="bg-shades-transparent w-100">
<v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0"> <v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
<div class="d-flex flex-row justify-start align-center"> <div class="d-flex flex-row justify-space-between align-center w-100">
<div>View Details</div> <div>View Details</div>
<v-btn color="primary" variant="elevated" @click="emit('close')">
Back to List
</v-btn>
</div> </div>
</v-card-item> </v-card-item>
</v-card> </v-card>
@ -335,10 +398,6 @@ watch(
/> />
</v-card-actions> </v-card-actions>
</v-card-text> </v-card-text>
<div class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</div>
</v-card> </v-card>
</v-card> </v-card>
</v-container> </v-container>

@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue"; import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue"; import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue"; import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue";
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { storage } from "@/utils/storage"; import { storage } from "@/utils/storage";
import ViewComponent from "@/components/templates/trainingscript/ViewComponent.vue"; import ViewComponent from "@/components/templates/trainingscript/ViewComponent.vue";
import TrainingScriptBaseDoalog from "@/components/atoms/organisms/TrainingScriptBaseDoalog.vue"; import TrainingScriptBaseDoalog from "@/components/atoms/organisms/TrainingScriptBaseDoalog.vue";
import AutoScriptDialog from "@/components/atoms/organisms/AutoScriptDialog.vue";
import ScriptCompileDialog from "@/components/atoms/organisms/ScriptCompileDialog.vue";
import { AttachmentsService } from "@/components/service/management/AttachmentsService"; import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { commonStore } from "@/stores/commonStore"; import { commonStore } from "@/stores/commonStore";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@ -41,8 +44,9 @@ const pageSizeOptions = [
{ text: "100 페이지", value: 100 }, { text: "100 페이지", value: 100 },
]; ];
// // ( )
const tableHeader = [ const tableHeader = [
{ label: "", width: "4%", style: "word-break: keep-all;" },
{ label: "No", width: "5%", style: "word-break: keep-all;" }, { label: "No", width: "5%", style: "word-break: keep-all;" },
{ label: "Title", width: "7%", style: "word-break: keep-all;" }, { label: "Title", width: "7%", style: "word-break: keep-all;" },
{ label: "File Name", width: "7%", style: "word-break: keep-all;" }, { label: "File Name", width: "7%", style: "word-break: keep-all;" },
@ -68,6 +72,7 @@ const data = ref({
selected: [] as Array<{ deviceKey: number }>, selected: [] as Array<{ deviceKey: number }>,
isCreateVisible: false, isCreateVisible: false,
isUploadVisible: false, isUploadVisible: false,
isAutoScriptVisible: false,
isModalVisible: false, isModalVisible: false,
isConfirmDialogVisible: false, isConfirmDialogVisible: false,
userOption: [] as any[], userOption: [] as any[],
@ -150,7 +155,8 @@ const fetchList = async () => {
sortField: "id", sortField: "id",
sortDirection: "DESC", sortDirection: "DESC",
refType: "TRAINING_SCRIPT", refType: "TRAINING_SCRIPT",
refId: activeRefId.value, // refId=0 (Auto Script refId 0 )
refId: activeRefId.value ?? 0,
}; };
try { try {
@ -288,6 +294,59 @@ const closeDetail = () => {
openView.value = false; openView.value = false;
}; };
async function downloadScript(item: any) {
const objectName = item?.filePath || item?.storagePath;
if (!objectName) {
store.setSnackbarMsg({ color: "warning", text: "다운로드 경로가 없습니다.", result: 400 });
return;
}
try {
const res = await AttachmentsService.downloadFile(objectName);
const ct = String(res.headers["content-type"] || "").toLowerCase();
if (ct.includes("application/json")) {
const text = await (res.data as Blob).text();
try {
const json = JSON.parse(text);
throw new Error(json.message || text);
} catch {
throw new Error(text);
}
}
const cd = res.headers["content-disposition"] || "";
const mUtf8 = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
const mStd = cd.match(/filename\s*=\s*(?:"([^"]+)"|([^;]+))/i);
const filename =
(mUtf8 && decodeURIComponent(mUtf8[1].trim())) ||
(mStd && (mStd[1] || mStd[2])?.trim()) ||
item?.fileName ||
objectName.split(/[\\/]/).pop() ||
"download";
const blob = new Blob([res.data]);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.setAttribute("download", filename);
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
store.setSnackbarMsg({ color: "success", text: "다운로드되었습니다.", result: 200 });
} catch (e) {
console.error("[TrainingScript] 다운로드 실패:", e);
store.setSnackbarMsg({ color: "warning", text: "다운로드에 실패했습니다.", result: 500 });
}
}
const compileDialogOpen = ref(false);
const compileDialogItem = ref<{ deviceKey?: number; id?: number; title?: string; fileName?: string; filePath?: string } | null>(null);
function openCompileDialog(item: any) {
compileDialogItem.value = item ? { deviceKey: item.deviceKey, id: item.id, title: item.title, fileName: item.fileName, filePath: item.filePath } : null;
compileDialogOpen.value = true;
}
function onCompiled() {
store.setSnackbarMsg({ color: "success", text: "스크립트 컴파일이 완료되었습니다.", result: 200 });
}
const openDetailModal = (selectedItem: any) => { const openDetailModal = (selectedItem: any) => {
data.value.selectedData = selectedItem; data.value.selectedData = selectedItem;
openView.value = true; openView.value = true;
@ -312,6 +371,94 @@ const openModifyModal = (item: any) => {
}; };
data.value.isCreateVisible = true; data.value.isCreateVisible = true;
}; };
const openAutoScriptModal = () => {
data.value.isAutoScriptVisible = true;
};
const closeAutoScriptModal = () => {
data.value.isAutoScriptVisible = false;
};
const openMergeScriptModal = async () => {
const selected = data.value.selected;
if (!selected || selected.length === 0) {
store.setSnackbarMsg({
color: "warning",
text: "머지할 스크립트를 선택하세요.",
result: 400,
});
return;
}
const projectId = getProjectId();
if (!projectId) {
store.setSnackbarMsg({
color: "warning",
text: "프로젝트를 먼저 선택하세요.",
result: 400,
});
return;
}
const scriptIds = selected.map((x) => x.deviceKey);
const defaultTitle = `merged-${scriptIds.join("-")}`;
const title = window.prompt("머지 결과 스크립트 제목을 입력하세요.", defaultTitle);
if (!title) {
return;
}
try {
const raw =
storage?.get?.("autoflow-auth") ??
storage?.getAuth?.() ??
localStorage.getItem("autoflow-auth") ??
null;
const auth = typeof raw === "string" ? JSON.parse(raw) : raw;
const userInfo = auth?.userInfo ?? auth?.userinfo ?? auth ?? {};
const userId = userInfo.username ?? userInfo.id ?? "unknown";
const payload = {
scriptIds,
title,
description: "",
refId: activeRefId.value ?? 0,
refType: "TRAINING_SCRIPT",
regUserId: String(userId),
projectId,
};
const res = await AttachmentsService.mergeScripts(payload as any);
const att = (res as any)?.data?.attachment ?? (res as any)?.attachment ?? null;
store.setSnackbarMsg({
color: "success",
text: "Merge Script가 생성되었습니다.",
result: 200,
});
if (att?.id) {
//
data.value.params.pageNum = 1;
}
fetchList();
} catch (e: any) {
console.error("[TrainingScript] Merge Script 실패:", e);
const msg =
e?.response?.data?.error ??
e?.response?.data?.message ??
e?.message ??
"Merge Script 생성에 실패했습니다.";
store.setSnackbarMsg({
color: "warning",
text: msg,
result: e?.response?.status ?? 500,
});
}
};
// Auto Script : ( refId )
const onAutoScriptSaved = () => {
fetchList();
};
const closeCreateModal = () => { const closeCreateModal = () => {
data.value.isModalVisible = false; data.value.isModalVisible = false;
data.value.isCreateVisible = false; data.value.isCreateVisible = false;
@ -457,8 +604,12 @@ watch(
</v-sheet> </v-sheet>
</v-sheet> </v-sheet>
<v-sheet class="justify-end mb-2"> <v-sheet class="d-flex align-center justify-end mb-2">
<v-btn color="info" @click="openCreateModal">Create Script</v-btn> <v-btn color="info" class="mr-2" @click="openCreateModal"
>Upload Script</v-btn
>
<v-btn color="info" class="mr-2" @click="openAutoScriptModal">Auto Script</v-btn>
<v-btn color="info" @click="openMergeScriptModal">Merge Script</v-btn>
</v-sheet> </v-sheet>
</v-sheet> </v-sheet>
@ -488,7 +639,17 @@ watch(
class="text-center font-weight-bold" class="text-center font-weight-bold"
:style="item.style" :style="item.style"
> >
{{ item.label }} <template v-if="i === 0">
<v-checkbox
v-model="data.allSelected"
density="compact"
hide-details
@change="getSelectedAllData"
/>
</template>
<template v-else>
{{ item.label }}
</template>
</th> </th>
</tr> </tr>
</thead> </thead>
@ -499,6 +660,14 @@ watch(
:key="i" :key="i"
class="text-center" class="text-center"
> >
<td>
<v-checkbox
v-model="data.selected"
:value="{ deviceKey: item.deviceKey }"
density="compact"
hide-details
/>
</td>
<td> <td>
{{ {{
data.totalElements - data.totalElements -
@ -512,6 +681,21 @@ watch(
<td>{{ item.createdData }}</td> <td>{{ item.createdData }}</td>
<td style="white-space: nowrap"> <td style="white-space: nowrap">
<v-tooltip location="bottom" text="스크립트 컴파일">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
icon="mdi-hammer-wrench"
color="secondary"
density="comfortable"
elevation="0"
size="small"
class="ma-1"
@click="openCompileDialog(item)"
/>
</template>
</v-tooltip>
<IconDownloadBtn @on-click="downloadScript(item)" />
<IconInfoBtn @on-click="openDetailModal(item)" /> <IconInfoBtn @on-click="openDetailModal(item)" />
<IconModifyBtn @on-click="openModifyModal(item)" /> <IconModifyBtn @on-click="openModifyModal(item)" />
<IconDeleteBtn <IconDeleteBtn
@ -551,6 +735,22 @@ watch(
:user-option="data.userOption" :user-option="data.userOption"
/> />
</v-dialog> </v-dialog>
<!-- Auto Script 다이얼로그 (선택된 그룹 refId 전달 해당 그룹 목록에 저장) -->
<v-dialog v-model="data.isAutoScriptVisible" max-width="720" persistent>
<AutoScriptDialog
:current-ref-id="activeRefId"
@close-modal="closeAutoScriptModal"
@generated="onAutoScriptSaved"
/>
</v-dialog>
<!-- 스크립트 컴파일 (로그 확인 컴파일 실행) -->
<ScriptCompileDialog
v-model="compileDialogOpen"
:item="compileDialogItem"
@compiled="onCompiled"
/>
</div> </div>
<div class="w-100" v-else> <div class="w-100" v-else>

@ -109,8 +109,11 @@ onBeforeUnmount(() => {
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center"> <v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card flat class="bg-shades-transparent w-100 mb-6"> <v-card flat class="bg-shades-transparent w-100 mb-6">
<v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0"> <v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
<div class="d-flex flex-row justify-start align-center"> <div class="d-flex flex-row justify-space-between align-center w-100">
<div>View Details</div> <div>View Details</div>
<v-btn color="primary" variant="elevated" @click="emit('close')">
Back to List
</v-btn>
</div> </div>
</v-card-item> </v-card-item>
</v-card> </v-card>
@ -167,9 +170,6 @@ onBeforeUnmount(() => {
<v-card-text class="px-6 pb-6 pt-4"> <v-card-text class="px-6 pb-6 pt-4">
<div ref="editorRef" class="editor-container"></div> <div ref="editorRef" class="editor-container"></div>
</v-card-text> </v-card-text>
<div class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</div>
</v-card> </v-card>
</v-container> </v-container>
</template> </template>

@ -3,12 +3,15 @@ import { onMounted, ref, watch, onBeforeUnmount } from "vue";
import * as monaco from "monaco-editor"; import * as monaco from "monaco-editor";
import "monaco-editor/min/vs/editor/editor.main.css"; import "monaco-editor/min/vs/editor/editor.main.css";
import { WorkflowService } from "@/components/service/management/WorkflowService"; import { WorkflowService } from "@/components/service/management/WorkflowService";
import { AttachmentsService } from "@/components/service/management/AttachmentsService"; // import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { storeToRefs } from "pinia";
import { useAutoflowStore } from "@/stores/autoflowStore";
type TabKey = "details" | "yaml"; type TabKey = "details" | "yaml";
const props = defineProps<{ id: number | string }>(); const props = defineProps<{ id: number | string }>();
const emit = defineEmits<{ (e: "close"): void }>(); const emit = defineEmits<{ (e: "close"): void }>();
const { projectId } = storeToRefs(useAutoflowStore());
const activeTab = ref<TabKey>("details"); const activeTab = ref<TabKey>("details");
@ -109,14 +112,39 @@ async function loadYamlFromStoragePath(objectName?: string) {
} }
} }
/** 상세 조회 → YAML 문자열 우선 → storagePath 후보들 시도 → 실패 시 기본 YAML */ /** workflow id에 연결된 attachment를 refType/refId로 검색해 YAML storagePath 반환 */
async function fetchDetail(id: number | string) { async function findWorkflowAttachmentStoragePath(workflowId: number, projId: number): Promise<string | null> {
try { try {
// 0) YAML attachments -> readYamlText const searchRes = await AttachmentsService.search({
await loadYamlByAttachmentId(id); projectId: projId,
page: 1,
size: 1,
refType: "workflows",
refId: workflowId,
});
const page = searchRes?.data ?? searchRes ?? {};
const content = page.content ?? page.data ?? [];
const att = Array.isArray(content) ? content[0] : null;
if (!att) return null;
return (
att.storagePath ??
att.storedName ??
att.objectName ??
att.object_key ??
null
);
} catch (e) {
console.warn("[Workflow Detail] attachment search failed:", e);
return null;
}
}
// 1) () API /** 상세 조회 → workflow 상세 API 후, refType=workflows/refId=workflowId로 attachment 검색해 YAML 로드 */
const res = await WorkflowService.view(Number(id)); async function fetchDetail(id: number | string) {
const wid = Number(id);
try {
// 1) API
const res = await WorkflowService.view(wid);
const d = res?.data ?? {}; const d = res?.data ?? {};
console.log("[Workflow Detail] view response:", d); console.log("[Workflow Detail] view response:", d);
@ -130,7 +158,7 @@ async function fetchDetail(id: number | string) {
regDt: formatDateTime(d.reg_dt ?? d.regDt ?? d.regDate), regDt: formatDateTime(d.reg_dt ?? d.regDt ?? d.regDate),
}; };
// YAML // 2) YAML
const yamlFromServer = const yamlFromServer =
d.workflowYaml || d.workflowYaml ||
d.yaml || d.yaml ||
@ -141,11 +169,30 @@ async function fetchDetail(id: number | string) {
if (yamlFromServer) { if (yamlFromServer) {
ensureEditor(); ensureEditor();
editorInstance!.setValue(yamlFromServer); editorInstance!.setValue(yamlFromServer);
} else {
// 3) workflow attachment (refType=workflows, refId=workflowId) storagePath YAML
const projId = d.projectId ?? projectId.value ?? Number(localStorage.getItem("projectId"));
if (projId) {
const storagePath = await findWorkflowAttachmentStoragePath(wid, projId);
if (storagePath) {
const loaded = await loadYamlFromStoragePath(storagePath);
if (!loaded) {
ensureEditor();
editorInstance?.setValue(defaultYaml);
}
} else {
ensureEditor();
editorInstance?.setValue(defaultYaml);
}
} else {
ensureEditor();
editorInstance?.setValue(defaultYaml);
}
} }
} catch (e) { } catch (e) {
console.error("[Workflow Detail] view API failed:", e); console.error("[Workflow Detail] view API failed:", e);
ensureEditor(); ensureEditor();
editorInstance!.setValue(defaultYaml); editorInstance?.setValue(defaultYaml);
} }
} }
@ -203,8 +250,11 @@ const steps = ref<
<!-- 헤더 --> <!-- 헤더 -->
<v-card flat class="bg-shades-transparent w-100"> <v-card flat class="bg-shades-transparent w-100">
<v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0"> <v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
<div class="d-flex flex-row justify-start align-center"> <div class="d-flex flex-row justify-space-between align-center w-100">
<div>View Details</div> <div>View Details</div>
<v-btn color="primary" variant="elevated" @click="emit('close')">
Back to List
</v-btn>
</div> </div>
</v-card-item> </v-card-item>
</v-card> </v-card>
@ -277,12 +327,6 @@ const steps = ref<
<v-col cols="9">{{ detail.regDt || "-" }}</v-col> <v-col cols="9">{{ detail.regDt || "-" }}</v-col>
</v-row> </v-row>
</v-card-text> </v-card-text>
<v-sheet class="d-flex justify-end mt-4">
<v-btn class="back-to-list" color="primary" @click="emit('close')">
Back to List
</v-btn>
</v-sheet>
</v-card> </v-card>
</template> </template>

@ -1,6 +1,6 @@
<template> <template>
<LayoutComponent> <LayoutComponent>
<router-view /> <router-view :key="$route.fullPath" />
</LayoutComponent> </LayoutComponent>
</template> </template>

@ -43,17 +43,23 @@ export const menuUtils = {
}, },
], ],
adminMenuItem: [ adminMenuItem: [
// { {
// title: "Training Script", title: "Projects",
// path: "/training-script", path: "/project",
// value: "training-script", value: "project",
// icon: "mdi-file-code-outline", icon: "mdi-briefcase",
// }, },
// { {
// title: "Datasets", title: "Users",
// path: "/datasets", path: "/users",
// value: "datasets", value: "users",
// icon: "mdi-database-outline", icon: "mdi-account-multiple",
// }, },
{
title: "관리자",
path: "/system-status",
value: "admin",
icon: "mdi-cog",
},
], ],
}; };

@ -73,14 +73,7 @@ export default defineConfig({
}, },
server: { server: {
port: 3000, port: 3000,
proxy: {
"/autoflow-server-mgmt": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
sass: { sass: {

Loading…
Cancel
Save