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

11 months ago
<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"})`;
}
11 months ago
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 */
11 months ago
const form = ref({
name: "",
description: "",
file: null as File | null,
11 months ago
});
/** 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));
11 months ago
</script>
<template>
<v-card>
<!-- 타이틀 -->
11 months ago
<v-card-title
class="text-white font-weight-bold text-h6"
style="background-color: #1976d2"
>
{{ isEdit ? "Edit Workflow" : "Create Workflow" }}
11 months ago
</v-card-title>
<v-card-text class="pa-6">
<div class="text-subtitle-1 font-weight-medium mb-4">
Workflow Information
</div>
11 months ago
<v-form @submit.prevent="submit">
<!-- Name -->
11 months ago
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Workflow Name
</label>
11 months ago
<v-text-field
v-model="form.name"
variant="outlined"
:disabled="saving"
11 months ago
dense
hide-details="auto"
persistent-hint
:hint="nameHint"
:error="nameInvalid"
:error-messages="nameErrorMsg"
@update:model-value="onNameInput"
11 months ago
required
/>
</div>
<!-- Description -->
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Workflow Description
</label>
11 months ago
<v-textarea
v-model="form.description"
variant="outlined"
:disabled="saving"
11 months ago
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
11 months ago
hide-details
placeholder="파이프라인 파일 선택"
11 months ago
/>
</div>
<div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
11 months ago
</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')"
11 months ago
>
Close
</v-btn>
11 months ago
</v-card-actions>
</v-card>
</template>