From c2fb4c69663098a32b9737aabfc98489025eb6d7 Mon Sep 17 00:00:00 2001 From: jschoi Date: Tue, 23 Sep 2025 17:04:17 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/atoms/button/IconRunBtn.vue | 27 ++ .../atoms/organisms/WorkflowsBaseDialog.vue | 271 +++++++++++------- .../organisms/WorklfowStepBaseDialog.vue | 6 +- .../templates/workflow/ListComponent.vue | 50 ++-- .../templates/workflow/ViewComponent.vue | 155 ++++++---- 5 files changed, 326 insertions(+), 183 deletions(-) create mode 100644 src/components/atoms/button/IconRunBtn.vue diff --git a/src/components/atoms/button/IconRunBtn.vue b/src/components/atoms/button/IconRunBtn.vue new file mode 100644 index 0000000..21d21e8 --- /dev/null +++ b/src/components/atoms/button/IconRunBtn.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/atoms/organisms/WorkflowsBaseDialog.vue b/src/components/atoms/organisms/WorkflowsBaseDialog.vue index 317a652..a84d065 100644 --- a/src/components/atoms/organisms/WorkflowsBaseDialog.vue +++ b/src/components/atoms/organisms/WorkflowsBaseDialog.vue @@ -9,6 +9,11 @@ import { storage } from "@/utils/storage"; import type { Workflow } from "@/components/models/management/Workflow"; import { storeToRefs } from "pinia"; import { useAutoflowStore } from "@/stores/autoflowStore"; +import { kubeflowService } from "@/components/service/management/kubeflowService"; +import { + toKubeflowForm, + type KubeflowUploadDto, +} from "@/components/models/management/Kubeflow"; const { projectId } = storeToRefs(useAutoflowStore()); @@ -27,6 +32,84 @@ const isEdit = computed(() => props.mode === "edit"); const saving = ref(false); const errorMsg = ref(""); +// ====== KFP 이름 제한 & 한글 제한 유틸 ====== +const KFP_NAME_REGEX = /^[a-z0-9]([-a-z0-9\.]*[a-z0-9])?$/; // 소문자/숫자/.-, 시작/끝 영숫자 +const KOREAN_RX = /[ㄱ-ㅎㅏ-ㅣ가-힣]/g; + +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 stripKorean = (s: string) => (s ?? "").replace(KOREAN_RX, ""); + +// 힌트/에러 메시지 +const nameHint = ref( + "허용 문자: 소문자 a–z, 숫자 0–9, '-', '.' (시작/끝은 영숫자). 한글/공백/대문자/언더스코어 불가", +); +const descHint = ref("한글은 사용할 수 없습니다."); +const nameInvalid = computed( + () => !!form.value.name && !KFP_NAME_REGEX.test(form.value.name), +); +const nameErrorMsg = computed(() => + nameInvalid.value + ? "형식이 올바르지 않습니다. (소문자/숫자, '-', '.', 시작/끝은 영숫자)" + : "", +); + +// 입력 시 자동 정제 +function onNameInput(v: string) { + const cleaned = sanitizeKfpName(v || ""); + if (cleaned !== v) { + nameHint.value = "허용되지 않는 문자는 자동으로 제거됩니다."; + } else { + nameHint.value = + "허용 문자: 소문자 a–z, 숫자 0–9, '-', '.' (시작/끝은 영숫자)"; + } + form.value.name = cleaned; +} +function onDescInput(v: string) { + const cleaned = stripKorean(v || ""); + form.value.description = cleaned; +} + +function extractApiErrorMessage(err: any): string { + const status = err?.response?.status; + const data = err?.response?.data; + const raw = + (typeof data === "string" + ? data + : data?.message || data?.error || data?.detail) || + err?.message || + ""; + + const text = String(raw); + + if ( + status === 409 || + /already\s*exists|duplicate|이미 존재|중복/i.test(text) + ) { + return "같은 이름의 파이프라인이 이미 존재합니다. 다른 이름으로 등록해주세요."; + } + if (status === 400 && /name|display[_ ]?name|invalid/i.test(text)) { + return "이름(name)이 유효하지 않습니다. 공백/특수문자 여부를 확인해주세요."; + } + if (status === 401 || status === 403) { + return "권한이 없거나 로그인 정보가 만료되었습니다. 다시 로그인 후 시도하세요."; + } + if (status === 413 || /file too large|payload too large|size/i.test(text)) { + return "업로드 파일 용량이 너무 큽니다."; + } + if (status === 500 && /InvalidUrl|Bad authority|host/i.test(text)) { + return "서버 설정 오류로 업로드에 실패했습니다. (관리자에게 KFP URL 설정 점검을 요청하세요)"; + } + return text || `요청에 실패했습니다. (HTTP ${status ?? "Error"})`; +} + const steps = ref([ { order: 1, stepName: "Data Load", type: "DataPrep", status: "Configured" }, { @@ -47,15 +130,16 @@ const steps = ref([ const form = ref({ name: "", description: "", + file: null as File | null, }); /** props.editData -> form 바인딩 */ function hydrateFormFromEdit(data: any) { if (!data) return; + // 표시값은 원본을 보여주되, 저장 시 최종 검증/정제 form.value.name = data.workflowName ?? data.name ?? ""; form.value.description = data.workflowDescription ?? data.description ?? ""; } - onMounted(() => { if (isEdit.value) hydrateFormFromEdit(props.editData); }); @@ -75,9 +159,14 @@ const nowLocalIso = (): string => { async function submit() { errorMsg.value = ""; + // 제출 직전에 한 번 더 정제 & 검증 + form.value.name = sanitizeKfpName(form.value.name); + form.value.description = stripKorean(form.value.description); + const name = form.value.name.trim(); - if (!name) { - errorMsg.value = "Workflow Name은 필수입니다."; + if (!name || !KFP_NAME_REGEX.test(name)) { + errorMsg.value = + "Workflow Name 형식이 올바르지 않습니다. (소문자/숫자, '-', '.', 시작/끝은 영숫자)"; return; } @@ -96,7 +185,6 @@ async function submit() { errorMsg.value = "로그인 사용자 정보를 찾을 수 없습니다."; return; } - if (!projectId.value) { errorMsg.value = "프로젝트가 선택되지 않았습니다."; return; @@ -116,50 +204,67 @@ async function submit() { return; } - // 기존 레코드 먼저 조회해서 보존해야 할 필드 채우기 + // ① 기존 값 조회 const viewRes = await WorkflowService.view(id); const current = (viewRes?.data ?? viewRes) || {}; - const updatePayload: any = { + // ② name/description만 변경, 그 외는 기존 값 유지해서 null 덮어쓰기 방지 + const updatePayload = cleanUndefined({ id, - workflowName: name, - workflowDescription: form.value.description?.trim() || "", - uploadYn: current.uploadYn ?? "Y", - // ← 기존 값 유지 (null로 덮어쓰지 않도록) + name, // 변경 + description: form.value.description?.trim() || "", // 변경 + + // ===== 기존 유지 필드 ===== + displayName: current.displayName, + namespace: current.namespace, + pipelineId: current.pipelineId, + kubeflowStatus: current.kubeflowStatus, + version: current.version, + regUserId: current.regUserId ?? regUserId, - regDt: current.regDt ?? now, projectId: current.projectId ?? projectId.value, - // ← 수정 시각만 갱신 + regDt: current.regDt, modDt: now, - }; + }); const { data } = await WorkflowService.update(id, updatePayload); emit("saved", data); emit("close-modal"); } else { // ===== 생성 ===== - const createPayload: any = { - workflowName: name, - workflowDescription: form.value.description?.trim() || "", - uploadYn: "Y", - regUserId, // 생성자 - regDt: now, // 생성 시각 - projectId: projectId.value, - // modDt는 보내지 않아도 됨 (서버에서 필요하면 세팅) + if (!form.value.file) { + errorMsg.value = "업로드할 파일을 선택하세요."; + return; + } + const dto: KubeflowUploadDto = { + name, + display_name: name, + description: form.value.description?.trim() || "", + namespace: "default", + regUserId, + projectId: projectId.value!, + uploadfile: form.value.file, }; - - const { data } = await WorkflowService.add(createPayload); + const fd = toKubeflowForm(dto); + const { data } = await kubeflowService.upload(fd); emit("saved", data); emit("close-modal"); } - } catch (e) { + } catch (e: any) { console.error("워크플로우 저장 실패:", e); - errorMsg.value = "저장에 실패했습니다. 잠시 후 다시 시도하세요."; + errorMsg.value = extractApiErrorMessage(e); } finally { saving.value = false; } } +/** undefined 필드는 제거해서 불필요한 키 전송 방지 */ +function cleanUndefined>(obj: T): T { + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== undefined), + ) as T; +} + /** ESC로 닫기 */ function onEsc(e: KeyboardEvent) { if (e.key === "Escape") emit("close-modal"); @@ -184,31 +289,57 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc)); +
- +
-
- + +
+ +
+ + +
+ +
@@ -216,82 +347,18 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc)); - -
Workflow Steps
- - - mdi-plus Add Step - {{ - isEdit ? "Update" : "Save" - }} - Cancel - - - - - - - Order - Step Name - Component Type - Status - Action - - - - - {{ step.order }} - {{ step.stepName }} - - - - {{ step.status }} - - - - - - - - - -
- - {{ - isEdit ? "Update" : "Save" - }} + + {{ isEdit ? "Update" : "Save" }} + Close + Close + diff --git a/src/components/atoms/organisms/WorklfowStepBaseDialog.vue b/src/components/atoms/organisms/WorklfowStepBaseDialog.vue index de23b2c..89dc1f7 100644 --- a/src/components/atoms/organisms/WorklfowStepBaseDialog.vue +++ b/src/components/atoms/organisms/WorklfowStepBaseDialog.vue @@ -126,9 +126,8 @@ async function submit() { if (!projectId.value) return (errorMsg.value = "프로젝트가 선택되지 않았습니다."); if (!selectedWorkflowId.value) - return (errorMsg.value = "Workflow를 선택해주세요."); // ⬅️ 중요 + return (errorMsg.value = "Workflow를 선택해주세요."); - // 백엔드 스키마에 맞춤 (NOT NULL 컬럼들 포함) const payload: any = { stepName, status: form.value.status, @@ -136,7 +135,7 @@ async function submit() { regDt: nowLocalIso(), version: 1, projectId: projectId.value, - workflowStepId: selectedWorkflowId.value, // ⬅️ 이게 없어서 500났었음 + workflowStepId: selectedWorkflowId.value, }; try { @@ -197,6 +196,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc)); required />
+
(null); + const tableHeader = [ { label: "No", width: "5%", style: "word-break: keep-all;" }, - { label: "Workflow Name", width: "7%", style: "word-break: keep-all;" }, - { label: "Step Count", width: "7%", style: "word-break: keep-all;" }, - { label: "Config Progress", width: "7%", style: "word-break: keep-all;" }, - { label: "Kubeflow Status", width: "7%", style: "word-break: keep-all;" }, - { label: "Created DateTime", width: "7%", style: "word-break: keep-all;" }, - { label: "Action", width: "7%", style: "word-break: keep-all;" }, + { label: "Workflow Name", width: "18%", style: "word-break: keep-all;" }, + { label: "Description", width: "28%", style: "word-break: keep-all;" }, + { label: "Version", width: "10%", style: "word-break: keep-all;" }, + { label: "Kubeflow Status", width: "12%", style: "word-break: keep-all;" }, + { label: "Created DateTime", width: "15%", style: "word-break: keep-all;" }, + { label: "Action", width: "12%", style: "word-break: keep-all;" }, ]; - const searchOptions = [ { label: "전체", value: "전체" as SearchType }, { label: "제목", value: "제목" as SearchType }, @@ -77,14 +80,13 @@ const formatDateTime = ( const toRow = (w: any, no: number) => ({ no, - name: w.workflowName, - description: w.workflowDescription, + name: w.name, + description: w.description, version: w.version, - stepCount: w.stepCount, - configProgress: w.configProgress, kubeflowStatus: w.kubeflowStatus, registDt: w.regDt, deviceKey: w.id, + pipelineId: w.pipelineId ?? w.pipeline_id ?? "", }); const fetchList = () => { @@ -130,7 +132,7 @@ const fetchList = () => { if (mapped === "TITLE") { list = list.filter((w: any) => - String(w?.workflowName ?? "") + String(w?.name ?? "") .toLowerCase() .includes(kw), ); @@ -279,6 +281,12 @@ const openDetailModal = (selectedItem: any) => { }; const closeDetail = () => (openView.value = false); +const closeRunModal = () => (isRunVisible.value = false); + +const openRunModal = (item: any) => { + selectedRun.value = item; + isRunVisible.value = true; +}; const openModifyModal = (item: any) => { data.value.selectedData = { @@ -484,14 +492,14 @@ onMounted(() => { {{ item.no }} {{ item.name }} - {{ item.stepCount }} - {{ item.configProgress }} + {{ item.description }} + {{ item.version }} {{ item.kubeflowStatus }} {{ formatDateTime(item.registDt) }} + - - + + + +
diff --git a/src/components/templates/workflow/ViewComponent.vue b/src/components/templates/workflow/ViewComponent.vue index 4d481f4..dfe770f 100644 --- a/src/components/templates/workflow/ViewComponent.vue +++ b/src/components/templates/workflow/ViewComponent.vue @@ -10,32 +10,22 @@ const props = defineProps<{ id: number | string }>(); const emit = defineEmits<{ (e: "close"): void }>(); const activeTab = ref("details"); + +// ----- Monaco Editor ----- const editorRef = ref(null); let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null; +// 화면에 뿌릴 상세 데이터(요청 항목만) const detail = ref({ - workflowName: "", + name: "", version: "", - workflowDescription: "", - createdDate: "", - createdId: "", + description: "", + kubeflowStatus: "", + namespace: "", + pipelineId: "", + regDt: "", }); -const stepHeaders = [ - { title: "Order", key: "order", width: "10%", align: "center" }, - { title: "Step Name", key: "name", width: "40%", align: "center" }, - { - title: "Component Type", - key: "componentType", - width: "30%", - align: "center", - }, - { title: "Status", key: "status", width: "20%", align: "center" }, -]; -const steps = ref< - Array<{ order: number; name: string; componentType: string; status: string }> ->([]); - const defaultYaml = `# YAML not provided by server apiVersion: argoproj.io/v1alpha1 kind: Workflow @@ -51,30 +41,32 @@ spec: args: ["echo hello"] `; -/** ===== 상세 조회 ===== */ +// 간단한 날짜 포맷터 (ISO/T 포함 모두 대응) +function formatDateTime(raw?: string): string { + if (!raw) return "-"; + const s = String(raw).replace("T", " "); + const m = s.match(/^(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2})/); + return m ? m[1] : s.slice(0, 19); +} + +// ===== 상세 조회 ===== async function fetchDetail(id: number | string) { try { const res = await WorkflowService.view(Number(id)); - const d = res.data; + const d = res?.data ?? {}; - detail.value.workflowName = d.workflowName || ""; - detail.value.version = String(d.version || 1); - detail.value.workflowDescription = d.workflowDescription || ""; - detail.value.createdDate = d.regDt || d.regDate || "-"; - detail.value.createdId = d.regUserId || "-"; - - if (Array.isArray(d.steps)) { - steps.value = d.steps.map((s: any, idx: number) => ({ - order: idx + 1, - name: s.stepName || s.name || `Step ${idx + 1}`, - componentType: s.componentType || s.type || "-", - status: s.status || "Not Configured", - })); - } else { - steps.value = []; - } + // 백엔드 필드명(스네이크/카멜) 혼재 대비 매핑 + detail.value = { + name: d.name ?? d.workflowName ?? "", + version: String(d.version ?? ""), + description: d.description ?? d.workflowDescription ?? "", + kubeflowStatus: d.kubeflow_status ?? d.kubeflowStatus ?? "", + namespace: d.namespace ?? "", + pipelineId: d.pipeline_id ?? d.pipelineId ?? "", + regDt: formatDateTime(d.reg_dt ?? d.regDt ?? d.regDate), + }; - // YAML 표시 (서버 필드 이름에 맞춰 하나라도 있으면 사용) + // YAML (있으면 보여주고, 없으면 기본 예시) const yamlFromServer = d.workflowYaml || d.yaml || @@ -86,11 +78,11 @@ async function fetchDetail(id: number | string) { editorInstance.setValue(yamlFromServer || defaultYaml); } } catch (e) { - console.error("[Child] view API failed:", e); + console.error("[Workflow Detail] view API failed:", e); } } -/** ===== 마운트 & 변경 감지 ===== */ +// ===== 마운트 & 변경 감지 ===== onMounted(() => { if (editorRef.value) { editorInstance = monaco.editor.create(editorRef.value, { @@ -105,7 +97,6 @@ onMounted(() => { } }); -// props.id가 바뀌면 재조회 watch( () => props.id, (val) => { @@ -122,6 +113,22 @@ onBeforeUnmount(() => { editorInstance = null; } }); + +// ===== (나중 사용) Step 테이블 정의 ===== +const stepHeaders = [ + { title: "Order", key: "order", width: "10%", align: "center" }, + { title: "Step Name", key: "name", width: "40%", align: "center" }, + { + title: "Component Type", + key: "componentType", + width: "30%", + align: "center", + }, + { title: "Status", key: "status", width: "20%", align: "center" }, +]; +const steps = ref< + Array<{ order: number; name: string; componentType: string; status: string }> +>([]); @@ -253,10 +282,14 @@ onBeforeUnmount(() => { min-height: 500px; padding-bottom: 84px; } - .back-to-list { position: absolute; right: 24px; bottom: 24px; } +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +}