Compare commits
No commits in common. 'feature/apply-patched-updates' and 'main' have entirely different histories.
feature/ap
...
main
@ -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 = ""
|
||||||
@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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,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" });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
|
||||||
Loading…
Reference in new issue