Compare commits

..

No commits in common. 'feature/apply-patched-updates' and 'main' have entirely different histories.

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

4
components.d.ts vendored

@ -9,7 +9,6 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AppFooter: typeof import('./src/components/AppFooter.vue')['default'] AppFooter: typeof import('./src/components/AppFooter.vue')['default']
AutoScriptDialog: typeof import('./src/components/atoms/organisms/AutoScriptDialog.vue')['default']
CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default'] CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default']
CompareRunDialog: typeof import('./src/components/atoms/organisms/CompareRunDialog.vue')['default'] CompareRunDialog: typeof import('./src/components/atoms/organisms/CompareRunDialog.vue')['default']
DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default'] DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default']
@ -32,10 +31,9 @@ declare module 'vue' {
IconRunBtn: typeof import('./src/components/atoms/button/IconRunBtn.vue')['default'] IconRunBtn: typeof import('./src/components/atoms/button/IconRunBtn.vue')['default']
IconSettingBtn: typeof import('./src/components/atoms/button/IconSettingBtn.vue')['default'] IconSettingBtn: typeof import('./src/components/atoms/button/IconSettingBtn.vue')['default']
LayoutComponent: typeof import('./src/components/common/LayoutComponent.vue')['default'] LayoutComponent: typeof import('./src/components/common/LayoutComponent.vue')['default']
ListComponent: typeof import('./src/components/templates/Datasets/ListComponent.vue')['default'] ListComponent: typeof import('./src/components/templates/datagroup/ListComponent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
ScriptCompileDialog: typeof import('./src/components/atoms/organisms/ScriptCompileDialog.vue')['default']
SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default'] SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default']
TrainingGroupBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingGroupBaseDoalog.vue')['default'] TrainingGroupBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingGroupBaseDoalog.vue')['default']
TrainingScriptBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingScriptBaseDoalog.vue')['default'] TrainingScriptBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingScriptBaseDoalog.vue')['default']

@ -10,7 +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,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: autoflow-web name: autoflow-web
namespace: etri-aisw namespace: autoflow
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@ -13,70 +13,26 @@ spec:
labels: labels:
app: autoflow-web app: autoflow-web
spec: spec:
nodeSelector:
nodegroup: cpu
containers: containers:
- name: autoflow-web - name: autoflow-web
# [수정] 외부 레지스트리 주소를 제거하고 로컬 태그만 사용
image: autoflow-web:latest image: autoflow-web:latest
# [추가] 외부에서 이미지를 다운로드하지 않고 로컬 이미지를 사용하도록 강제
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 80 - containerPort: 80
livenessProbe:
httpGet:
path: /autoflow/index.html
port: 80
initialDelaySeconds: 30
periodSeconds: 15
readinessProbe:
httpGet:
path: /autoflow/index.html
port: 80
initialDelaySeconds: 30
periodSeconds: 15
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: autoflow-web-svc name: autoflow-web-svc
namespace: etri-aisw namespace: autoflow
spec: spec:
# Outpost EKS 환경에 따라 LoadBalancer 또는 NodePort를 선택하세요.
type: LoadBalancer
selector: selector:
app: autoflow-web app: autoflow-web
ports: ports:
- protocol: TCP - protocol: TCP
port: 80 port: 80
targetPort: 80 targetPort: 80
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: autoflow-web-ingress
namespace: etri-aisw
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/backend-protocol: HTTP
# [수정 필요] 실제 서브넷 ID 및 보안 그룹 ID 입력 필요
alb.ingress.kubernetes.io/subnets: subnet-xxxx, subnet-yyyy
alb.ingress.kubernetes.io/group.name: etri-group
alb.ingress.kubernetes.io/security-groups: sg-xxxx
alb.ingress.kubernetes.io/customer-owned-ipv4-pool: ipv4pool-xxxx
alb.ingress.kubernetes.io/healthcheck-protocol: HTTP
alb.ingress.kubernetes.io/healthcheck-path: /autoflow/index.html
alb.ingress.kubernetes.io/healthcheck-interval-seconds: "15"
alb.ingress.kubernetes.io/healthy-threshold-count: "2"
alb.ingress.kubernetes.io/unhealthy-threshold-count: "3"
alb.ingress.kubernetes.io/success-codes: "200"
spec:
ingressClassName: alb
rules:
- http:
paths:
- path: /autoflow
pathType: Prefix
backend:
service:
name: autoflow-web-svc
port:
number: 80

@ -0,0 +1,43 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: autoflow
namespace: autoflow
labels:
app: autoflow
spec:
replicas: 1
selector:
matchLabels:
app: autoflow
template:
metadata:
labels:
app: autoflow
spec:
containers:
- image: 192.168.10.120:32100/autoflow:2025.07.005
name: autoflow
ports:
- containerPort: 80
resources:
requests:
cpu: 500m
memory: 100Mi
# limits:
# cpu: 1000m
# memory: 2Gi
---
apiVersion: v1
kind: Service
metadata:
name: autoflow
namespace: autoflow
spec:
type: ClusterIP
selector:
app: autoflow
ports:
- protocol: TCP
port: 80
targetPort: 80

@ -0,0 +1,19 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
namespace: autoflow
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: 1g
name: autoflow
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /autoflow
pathType: Prefix
backend:
service:
name: autoflow
port:
number: 80

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==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"

File diff suppressed because it is too large Load Diff

@ -25,8 +25,6 @@ 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;
@ -36,7 +34,6 @@ const props = withDefaults(
items: () => [], items: () => [],
selectedRunIds: () => [], selectedRunIds: () => [],
selectedMetricKeys: () => [], selectedMetricKeys: () => [],
runIdToExecutionName: () => ({}),
compareChartMode: "byMetric", compareChartMode: "byMetric",
normalizeValues: false, normalizeValues: false,
baselineRunId: null, baselineRunId: null,
@ -126,7 +123,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 = 2 + activeMetricKeys.value.length; // Execution name + Run + metric const cols = 1 + activeMetricKeys.value.length; // Run + metric
return Math.max(900, cols * 160); return Math.max(900, cols * 160);
}); });
@ -288,9 +285,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">
<div class="text-subtitle-2 font-weight-medium mb-2"> <v-subheader class="font-weight-medium mb-2"
Select Runs >Select Runs</v-subheader
</div> >
<v-autocomplete <v-autocomplete
v-model="selectedRunIdsProxy" v-model="selectedRunIdsProxy"
:items="items" :items="items"
@ -322,7 +319,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">
<div class="text-subtitle-2 font-weight-medium mb-2">Metrics</div> <v-subheader class="font-weight-medium mb-2">Metrics</v-subheader>
<v-autocomplete <v-autocomplete
v-model="selectedMetricKeysProxy" v-model="selectedMetricKeysProxy"
:items="activeMetricKeys.length ? activeMetricKeys : []" :items="activeMetricKeys.length ? activeMetricKeys : []"
@ -389,25 +386,14 @@ onBeforeUnmount(() => window.removeEventListener("resize", onResize));
<v-table density="comfortable"> <v-table density="comfortable">
<thead> <thead>
<tr> <tr>
<th style="width: 22%">Execution name</th> <th style="width: 28%">Run</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 <tr v-for="r in compareRuns" :key="r.info.run_id">
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,
AddMinioParamsSwagger, AddStorageParamsSwagger,
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 MinioRegisterModel = EdgePkgInfoVOModel & { type StorageRegisterModel = 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 minioType = ref<"type1" | "type2">("type2"); const storageType = 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: minioType.value, type: storageType.value,
localPath: (form.value.install_location || "").trim(), localPath: (form.value.install_location || "").trim(),
}; };
res = await ExternalAuthControllerService.addMinio(params); res = await ExternalAuthControllerService.addStorage(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">
<div class="text-subtitle-2 font-weight-medium mb-2 text-white"> <v-subheader class="font-weight-medium white--text mb-2">
Select Workflow Select Workflow
</div> </v-subheader>
<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">
<div class="text-subtitle-2 font-weight-medium mb-2 text-white"> <v-subheader class="font-weight-medium white--text mb-2">
Execution Type Execution Type
</div> </v-subheader>
<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">
<div class="text-subtitle-2 font-weight-medium mb-2 text-white"> <v-subheader class="font-weight-medium white--text mb-2">
Execution Type Execution Type
</div> </v-subheader>
<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">
<div class="text-subtitle-2 font-weight-medium mb-2 text-white"> <v-subheader class="font-weight-medium white--text mb-2">
Description Description
</div> </v-subheader>
<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">
<div class="text-subtitle-2 font-weight-medium mb-2 text-white"> <v-subheader class="font-weight-medium white--text mb-2">
Experiment Experiment
</div> </v-subheader>
<v-text-field <v-text-field
v-model="internalName" v-model="internalName"

@ -1,430 +0,0 @@
<script setup lang="ts">
import { ref, watch, nextTick, computed } from "vue";
import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { KubeflowService } from "@/components/service/management/KubeflowService";
import {
toKubeflowForm,
type KubeflowUploadDto,
} from "@/components/models/management/Kubeflow";
import { commonStore } from "@/stores/commonStore";
import { storeToRefs } from "pinia";
import { useAutoflowStore } from "@/stores/autoflowStore";
import { storage } from "@/utils/storage";
const store = commonStore();
const { projectId } = storeToRefs(useAutoflowStore());
const logPreRef = ref<HTMLElement | null>(null);
function scrollLogToBottom() {
nextTick(() => {
if (logPreRef.value) logPreRef.value.scrollTop = logPreRef.value.scrollHeight;
});
}
const props = withDefaults(
defineProps<{
modelValue: boolean;
item: { deviceKey?: number; id?: number; title?: string; fileName?: string; filePath?: string } | null;
}>(),
{ modelValue: false, item: null }
);
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
(e: "compiled"): void;
}>();
const open = ref(props.modelValue);
const logText = ref("");
const loading = ref(false);
/** 컴파일 성공 시 반환된 YAML MinIO 경로 (다운로드 버튼 표시용) */
const lastYamlPath = ref<string | null>(null);
watch(
() => props.modelValue,
async (v) => {
open.value = v;
if (v) {
logText.value = "[대기] 컴파일 버튼을 누르면 실행됩니다.\n";
loading.value = false;
lastYamlPath.value = null;
const id = props.item?.deviceKey ?? props.item?.id;
if (id != null) {
try {
const res = await AttachmentsService.getCompiledInfo(Number(id));
const data = res?.data ?? res;
const yamlPath = data?.yamlStoragePath;
if (yamlPath) {
lastYamlPath.value = yamlPath;
appendLog("이미 컴파일된 YAML이 있어 바로 다운로드할 수 있습니다.");
appendLog(`저장 경로: ${yamlPath}`);
}
} catch (err: any) {
// 204(no content) ,
console.debug("[ScriptCompile] 기존 컴파일 정보 조회 실패 또는 없음:", err);
}
}
}
},
{ immediate: true }
);
watch(open, (v) => emit("update:modelValue", v));
function appendLog(line: string) {
const ts = new Date().toLocaleTimeString("ko-KR", { hour12: false });
logText.value += `[${ts}] ${line}\n`;
scrollLogToBottom();
}
async function runCompile() {
const id = props.item?.deviceKey ?? props.item?.id;
if (id == null) {
appendLog("[오류] 스크립트 ID가 없습니다.");
return;
}
loading.value = true;
appendLog("컴파일 시작... (API 호출 중)");
try {
const res = await AttachmentsService.compile(Number(id));
const data = res?.data ?? res;
const yamlPath = data?.yamlStoragePath;
if (yamlPath) {
lastYamlPath.value = yamlPath;
appendLog(`성공: KFP 학습 실행용 YAML이 생성되었습니다.`);
appendLog(`저장 경로: ${yamlPath}`);
emit("compiled");
} else {
appendLog("완료: (경로 정보 없음)");
}
} catch (err: any) {
const dataMsg = err?.response?.data?.error ?? err?.response?.data?.message;
const msg = dataMsg ?? err?.message ?? "컴파일 요청 실패";
appendLog("실패:");
appendLog(msg);
} finally {
loading.value = false;
}
}
function closeDialog() {
open.value = false;
}
function filenameFromPath(path: string): string {
return path.includes("/") ? path.substring(path.lastIndexOf("/") + 1) : path;
}
async function downloadYaml() {
const path = lastYamlPath.value;
if (!path) return;
try {
const res = await AttachmentsService.downloadFile(path);
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);
store.setSnackbarMsg({ color: "warning", text: json.message || text, result: 400 });
} catch {
store.setSnackbarMsg({ color: "warning", text: text, result: 400 });
}
return;
}
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()) ||
filenameFromPath(path) ||
"pipeline.yaml";
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: "YAML이 다운로드되었습니다.", result: 200 });
} catch (e) {
console.error("[ScriptCompile] YAML 다운로드 실패:", e);
store.setSnackbarMsg({ color: "warning", text: "YAML 다운로드에 실패했습니다.", result: 500 });
}
}
async function downloadBundle() {
const path = lastYamlPath.value;
const id = props.item?.deviceKey ?? props.item?.id;
if (!path || id == null) return;
try {
const res = await AttachmentsService.downloadCompiledBundle(Number(id), path);
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);
store.setSnackbarMsg({ color: "warning", text: json.message || text, result: 400 });
} catch {
store.setSnackbarMsg({ color: "warning", text: text, result: 400 });
}
return;
}
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 base = filenameFromPath(path).replace(/\.yaml$/i, "");
const filename =
(mUtf8 && decodeURIComponent(mUtf8[1].trim())) ||
(mStd && (mStd[1] || mStd[2])?.trim()) ||
`${base}_bundle.zip`;
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: "YAML + 스크립트(ZIP)가 다운로드되었습니다.", result: 200 });
} catch (e) {
console.error("[ScriptCompile] 번들 다운로드 실패:", e);
store.setSnackbarMsg({ color: "warning", text: "번들 다운로드에 실패했습니다.", result: 500 });
}
}
// ----- Workflows (Create Workflow name/description ) -----
const KFP_NAME_REGEX = /^[a-z0-9]([-a-z0-9\.]*[a-z0-9])?$/;
const sanitizeKfpName = (s: string) => {
let x = (s ?? "").toLowerCase();
x = x.replace(/[\s_]+/g, "-");
x = x.replace(/[^a-z0-9.-]/g, "");
x = x.replace(/-+/g, "-");
x = x.replace(/^[^a-z0-9]+/, "");
x = x.replace(/[^a-z0-9]+$/, "");
return x;
};
const showRegisterWorkflow = ref(false);
const registerForm = ref({ name: "", description: "" });
const registerLoading = ref(false);
const registerError = ref("");
const registerNameHint = ref(
"허용: 소문자, 숫자, '-', '.' (시작/끝은 영숫자)",
);
const registerNameInvalid = computed(
() => !!registerForm.value.name && !KFP_NAME_REGEX.test(registerForm.value.name),
);
const registerNameErrorMsg = computed(() =>
registerNameInvalid.value
? "형식이 올바르지 않습니다. (소문자/숫자, '-', '.')"
: "",
);
function openRegisterWorkflowDialog() {
registerError.value = "";
const base = (props.item?.title ?? props.item?.fileName ?? "pipeline") as string;
registerForm.value.name = sanitizeKfpName(base) || "training-pipeline";
registerForm.value.description = "";
showRegisterWorkflow.value = true;
}
function onRegisterNameInput(v: string) {
registerForm.value.name = sanitizeKfpName(v || "");
}
async function submitRegisterWorkflow() {
registerError.value = "";
const name = sanitizeKfpName(registerForm.value.name).trim();
const description = (registerForm.value.description ?? "").trim();
if (!name || !KFP_NAME_REGEX.test(name)) {
registerError.value = "Workflow Name 형식이 올바르지 않습니다.";
return;
}
const authObj =
(typeof storage?.getAuth === "function" ? storage.getAuth() : null) ??
JSON.parse(localStorage.getItem("autoflow-auth") || "{}");
const ui = authObj?.userInfo ?? authObj?.userinfo ?? authObj ?? {};
const userId = Number(ui.id);
if (!userId) {
registerError.value = "로그인 사용자 정보를 찾을 수 없습니다.";
return;
}
if (!projectId.value) {
registerError.value = "프로젝트가 선택되지 않았습니다.";
return;
}
const path = lastYamlPath.value;
if (!path) {
registerError.value = "컴파일된 YAML이 없습니다. 먼저 컴파일을 실행하세요.";
return;
}
try {
registerLoading.value = true;
const res = await AttachmentsService.downloadFile(path);
const ct = String(res.headers["content-type"] || "").toLowerCase();
if (ct.includes("application/json")) {
const text = await (res.data as Blob).text();
registerError.value = JSON.parse(text)?.message || text || "YAML 다운로드 실패";
return;
}
const blob = res.data instanceof Blob ? res.data : new Blob([res.data]);
const fileName = filenameFromPath(path) || "pipeline.yaml";
const file = new File([blob], fileName, { type: "application/x-yaml" });
const dto: KubeflowUploadDto = {
name,
display_name: name,
description,
namespace: "default",
regUserId: userId,
projectId: projectId.value!,
uploadfile: file,
};
const fd = toKubeflowForm(dto);
await KubeflowService.upload(fd);
store.setSnackbarMsg({ color: "success", text: "Workflow에 등록되었습니다.", result: 200 });
showRegisterWorkflow.value = false;
emit("compiled");
} catch (e: any) {
console.error("[ScriptCompile] Workflow 등록 실패:", e);
const msg =
e?.response?.data?.message ?? e?.response?.data?.error ?? e?.message ?? "Workflow 등록에 실패했습니다.";
registerError.value = msg;
store.setSnackbarMsg({ color: "warning", text: msg, result: e?.response?.status ?? 500 });
} finally {
registerLoading.value = false;
}
}
</script>
<template>
<v-dialog :model-value="open" max-width="720" persistent @update:model-value="(v) => (open = v)">
<v-card>
<v-card-title class="d-flex align-center">
<span class="font-weight-bold">스크립트 컴파일</span>
<v-spacer />
<v-btn icon variant="text" size="small" @click="closeDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider />
<v-card-text>
<template v-if="item">
<div class="text-body-2 mb-3">
<div><strong>제목:</strong> {{ item.title ?? "—" }}</div>
<div><strong>파일명:</strong> {{ item.fileName ?? "—" }}</div>
<div v-if="item.filePath" class="text-medium-emphasis" style="word-break: break-all;">
<strong>경로:</strong> {{ item.filePath }}
</div>
</div>
<v-divider class="my-3" />
<div class="text-subtitle-2 font-weight-medium mb-1">로그</div>
<pre
ref="logPreRef"
class="compile-log pa-3 overflow-auto rounded"
style="max-height: 360px; min-height: 120px; white-space: pre-wrap; word-break: break-all; font-size: 0.85rem;"
>{{ logText || " " }}</pre>
</template>
</v-card-text>
<v-divider />
<v-card-actions class="justify-end pa-4 flex-wrap">
<v-btn v-if="lastYamlPath" variant="outlined" size="small" @click="downloadYaml">
YAML 다운로드
</v-btn>
<v-btn v-if="lastYamlPath" variant="outlined" size="small" @click="downloadBundle">
YAML + 스크립트 다운로드 (ZIP)
</v-btn>
<v-btn
v-if="lastYamlPath"
color="primary"
variant="outlined"
size="small"
@click="openRegisterWorkflowDialog"
>
Workflows 등록
</v-btn>
<v-spacer />
<v-btn variant="text" @click="closeDialog"></v-btn>
<v-btn
color="primary"
variant="elevated"
:loading="loading"
:disabled="!item || (item?.deviceKey == null && item?.id == null)"
@click="runCompile"
>
컴파일 실행
</v-btn>
</v-card-actions>
</v-card>
<!-- Workflows 등록: 이름·설명 입력 다이얼로그 (Create Workflow와 동일) -->
<v-dialog
v-model="showRegisterWorkflow"
max-width="520"
persistent
scrollable
>
<v-card>
<v-card-title class="text-white font-weight-bold text-h6" style="background-color: #1976d2">
Workflow 등록
</v-card-title>
<v-divider />
<v-card-text class="pa-5">
<div class="text-subtitle-2 font-weight-medium mb-2">Workflow Name</div>
<v-text-field
:model-value="registerForm.name"
variant="outlined"
density="comfortable"
hide-details="auto"
persistent-hint
:hint="registerNameHint"
:error="registerNameInvalid"
:error-messages="registerNameErrorMsg"
:disabled="registerLoading"
@update:model-value="onRegisterNameInput"
/>
<div class="text-subtitle-2 font-weight-medium mb-2 mt-4">Workflow Description</div>
<v-textarea
v-model="registerForm.description"
variant="outlined"
density="comfortable"
rows="3"
hide-details="auto"
:disabled="registerLoading"
/>
<div v-if="registerError" class="mt-3 text-error text-body-2">{{ registerError }}</div>
</v-card-text>
<v-divider />
<v-card-actions class="justify-end pa-4">
<v-btn variant="text" :disabled="registerLoading" @click="showRegisterWorkflow = false">
취소
</v-btn>
<v-btn
color="primary"
variant="elevated"
:loading="registerLoading"
:disabled="registerNameInvalid"
@click="submitRegisterWorkflow"
>
등록
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<style scoped>
.compile-log {
background-color: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.08);
}
</style>

