|
|
|
|
@ -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<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");
|
|
|
|
|
@ -184,31 +289,57 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
|
|
|
|
|
</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
|
|
|
|
|
>
|
|
|
|
|
<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
|
|
|
|
|
hide-details="auto"
|
|
|
|
|
persistent-hint
|
|
|
|
|
:hint="nameHint"
|
|
|
|
|
:error="nameInvalid"
|
|
|
|
|
:error-messages="nameErrorMsg"
|
|
|
|
|
@update:model-value="onNameInput"
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
|
|
|
|
|
>Workflow Description</label
|
|
|
|
|
>
|
|
|
|
|
<!-- 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>
|
|
|
|
|
|
|
|
|
|
@ -216,82 +347,18 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
|
|
|
|
|
</v-form>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
|
|
|
|
|
<v-card-text class="pt-6 pb-4 px-6">
|
|
|
|
|
<div class="text-subtitle-1 font-weight-medium mb-4">Workflow Steps</div>
|
|
|
|
|
|
|
|
|
|
<v-row class="align-center mb-4">
|
|
|
|
|
<v-col cols="auto"
|
|
|
|
|
><v-btn color="primary" small :disabled="saving"
|
|
|
|
|
><v-icon left>mdi-plus</v-icon> Add Step</v-btn
|
|
|
|
|
></v-col
|
|
|
|
|
>
|
|
|
|
|
<v-col cols="auto"
|
|
|
|
|
><v-btn color="success" small :loading="saving" @click="submit">{{
|
|
|
|
|
isEdit ? "Update" : "Save"
|
|
|
|
|
}}</v-btn></v-col
|
|
|
|
|
>
|
|
|
|
|
<v-col cols="auto"
|
|
|
|
|
><v-btn
|
|
|
|
|
color="grey"
|
|
|
|
|
small
|
|
|
|
|
:disabled="saving"
|
|
|
|
|
@click="$emit('close-modal')"
|
|
|
|
|
>Cancel</v-btn
|
|
|
|
|
></v-col
|
|
|
|
|
>
|
|
|
|
|
<v-spacer />
|
|
|
|
|
</v-row>
|
|
|
|
|
|
|
|
|
|
<v-simple-table dense>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr class="grey lighten-2">
|
|
|
|
|
<th class="text-center" style="width: 5%">Order</th>
|
|
|
|
|
<th class="text-center">Step Name</th>
|
|
|
|
|
<th class="text-center">Component Type</th>
|
|
|
|
|
<th class="text-center">Status</th>
|
|
|
|
|
<th class="text-center" style="width: 20%">Action</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr
|
|
|
|
|
v-for="step in steps"
|
|
|
|
|
:key="step.order"
|
|
|
|
|
style="border: 1px solid #ccc"
|
|
|
|
|
>
|
|
|
|
|
<td class="text-center">{{ step.order }}</td>
|
|
|
|
|
<td class="text-center">{{ step.stepName }}</td>
|
|
|
|
|
<td class="d-flex justify-center align-center">
|
|
|
|
|
<v-select
|
|
|
|
|
v-model="step.type"
|
|
|
|
|
:items="['DataPrep', 'Preprocess', 'Train']"
|
|
|
|
|
dense
|
|
|
|
|
hide-details
|
|
|
|
|
style="max-width: 180px"
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="text-center">{{ step.status }}</td>
|
|
|
|
|
<td class="text-center">
|
|
|
|
|
<IconArrowUp />
|
|
|
|
|
<IconArrowDown />
|
|
|
|
|
<IconModifyBtn />
|
|
|
|
|
<IconDeleteBtn />
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</v-simple-table>
|
|
|
|
|
</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 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
|
|
|
|
|
>
|
|
|
|
|
Close
|
|
|
|
|
</v-btn>
|
|
|
|
|
</v-card-actions>
|
|
|
|
|
</v-card>
|
|
|
|
|
</template>
|
|
|
|
|
|