You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
autoflow-web-console/src/components/atoms/organisms/WorkflowsBaseDialog.vue

359 lines
11 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
import IconArrowDown from "@/components/atoms/button/IconArrowDown.vue";
import IconArrowUp from "@/components/atoms/button/IconArrowUp.vue";
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
import { computed, onBeforeUnmount, onMounted, watch, ref } from "vue";
import { WorkflowService } from "@/components/service/management/WorkflowService";
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());
const props = defineProps<{
editData: any;
mode: "create" | "edit";
}>();
const emit = defineEmits<{
(e: "close-modal"): void;
(e: "saved", value: any): void;
}>();
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 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 nameHint = ref(
"허용 문자: 소문자 az, 숫자 09, '-', '.' (시작/끝은 영숫자). 한글/공백/대문자/언더스코어 불가",
);
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 =
"허용 문자: 소문자 az, 숫자 09, '-', '.' (시작/끝은 영숫자)";
}
form.value.name = 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" },
{
order: 2,
stepName: "Preprocessing",
type: "Preprocess",
status: "Not Configured",
},
{
order: 3,
stepName: "Train Model",
type: "Train",
status: "Not Configured",
},
]);
/** form state */
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);
});
watch(
() => props.editData,
(v) => {
if (isEdit.value) hydrateFormFromEdit(v);
},
);
function onDescInput(v: string) {
form.value.description = v ?? "";
}
/** 시간 포맷 */
const nowLocalIso = (): string => {
const t = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
return t.toISOString().slice(0, 23);
};
async function submit() {
errorMsg.value = "";
// 제출 직전에 한 번 더 정제 & 검증
form.value.name = sanitizeKfpName(form.value.name);
form.value.description = (form.value.description ?? "").trim();
const name = form.value.name.trim();
if (!name || !KFP_NAME_REGEX.test(name)) {
errorMsg.value =
"Workflow Name 형식이 올바르지 않습니다. (소문자/숫자, '-', '.', 시작/끝은 영숫자)";
return;
}
// 로그인 사용자
const authObj =
(typeof storage?.getAuth === "function" ? storage.getAuth() : null) ??
JSON.parse(localStorage.getItem("autoflow-auth") || "{}");
const regUserId =
authObj?.userInfo?.username ??
authObj?.userinfo?.username ??
authObj?.username ??
authObj?.userId ??
"";
if (!regUserId) {
errorMsg.value = "로그인 사용자 정보를 찾을 수 없습니다.";
return;
}
if (!projectId.value) {
errorMsg.value = "프로젝트가 선택되지 않았습니다.";
return;
}
const now = nowLocalIso();
try {
saving.value = true;
if (isEdit.value) {
// ===== 수정 =====
const rawId = props.editData?.id ?? props.editData?.deviceKey;
const id = Number(rawId);
if (!id) {
errorMsg.value = "수정할 ID가 없습니다.";
return;
}
// ① 기존 값 조회
const viewRes = await WorkflowService.view(id);
const current = (viewRes?.data ?? viewRes) || {};
// ② name/description만 변경, 그 외는 기존 값 유지해서 null 덮어쓰기 방지
const updatePayload = cleanUndefined({
id,
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,
projectId: current.projectId ?? projectId.value,
regDt: current.regDt,
modDt: now,
});
const { data } = await WorkflowService.update(id, updatePayload);
emit("saved", data);
emit("close-modal");
} else {
// ===== 생성 =====
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 fd = toKubeflowForm(dto);
const { data } = await kubeflowService.upload(fd);
emit("saved", data);
emit("close-modal");
}
} catch (e: any) {
console.error("워크플로우 저장 실패:", e);
errorMsg.value = extractApiErrorMessage(e);
} finally {
saving.value = false;
}
}
/** undefined 필드는 제거해서 불필요한 키 전송 방지 */
function cleanUndefined<T extends Record<string, any>>(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");
}
onMounted(() => window.addEventListener("keydown", onEsc));
onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
</script>
<template>
<v-card>
<!-- 타이틀 -->
<v-card-title
class="text-white font-weight-bold text-h6"
style="background-color: #1976d2"
>
{{ isEdit ? "Edit Workflow" : "Create Workflow" }}
</v-card-title>
<v-card-text class="pa-6">
<div class="text-subtitle-1 font-weight-medium mb-4">
Workflow Information
</div>
<v-form @submit.prevent="submit">
<!-- Name -->
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Workflow Name
</label>
<v-text-field
v-model="form.name"
variant="outlined"
:disabled="saving"
dense
hide-details="auto"
persistent-hint
:hint="nameHint"
:error="nameInvalid"
:error-messages="nameErrorMsg"
@update:model-value="onNameInput"
required
/>
</div>
<!-- Description -->
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Workflow Description
</label>
<v-textarea
v-model="form.description"
variant="outlined"
:disabled="saving"
rows="3"
dense
hide-details="auto"
persistent-hint
@update:model-value="onDescInput"
/>
</div>
<!-- Upload File -->
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Upload File
</label>
<v-file-input
v-model="form.file"
accept=".zip,.tar,.gz,.yaml,.yml"
show-size
variant="outlined"
dense
hide-details
placeholder="파이프라인 파일 선택"
/>
</div>
<div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
</v-form>
</v-card-text>
<v-card-actions class="justify-end" style="padding: 16px 24px">
<v-btn color="success" :loading="saving" @click="submit">
{{ isEdit ? "Update" : "Save" }}
</v-btn>
<v-btn
text
class="white--text"
:disabled="saving"
@click="$emit('close-modal')"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</template>