From 07f63adc003a17a31db2664fc8d3ccd4325c201f Mon Sep 17 00:00:00 2001 From: jschoi Date: Tue, 14 Oct 2025 13:38:12 +0900 Subject: [PATCH] =?UTF-8?q?executions=20=EC=83=81=EC=84=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20workflow=20yaml=20=ED=8C=8C=EC=9D=BC=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/management/workflowService.ts | 3 + .../run/executions/ListComponent.vue | 284 ++++++++++++++---- .../run/executions/ViewComponent.vue | 211 +++++++------ .../templates/workflow/ViewComponent.vue | 130 +++----- 4 files changed, 402 insertions(+), 226 deletions(-) diff --git a/src/components/service/management/workflowService.ts b/src/components/service/management/workflowService.ts index b0cf7c8..f8885ac 100644 --- a/src/components/service/management/workflowService.ts +++ b/src/components/service/management/workflowService.ts @@ -22,4 +22,7 @@ export const WorkflowService = { search: (payload: WorkflowSearch) => { return request.get("/api/workflows/search", payload); }, + pipelineIdName: (pipelineId: string | number) => { + return request.get(`/api/workflows/pipeline/${pipelineId}`, {}); + }, }; diff --git a/src/components/templates/run/executions/ListComponent.vue b/src/components/templates/run/executions/ListComponent.vue index 973bc24..d958e1a 100644 --- a/src/components/templates/run/executions/ListComponent.vue +++ b/src/components/templates/run/executions/ListComponent.vue @@ -10,6 +10,7 @@ import { ExperimentService } from "@/components/service/management/ExperimentSer import { commonStore } from "@/stores/commonStore"; import { KubeflowRunService } from "@/components/service/management/KubeflowRunService"; import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue"; +import { WorkflowService } from "@/components/service/management/WorkflowService"; const store = commonStore(); @@ -17,6 +18,7 @@ const openView = ref(false); const execSelected = ref(null); const username = ref(""); const experimentNameMap = ref>({}); +const pipelineNameMap = ref>({}); const tableHeader = [ { label: "No", width: "5%", style: "word-break: keep-all;" }, @@ -65,6 +67,89 @@ const data = ref({ isCreateVisible: false, }); +// ---------- NEW: 상태/시간 표준화 유틸 ---------- +function pick(obj: any, keys: string[]): T | undefined { + for (const k of keys) { + const v = obj?.[k]; + if (v !== undefined && v !== null && v !== "") return v as T; + } + return undefined; +} +function normTime(v: any): string | undefined { + if (!v) return undefined; + const d = new Date(v); + return isNaN(d.getTime()) ? undefined : d.toISOString(); +} +function normalizeRun(raw: any) { + const stateRaw = + pick(raw, [ + "state", + "status", + "lifecycle_state", + "lifecycleState", + "lifecycleStage", // MLflow + "phase", // K8s 스타일 + ]) ?? ""; + + const createdAt = + normTime( + pick(raw, ["createdAt", "startTime", "startedAt", "start_time"]), + ) ?? undefined; + + const finishedAt = + normTime( + pick(raw, [ + "finishedAt", + "finished_at", + "endTime", + "end_time", + "completedAt", + "completed_at", + "terminateTime", + ]), + ) ?? undefined; + + return { stateRaw, createdAt, finishedAt }; +} + +function toUiStatusByRaw( + raw: any, +): "Succeeded" | "Failed" | "Running" | "Pending" { + const { stateRaw, finishedAt } = normalizeRun(raw); + const s = String(stateRaw || "").toUpperCase(); + + if ( + s.includes("SUCCEED") || + s.includes("SUCCESS") || + s.includes("FINISH") || // FINISHED + s.includes("COMPLETE") || // COMPLETED + s === "DONE" || + s === "OK" + ) + return "Succeeded"; + + if ( + s.includes("FAIL") || + s.includes("ERROR") || + s.includes("CANCEL") || + s.includes("KILL") || + s.includes("TERMINAT") || + s.includes("ABORT") + ) + return "Failed"; + + if (s.includes("RUN") || s.includes("ACTIVE") || s.includes("EXECUT")) + return "Running"; + + if (!s && finishedAt) return "Succeeded"; + + if (s.includes("PEND") || s.includes("QUEUE") || s.includes("SCHED")) + return "Pending"; + + return "Pending"; +} +// ------------------------------------------------- + async function resolveExperimentNamesWithApi(ids: Array) { const targets = Array.from( new Set( @@ -78,20 +163,43 @@ async function resolveExperimentNamesWithApi(ids: Array) { await Promise.all( targets.map(async (id) => { try { - // ✅ 너가 만든 API 사용 (POST /api/kubeflow/experiments/{id}) const res = await KubeflowService.experimentData(id); const body = res?.data ?? res ?? {}; const name = body.display_name ?? body.name ?? body.experiment_name ?? String(id); experimentNameMap.value[id] = name; - } catch (e) { - // 실패 시 ID fallback + } catch { experimentNameMap.value[id] = String(id); } }), ); } +async function resolvePipelineNamesWithApi(ids: Array) { + const targets = Array.from( + new Set( + (ids || []) + .map((x) => String(x)) + .filter((id) => id && pipelineNameMap.value[id] == null), + ), + ); + if (!targets.length) return; + + await Promise.all( + targets.map(async (id) => { + try { + const res = await WorkflowService.pipelineIdName(id); + const body = res?.data ?? res ?? {}; + const name = + body.displayName ?? body.display_name ?? body.name ?? String(id); + pipelineNameMap.value[id] = name; + } catch { + pipelineNameMap.value[id] = String(id); + } + }), + ); +} + function readUsernameFromStorage(): string { try { const raw = @@ -121,10 +229,20 @@ const toRow = (r: any, no: number) => { r.experiment?.displayName ?? r.experiment?.name ?? "-"; - const fmtStart = (start?: string) => { - if (!start) return "-"; - const d = new Date(start); - if (isNaN(d.getTime())) return start; + + const pipelineId = + r.pipelineId ?? r.pipelineVersionId ?? r.pipeline?.id ?? r.pipeline_id; + const wfName = + (pipelineId && pipelineNameMap.value[String(pipelineId)]) ?? + r.pipeline?.displayName ?? + r.pipeline?.name ?? + "-"; + + const { createdAt: createdIso, finishedAt: finishedIso } = normalizeRun(r); + + const fmt = (iso?: string) => { + if (!iso) return "-"; + const d = new Date(iso); const yyyy = d.getFullYear(); const MM = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); @@ -133,9 +251,9 @@ const toRow = (r: any, no: number) => { return `${yyyy}-${MM}-${dd} ${hh}:${mi}`; }; - const fmtDuration = (start?: string, end?: string) => { - if (!start || !end) return "-"; - const ms = new Date(end).getTime() - new Date(start).getTime(); + const fmtDuration = (startIso?: string, endIso?: string) => { + if (!startIso || !endIso) return "-"; + const ms = new Date(endIso).getTime() - new Date(startIso).getTime(); if (!isFinite(ms) || ms < 0) return "-"; const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); @@ -145,58 +263,98 @@ const toRow = (r: any, no: number) => { return `${h}:${pad(m)}:${pad(sec)}`; }; - const toUiStatus = (state?: string, finishedAt?: string) => { - const s = String(state || "").toUpperCase(); - - // 완료 판단: SUCCEED* 이거나 상태가 비어있지만 finishedAt이 있으면 완료로 간주 - if (s.includes("SUCCEED") || (!s && finishedAt)) return "Succeeded"; - - // 실패 - if (s.includes("FAIL") || s.includes("ERROR")) return "Failed"; - - // 실행 중 - if (s.includes("RUN")) return "Running"; - - // 대기 - if (s.includes("PEND") || s.includes("QUEUE") || s.includes("SCHED")) - return "Pending"; - - // 모르겠으면 대기로 - return "Pending"; - }; - return { - no, // ← 전달받은 번호 사용 + no, name: r.displayName ?? r.name ?? r.runId ?? "(no name)", - status: toUiStatus(r.state, r.finishedAt), - duration: fmtDuration(r.createdAt, r.finishedAt), + status: toUiStatusByRaw(r), + duration: fmtDuration(createdIso, finishedIso), expName, - workflow: r.pipelineId ?? r.pipelineVersionId ?? "-", - startTime: fmtStart(r.createdAt), + workflow: wfName, + startTime: fmt(createdIso), registryStatus: r.storageState ?? "-", run_id: r.runId, raw: r, }; }; +// --- [추가] 로컬 매칭 유틸: 제목/작성자/전체 --- +function includes(hay: any, needle: string) { + if (!hay) return false; + return String(hay).toLowerCase().includes(needle.toLowerCase()); +} +function matchBySearchType( + rowRaw: any, + mapped: "ALL" | "TITLE" | "AUTHOR", + kw: string, +) { + if (!kw) return true; + + // 제목 후보들 + const titleFields = [ + rowRaw.displayName, + rowRaw.name, + rowRaw.runName, + rowRaw.title, + rowRaw.pipeline?.displayName, + rowRaw.pipeline?.name, + ]; + // 작성자 후보들 + const authorFields = [ + rowRaw.username, + rowRaw.userName, + rowRaw.owner, + rowRaw.createdBy, + rowRaw.user?.name, + rowRaw.user?.username, + ]; + + if (mapped === "TITLE") { + return titleFields.some((v) => includes(v, kw)); + } + if (mapped === "AUTHOR") { + return authorFields.some((v) => includes(v, kw)); + } + // ALL: 제목/작성자 + 기타 설명, 태그까지 포괄 + const extraFields = [ + rowRaw.description, + rowRaw.desc, + rowRaw.tags, + rowRaw.tagString, + ]; + return [...titleFields, ...authorFields, ...extraFields].some((v) => + includes(v, kw), + ); +} + async function fetchList() { const { pageNum, pageSize, searchType, searchText } = data.value.params; const mapped = SEARCH_TYPE_MAP[searchType] || "ALL"; const keyword = (searchText || "").trim(); - const payload = { + // ✅ 여러 서버 구현과 호환되도록 alias를 같이 보냄 + const payload: any = { projectId: getProjectId(), - page: pageNum - 1, // 0-based size: pageSize, - keyword, - searchType: mapped, sortField: "createdAt", sortDirection: "DESC", + + // --- keyword aliases --- + keyword, + searchKeyword: keyword, + query: keyword, + q: keyword, + search_text: keyword, + searchText: keyword, + + // --- type aliases --- + searchType: mapped, + type: mapped, + filterType: mapped, }; try { - const res = await KubeflowRunService.search(payload as any); + const res = await KubeflowRunService.search(payload); const result = res?.data ?? res; let list: any[] = []; @@ -213,7 +371,16 @@ async function fetchList() { isServerPaged = true; } else if (Array.isArray(result?.runs)) list = result.runs; - // 클라 보정 정렬 (서버 미적용 대비) + // 💡 서버가 검색을 안 해줬을 경우 대비: 클라이언트 필터 + if (keyword) { + list = list.filter((raw) => matchBySearchType(raw, mapped, keyword)); + // 서버가 totalElements를 내려줬더라도, 클라이언트에서 다시 줄였으므로 덮어씌움 + totalElements = list.length; + totalPages = Math.ceil(list.length / pageSize); + isServerPaged = false; + } + + // 최신순 정렬 (서버 미적용 대비) list.sort((a, b) => { const ta = new Date( a.createdAt || @@ -232,42 +399,51 @@ async function fetchList() { 0, ).getTime(); - if (tb !== ta) return tb - ta; // 최신순 + if (tb !== ta) return tb - ta; const aid = a.id ?? a.runId ?? a.run_id ?? a.name ?? ""; const bid = b.id ?? b.runId ?? b.run_id ?? b.name ?? ""; - return String(bid).localeCompare(String(aid)); // 안정화 + return String(bid).localeCompare(String(aid)); }); + + // 이름 매핑 미리 채우기 const expIds = list .map((r) => r.experimentId ?? r.experiment_id ?? r.experiment?.id) - .filter((v) => v != null); - - // ✅ 여기서 네가 만든 API로 display_name 미리 채움 + .filter(Boolean); await resolveExperimentNamesWithApi(expIds); + + const pipeIds = list + .map( + (r) => + r.pipelineId ?? + r.pipelineVersionId ?? + r.pipeline?.id ?? + r.pipeline_id, + ) + .filter(Boolean); + await resolvePipelineNamesWithApi(pipeIds); + + // 페이지닝 if (!isServerPaged) { - const total = list.length; + const total = + typeof totalElements === "number" ? totalElements : list.length; const pages = Math.max(1, Math.ceil(total / pageSize)); const safePage = Math.min(Math.max(1, pageNum), pages); const start = (safePage - 1) * pageSize; const slice = list.slice(start, start + pageSize); - // ↓ 이번 페이지의 시작 번호(내림차순) const startNo = total - (safePage - 1) * pageSize; data.value.results = slice.map((r, i) => toRow(r, Math.max(startNo - i, 1)), ); - data.value.totalElements = total; data.value.pageLength = pages; } else { const te = typeof totalElements === "number" ? totalElements : list.length; - - // ↓ 서버 페이징일 때도 동일하게 시작 번호 계산 const startNo = te - (pageNum - 1) * pageSize; data.value.results = list.map((r, i) => toRow(r, Math.max(startNo - i, 1)), ); - data.value.totalElements = te; data.value.pageLength = typeof totalPages === "number" @@ -525,7 +701,6 @@ onMounted(() => { width="2" color="info" /> - { > mdi-loading - - mdi-help-circle {{ item.duration }} {{ item.expName }} - {{ item.workflow }} {{ item.startTime }} {{ item.registryStatus }} diff --git a/src/components/templates/run/executions/ViewComponent.vue b/src/components/templates/run/executions/ViewComponent.vue index 09ada4c..c348349 100644 --- a/src/components/templates/run/executions/ViewComponent.vue +++ b/src/components/templates/run/executions/ViewComponent.vue @@ -31,12 +31,9 @@ const selectedRunId = ref(""); const loadingRunDetail = ref(false); const runDetail = ref(null); -/* 목록 옵션 (run_name 표시, 값은 run_id / run_uuid) */ -const runOptions = computed(() => - runs.value.map((r) => ({ - title: r?.info?.run_name || r?.info?.run_id || "—", - value: r?.info?.run_id || r?.info?.run_uuid || "", - })), +/* 표시용 라벨 */ +const selectedLabel = computed( + () => runDetail.value?.info?.run_name ?? runDetail.value?.info?.run_id ?? "-", ); /* ============ Plotly refs ============ */ @@ -52,9 +49,6 @@ function metricValue(key: "accuracy" | "precision" | "recall" | "f1_score") { ); return m?.value ?? null; } -const selectedLabel = computed( - () => runDetail.value?.info?.run_name ?? runDetail.value?.info?.run_id ?? "-", -); /* 선택한 run의 메트릭 표 데이터 */ const selectedMetrics = computed(() => { @@ -63,62 +57,72 @@ const selectedMetrics = computed(() => { return mm.map((m) => ({ key: m.key, value: m.value })); }); +/* ===== 부모에서 내려온 runId 픽 ===== */ +function pickParentRunId(v: any): string { + const cand = [ + v?.run_id, + v?.runId, + v?.raw?.run_id, + v?.raw?.runId, + v?.raw?.kfp_run_id, + v?.raw?.kubeflow_run_id, + v?.raw?.id, + ].filter(Boolean); + return String(cand[0] ?? ""); +} + +/* ===== MLflow 태그 읽기 (array or object 모두 대응) ===== */ +function getTag(run: any, key: string): string | undefined { + const tags = run?.data?.tags; + if (Array.isArray(tags)) return tags.find((t: any) => t?.key === key)?.value; + if (tags && typeof tags === "object") return tags[key]; + return undefined; +} + /* ===== 차트 렌더: 선택한 단건만 ===== */ function drawCharts() { - // Plotly Layout을 타입 안전하게 만들기 const baseLayout = (titleText: string, xlabel: string): Partial => ({ - title: { text: titleText }, // ← 문자열 대신 객체 + title: { text: titleText }, margin: { t: 40, r: 20, b: 40, l: 40 }, height: 290, yaxis: { rangemode: "tozero" }, - xaxis: { - tickmode: "array", - tickvals: [xlabel], - ticktext: [xlabel], - }, + xaxis: { tickmode: "array", tickvals: [xlabel], ticktext: [xlabel] }, showlegend: false, }); - const config = { displayModeBar: false, responsive: true }; - if (elAccuracy.value) { - const x = ["accuracy"]; + if (elAccuracy.value) Plotly.react( elAccuracy.value, - [{ x, y: [metricValue("accuracy")], type: "bar" }], - baseLayout("accuracy", x[0]), + [{ x: ["accuracy"], y: [metricValue("accuracy")], type: "bar" }], + baseLayout("accuracy"), config, ); - } - if (elF1.value) { - const x = ["f1_score"]; + + if (elF1.value) Plotly.react( elF1.value, - [{ x, y: [metricValue("f1_score")], type: "bar" }], - baseLayout("f1_score", x[0]), + [{ x: ["f1_score"], y: [metricValue("f1_score")], type: "bar" }], + baseLayout("f1_score"), config, ); - } - if (elPrecision.value) { - const x = ["precision"]; + + if (elPrecision.value) Plotly.react( elPrecision.value, - [{ x, y: [metricValue("precision")], type: "bar" }], - baseLayout("precision", x[0]), + [{ x: ["precision"], y: [metricValue("precision")], type: "bar" }], + baseLayout("precision"), config, ); - } - if (elRecall.value) { - const x = ["recall"]; + + if (elRecall.value) Plotly.react( elRecall.value, - [{ x, y: [metricValue("recall")], type: "bar" }], - baseLayout("recall", x[0]), + [{ x: ["recall"], y: [metricValue("recall")], type: "bar" }], + baseLayout("recall"), config, ); - } } - function resizeCharts() { [elAccuracy.value, elF1.value, elPrecision.value, elRecall.value] .filter(Boolean) @@ -142,30 +146,44 @@ function findFromList(runId: string) { } /* ============ API: runs 목록 & 단건 run ============ */ -async function fetchRunsOnce(expName?: string) { - if (!expName || runs.value.length > 0) return; +async function fetchRuns(expName?: string) { + const parentRunId = pickParentRunId(props.experimentInfo); + if (!expName || !parentRunId) { + runs.value = []; + selectedRunId.value = ""; + return; + } loadingRuns.value = true; try { const expRes = await MlflowService.getExperimentByName(expName); - const exp = expRes?.data ?? exp; + const exp = expRes?.data ?? expRes; const expId = String( exp?.experiment_id ?? exp?.experimentId ?? exp?.id ?? "", ); - if (!expId) return; + if (!expId) { + runs.value = []; + selectedRunId.value = ""; + return; + } const runsRes = await MlflowService.getRuns(expId); const body = runsRes?.data ?? runsRes; const list = body?.runs ?? body?.data?.runs ?? (Array.isArray(body) ? body : []); - runs.value = Array.isArray(list) ? list : []; - // 기본 선택: 최신 run - const first = [...runs.value].sort( + // 부모 runId와 MLflow tag(kubeflow_run_id) 매칭 + const filtered = (Array.isArray(list) ? list : []).filter( + (r) => getTag(r, "kubeflow_run_id") === parentRunId, + ); + + runs.value = filtered; + + // 기본 선택: 최신 1개 + const first = [...filtered].sort( (a, b) => (b?.info?.start_time ?? 0) - (a?.info?.start_time ?? 0), )[0]; - selectedRunId.value = - first?.info?.run_id || first?.info?.run_uuid || selectedRunId.value || ""; + selectedRunId.value = first?.info?.run_id || first?.info?.run_uuid || ""; } finally { loadingRuns.value = false; } @@ -174,7 +192,6 @@ async function fetchRunsOnce(expName?: string) { async function fetchRunDetail(runId: string) { if (!runId) { runDetail.value = null; - // 비움 처리 await nextTick(); drawCharts(); return; @@ -191,19 +208,30 @@ async function fetchRunDetail(runId: string) { } /* ============ 트리거 ============ */ -// Visualizations 탭 진입 시 목록 로드 + 기본 단건 로드 +// 탭 전환 시 viz 탭이면 로드 watch( () => mainTab.value, async (t) => { if (t === "viz") { - await fetchRunsOnce(pickExpName(props.experimentInfo)); - if (selectedRunId.value) await fetchRunDetail(selectedRunId.value); + await fetchRuns(pickExpName(props.experimentInfo)); + await fetchRunDetail(selectedRunId.value); } }, { immediate: true }, ); -// 드롭다운에서 Run 변경 시 단건 조회 +// 부모 execution 바뀌면 viz 탭일 때만 재조회 +watch( + () => props.experimentInfo, + async () => { + if (mainTab.value === "viz") { + await fetchRuns(pickExpName(props.experimentInfo)); + await fetchRunDetail(selectedRunId.value); + } + }, +); + +// 선택된 runId가 바뀌면 단건 상세 갱신 watch(selectedRunId, (id) => fetchRunDetail(id)); // metrics 서브탭일 때 리렌더 @@ -234,7 +262,6 @@ const rawHistory = computed(() => { if (Array.isArray(h) && h.length > 0) return h; - // fallback 합성 const startIso = props.experimentInfo?.startTime && !isNaN(new Date(props.experimentInfo.startTime).getTime()) @@ -512,25 +539,23 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—"); - - + + > + 부모 runId와 일치하는 MLflow run이 없습니다. +
- Runs: + Runs (matched): {{ runs.length.toLocaleString() }} (iso ? new Date(iso).toLocaleString() : "—"); class="ml-2" />
+
+ Selected: {{ selectedLabel }} +
@@ -711,48 +739,51 @@ const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—"); - -
- - -
-
- -
+
+
-
- -
+
-
+ >
- - (준비중) X/Y 축 선택 후 산점도 표시 - + (준비중) X/Y 축 선택 후 산점도 표시 - - (준비중) 메트릭 분포 Box Plot - + (준비중) 메트릭 분포 Box Plot - - (준비중) 2D/3D Contour Plot - + (준비중) 2D/3D Contour Plot + + Back to List + diff --git a/src/components/templates/workflow/ViewComponent.vue b/src/components/templates/workflow/ViewComponent.vue index 7465f1e..1ac72c8 100644 --- a/src/components/templates/workflow/ViewComponent.vue +++ b/src/components/templates/workflow/ViewComponent.vue @@ -40,20 +40,7 @@ const detail = ref({ regDt: "", }); -const defaultYaml = `# YAML not provided by server -apiVersion: argoproj.io/v1alpha1 -kind: Workflow -metadata: - generateName: dummy- -spec: - entrypoint: main - templates: - - name: main - container: - image: alpine:latest - command: [sh, -c] - args: ["echo hello"] -`; +const defaultYaml = ""; // 날짜 포맷 function formatDateTime(raw?: string): string { @@ -63,12 +50,54 @@ function formatDateTime(raw?: string): string { return m ? m[1] : s.slice(0, 19); } +async function loadYamlByAttachmentId(attachmentId?: number | string) { + if (attachmentId == null || attachmentId === "") return false; + + try { + console.log("[YAML] call /api/attachments/{id} with id:", attachmentId); + const attRes = await AttachmentsService.view(Number(attachmentId)); // GET /api/attachments/{id} + const att = attRes?.data ?? attRes ?? {}; + console.log("[YAML] attachments response:", att); + + const storagePath = + att.storagePath || + att.storedName || + att.objectName || + att.object_key || + ""; + + if (!storagePath) { + console.warn("[YAML] storagePath not found on attachment:", att); + ensureEditor(); + editorInstance?.setValue(defaultYaml); + return false; + } + + console.log("[YAML] call readYamlText with objectName:", storagePath); + const textRes = await AttachmentsService.readTextByPath(storagePath); + const text = + typeof textRes?.data === "string" + ? textRes.data + : String(textRes?.data ?? ""); + + ensureEditor(); + editorInstance?.setValue(text || defaultYaml); + return Boolean(text); + } catch (e) { + console.error("[YAML] loadYamlByAttachmentId failed:", e); + ensureEditor(); + editorInstance?.setValue(defaultYaml); + return false; + } +} + /** ⬅️ storagePath(object key)로부터 YAML 읽기 */ async function loadYamlFromStoragePath(objectName?: string) { const key = (objectName || "").trim(); if (!key) return false; try { const res = await AttachmentsService.readTextByPath(key); + const text = typeof res?.data === "string" ? res.data : String(res?.data ?? ""); ensureEditor(); @@ -83,10 +112,14 @@ async function loadYamlFromStoragePath(objectName?: string) { /** 상세 조회 → YAML 문자열 우선 → storagePath 후보들 시도 → 실패 시 기본 YAML */ async function fetchDetail(id: number | string) { try { + // 0) 먼저 YAML은 반드시 attachments -> readYamlText 경로로 시도 + await loadYamlByAttachmentId(id); + + // 1) (옵션) 상세 정보는 기존대로 워크플로우 상세 API를 사용 const res = await WorkflowService.view(Number(id)); const d = res?.data ?? {}; + console.log("[Workflow Detail] view response:", d); - // 정보 매핑(스네이크/카멜 혼용 방어) detail.value = { name: d.name ?? d.workflowName ?? "", version: String(d.version ?? ""), @@ -97,9 +130,7 @@ async function fetchDetail(id: number | string) { regDt: formatDateTime(d.reg_dt ?? d.regDt ?? d.regDate), }; - ensureEditor(); - - // 1) 서버가 YAML 문자열을 직접 줄 때 + // 서버가 YAML 문자열을 직접 줄 경우엔 덮어씌우고 끝 const yamlFromServer = d.workflowYaml || d.yaml || @@ -108,33 +139,9 @@ async function fetchDetail(id: number | string) { d.yamlStr || ""; if (yamlFromServer) { + ensureEditor(); editorInstance!.setValue(yamlFromServer); - return; - } - - // 2) 업로드 파일의 object key(=storagePath 등) 후보 - const objectKeyCandidates = [ - d.yamlStoragePath, - d.yaml_object_name, - d.yamlObjectName, - d.storagePath, - d.storedName, - d.objectName, - d.object_key, - d.yamlPath, - d.filePath, - d.yamlFile, - d.yamlFilePath, - ].filter(Boolean) as string[]; - - // 3) 후보 중 하나라도 성공하면 반환 - for (const key of objectKeyCandidates) { - const ok = await loadYamlFromStoragePath(key); - if (ok) return; } - - // 4) 전부 실패하면 기본 YAML 출력 - editorInstance!.setValue(defaultYaml); } catch (e) { console.error("[Workflow Detail] view API failed:", e); ensureEditor(); @@ -144,7 +151,6 @@ async function fetchDetail(id: number | string) { // ===== 마운트 & 변경 감지 ===== onMounted(() => { - // 탭 전환 관계없이 미리 생성해도 ok (automaticLayout) ensureEditor(); }); @@ -278,42 +284,6 @@ const steps = ref< - -