|
|
|
|
|
<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 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" },
|
|
|
|
|
|
{
|
|
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/** 시간 포맷 */
|
|
|
|
|
|
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 = stripKorean(form.value.description);
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
:hint="descHint"
|
|
|
|
|
|
@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>
|