@ -1,10 +1,7 @@
<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;
@ -97,12 +94,6 @@ 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() && {
@ -113,11 +104,6 @@ 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 {
@ -125,11 +111,6 @@ 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, nextTick } from "vue"; import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { storage } from "@/utils/storage.js"; import { storage } from "@/utils/storage.js";
import { UserManagerService } from "@/components/service/management/UserManagerService"; import { UserManagerService } from "@/components/service/management/UserManagerService";
@ -49,31 +49,12 @@ function readAuth() {
function computeIsAdmin() { function computeIsAdmin() {
const auth = readAuth(); const auth = readAuth();
let roles = auth?.userInfo?.roles ?? auth?.roles ?? []; const roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
if (typeof roles === "string") { const authCd = auth?.userInfo?.authCd ?? auth?.authCd ?? auth?.auth;
roles = roles.split(",").map((s: string) => String(s).trim()); const inRoles = Array.isArray(roles)
} ? roles.includes("ROLE_ADMIN")
const rolesArr = Array.isArray(roles) ? roles : []; : roles === "ROLE_ADMIN";
const roleStrings = rolesArr.map((r) => isAdmin.value = inRoles || authCd === "ADMIN";
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() {
@ -93,7 +74,6 @@ 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;
@ -126,7 +106,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 menuItemsBase: MenuItem[] = [ const menuItems: MenuItem[] = [
{ title: "Select Project", click: () => goSelect() }, { title: "Select Project", click: () => goSelect() },
{ {
title: "Change Password", title: "Change Password",
@ -163,45 +143,6 @@ 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)
@ -230,7 +171,6 @@ 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 },
@ -251,34 +191,11 @@ 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.value; menu.value = menuItems;
window.addEventListener("storage", onStorage); window.addEventListener("storage", onStorage);
}); });
@ -341,23 +258,17 @@ onBeforeUnmount(() => {
</v-list> </v-list>
</v-menu> </v-menu>
<!-- 드롭다운 없는 단일 항목: 버튼/링크 클릭 이동 (Project/Users에서도 동작) --> <!-- 드롭다운 없는 단일 항목 -->
<a <v-btn
v-else v-else
:href="resolveHref(m.path)" variant="text"
class="admin-nav-link" class="nav-btn"
@click.prevent="goToAdminMenu(m.path)" :class="{ 'nav-active': isLinkActive(m.path) }"
@click="m.path && router.push(m.path)"
> >
<v-btn <v-icon start :icon="m.icon" class="mr-1" />
variant="text" {{ m.title }}
class="nav-btn" </v-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>
@ -477,21 +388,6 @@ 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" }}
@ -512,10 +408,8 @@ onBeforeUnmount(() => {
v-for="(item, index) in menu" v-for="(item, index) in menu"
:key="index" :key="index"
:value="index" :value="index"
:to="item.path && !isAdminMenuItem(item) ? item.path : undefined" @click="item.click"
: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>
@ -543,13 +437,12 @@ 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;
} }
/* 중앙 고정 네비게이션 */ /* 중앙 고정 네비게이션 */
@ -561,13 +454,6 @@ 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,6 +20,5 @@ export type AttachmentSearch = {
endDate?: string; endDate?: string;
sortField?: string; sortField?: string;
sortDirection?: "ASC" | "DESC"; sortDirection?: "ASC" | "DESC";
refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT" | "workflows"; refType?: "WORKFLOW_STEP" | "DATASET" | "TRAINING_SCRIPT";
refId?: number;
}; };

@ -28,7 +28,7 @@ export type AddFileParamsSwagger = {
creation_datetime: string; creation_datetime: string;
}; };
export type AddMinioParamsSwagger = AddFileParamsSwagger & { export type AddStorageParamsSwagger = 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, config?: any): any => { get: (uri: string, param: any): any => {
return axios.get(`${API_URL}${uri}`, { params: param, ...config }); return axios.get(`${API_URL}${uri}`, { params: param });
}, },
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,
AddMinioParamsSwagger, AddStorageParamsSwagger,
} 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 = {
}); });
}, },
addMinio: (params: AddMinioParamsSwagger) => { addStorage: (params: AddStorageParamsSwagger) => {
return request.postWithConfig( return request.postWithConfig(
"/api/external-auth/register-with-minio-file", "/api/external-auth/register-with-minio-file",
{}, {},

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

@ -1,112 +0,0 @@
import { request } from "@/components/service/index";
export type ServiceStatus = "ok" | "error" | "skip";
export interface ComponentStatus {
status: ServiceStatus;
message?: string;
}
export interface PodStatusItem {
name: string | null;
phase: string | null;
}
export interface PodStatus {
ok: boolean;
message: string;
running: number;
total: number;
pods?: PodStatusItem[];
}
export interface AdminStatusResponse {
kfp: ComponentStatus;
mlflow: ComponentStatus;
minio: ComponentStatus;
updatedAt?: string;
}
export interface AdminPodStatusResponse {
namespace?: string;
kfp?: PodStatus;
mlflow?: PodStatus;
minio?: PodStatus;
updatedAt?: string;
message?: string;
}
export interface RestartResponse {
success: boolean;
message: string;
}
/** 상태 조회 타임아웃(ms). 백엔드 없을 때 오래 기다리지 않도록 */
const STATUS_REQUEST_TIMEOUT_MS = 6_000;
export const AdminService = {
getStatus: (): Promise<{ data: AdminStatusResponse }> =>
request.get("/api/admin/status", {}, { timeout: STATUS_REQUEST_TIMEOUT_MS }),
getPodStatus: (): Promise<{ data: AdminPodStatusResponse }> =>
request.get("/api/admin/pods/status", {}, { timeout: STATUS_REQUEST_TIMEOUT_MS }),
restart: (service: string): Promise<{ data: RestartResponse }> =>
request.postNoParam(`/api/admin/restart/${encodeURIComponent(service)}`) as Promise<{
data: RestartResponse;
}>,
/** Run별 Pod 목록 (Executions 상세 로그 버튼용). 응답: { namespace, pods: [{ name, phase }], message } */
getPodsByRunId: (runId: string) =>
request.get("/api/admin/pods/by-run", { runId }, { timeout: 30_000 }),
/** 응답은 axios response. 로그 텍스트는 response.data */
getPodLog: (params: {
namespace: string;
pod: string;
container?: string;
tailLines?: number;
}) =>
request.get("/api/admin/pods/logs", params as Record<string, string | number | undefined>, {
timeout: 120_000,
responseType: "text",
}),
/**
* Run . : KFP ( ). allSteps=true .
*/
getPodLogsByRunId: (
runId: string,
opts?: {
tailLines?: number;
allSteps?: boolean;
podName?: string;
stepName?: string;
workflowName?: string;
workflowNamespace?: string;
},
) => {
const q: Record<string, string | number | boolean> = { runId };
if (opts?.tailLines !== undefined) q.tailLines = opts.tailLines;
if (opts?.allSteps) q.allSteps = true;
if (opts?.podName) q.podName = opts.podName;
if (opts?.stepName) q.stepName = opts.stepName;
if (opts?.workflowName) q.workflowName = opts.workflowName;
if (opts?.workflowNamespace) q.workflowNamespace = opts.workflowNamespace;
return request.get("/api/admin/pods/logs-by-run", q, {
timeout: 120_000,
responseType: "text",
});
},
/** 관리자 Pod 카드: 같은 카드의 Pod 이름들 로그를 한 덩어리로 (pod 쿼리 반복) */
getPodLogsAggregate: (namespace: string, podNames: string[], tailLines?: number) => {
const q = new URLSearchParams();
q.set("namespace", namespace);
podNames.forEach((n) => {
if (n) q.append("pod", n);
});
if (tailLines !== undefined) q.set("tailLines", String(tailLines));
return request.get(`/api/admin/pods/logs-aggregate?${q.toString()}`, {}, { timeout: 120_000, responseType: "text" });
},
};

@ -33,41 +33,4 @@ 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,5 +1,4 @@
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) => {
@ -8,18 +7,6 @@ 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,
@ -48,19 +35,4 @@ 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,25 +1,21 @@
<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, onBeforeUnmount, ref } from "vue"; import { onMounted, 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>>({});
@ -274,7 +270,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 ?? r.run_id, run_id: r.runId,
raw: r, raw: r,
}; };
}; };
@ -407,7 +403,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;
@ -416,8 +412,7 @@ 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 rawStartNo = total - (safePage - 1) * pageSize; const startNo = 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)),
); );
@ -426,8 +421,7 @@ async function fetchList() {
} else { } else {
const te = const te =
typeof totalElements === "number" ? totalElements : list.length; typeof totalElements === "number" ? totalElements : list.length;
const rawStartNo = te - (pageNum - 1) * pageSize; const startNo = 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)),
); );
@ -443,62 +437,6 @@ 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
}
} }
// / // /
@ -516,27 +454,27 @@ const changePageSize = (size: number) => {
fetchList(); fetchList();
}; };
// ( DELETE /api/kubeflow/runs/{runId} , Workflows ) //
const removeData = (value: Array<{ deviceKey: string | number }>) => { const removeData = (value: Array<{ deviceKey: number }>) => {
const runIds = (value || []).map((x) => String(x.deviceKey)); const ids = (value || []).map((x) => x.deviceKey);
if (runIds.length === 0) return; if (ids.length === 0) return;
const removeOne = (runId: string) => const removeOne = (id: number) =>
KubeflowRunService.delete(runId).then((res) => { ExperimentService.delete(id).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 (
runIds.length >= data.value.results.length && ids.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 (runIds.length === 1) { if (ids.length === 1) {
removeOne(runIds[0]) removeOne(ids[0])
.then(() => .then(() =>
store.setSnackbarMsg({ store.setSnackbarMsg({
color: "success", color: "success",
@ -554,7 +492,7 @@ const removeData = (value: Array<{ deviceKey: string | number }>) => {
}) })
.finally(after); .finally(after);
} else { } else {
Promise.all(runIds.map(removeOne)) Promise.all(ids.map(removeOne))
.then(() => .then(() =>
store.setSnackbarMsg({ store.setSnackbarMsg({
color: "success", color: "success",
@ -596,28 +534,10 @@ 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>
@ -747,12 +667,7 @@ onBeforeUnmount(() => stopListPolling());
class="text-center" class="text-center"
> >
<td>{{ item.no }}</td> <td>{{ item.no }}</td>
<td <td class="text-truncate">{{ item.name }}</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
@ -786,10 +701,7 @@ onBeforeUnmount(() => stopListPolling());
<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,
},
]) ])
" "
/> />
@ -836,11 +748,4 @@ onBeforeUnmount(() => stopListPolling());
</div> </div>
</template> </template>
<style scoped> <style scoped></style>
.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: KFP run id 또는 MLflow run id) ---------- */ /* ---------- API (runId 단건만 조회) ---------- */
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,28 +132,16 @@ async function fetchRunDetailById(runId: string) {
return; return;
} }
let one: any = null; const res = await MlflowService.getExperimentRun(runId);
// 1) runId KFP run id kubeflow_run_id MLflow run const one = normalizeRun(res);
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; 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) {
@ -194,11 +182,8 @@ 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 d-flex align-center justify-space-between"> <v-card-title class="grey lighten-4 py-2 px-4">
<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">
@ -389,6 +374,10 @@ 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,7 +104,6 @@ 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,14 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"; import { ref, computed, onMounted, watch } from "vue";
import { KubeflowRunService } from "@/components/service/management/KubeflowRunService"; import { MlflowService } from "@/components/service/mlflow/MlflowService";
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 }>();
@ -133,80 +127,27 @@ const pagedRows = computed(() => {
return runRows.value.slice(start, start + pageSize.value); return runRows.value.slice(start, start + pageSize.value);
}); });
// Run() runRows // experiment_id id runs
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 {
const projectId = getProjectId(); // 1) Experiment
if (!projectId) { const expRes = await MlflowService.getExperimentByName(expName);
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("[Experiment runs] fetch error:", e); console.error("[MLflow] fetch error:", e);
runs.value = []; runs.value = [];
} finally { } finally {
loading.value = false; loading.value = false;
@ -228,9 +169,8 @@ onMounted(() => {
fetchRunsByExperimentName(header.value.experimentName); fetchRunsByExperimentName(header.value.experimentName);
}); });
watch( watch(
() => ({ name: header.value.experimentName, mlFlowId: header.value.mlFlowId }), () => header.value.experimentName,
() => fetchRunsByExperimentName(header.value.experimentName), (nv) => fetchRunsByExperimentName(nv),
{ deep: true },
); );
</script> </script>
@ -244,11 +184,8 @@ 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-space-between align-center w-100"> <div class="d-flex flex-row justify-start align-center">
<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>
@ -398,6 +335,10 @@ 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,14 +1,11 @@
<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";
@ -44,9 +41,8 @@ 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;" },
@ -72,7 +68,6 @@ 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[],
@ -155,8 +150,7 @@ const fetchList = async () => {
sortField: "id", sortField: "id",
sortDirection: "DESC", sortDirection: "DESC",
refType: "TRAINING_SCRIPT", refType: "TRAINING_SCRIPT",
// refId=0 (Auto Script refId 0 ) refId: activeRefId.value,
refId: activeRefId.value ?? 0,
}; };
try { try {
@ -294,59 +288,6 @@ 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;
@ -371,94 +312,6 @@ 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;
@ -604,12 +457,8 @@ watch(
</v-sheet> </v-sheet>
</v-sheet> </v-sheet>
<v-sheet class="d-flex align-center justify-end mb-2"> <v-sheet class="justify-end mb-2">
<v-btn color="info" class="mr-2" @click="openCreateModal" <v-btn color="info" @click="openCreateModal">Create Script</v-btn>
>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>
@ -639,17 +488,7 @@ watch(
class="text-center font-weight-bold" class="text-center font-weight-bold"
:style="item.style" :style="item.style"
> >
<template v-if="i === 0"> {{ item.label }}
<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>
@ -660,14 +499,6 @@ 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 -
@ -681,21 +512,6 @@ 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
@ -735,22 +551,6 @@ 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,11 +109,8 @@ 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-space-between align-center w-100"> <div class="d-flex flex-row justify-start align-center">
<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>
@ -170,6 +167,9 @@ 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,15 +3,12 @@ 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");
@ -112,39 +109,14 @@ async function loadYamlFromStoragePath(objectName?: string) {
} }
} }
/** workflow id에 연결된 attachment를 refType/refId로 검색해 YAML storagePath 반환 */ /** 상세 조회 → YAML 문자열 우선 → storagePath 후보들 시도 → 실패 시 기본 YAML */
async function findWorkflowAttachmentStoragePath(workflowId: number, projId: number): Promise<string | null> {
try {
const searchRes = await AttachmentsService.search({
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;
}
}
/** 상세 조회 → workflow 상세 API 후, refType=workflows/refId=workflowId로 attachment 검색해 YAML 로드 */
async function fetchDetail(id: number | string) { async function fetchDetail(id: number | string) {
const wid = Number(id);
try { try {
// 1) API // 0) YAML attachments -> readYamlText
const res = await WorkflowService.view(wid); await loadYamlByAttachmentId(id);
// 1) () API
const res = await WorkflowService.view(Number(id));
const d = res?.data ?? {}; const d = res?.data ?? {};
console.log("[Workflow Detail] view response:", d); console.log("[Workflow Detail] view response:", d);
@ -158,7 +130,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),
}; };
// 2) YAML // YAML
const yamlFromServer = const yamlFromServer =
d.workflowYaml || d.workflowYaml ||
d.yaml || d.yaml ||
@ -169,30 +141,11 @@ 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);
} }
} }
@ -250,11 +203,8 @@ 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-space-between align-center w-100"> <div class="d-flex flex-row justify-start align-center">
<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>
@ -327,6 +277,12 @@ 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 :key="$route.fullPath" /> <router-view />
</LayoutComponent> </LayoutComponent>
</template> </template>

@ -1,268 +0,0 @@
<template>
<LayoutComponent>
<div class="admin-page-wrapper pa-4">
<h1 class="text-h4 mb-4">관리자 · 시스템 상태</h1>
<p class="text-body-2 text-medium-emphasis mb-4">
KFP, MLflow, MinIO 헬스 상태. 아래 버튼으로 조회 30초마다 자동 갱신됩니다.
</p>
<div class="d-flex align-center mb-4 flex-wrap gap-2">
<v-btn color="primary" :loading="loading" @click="fetchStatus">
<v-icon start>mdi-refresh</v-icon>
상태 조회
</v-btn>
<v-btn color="secondary" variant="outlined" :loading="podLoading" @click="fetchPodStatus">
<v-icon start>mdi-kubernetes</v-icon>
Pod 상태 조회
</v-btn>
<span v-if="loading" class="text-body-2"> </span>
<span v-if="podLoading" class="text-body-2">Pod </span>
</div>
<!-- 기존: HTTP 연결 확인 -->
<v-row dense>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1">KFP</v-card-title>
<v-card-text>
<v-chip v-if="status?.kfp" :color="status?.kfp?.status === 'ok' ? 'success' : 'grey'" size="small">{{ status?.kfp?.status === 'ok' ? '' : (status?.kfp?.status === 'error' ? '' : '') }}</v-chip>
<v-chip v-else color="grey" size="small">{{ loading ? '조회 중' : '미조회' }}</v-chip>
<p class="text-caption mt-2">{{ status?.kfp?.message ?? (loading ? '연결 중...' : '위 [상태 조회] 버튼을 눌러 주세요.') }}</p>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1">MLflow</v-card-title>
<v-card-text>
<v-chip v-if="status?.mlflow" :color="status?.mlflow?.status === 'ok' ? 'success' : 'grey'" size="small">{{ status?.mlflow?.status === 'ok' ? '' : (status?.mlflow?.status === 'error' ? '' : '') }}</v-chip>
<v-chip v-else color="grey" size="small">{{ loading ? '조회 중' : '미조회' }}</v-chip>
<p class="text-caption mt-2">{{ status?.mlflow?.message ?? (loading ? '연결 중...' : '위 [상태 조회] 버튼을 눌러 주세요.') }}</p>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1">MinIO</v-card-title>
<v-card-text>
<v-chip v-if="status?.minio" :color="status?.minio?.status === 'ok' ? 'success' : 'grey'" size="small">{{ status?.minio?.status === 'ok' ? '' : (status?.minio?.status === 'error' ? '' : '') }}</v-chip>
<v-chip v-else color="grey" size="small">{{ loading ? '조회 중' : '미조회' }}</v-chip>
<p class="text-caption mt-2">{{ status?.minio?.message ?? (loading ? '연결 중...' : '위 [상태 조회] 버튼을 눌러 주세요.') }}</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 신규: Pod 상태 (K8s) -->
<p class="text-body-2 text-medium-emphasis mt-4 mb-2">Kubernetes Pod 상태</p>
<v-row dense>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 d-flex align-center justify-space-between">
KFP Pod
<v-btn size="small" variant="tonal" @click="openDetail('kfp')"></v-btn>
</v-card-title>
<v-card-text>
<template v-if="podStatus('kfp')">
<v-chip :color="podStatus('kfp')?.ok ? 'success' : 'warning'" size="small">
{{ podStatus('kfp')?.running }}/{{ podStatus('kfp')?.total }} Running
</v-chip>
<p class="text-caption mt-2">{{ podStatus('kfp')?.message }}</p>
</template>
<template v-else>
<v-chip color="grey" size="small">{{ podLoading ? '조회 중' : '미조회' }}</v-chip>
<p class="text-caption mt-2">{{ podStatusHint('kfp') }}</p>
</template>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 d-flex align-center justify-space-between">
MLflow Pod
<v-btn size="small" variant="tonal" @click="openDetail('mlflow')"></v-btn>
</v-card-title>
<v-card-text>
<template v-if="podStatus('mlflow')">
<v-chip :color="podStatus('mlflow')?.ok ? 'success' : 'warning'" size="small">
{{ podStatus('mlflow')?.running }}/{{ podStatus('mlflow')?.total }} Running
</v-chip>
<p class="text-caption mt-2">{{ podStatus('mlflow')?.message }}</p>
</template>
<template v-else>
<v-chip color="grey" size="small">{{ podLoading ? '조회 중' : '미조회' }}</v-chip>
<p class="text-caption mt-2">{{ podStatusHint('mlflow') }}</p>
</template>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 d-flex align-center justify-space-between">
MinIO Pod
<v-btn size="small" variant="tonal" @click="openDetail('minio')"></v-btn>
</v-card-title>
<v-card-text>
<template v-if="podStatus('minio')">
<v-chip :color="podStatus('minio')?.ok ? 'success' : 'warning'" size="small">
{{ podStatus('minio')?.running }}/{{ podStatus('minio')?.total }} Running
</v-chip>
<p class="text-caption mt-2">{{ podStatus('minio')?.message }}</p>
</template>
<template v-else>
<v-chip color="grey" size="small">{{ podLoading ? '조회 중' : '미조회' }}</v-chip>
<p class="text-caption mt-2">{{ podStatusHint('minio') }}</p>
</template>
</v-card-text>
</v-card>
</v-col>
</v-row>
<p v-if="status?.updatedAt" class="text-caption text-grey mt-2"> : {{ status.updatedAt }}</p>
<p v-if="podStatusData?.updatedAt" class="text-caption text-grey">Pod : {{ podStatusData.updatedAt }}</p>
<v-dialog v-model="detailDialog" max-width="min(96vw, 1100px)" persistent scrollable>
<v-card>
<v-card-title class="d-flex align-center">
{{ detailService === 'kfp' ? 'KFP' : detailService === 'mlflow' ? 'MLflow' : 'MinIO' }} Pod 상세
<v-spacer />
<v-btn
v-if="detailPods.length > 0"
color="primary"
variant="tonal"
size="small"
class="mr-2"
:loading="logLoading"
@click="loadAllPodLogs"
>
전체 로그
</v-btn>
<v-btn icon variant="text" @click="detailDialog = false"><v-icon>mdi-close</v-icon></v-btn>
</v-card-title>
<v-card-text>
<p v-if="detailPods.length === 0" class="text-caption text-medium-emphasis">Pod .</p>
<div v-else class="mb-3">
<p class="text-caption text-medium-emphasis mb-2">아래 Pod들의 로그를 [전체 로그] 번에 있습니다.</p>
<div v-for="p in detailPods" :key="p.name" class="d-flex align-center mb-1">
<span class="text-body-2 mr-2">{{ p.name }}</span>
<v-chip size="small" :color="p.phase === 'Running' ? 'success' : 'grey'">{{ p.phase ?? '-' }}</v-chip>
</div>
</div>
<div v-if="logContent" class="mt-2">
<p class="text-caption text-medium-emphasis mb-1">통합 로그 (Pod 순서대로, 전체 )</p>
<pre class="log-viewer log-viewer--wide">{{ logContent }}</pre>
</div>
</v-card-text>
</v-card>
</v-dialog>
</div>
</LayoutComponent>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import LayoutComponent from "@/components/common/LayoutComponent.vue";
import { AdminService } from "@/components/service/management/adminService";
import type { PodStatus, PodStatusItem } from "@/components/service/management/adminService";
import { commonStore } from "@/stores/commonStore";
const loading = ref(false);
const status = ref<any>(null);
const podLoading = ref(false);
const podStatusData = ref<any>(null);
const store = commonStore();
const detailDialog = ref(false);
const detailService = ref<"kfp" | "mlflow" | "minio" | null>(null);
const logContent = ref("");
const logLoading = ref(false);
const detailPods = computed((): PodStatusItem[] => {
if (!detailService.value || !podStatusData.value) return [];
const ps = podStatusData.value[detailService.value] as PodStatus | undefined;
return ps?.pods ?? [];
});
function podStatus(service: "kfp" | "mlflow" | "minio"): PodStatus | undefined {
return podStatusData.value?.[service];
}
function podStatusHint(service: "kfp" | "mlflow" | "minio"): string {
if (podLoading.value) return "...";
if (!podStatusData.value) return "위 [Pod 상태 조회] 버튼을 눌러 주세요.";
const msg = podStatusData.value?.message;
if (msg) return msg;
return "Pod 정보 없음 (label-selector 확인)";
}
function openDetail(service: "kfp" | "mlflow" | "minio") {
detailService.value = service;
logContent.value = "";
detailDialog.value = true;
}
async function loadAllPodLogs() {
const ns = podStatusData.value?.namespace ?? "kubeflow";
const names = detailPods.value.map((p) => p.name).filter(Boolean) as string[];
if (names.length === 0) return;
logLoading.value = true;
logContent.value = "전체 Pod 로그 불러오는 중…";
try {
const res = await AdminService.getPodLogsAggregate(ns, names, 0);
logContent.value = (res as any)?.data ?? res ?? "로그를 불러올 수 없습니다.";
} catch {
logContent.value = "로그 조회 실패";
store.setSnackbarMsg({ text: "Pod 통합 로그 조회 실패", color: "error", result: 500 });
} finally {
logLoading.value = false;
}
}
async function fetchStatus() {
loading.value = true;
try {
const res = await AdminService.getStatus();
status.value = (res as any)?.data ?? res ?? null;
} catch {
status.value = null;
store.setSnackbarMsg({ text: "상태 조회 실패", color: "error", result: 500 });
} finally {
loading.value = false;
}
}
async function fetchPodStatus() {
podLoading.value = true;
try {
const res = await AdminService.getPodStatus();
podStatusData.value = (res as any)?.data ?? res ?? null;
if (podStatusData.value?.message) {
store.setSnackbarMsg({ text: podStatusData.value.message, color: "warning", result: 200 });
}
} catch {
podStatusData.value = null;
store.setSnackbarMsg({ text: "Pod 상태 조회 실패", color: "error", result: 500 });
} finally {
podLoading.value = false;
}
}
</script>
<style scoped>
.admin-page-wrapper {
width: 100%;
min-height: 40vh;
}
.log-viewer--wide {
max-height: min(70vh, 800px);
}
.log-viewer {
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 4px;
max-height: 400px;
overflow: auto;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
</style>

@ -1,217 +0,0 @@
<script setup lang="ts">
import { ref, onBeforeUnmount } from "vue";
import AdminService, {
type AdminStatusResponse,
type ComponentStatus,
} from "@/components/service/management/adminService";
import { commonStore } from "@/stores/commonStore";
const POLL_INTERVAL_MS = 30_000;
const status = ref<AdminStatusResponse | null>(null);
const loading = ref(false);
const restarting = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const store = commonStore();
function statusLabel(s: ComponentStatus): string {
if (s.status === "ok") return "정상";
if (s.status === "error") return "오류";
return "미설정";
}
function statusColor(s: ComponentStatus): string {
if (s.status === "ok") return "success";
if (s.status === "error") return "error";
return "grey";
}
async function fetchStatus() {
loading.value = true;
try {
const res = await AdminService.getStatus();
status.value = (res as any).data ?? res;
startPollIfNeeded();
} catch (e) {
status.value = null;
store.setSnackbarMsg({
text: "상태 조회 실패",
color: "error",
result: 500,
});
} finally {
loading.value = false;
}
}
async function doRestart(service: string) {
restarting.value = service;
try {
const res = await AdminService.restart(service);
const body = (res as any).data ?? res;
store.setSnackbarMsg({
text: body?.message ?? (body?.success ? "요청 접수됨" : "실패"),
color: body?.success ? "success" : "warning",
result: body?.success ? 200 : 400,
});
if (body?.success) await fetchStatus();
} catch (e) {
store.setSnackbarMsg({
text: "재시작 요청 실패",
color: "error",
result: 500,
});
} finally {
restarting.value = null;
}
}
/** 조회 성공 후 30초마다 자동 갱신 */
function startPollIfNeeded() {
if (!pollTimer) {
pollTimer = setInterval(fetchStatus, POLL_INTERVAL_MS);
}
}
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<template>
<div class="admin-page">
<v-container fluid class="pa-6">
<h1 class="text-h4 mb-4">관리자 · 시스템 상태</h1>
<p class="text-body-2 text-medium-emphasis mb-4">
KFP, MLflow, MinIO 헬스 상태. 아래 버튼으로 조회 30초마다 자동 갱신됩니다.
</p>
<div class="d-flex align-center mb-4 flex-wrap gap-2">
<v-btn
color="primary"
:loading="loading"
@click="fetchStatus()"
>
<v-icon start>mdi-refresh</v-icon>
상태 조회
</v-btn>
<span v-if="loading" class="text-body-2 text-medium-emphasis"> </span>
</div>
<v-row dense>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 d-flex align-center">
<v-icon start>mdi-api</v-icon>
KFP
</v-card-title>
<v-card-text>
<v-chip
v-if="status?.kfp"
:color="statusColor(status.kfp)"
size="small"
class="mb-2"
>
{{ statusLabel(status.kfp) }}
</v-chip>
<v-chip v-else color="grey" size="small" class="mb-2">{{ loading ? "조회 중" : "미조회" }}</v-chip>
<p class="text-caption text-medium-emphasis mb-0">
{{ status?.kfp?.message ?? (loading ? "연결 중..." : "위 [상태 조회] 버튼을 눌러 주세요.") }}
</p>
</v-card-text>
<v-card-actions>
<v-btn
size="small"
variant="outlined"
:loading="restarting === 'kfp'"
:disabled="!status?.kfp || status.kfp.status === 'skip'"
@click="doRestart('kfp')"
>
재시작 요청
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 d-flex align-center">
<v-icon start>mdi-chart-line</v-icon>
MLflow
</v-card-title>
<v-card-text>
<v-chip
v-if="status?.mlflow"
:color="statusColor(status.mlflow)"
size="small"
class="mb-2"
>
{{ statusLabel(status.mlflow) }}
</v-chip>
<v-chip v-else color="grey" size="small" class="mb-2">{{ loading ? "조회 중" : "미조회" }}</v-chip>
<p class="text-caption text-medium-emphasis mb-0">
{{ status?.mlflow?.message ?? (loading ? "연결 중..." : "위 [상태 조회] 버튼을 눌러 주세요.") }}
</p>
</v-card-text>
<v-card-actions>
<v-btn
size="small"
variant="outlined"
:loading="restarting === 'mlflow'"
:disabled="!status?.mlflow || status.mlflow.status === 'skip'"
@click="doRestart('mlflow')"
>
재시작 요청
</v-btn>
</v-card-actions>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-1 d-flex align-center">
<v-icon start>mdi-database</v-icon>
MinIO
</v-card-title>
<v-card-text>
<v-chip
v-if="status?.minio"
:color="statusColor(status.minio)"
size="small"
class="mb-2"
>
{{ statusLabel(status.minio) }}
</v-chip>
<v-chip v-else color="grey" size="small" class="mb-2">{{ loading ? "조회 중" : "미조회" }}</v-chip>
<p class="text-caption text-medium-emphasis mb-0">
{{ status?.minio?.message ?? (loading ? "연결 중..." : "위 [상태 조회] 버튼을 눌러 주세요.") }}
</p>
</v-card-text>
<v-card-actions>
<v-btn
size="small"
variant="outlined"
:loading="restarting === 'minio'"
:disabled="!status?.minio || status.minio.status === 'skip'"
@click="doRestart('minio')"
>
재시작 요청
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<p v-if="status?.updatedAt" class="text-caption text-grey mt-4">
마지막 갱신: {{ status.updatedAt }}
</p>
<p v-else class="text-caption text-grey mt-4">
[상태 조회] 버튼을 누르면 KFP, MLflow, MinIO 상태를 조회합니다.
</p>
</v-container>
</div>
</template>
<style scoped lang="sass">
.admin-page
min-height: 40vh
width: 100%
</style>

@ -114,14 +114,6 @@ const routes = [
], ],
}, },
/** ■ 관리자(시스템 상태) - 래퍼 하나로 레이아웃+본문 직접 렌더 (중첩 router-view 없음) */
{
path: "/system-status",
name: "admin",
meta: { title: "관리자", requiresAuth: false, requiresAdmin: true },
component: () => import("@/pages/AdminPageWrapper.vue"),
},
/** ■ 인증(로그인/회원가입) */ /** ■ 인증(로그인/회원가입) */
{ {
name: "login", name: "login",
@ -159,14 +151,10 @@ router.beforeEach((to, from) => {
const hasProject = !!localStorage.getItem("projectId"); // ✅ 프로젝트 선택 여부 const hasProject = !!localStorage.getItem("projectId"); // ✅ 프로젝트 선택 여부
const bootDone = sessionStorage.getItem("initialRedirectDone") === "1"; const bootDone = sessionStorage.getItem("initialRedirectDone") === "1";
const isAdminRoute = to.name === "admin" || to.name === "project" || to.name === "users" || to.path === "/system-status" || to.meta?.requiresAdmin;
// 이미 프로젝트 선택됨 → 어떤 화면이든 통과 // 이미 프로젝트 선택됨 → 어떤 화면이든 통과
if (hasProject) return true; if (hasProject) return true;
// 관리자 전용 페이지는 프로젝트 없이도 접근 허용
if (isAdminRoute) return true;
// 아직 프로젝트 미선택 // 아직 프로젝트 미선택
if (!bootDone) { if (!bootDone) {
// ✅ 선택 화면에 "들어온 순간"을 부트 완료로 간주 (여기서 한 번만 세팅) // ✅ 선택 화면에 "들어온 순간"을 부트 완료로 간주 (여기서 한 번만 세팅)

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

2
typed-router.d.ts vendored

@ -19,8 +19,6 @@ declare module 'vue-router/auto-routes' {
*/ */
export interface RouteNamedMap { export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>, '/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/AdminPageWrapper': RouteRecordInfo<'/AdminPageWrapper', '/AdminPageWrapper', Record<never, never>, Record<never, never>>,
'/AdminView': RouteRecordInfo<'/AdminView', '/AdminView', Record<never, never>, Record<never, never>>,
'/DatagroupView': RouteRecordInfo<'/DatagroupView', '/DatagroupView', Record<never, never>, Record<never, never>>, '/DatagroupView': RouteRecordInfo<'/DatagroupView', '/DatagroupView', Record<never, never>, Record<never, never>>,
'/DatasetView': RouteRecordInfo<'/DatasetView', '/DatasetView', Record<never, never>, Record<never, never>>, '/DatasetView': RouteRecordInfo<'/DatasetView', '/DatasetView', Record<never, never>, Record<never, never>>,
'/DeploymentView': RouteRecordInfo<'/DeploymentView', '/DeploymentView', Record<never, never>, Record<never, never>>, '/DeploymentView': RouteRecordInfo<'/DeploymentView', '/DeploymentView', Record<never, never>, Record<never, never>>,

Loading…
Cancel
Save