Compare commits
5 Commits
main
...
feature/ap
| Author | SHA1 | Date |
|---|---|---|
|
|
83954c5fdf | 6 days ago |
|
|
0eff922a6b | 4 weeks ago |
|
|
fa21667541 | 4 weeks ago |
|
|
ce5b2d8d14 | 4 weeks ago |
|
|
7728fbccfe | 4 weeks ago |
@ -1,3 +1,4 @@
|
|||||||
NODE_ENV = "dev"
|
NODE_ENV = "dev"
|
||||||
VITE_APP_API_SERVER_URL = "http://localhost:80"
|
# WSL에서 백엔드 8080 구동 시
|
||||||
|
VITE_APP_API_SERVER_URL = "http://localhost:8080"
|
||||||
VITE_ROOT_PATH = ""
|
VITE_ROOT_PATH = ""
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
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" });
|
||||||
|
},
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||||
Loading…
Reference in new issue