chore: merge patched updates from patched folder

feature/apply-patched-updates
bjkim 4 weeks ago
parent cae77e2a12
commit 7728fbccfe

@ -0,0 +1,9 @@
FROM nginx:stable-alpine
RUN apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
COPY default.conf /etc/nginx/conf.d/default.conf
COPY dist/. /usr/share/nginx/html/autoflow
EXPOSE 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]

@ -10,7 +10,8 @@ server {
# 백엔드 API 프록시 # 백엔드 API 프록시
location /autoflow-server-mgmt/ { location /autoflow-server-mgmt/ {
proxy_pass http://backend:8080; proxy_pass http://autoflow-server-mgmt-svc:80;
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: autoflow namespace: etri-aisw
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@ -13,26 +13,70 @@ 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: autoflow namespace: etri-aisw
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

File diff suppressed because it is too large Load Diff

@ -0,0 +1,430 @@
<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>

@ -0,0 +1,112 @@
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" });
},
};

@ -0,0 +1,268 @@
<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>

@ -0,0 +1,217 @@
<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>

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

Loading…
Cancel
Save