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"
|
||||
# WSL에서 백엔드 8080 구동 시
|
||||
VITE_APP_API_SERVER_URL = "http://localhost:8080"
|
||||
VITE_APP_API_SERVER_URL = "http://localhost:80"
|
||||
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