|
|
|
|
@ -10,6 +10,7 @@ import { ExperimentService } from "@/components/service/management/ExperimentSer
|
|
|
|
|
import { commonStore } from "@/stores/commonStore";
|
|
|
|
|
import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
|
|
|
|
|
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
|
|
|
|
|
import { WorkflowService } from "@/components/service/management/WorkflowService";
|
|
|
|
|
|
|
|
|
|
const store = commonStore();
|
|
|
|
|
|
|
|
|
|
@ -17,6 +18,7 @@ const openView = ref(false);
|
|
|
|
|
const execSelected = ref<any>(null);
|
|
|
|
|
const username = ref<string>("");
|
|
|
|
|
const experimentNameMap = ref<Record<string, string>>({});
|
|
|
|
|
const pipelineNameMap = ref<Record<string, string>>({});
|
|
|
|
|
|
|
|
|
|
const tableHeader = [
|
|
|
|
|
{ label: "No", width: "5%", style: "word-break: keep-all;" },
|
|
|
|
|
@ -65,6 +67,89 @@ const data = ref({
|
|
|
|
|
isCreateVisible: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------- NEW: 상태/시간 표준화 유틸 ----------
|
|
|
|
|
function pick<T = any>(obj: any, keys: string[]): T | undefined {
|
|
|
|
|
for (const k of keys) {
|
|
|
|
|
const v = obj?.[k];
|
|
|
|
|
if (v !== undefined && v !== null && v !== "") return v as T;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
function normTime(v: any): string | undefined {
|
|
|
|
|
if (!v) return undefined;
|
|
|
|
|
const d = new Date(v);
|
|
|
|
|
return isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
|
|
|
}
|
|
|
|
|
function normalizeRun(raw: any) {
|
|
|
|
|
const stateRaw =
|
|
|
|
|
pick<string>(raw, [
|
|
|
|
|
"state",
|
|
|
|
|
"status",
|
|
|
|
|
"lifecycle_state",
|
|
|
|
|
"lifecycleState",
|
|
|
|
|
"lifecycleStage", // MLflow
|
|
|
|
|
"phase", // K8s 스타일
|
|
|
|
|
]) ?? "";
|
|
|
|
|
|
|
|
|
|
const createdAt =
|
|
|
|
|
normTime(
|
|
|
|
|
pick(raw, ["createdAt", "startTime", "startedAt", "start_time"]),
|
|
|
|
|
) ?? undefined;
|
|
|
|
|
|
|
|
|
|
const finishedAt =
|
|
|
|
|
normTime(
|
|
|
|
|
pick(raw, [
|
|
|
|
|
"finishedAt",
|
|
|
|
|
"finished_at",
|
|
|
|
|
"endTime",
|
|
|
|
|
"end_time",
|
|
|
|
|
"completedAt",
|
|
|
|
|
"completed_at",
|
|
|
|
|
"terminateTime",
|
|
|
|
|
]),
|
|
|
|
|
) ?? undefined;
|
|
|
|
|
|
|
|
|
|
return { stateRaw, createdAt, finishedAt };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toUiStatusByRaw(
|
|
|
|
|
raw: any,
|
|
|
|
|
): "Succeeded" | "Failed" | "Running" | "Pending" {
|
|
|
|
|
const { stateRaw, finishedAt } = normalizeRun(raw);
|
|
|
|
|
const s = String(stateRaw || "").toUpperCase();
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
s.includes("SUCCEED") ||
|
|
|
|
|
s.includes("SUCCESS") ||
|
|
|
|
|
s.includes("FINISH") || // FINISHED
|
|
|
|
|
s.includes("COMPLETE") || // COMPLETED
|
|
|
|
|
s === "DONE" ||
|
|
|
|
|
s === "OK"
|
|
|
|
|
)
|
|
|
|
|
return "Succeeded";
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
s.includes("FAIL") ||
|
|
|
|
|
s.includes("ERROR") ||
|
|
|
|
|
s.includes("CANCEL") ||
|
|
|
|
|
s.includes("KILL") ||
|
|
|
|
|
s.includes("TERMINAT") ||
|
|
|
|
|
s.includes("ABORT")
|
|
|
|
|
)
|
|
|
|
|
return "Failed";
|
|
|
|
|
|
|
|
|
|
if (s.includes("RUN") || s.includes("ACTIVE") || s.includes("EXECUT"))
|
|
|
|
|
return "Running";
|
|
|
|
|
|
|
|
|
|
if (!s && finishedAt) return "Succeeded";
|
|
|
|
|
|
|
|
|
|
if (s.includes("PEND") || s.includes("QUEUE") || s.includes("SCHED"))
|
|
|
|
|
return "Pending";
|
|
|
|
|
|
|
|
|
|
return "Pending";
|
|
|
|
|
}
|
|
|
|
|
// -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function resolveExperimentNamesWithApi(ids: Array<string | number>) {
|
|
|
|
|
const targets = Array.from(
|
|
|
|
|
new Set(
|
|
|
|
|
@ -78,20 +163,43 @@ async function resolveExperimentNamesWithApi(ids: Array<string | number>) {
|
|
|
|
|
await Promise.all(
|
|
|
|
|
targets.map(async (id) => {
|
|
|
|
|
try {
|
|
|
|
|
// ✅ 너가 만든 API 사용 (POST /api/kubeflow/experiments/{id})
|
|
|
|
|
const res = await KubeflowService.experimentData(id);
|
|
|
|
|
const body = res?.data ?? res ?? {};
|
|
|
|
|
const name =
|
|
|
|
|
body.display_name ?? body.name ?? body.experiment_name ?? String(id);
|
|
|
|
|
experimentNameMap.value[id] = name;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// 실패 시 ID fallback
|
|
|
|
|
} catch {
|
|
|
|
|
experimentNameMap.value[id] = String(id);
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolvePipelineNamesWithApi(ids: Array<string | number>) {
|
|
|
|
|
const targets = Array.from(
|
|
|
|
|
new Set(
|
|
|
|
|
(ids || [])
|
|
|
|
|
.map((x) => String(x))
|
|
|
|
|
.filter((id) => id && pipelineNameMap.value[id] == null),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
if (!targets.length) return;
|
|
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
targets.map(async (id) => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await WorkflowService.pipelineIdName(id);
|
|
|
|
|
const body = res?.data ?? res ?? {};
|
|
|
|
|
const name =
|
|
|
|
|
body.displayName ?? body.display_name ?? body.name ?? String(id);
|
|
|
|
|
pipelineNameMap.value[id] = name;
|
|
|
|
|
} catch {
|
|
|
|
|
pipelineNameMap.value[id] = String(id);
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readUsernameFromStorage(): string {
|
|
|
|
|
try {
|
|
|
|
|
const raw =
|
|
|
|
|
@ -121,10 +229,20 @@ const toRow = (r: any, no: number) => {
|
|
|
|
|
r.experiment?.displayName ??
|
|
|
|
|
r.experiment?.name ??
|
|
|
|
|
"-";
|
|
|
|
|
const fmtStart = (start?: string) => {
|
|
|
|
|
if (!start) return "-";
|
|
|
|
|
const d = new Date(start);
|
|
|
|
|
if (isNaN(d.getTime())) return start;
|
|
|
|
|
|
|
|
|
|
const pipelineId =
|
|
|
|
|
r.pipelineId ?? r.pipelineVersionId ?? r.pipeline?.id ?? r.pipeline_id;
|
|
|
|
|
const wfName =
|
|
|
|
|
(pipelineId && pipelineNameMap.value[String(pipelineId)]) ??
|
|
|
|
|
r.pipeline?.displayName ??
|
|
|
|
|
r.pipeline?.name ??
|
|
|
|
|
"-";
|
|
|
|
|
|
|
|
|
|
const { createdAt: createdIso, finishedAt: finishedIso } = normalizeRun(r);
|
|
|
|
|
|
|
|
|
|
const fmt = (iso?: string) => {
|
|
|
|
|
if (!iso) return "-";
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
const yyyy = d.getFullYear();
|
|
|
|
|
const MM = String(d.getMonth() + 1).padStart(2, "0");
|
|
|
|
|
const dd = String(d.getDate()).padStart(2, "0");
|
|
|
|
|
@ -133,9 +251,9 @@ const toRow = (r: any, no: number) => {
|
|
|
|
|
return `${yyyy}-${MM}-${dd} ${hh}:${mi}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fmtDuration = (start?: string, end?: string) => {
|
|
|
|
|
if (!start || !end) return "-";
|
|
|
|
|
const ms = new Date(end).getTime() - new Date(start).getTime();
|
|
|
|
|
const fmtDuration = (startIso?: string, endIso?: string) => {
|
|
|
|
|
if (!startIso || !endIso) return "-";
|
|
|
|
|
const ms = new Date(endIso).getTime() - new Date(startIso).getTime();
|
|
|
|
|
if (!isFinite(ms) || ms < 0) return "-";
|
|
|
|
|
const s = Math.floor(ms / 1000);
|
|
|
|
|
const h = Math.floor(s / 3600);
|
|
|
|
|
@ -145,58 +263,98 @@ const toRow = (r: any, no: number) => {
|
|
|
|
|
return `${h}:${pad(m)}:${pad(sec)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toUiStatus = (state?: string, finishedAt?: string) => {
|
|
|
|
|
const s = String(state || "").toUpperCase();
|
|
|
|
|
|
|
|
|
|
// 완료 판단: SUCCEED* 이거나 상태가 비어있지만 finishedAt이 있으면 완료로 간주
|
|
|
|
|
if (s.includes("SUCCEED") || (!s && finishedAt)) return "Succeeded";
|
|
|
|
|
|
|
|
|
|
// 실패
|
|
|
|
|
if (s.includes("FAIL") || s.includes("ERROR")) return "Failed";
|
|
|
|
|
|
|
|
|
|
// 실행 중
|
|
|
|
|
if (s.includes("RUN")) return "Running";
|
|
|
|
|
|
|
|
|
|
// 대기
|
|
|
|
|
if (s.includes("PEND") || s.includes("QUEUE") || s.includes("SCHED"))
|
|
|
|
|
return "Pending";
|
|
|
|
|
|
|
|
|
|
// 모르겠으면 대기로
|
|
|
|
|
return "Pending";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
no, // ← 전달받은 번호 사용
|
|
|
|
|
no,
|
|
|
|
|
name: r.displayName ?? r.name ?? r.runId ?? "(no name)",
|
|
|
|
|
status: toUiStatus(r.state, r.finishedAt),
|
|
|
|
|
duration: fmtDuration(r.createdAt, r.finishedAt),
|
|
|
|
|
status: toUiStatusByRaw(r),
|
|
|
|
|
duration: fmtDuration(createdIso, finishedIso),
|
|
|
|
|
expName,
|
|
|
|
|
workflow: r.pipelineId ?? r.pipelineVersionId ?? "-",
|
|
|
|
|
startTime: fmtStart(r.createdAt),
|
|
|
|
|
workflow: wfName,
|
|
|
|
|
startTime: fmt(createdIso),
|
|
|
|
|
registryStatus: r.storageState ?? "-",
|
|
|
|
|
run_id: r.runId,
|
|
|
|
|
raw: r,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// --- [추가] 로컬 매칭 유틸: 제목/작성자/전체 ---
|
|
|
|
|
function includes(hay: any, needle: string) {
|
|
|
|
|
if (!hay) return false;
|
|
|
|
|
return String(hay).toLowerCase().includes(needle.toLowerCase());
|
|
|
|
|
}
|
|
|
|
|
function matchBySearchType(
|
|
|
|
|
rowRaw: any,
|
|
|
|
|
mapped: "ALL" | "TITLE" | "AUTHOR",
|
|
|
|
|
kw: string,
|
|
|
|
|
) {
|
|
|
|
|
if (!kw) return true;
|
|
|
|
|
|
|
|
|
|
// 제목 후보들
|
|
|
|
|
const titleFields = [
|
|
|
|
|
rowRaw.displayName,
|
|
|
|
|
rowRaw.name,
|
|
|
|
|
rowRaw.runName,
|
|
|
|
|
rowRaw.title,
|
|
|
|
|
rowRaw.pipeline?.displayName,
|
|
|
|
|
rowRaw.pipeline?.name,
|
|
|
|
|
];
|
|
|
|
|
// 작성자 후보들
|
|
|
|
|
const authorFields = [
|
|
|
|
|
rowRaw.username,
|
|
|
|
|
rowRaw.userName,
|
|
|
|
|
rowRaw.owner,
|
|
|
|
|
rowRaw.createdBy,
|
|
|
|
|
rowRaw.user?.name,
|
|
|
|
|
rowRaw.user?.username,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (mapped === "TITLE") {
|
|
|
|
|
return titleFields.some((v) => includes(v, kw));
|
|
|
|
|
}
|
|
|
|
|
if (mapped === "AUTHOR") {
|
|
|
|
|
return authorFields.some((v) => includes(v, kw));
|
|
|
|
|
}
|
|
|
|
|
// ALL: 제목/작성자 + 기타 설명, 태그까지 포괄
|
|
|
|
|
const extraFields = [
|
|
|
|
|
rowRaw.description,
|
|
|
|
|
rowRaw.desc,
|
|
|
|
|
rowRaw.tags,
|
|
|
|
|
rowRaw.tagString,
|
|
|
|
|
];
|
|
|
|
|
return [...titleFields, ...authorFields, ...extraFields].some((v) =>
|
|
|
|
|
includes(v, kw),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchList() {
|
|
|
|
|
const { pageNum, pageSize, searchType, searchText } = data.value.params;
|
|
|
|
|
const mapped = SEARCH_TYPE_MAP[searchType] || "ALL";
|
|
|
|
|
const keyword = (searchText || "").trim();
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
// ✅ 여러 서버 구현과 호환되도록 alias를 같이 보냄
|
|
|
|
|
const payload: any = {
|
|
|
|
|
projectId: getProjectId(),
|
|
|
|
|
|
|
|
|
|
page: pageNum - 1, // 0-based
|
|
|
|
|
size: pageSize,
|
|
|
|
|
keyword,
|
|
|
|
|
searchType: mapped,
|
|
|
|
|
sortField: "createdAt",
|
|
|
|
|
sortDirection: "DESC",
|
|
|
|
|
|
|
|
|
|
// --- keyword aliases ---
|
|
|
|
|
keyword,
|
|
|
|
|
searchKeyword: keyword,
|
|
|
|
|
query: keyword,
|
|
|
|
|
q: keyword,
|
|
|
|
|
search_text: keyword,
|
|
|
|
|
searchText: keyword,
|
|
|
|
|
|
|
|
|
|
// --- type aliases ---
|
|
|
|
|
searchType: mapped,
|
|
|
|
|
type: mapped,
|
|
|
|
|
filterType: mapped,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const res = await KubeflowRunService.search(payload as any);
|
|
|
|
|
const res = await KubeflowRunService.search(payload);
|
|
|
|
|
const result = res?.data ?? res;
|
|
|
|
|
|
|
|
|
|
let list: any[] = [];
|
|
|
|
|
@ -213,7 +371,16 @@ async function fetchList() {
|
|
|
|
|
isServerPaged = true;
|
|
|
|
|
} else if (Array.isArray(result?.runs)) list = result.runs;
|
|
|
|
|
|
|
|
|
|
// 클라 보정 정렬 (서버 미적용 대비)
|
|
|
|
|
// 💡 서버가 검색을 안 해줬을 경우 대비: 클라이언트 필터
|
|
|
|
|
if (keyword) {
|
|
|
|
|
list = list.filter((raw) => matchBySearchType(raw, mapped, keyword));
|
|
|
|
|
// 서버가 totalElements를 내려줬더라도, 클라이언트에서 다시 줄였으므로 덮어씌움
|
|
|
|
|
totalElements = list.length;
|
|
|
|
|
totalPages = Math.ceil(list.length / pageSize);
|
|
|
|
|
isServerPaged = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 최신순 정렬 (서버 미적용 대비)
|
|
|
|
|
list.sort((a, b) => {
|
|
|
|
|
const ta = new Date(
|
|
|
|
|
a.createdAt ||
|
|
|
|
|
@ -232,42 +399,51 @@ async function fetchList() {
|
|
|
|
|
0,
|
|
|
|
|
).getTime();
|
|
|
|
|
|
|
|
|
|
if (tb !== ta) return tb - ta; // 최신순
|
|
|
|
|
if (tb !== ta) return tb - ta;
|
|
|
|
|
const aid = a.id ?? a.runId ?? a.run_id ?? a.name ?? "";
|
|
|
|
|
const bid = b.id ?? b.runId ?? b.run_id ?? b.name ?? "";
|
|
|
|
|
return String(bid).localeCompare(String(aid)); // 안정화
|
|
|
|
|
return String(bid).localeCompare(String(aid));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 이름 매핑 미리 채우기
|
|
|
|
|
const expIds = list
|
|
|
|
|
.map((r) => r.experimentId ?? r.experiment_id ?? r.experiment?.id)
|
|
|
|
|
.filter((v) => v != null);
|
|
|
|
|
|
|
|
|
|
// ✅ 여기서 네가 만든 API로 display_name 미리 채움
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
await resolveExperimentNamesWithApi(expIds);
|
|
|
|
|
|
|
|
|
|
const pipeIds = list
|
|
|
|
|
.map(
|
|
|
|
|
(r) =>
|
|
|
|
|
r.pipelineId ??
|
|
|
|
|
r.pipelineVersionId ??
|
|
|
|
|
r.pipeline?.id ??
|
|
|
|
|
r.pipeline_id,
|
|
|
|
|
)
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
await resolvePipelineNamesWithApi(pipeIds);
|
|
|
|
|
|
|
|
|
|
// 페이지닝
|
|
|
|
|
if (!isServerPaged) {
|
|
|
|
|
const total = list.length;
|
|
|
|
|
const total =
|
|
|
|
|
typeof totalElements === "number" ? totalElements : list.length;
|
|
|
|
|
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
|
|
const safePage = Math.min(Math.max(1, pageNum), pages);
|
|
|
|
|
const start = (safePage - 1) * pageSize;
|
|
|
|
|
const slice = list.slice(start, start + pageSize);
|
|
|
|
|
|
|
|
|
|
// ↓ 이번 페이지의 시작 번호(내림차순)
|
|
|
|
|
const startNo = total - (safePage - 1) * pageSize;
|
|
|
|
|
data.value.results = slice.map((r, i) =>
|
|
|
|
|
toRow(r, Math.max(startNo - i, 1)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
data.value.totalElements = total;
|
|
|
|
|
data.value.pageLength = pages;
|
|
|
|
|
} else {
|
|
|
|
|
const te =
|
|
|
|
|
typeof totalElements === "number" ? totalElements : list.length;
|
|
|
|
|
|
|
|
|
|
// ↓ 서버 페이징일 때도 동일하게 시작 번호 계산
|
|
|
|
|
const startNo = te - (pageNum - 1) * pageSize;
|
|
|
|
|
data.value.results = list.map((r, i) =>
|
|
|
|
|
toRow(r, Math.max(startNo - i, 1)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
data.value.totalElements = te;
|
|
|
|
|
data.value.pageLength =
|
|
|
|
|
typeof totalPages === "number"
|
|
|
|
|
@ -525,7 +701,6 @@ onMounted(() => {
|
|
|
|
|
width="2"
|
|
|
|
|
color="info"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<v-icon
|
|
|
|
|
v-else-if="item.status === 'Pending'"
|
|
|
|
|
color="grey"
|
|
|
|
|
@ -533,13 +708,10 @@ onMounted(() => {
|
|
|
|
|
>
|
|
|
|
|
mdi-loading
|
|
|
|
|
</v-icon>
|
|
|
|
|
|
|
|
|
|
<!-- 그 외 -->
|
|
|
|
|
<v-icon v-else color="grey">mdi-help-circle</v-icon>
|
|
|
|
|
</td>
|
|
|
|
|
<td>{{ item.duration }}</td>
|
|
|
|
|
<td>{{ item.expName }}</td>
|
|
|
|
|
|
|
|
|
|
<td>{{ item.workflow }}</td>
|
|
|
|
|
<td>{{ item.startTime }}</td>
|
|
|
|
|
<td>{{ item.registryStatus }}</td>
|
|
|
|
|
|