fix 대시보드 수정

main
jschoi 9 months ago
parent ac1f47a72a
commit ff5dbc275e

3
components.d.ts vendored

@ -10,7 +10,7 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AppFooter: typeof import('./src/components/AppFooter.vue')['default'] AppFooter: typeof import('./src/components/AppFooter.vue')['default']
CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default'] CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default']
copy: typeof import('./src/components/atoms/organisms/TrainingScriptBaseDoalog copy.vue')['default'] copy: typeof import('./src/components/templates/run/executions/ListComponent copy.vue')['default']
DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default'] DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default']
DatasetsBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetsBaseDoalog.vue')['default'] DatasetsBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetsBaseDoalog.vue')['default']
DatesetBaseDoalog: typeof import('./src/components/atoms/organisms/DatesetBaseDoalog.vue')['default'] DatesetBaseDoalog: typeof import('./src/components/atoms/organisms/DatesetBaseDoalog.vue')['default']
@ -30,6 +30,7 @@ declare module 'vue' {
IconSettingBtn: typeof import('./src/components/atoms/button/IconSettingBtn.vue')['default'] IconSettingBtn: typeof import('./src/components/atoms/button/IconSettingBtn.vue')['default']
LayoutComponent: typeof import('./src/components/common/LayoutComponent.vue')['default'] LayoutComponent: typeof import('./src/components/common/LayoutComponent.vue')['default']
ListComponent: typeof import('./src/components/templates/Datasets/ListComponent.vue')['default'] ListComponent: typeof import('./src/components/templates/Datasets/ListComponent.vue')['default']
ListComponentback: typeof import('./src/components/templates/run/executions/ListComponentback.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default'] SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default']

@ -9,7 +9,7 @@ import { storage } from "@/utils/storage";
import type { Workflow } from "@/components/models/management/Workflow"; import type { Workflow } from "@/components/models/management/Workflow";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useAutoflowStore } from "@/stores/autoflowStore"; import { useAutoflowStore } from "@/stores/autoflowStore";
import { kubeflowService } from "@/components/service/management/KubeflowService"; import { KubeflowService } from "@/components/service/management/KubeflowService";
import { import {
toKubeflowForm, toKubeflowForm,
type KubeflowUploadDto, type KubeflowUploadDto,
@ -241,7 +241,7 @@ async function submit() {
uploadfile: form.value.file, uploadfile: form.value.file,
}; };
const fd = toKubeflowForm(dto); const fd = toKubeflowForm(dto);
const { data } = await kubeflowService.upload(fd); const { data } = await KubeflowService.upload(fd);
emit("saved", data); emit("saved", data);
emit("close-modal"); emit("close-modal");
} }

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue"; import { computed, onMounted, onBeforeUnmount, ref, watch } from "vue";
import { kubeflowService } from "@/components/service/management/KubeflowService"; import { KubeflowService } from "@/components/service/management/KubeflowService";
type RunPayload = { type RunPayload = {
display_name: string; display_name: string;
@ -53,12 +53,12 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
async function loadExperimentsAll() { async function loadExperimentsAll() {
expLoading.value = true; expLoading.value = true;
try { try {
const first = await kubeflowService.experiments({ pageSize: 500 }); const first = await KubeflowService.experiments({ pageSize: 500 });
const all: any[] = [...(first.data?.experiments ?? [])]; const all: any[] = [...(first.data?.experiments ?? [])];
let token: string | undefined = first.data?.next_page_token; let token: string | undefined = first.data?.next_page_token;
if (token) { if (token) {
const { data } = await kubeflowService.experiments({ const { data } = await KubeflowService.experiments({
pageSize: 500, pageSize: 500,
pageToken: token, pageToken: token,
}); });
@ -108,7 +108,7 @@ async function submitRun() {
try { try {
loading.value = true; loading.value = true;
const { data } = await kubeflowService.run(payload); const { data } = await KubeflowService.run(payload);
emit("submitted", data); emit("submitted", data);
emit("close-modal"); emit("close-modal");
} catch (e: any) { } catch (e: any) {

@ -11,7 +11,7 @@ export type KubeflowRunSearchParams = {
sortField?: string; // 정렬 기준 필드명 sortField?: string; // 정렬 기준 필드명
sortDirection?: "ASC" | "DESC"; // 정렬 방향 sortDirection?: "ASC" | "DESC"; // 정렬 방향
}; };
export const kubeflowRunService = { export const KubeflowRunService = {
getAll: () => { getAll: () => {
return request.get("/api/kubeflow/runs", {}); return request.get("/api/kubeflow/runs", {});
}, },

@ -1,6 +1,6 @@
import { kubeflow } from "@/components/models/management/Kubeflow"; import { kubeflow } from "@/components/models/management/Kubeflow";
import { request } from "@/components/service/index"; import { request } from "@/components/service/index";
export const kubeflowService = { export const KubeflowService = {
upload: (payload: kubeflow) => { upload: (payload: kubeflow) => {
return request.post("/pipelines/upload", payload); return request.post("/pipelines/upload", payload);
}, },

@ -5,7 +5,7 @@ import { WorkflowService } from "@/components/service/management/WorkflowService
import { ExecutionsService } from "@/components/service/management/ExecutionsService"; import { ExecutionsService } from "@/components/service/management/ExecutionsService";
import { AttachmentsService } from "@/components/service/management/AttachmentsService"; import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { useAutoflowStore } from "@/stores/autoflowStore"; import { useAutoflowStore } from "@/stores/autoflowStore";
import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
const store = useAutoflowStore(); const store = useAutoflowStore();
const currentProjectId = computed(() => store.projectId); const currentProjectId = computed(() => store.projectId);
@ -13,6 +13,8 @@ const pieChartRef = ref<HTMLElement | null>(null);
const workflows = ref<any[]>([]); const workflows = ref<any[]>([]);
const recentLimit = 10; const recentLimit = 10;
const runsLoading = ref(false); const runsLoading = ref(false);
const kfRunsLoading = ref(false);
const isRefreshing = ref(false);
const recentRuns = ref< const recentRuns = ref<
{ {
name: string; name: string;
@ -20,6 +22,7 @@ const recentRuns = ref<
time: string; time: string;
}[] }[]
>([]); >([]);
// dataset // dataset
const dsLoading = ref(false); const dsLoading = ref(false);
type DsItem = { type DsItem = {
@ -36,6 +39,38 @@ const dsWindowDays = ref<7 | 30 | 90>(30);
const toTime = (x?: string) => new Date(x ?? 0).getTime(); const toTime = (x?: string) => new Date(x ?? 0).getTime();
const fmtYmd = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "-"); const fmtYmd = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "-");
type KfRun = {
runId: string;
name: string;
state: string; // (SUCCEEDED/FAILED/)
status: "success" | "failed" | "running" | "pending"; // UI
createdAt?: string;
scheduledAt?: string;
finishedAt?: string;
experimentId?: string;
pipelineName?: string;
serviceAccount?: string;
};
const kfRuns = ref<KfRun[]>([]);
//
function toUiStatus2(state?: string): KfRun["status"] {
switch ((state || "").toUpperCase()) {
case "SUCCEEDED":
return "success";
case "FAILED":
return "failed";
case "RUNNING":
return "running";
case "PENDING":
case "QUEUED":
case "SCHEDULED":
return "pending";
default:
return "pending";
}
}
async function loadDatasetActivity() { async function loadDatasetActivity() {
dsLoading.value = true; dsLoading.value = true;
try { try {
@ -165,6 +200,49 @@ async function loadRecentRuns() {
} }
} }
async function loadKubeflowRuns() {
kfRunsLoading.value = true;
try {
const res = await KubeflowRunService.getAll();
// res.data (Swagger )
const arr: any[] = Array.isArray(res?.data) ? res.data : [];
kfRuns.value = arr
.map((r) => {
const state = String(r?.state ?? r?.status ?? "PENDING");
return {
runId: r?.runId ?? r?.run_id ?? r?.id ?? "",
name:
r?.displayName ??
r?.display_name ??
r?.name ??
r?.run_id ??
"(no name)",
state,
status: toUiStatus2(state),
createdAt: r?.createdAt ?? r?.created_at,
scheduledAt: r?.scheduledAt ?? r?.scheduled_at,
finishedAt: r?.finishedAt ?? r?.finished_at,
experimentId:
r?.experimentId ?? r?.experiment_id ?? r?.experimentName,
pipelineName: r?.pipelineName ?? r?.pipeline_name ?? r?.pipeline_id,
serviceAccount: r?.serviceAccount ?? r?.service_account,
} as KfRun;
})
.sort(
(a, b) =>
new Date(b.createdAt || 0).getTime() -
new Date(a.createdAt || 0).getTime(),
)
.slice(0, 20); //
} catch (e) {
console.error("[Kubeflow] loadKubeflowRuns error:", e);
kfRuns.value = [];
} finally {
kfRunsLoading.value = false;
}
}
function renderStatusPie() { function renderStatusPie() {
if (!pieChartRef.value) return; if (!pieChartRef.value) return;
@ -306,9 +384,27 @@ const data = ref({
selected: [], selected: [],
}); });
const handleRefresh = () => { const handleRefresh = async () => {
alert("Refresh 작업 진행중..."); if (isRefreshing.value) return;
try {
isRefreshing.value = true;
await Promise.all([
loadRecentRuns(),
(async () => {
await loadWorkflows();
renderStatusPie();
})(),
loadDatasetActivity(),
loadKubeflowRuns(),
]);
} catch (e) {
console.error("[Home] refresh failed:", e);
} finally {
isRefreshing.value = false;
}
}; };
const getSelectedAllData = () => { const getSelectedAllData = () => {
data.value.selected = data.value.allSelected data.value.selected = data.value.allSelected
? data.value.results.map(({ deviceKey }) => ({ deviceKey })) ? data.value.results.map(({ deviceKey }) => ({ deviceKey }))
@ -378,70 +474,30 @@ onMounted(async () => {
await loadRecentRuns(); await loadRecentRuns();
await loadWorkflows(); await loadWorkflows();
await loadDatasetActivity(); await loadDatasetActivity();
await loadKubeflowRuns();
}); });
// //
watch(currentProjectId, () => loadWorkflows()); watch(currentProjectId, () => loadWorkflows());
loadKubeflowRuns();
</script> </script>
<template> <template>
<v-container fluid> <v-container fluid>
<div class="d-flex justify-space-between align-center mb-6"> <div class="d-flex justify-space-between align-center mb-6">
<h2 class="text-h6 font-weight-bold">배터리 상태 예측 모델 프로젝트</h2> <h2 class="text-h6 font-weight-bold">배터리 상태 예측 모델 프로젝트</h2>
<v-btn color="primary" prepend-icon="mdi-refresh" @click="handleRefresh" <v-btn
>Refresh</v-btn color="primary"
prepend-icon="mdi-refresh"
:loading="isRefreshing"
:disabled="isRefreshing"
@click="handleRefresh"
> >
Refresh
</v-btn>
</div> </div>
<v-row> <v-row>
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Workflow Success Rate
</h3>
</div>
<div style="overflow-y: auto; padding: 8px 16px">
<div ref="pieChartRef" style="height: 280px"></div>
</div>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Recently Registered Workflow
</h3>
</div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<v-list density="comfortable" nav>
<v-list-item v-for="item in recentWorkflowList" :key="item.id">
<template #title>
<div class="d-flex justify-space-between align-center w-100">
<span class="text-body-2 font-weight-medium">{{
item.title
}}</span>
<span class="text-caption text-grey-lighten-1">
{{ formatToYmdHm(item.timestamp) }}
</span>
</div>
</template>
</v-list-item>
<v-list-item v-if="recentWorkflowList.length === 0">
<template #title>
<div class="text-caption text-grey">
최근 등록/수정된 워크플로우가 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px"> <v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc"> <div style="padding: 16px; border-bottom: 1px solid #ccc">
@ -449,7 +505,6 @@ watch(currentProjectId, () => loadWorkflows());
</div> </div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px"> <div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<!-- 로딩 표시 -->
<v-skeleton-loader <v-skeleton-loader
v-if="runsLoading" v-if="runsLoading"
type="list-item-two-line" type="list-item-two-line"
@ -458,18 +513,14 @@ watch(currentProjectId, () => loadWorkflows());
:key="i" :key="i"
/> />
<!-- 데이터 있을 -->
<v-list v-else density="comfortable"> <v-list v-else density="comfortable">
<v-list-item <v-list-item
v-for="(run, idx) in recentRuns" v-for="(run, idx) in recentRuns"
:key="idx" :key="idx"
class="py-2" class="py-2"
> >
<!-- 기존 div 대신 블록으로 교체 -->
<div class="d-flex align-center justify-space-between w-100"> <div class="d-flex align-center justify-space-between w-100">
<!-- 왼쪽: 상태칩 + (선택) 아이콘 -->
<div class="d-flex align-center ga-2"> <div class="d-flex align-center ga-2">
<!-- 아이콘을 계속 쓰고 싶으면 유지, 아니면 v-avatar는 삭제해도 -->
<v-avatar <v-avatar
size="28" size="28"
:color=" :color="
@ -538,6 +589,130 @@ watch(currentProjectId, () => loadWorkflows());
</div> </div>
</v-card> </v-card>
</v-col> </v-col>
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Recently Registered Workflow
</h3>
</div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<v-list density="comfortable" nav>
<v-list-item v-for="item in recentWorkflowList" :key="item.id">
<template #title>
<div class="d-flex justify-space-between align-center w-100">
<span class="text-body-2 font-weight-medium">{{
item.title
}}</span>
<span class="text-caption text-grey-lighten-1">
{{ formatToYmdHm(item.timestamp) }}
</span>
</div>
</template>
</v-list-item>
<v-list-item v-if="recentWorkflowList.length === 0">
<template #title>
<div class="text-caption text-grey">
최근 등록/수정된 워크플로우가 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-card>
</v-col>
</v-row>
<v-row class="mt-4">
<v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<h3 class="text-subtitle-1 font-weight-bold mb-0">Kubeflow Runs</h3>
</div>
<div style="overflow-y: auto; padding: 8px 16px; max-height: 300px">
<!-- 로딩 -->
<v-skeleton-loader
v-if="kfRunsLoading"
type="list-item-two-line"
class="mb-2"
v-for="i in 6"
:key="i"
/>
<!-- 리스트 -->
<v-list v-else density="comfortable">
<v-list-item
v-for="(r, idx) in kfRuns"
:key="r.runId || idx"
class="py-2"
>
<div class="d-flex align-center justify-space-between w-100">
<!-- 왼쪽: 상태칩 + 이름 -->
<div class="d-flex align-center ga-2">
<v-chip
size="small"
:color="
r.status === 'success'
? 'success'
: r.status === 'failed'
? 'error'
: r.status === 'running'
? 'info'
: 'grey'
"
variant="tonal"
class="text-uppercase"
>
{{ r.state }}
</v-chip>
<div class="d-flex flex-column">
<span
class="text-body-2 font-weight-medium truncate"
style="max-width: 260px"
>
{{ r.name }}
</span>
<span class="text-caption text-grey-darken-1">
{{ fmtYmdHm(r.createdAt) }}
<span v-if="r.finishedAt">
{{ fmtYmdHm(r.finishedAt) }}</span
>
</span>
</div>
</div>
<!-- 오른쪽: 보조 정보 -->
<div class="text-right" style="min-width: 200px">
<div class="text-caption">
<strong>Experiment</strong>:
<span class="text-medium-emphasis">{{
r.experimentId || "-"
}}</span>
</div>
<div class="text-caption">
<strong>Pipeline</strong>:
<span class="text-medium-emphasis">{{
r.pipelineName || "-"
}}</span>
</div>
</div>
</div>
</v-list-item>
<v-list-item v-if="!kfRunsLoading && kfRuns.length === 0">
<template #title>
<div class="text-caption text-grey">
표시할 Kubeflow Run 데이터가 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-card>
</v-col>
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-card class="pa-0" style="min-height: 360px; max-height: 360px"> <v-card class="pa-0" style="min-height: 360px; max-height: 360px">
<div style="padding: 16px; border-bottom: 1px solid #ccc"> <div style="padding: 16px; border-bottom: 1px solid #ccc">

@ -1,138 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue"; import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue"; import { onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue"; import { storage } from "@/utils/storage";
import ViewComponent from "@/components/templates/run/experiment/ViewComponent.vue";
import ExperimentCreateDialog from "@/components/atoms/organisms/ExperimentCreateDialog.vue";
import { ExperimentService } from "@/components/service/management/ExperimentService";
import { commonStore } from "@/stores/commonStore";
import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue"; import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
import CompareComponent from "@/components/templates/run/executions/CompareComponent.vue";
import ViewComponent from "@/components/templates/run/executions/ViewComponent.vue";
import ExecutionBaseDialog from "@/components/atoms/organisms/ExecutionBaseDialog.vue";
import { ExecutionsService } from "@/components/service/management/ExecutionsService";
// const store = commonStore();
const openCompare = ref(false); const store = commonStore();
const openView = ref(false); const openView = ref(false);
const runsLoading = ref(false); const username = ref<string>("");
const openCompare = ref(false);
// 1 const execSelected = ref<any>(null);
// ( 'Succeeded'/'Failed' ) const selectedExperiment = ref<{
function toUiStatus(state?: string) { name: string;
switch ((state || "").toUpperCase()) { description: string;
case "SUCCEEDED": createdDate: string;
return "Succeeded"; createdID: string;
case "FAILED": deviceKey: number;
return "Failed"; } | null>(null);
case "RUNNING":
return "Running"; // ===== =====
case "PENDING":
return "Pending";
case "SKIPPED":
return "Skipped";
default:
return state || "-";
}
}
// /
function fmtStart(start?: string) {
if (!start) return "-";
const d = new Date(start);
if (isNaN(d.getTime())) return start;
const yyyy = d.getFullYear();
const MM = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${yyyy}-${MM}-${dd} ${hh}:${mi}`;
}
function fmtDuration(start?: string, end?: string) {
if (!start || !end) return "-";
const ms = new Date(end).getTime() - new Date(start).getTime();
if (!isFinite(ms) || ms < 0) return "-";
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return `${h}:${pad(m)}:${pad(sec)}`;
}
const displayNo = (i: number) => {
const start = (data.value.params.pageNum - 1) * data.value.params.pageSize;
return data.value.totalDataLength - (start + i);
};
async function loadRunsAll() {
runsLoading.value = true;
try {
const all: any[] = [];
// 1
let resp = await ExecutionsService.search({
page_size: 500,
} as any);
all.push(...(resp?.data?.runs ?? []));
// (/ )
let token: string | undefined =
resp?.data?.next_page_token ?? resp?.data?.nextPageToken;
//
const seen = new Set<string>();
while (token && !seen.has(token)) {
seen.add(token);
// token (snake/camel )
resp = await ExecutionsService.search({
page_token: token,
pageToken: token,
page_size: 500,
} as any);
all.push(...(resp?.data?.runs ?? []));
token = resp?.data?.next_page_token ?? resp?.data?.nextPageToken;
}
//
const dedup = Array.from(
new Map(all.map((r: any) => [r?.run_id ?? r?.id ?? r?.name, r])).values(),
);
//
data.value.results = dedup.map((r: any, idx: number) => ({
no: idx + 1,
name: r?.display_name ?? r?.name ?? r?.run_id ?? "(no name)",
status: toUiStatus(r?.state),
duration: fmtDuration(r?.created_at, r?.finished_at),
experiment: r?.experiment_id ?? "-",
workflow:
r?.pipeline_version_reference?.pipeline_id ??
r?.pipeline_version_reference?.pipeline_version_id ??
"-",
startTime: fmtStart(r?.created_at),
registryStatus: r?.storage_state ?? "-",
run_id: r?.run_id,
raw: r,
}));
experimentOptions.value = Array.from(
new Set(data.value.results.map((r: any) => String(r.experiment || "-"))),
).filter((v) => v && v !== "-");
workflowOptions.value = Array.from(
new Set(data.value.results.map((r: any) => String(r.workflow || "-"))),
).filter((v) => v && v !== "-");
data.value.totalDataLength = data.value.results.length;
setPaginationLength();
} catch (e: any) {
console.error("[Runs] 호출 실패:", e?.response?.data ?? e);
} finally {
runsLoading.value = false;
}
}
const pagedResults = computed(() => {
const { pageNum, pageSize } = data.value.params;
const start = (pageNum - 1) * pageSize;
return data.value.results.slice(start, start + pageSize);
});
const tableHeader = [ const tableHeader = [
{ label: "No", width: "5%", style: "word-break: keep-all;" }, { label: "No", width: "5%", style: "word-break: keep-all;" },
{ label: "Execution Name", width: "20%", style: "word-break: keep-all;" }, { label: "Execution Name", width: "20%", style: "word-break: keep-all;" },
@ -142,29 +33,23 @@ const tableHeader = [
{ label: "Workflow", width: "15%", style: "word-break: keep-all;" }, { label: "Workflow", width: "15%", style: "word-break: keep-all;" },
{ label: "Start Time", width: "15%", style: "word-break: keep-all;" }, { label: "Start Time", width: "15%", style: "word-break: keep-all;" },
{ label: "Registry Status", width: "10%", style: "word-break: keep-all;" }, { label: "Registry Status", width: "10%", style: "word-break: keep-all;" },
{ label: "Action", width: "5%", style: "word-break: keep-all;" }, { label: "Action", width: "10%", style: "word-break: keep-all;" },
]; ];
// ===== / (/ '') =====
type SearchType = "전체" | "제목" | "작성자";
const searchOptions = [ const searchOptions = [
{ searchType: "All", searchText: "" }, { label: "전체", value: "전체" as SearchType },
{ searchType: "Execution Name", searchText: "name" }, { label: "제목", value: "제목" as SearchType },
{ searchType: "Status", searchText: "status" }, { label: "작성자", value: "작성자" as SearchType },
{ searchType: "Duration", searchText: "duration" },
{ searchType: "Experiment", searchText: "experiment" },
{ searchType: "Workflow", searchText: "workflow" },
{ searchType: "Registry Status", searchText: "registryStatus" },
]; ];
const experimentOptions = ref<string[]>([]); const SEARCH_TYPE_MAP: Record<SearchType | "", "ALL" | "TITLE" | "AUTHOR"> = {
const workflowOptions = ref<string[]>([]); "": "ALL",
const execDialogOpen = ref(false); 전체: "ALL",
const execMode = ref<"create" | "edit" | "clone">("create"); 제목: "TITLE",
const execSelected = ref<any>(null); 작성자: "AUTHOR",
const searchExperimentOptions = [{ searchType: "Experiment", searchText: "" }]; };
const searchWorkflowOptions = [{ searchType: "Workflow", searchText: "" }];
const workflowList = ref(["pipeline-a", "pipeline-b", "pipeline-c"]);
const executionTypes = ref(["One-off", "Recurring"]);
const pageSizeOptions = [ const pageSizeOptions = [
{ text: "10 페이지", value: 10 }, { text: "10 페이지", value: 10 },
@ -172,106 +57,360 @@ const pageSizeOptions = [
{ text: "100 페이지", value: 100 }, { text: "100 페이지", value: 100 },
]; ];
// ===== =====
const data = ref({ const data = ref({
params: { params: {
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 10,
searchType: "", searchType: "전체" as SearchType,
searchText: "", searchText: "",
experimentFilter: "",
workflowFilter: "",
}, },
results: [], results: [] as any[],
totalDataLength: 0, totalElements: 0,
pageLength: 0, pageLength: 0,
modalMode: "", modalMode: "" as "create" | "edit" | "",
selectedData: null, selectedData: null as any,
allSelected: false, allSelected: false,
selected: [], selected: [] as any[],
isCreateVisible: false,
isModalVisible: false, isModalVisible: false,
isConfirmDialogVisible: false, isConfirmDialogVisible: false,
userOption: [], userOption: [] as any[],
}); });
const filteredResults = computed(() => { // ===== =====
const { searchType, searchText, experimentFilter, workflowFilter } = function readUsernameFromStorage(): string {
data.value.params; try {
const raw =
storage?.get?.("autoflow-auth") ??
storage?.getAuth?.() ??
localStorage.getItem("autoflow-auth") ??
null;
const auth = typeof raw === "string" ? JSON.parse(raw) : raw;
const u1 = auth?.userInfo?.username;
const u2 = auth?.username;
const u3 = auth?.userInfo?.userName;
const u4 = auth?.userInfo?.email?.split?.("@")?.[0];
return (u1 || u2 || u3 || u4 || "").toString();
} catch {
return "";
}
}
const getProjectId = (): number => {
const v = Number(localStorage.getItem("projectId"));
return Number.isFinite(v) ? v : 0;
};
const fmtDate = (v?: string) =>
v ? String(v).replace("T", " ").slice(0, 19) : "";
// Row
// Execution Row
const toRow = (r: any, idx: number) => {
const fmtStart = (start?: string) => {
if (!start) return "-";
const d = new Date(start);
if (isNaN(d.getTime())) return start;
const yyyy = d.getFullYear();
const MM = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
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();
if (!isFinite(ms) || ms < 0) return "-";
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return `${h}:${pad(m)}:${pad(sec)}`;
};
const toUiStatus = (state?: string) => {
switch ((state || "").toUpperCase()) {
case "SUCCEEDED":
return "Succeeded";
case "FAILED":
return "Failed";
case "RUNNING":
return "Running";
case "PENDING":
return "Pending";
case "SKIPPED":
return "Skipped";
default:
return state || "-";
}
};
let list = data.value.results; const { pageNum, pageSize } = data.value.params;
// return {
if (experimentFilter) { no: (pageNum - 1) * pageSize + (idx + 1),
list = list.filter((r) => String(r.experiment).includes(experimentFilter)); name: r.displayName ?? r.name ?? r.runId ?? "(no name)",
} status: toUiStatus(r.state),
if (workflowFilter) { duration: fmtDuration(r.createdAt, r.finishedAt),
list = list.filter((r) => String(r.workflow).includes(workflowFilter)); experiment: r.experimentId ?? "-",
}
workflow: r.pipelineId ?? r.pipelineVersionId ?? "-",
startTime: fmtStart(r.createdAt),
registryStatus: r.storageState ?? "-",
run_id: r.runId,
raw: r,
};
};
// // async function fetchList() {
const q = (searchText || "").trim().toLowerCase(); // const { pageNum, pageSize, searchType, searchText } = data.value.params;
if (q) {
if (!searchType) { // const mapped = SEARCH_TYPE_MAP[searchType] || "ALL";
// All: OR // const keyword = (searchText || "").trim();
list = list.filter((r) => { // const needLocalFilter = mapped !== "ALL" && keyword.length > 0;
const pool = [
r.name, // // (: 1000) +
r.status, // // 0-based
r.duration, // const reqPage = needLocalFilter ? 0 : pageNum ;
r.experiment, // const reqSize = needLocalFilter ? 1000 : pageSize;
r.workflow,
r.registryStatus, // const payload = {
r.startTime, // projectId: getProjectId(),
]; // page: reqPage, // : reqPage
return pool.some((v) => // size: reqSize, // : reqSize
String(v ?? "") // keyword,
.toLowerCase() // searchType: mapped,
.includes(q), // sortField: "id",
); // sortDirection: "DESC",
}); // };
// try {
// const res = await kubeflowRunService.search(payload as any);
// const result = res?.data ?? res;
// // : content | data | runs | []
// let list: any[] = Array.isArray(result)
// ? result
// : Array.isArray(result?.data)
// ? result.data
// : Array.isArray(result?.content)
// ? result.content
// : Array.isArray(result?.runs)
// ? result.runs
// : [];
// if (needLocalFilter) {
// const kw = keyword.toLowerCase();
// if (mapped === "TITLE") {
// list = list.filter((r: any) =>
// String(r?.displayName ?? r?.name ?? r?.runId ?? "")
// .toLowerCase()
// .includes(kw),
// );
// } else if (mapped === "AUTHOR") {
// list = list.filter((r: any) =>
// String(r?.regUserId ?? r?.createdBy ?? r?.serviceAccount ?? "")
// .toLowerCase()
// .includes(kw),
// );
// }
// // ( )
// const total = 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);
// data.value.params.pageNum = safePage;
// data.value.results = slice.map((r, i) => toRow(r, i));
// data.value.totalElements = total;
// data.value.pageLength = pages;
// return;
// }
// //
// const totalElements =
// typeof result?.totalElements === "number"
// ? result.totalElements
// : list.length;
// const totalPages =
// typeof result?.totalPages === "number"
// ? Math.max(1, result.totalPages)
// : Math.max(1, Math.ceil(totalElements / pageSize));
// data.value.results = list.map((r, i) => toRow(r, i));
// data.value.totalElements = totalElements;
// data.value.pageLength = totalPages;
// } catch (err) {
// console.error("[Executions] :", err);
// data.value.results = [];
// data.value.totalElements = 0;
// data.value.pageLength = 1;
// }
// }
async function fetchList() {
const { pageNum, pageSize, searchType, searchText } = data.value.params;
const mapped = SEARCH_TYPE_MAP[searchType] || "ALL";
const keyword = (searchText || "").trim();
const payload = {
projectId: getProjectId(),
page: pageNum - 1, // 0-based
size: pageSize,
keyword,
searchType: mapped,
sortField: "id",
sortDirection: "DESC",
};
try {
const res = await KubeflowRunService.search(payload as any);
const result = res?.data ?? res;
// 1)
// - Page : { content, totalElements, totalPages }
// - runs : { runs }
// - : []
// - data : { data: [] }
let list: any[] = [];
let totalElements: number | undefined;
let totalPages: number | undefined;
let isServerPaged = false;
if (Array.isArray(result)) {
//
list = result;
} else if (Array.isArray(result?.data)) {
// data
list = result.data;
} else if (Array.isArray(result?.content)) {
// (Page)
list = result.content;
totalElements = result.totalElements;
totalPages = result.totalPages;
isServerPaged = true;
} else if (Array.isArray(result?.runs)) {
list = result.runs;
} else { } else {
list = list.filter((r) => list = [];
String(r[searchType] ?? "")
.toLowerCase()
.includes(q),
);
} }
}
return list; if (!isServerPaged) {
}); const total = list.length;
const setPaginationLength = () => { const pages = Math.max(1, Math.ceil(total / pageSize));
if (data.value.totalDataLength % data.value.params.pageSize === 0) { const safePage = Math.min(Math.max(1, pageNum), pages);
data.value.pageLength = const start = (safePage - 1) * pageSize;
data.value.totalDataLength / data.value.params.pageSize; const slice = list.slice(start, start + pageSize);
} else {
data.value.pageLength = Math.ceil(
data.value.totalDataLength / data.value.params.pageSize,
);
}
};
const handleTerminate = () => { data.value.results = slice.map((r, i) => toRow(r, i));
alert("Terminate 작업 진행중..."); data.value.totalElements = total;
data.value.pageLength = pages;
} else {
// 3)
data.value.results = (list as any[]).map((r, i) => toRow(r, i));
data.value.totalElements =
typeof totalElements === "number" ? totalElements : list.length;
data.value.pageLength =
typeof totalPages === "number"
? Math.max(1, totalPages)
: Math.max(1, Math.ceil((data.value.totalElements || 0) / pageSize));
}
} catch (err) {
console.error("[Executions] 조회 에러:", err);
data.value.results = [];
data.value.totalElements = 0;
data.value.pageLength = 1;
}
}
// ===== / =====
const doSearch = () => {
data.value.params.pageNum = 1;
fetchList();
}; };
const handleRetry = () => { const changePageNum = (page: number) => {
alert("Retry 작업 진행중..."); data.value.params.pageNum = page;
fetchList();
}; };
const handleClone = () => { const changePageSize = (size: number) => {
alert("Clone 작업 진행중..."); data.value.params.pageSize = size;
data.value.params.pageNum = 1;
fetchList();
}; };
const changePageNum = (page) => { // / ( )
data.value.params.pageNum = page; const removeData = (value?: Array<{ deviceKey: number }>) => {
}; const removeList = value ?? data.value.selected;
const openCreateExecution = () => { if (!removeList || removeList.length === 0) return;
execMode.value = "create";
execSelected.value = null; const ids = removeList.map((x) => x.deviceKey);
execDialogOpen.value = true; const removeOne = (id: number) =>
ExperimentService.delete(id).then((res) => {
if (res.status < 200 || res.status >= 300) return Promise.reject(res);
});
const after = () => {
if (
ids.length >= data.value.results.length &&
data.value.params.pageNum > 1
) {
data.value.params.pageNum -= 1;
}
fetchList();
data.value.isConfirmDialogVisible = false;
data.value.selected = [];
data.value.allSelected = false;
};
// /
if (ids.length === 1) {
removeOne(ids[0])
.then(() => {
store.setSnackbarMsg({
color: "success",
text: "삭제되었습니다.",
result: 200,
});
after();
})
.catch((err) => {
console.error("삭제 실패:", err);
store.setSnackbarMsg({
color: "warning",
text: "삭제 실패",
result: 500,
});
});
} else {
Promise.all(ids.map(removeOne))
.then(() => {
store.setSnackbarMsg({
color: "success",
text: "모두 삭제되었습니다.",
result: 200,
});
})
.catch((err) => {
console.error("일부 삭제 실패:", err);
store.setSnackbarMsg({
color: "warning",
text: "일부 삭제 실패",
result: 500,
});
})
.finally(after);
}
}; };
const openComparePage = () => { // ===== & ( ) =====
openCompare.value = true; const closeDetail = () => {
openView.value = false; openView.value = false;
selectedExperiment.value = null;
}; };
const openInfoModal = (item: any) => { const openInfoModal = (item: any) => {
execSelected.value = item; execSelected.value = item;
@ -279,39 +418,39 @@ const openInfoModal = (item: any) => {
openView.value = true; openView.value = true;
openCompare.value = false; openCompare.value = false;
}; };
const openModifyModal = (selectedItem) => {
execMode.value = "edit";
execDialogOpen.value = true;
};
const openDownloadModal = () => {
data.value.selectedData = null;
data.value.modalMode = "download";
};
function closeCompare() {
openCompare.value = false;
}
function closeView() { function closeView() {
openView.value = false; openView.value = false;
} }
const onSaved = () => fetchList();
const getSelectedAllData = () => { const openDetailModal = (selectedItem: any) => {
data.value.selected = data.value.allSelected console.log("[Experiment/List] row clicked:", selectedItem);
? data.value.results.map((item) => { if (!selectedItem?.deviceKey) {
return { console.warn("[Experiment/List] deviceKey 없음!", selectedItem);
deviceKey: item.deviceKey, }
}; data.value.selectedData = selectedItem;
}) openView.value = true;
: []; };
const openCreateModal = () => {
data.value.modalMode = "create";
data.value.selectedData = {
username: username.value,
projectId: getProjectId(),
};
data.value.isCreateVisible = true;
};
const closeModal = () => {
data.value.isCreateVisible = false;
data.value.selectedData = null;
}; };
onMounted(() => { onMounted(() => {
loadRunsAll(); username.value = readUsernameFromStorage();
fetchList();
}); });
</script> </script>
<template> <template>
<div class="w-100" v-if="!openCompare && !openView"> <div class="w-100" v-if="!openView">
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center"> <v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card <v-card
flat flat
@ -324,7 +463,9 @@ onMounted(() => {
</div> </div>
</v-card-item> </v-card-item>
</v-card> </v-card>
<v-card flat class="bg-shades-transparent w-100"> <v-card flat class="bg-shades-transparent w-100">
<!-- 검색 -->
<v-card flat class="bg-shades-transparent mb-4"> <v-card flat class="bg-shades-transparent mb-4">
<div class="d-flex justify-center flex-wrap align-center"> <div class="d-flex justify-center flex-wrap align-center">
<v-responsive <v-responsive
@ -337,41 +478,12 @@ onMounted(() => {
label="검색조건" label="검색조건"
density="compact" density="compact"
:items="searchOptions" :items="searchOptions"
item-title="searchType" item-title="label"
item-value="searchText" item-value="value"
hide-details hide-details
></v-select> />
</v-responsive>
<v-responsive
max-width="180"
min-width="180"
class="mr-3 mt-3 mb-3"
>
<v-select
v-model="data.params.searchType"
label="검색조건"
density="compact"
:items="searchExperimentOptions"
item-title="searchType"
item-value="searchText"
hide-details
></v-select>
</v-responsive>
<v-responsive
max-width="180"
min-width="180"
class="mr-3 mt-3 mb-3"
>
<v-select
v-model="data.params.searchType"
label="검색조건"
density="compact"
:items="searchWorkflowOptions"
item-title="searchType"
item-value="searchText"
hide-details
></v-select>
</v-responsive> </v-responsive>
<v-responsive min-width="540" max-width="540"> <v-responsive min-width="540" max-width="540">
<v-text-field <v-text-field
v-model="data.params.searchText" v-model="data.params.searchText"
@ -381,8 +493,8 @@ onMounted(() => {
required required
class="mt-3 mb-3" class="mt-3 mb-3"
hide-details hide-details
@keyup.enter="changePageNum(1)" @keyup.enter="doSearch"
></v-text-field> />
</v-responsive> </v-responsive>
<div class="ml-3"> <div class="ml-3">
@ -390,14 +502,15 @@ onMounted(() => {
size="large" size="large"
color="primary" color="primary"
:rounded="5" :rounded="5"
@click="changePageNum(1)" @click="doSearch"
> >
<v-icon> mdi-magnify</v-icon> <v-icon>mdi-magnify</v-icon>
</v-btn> </v-btn>
</div> </div>
</div> </div>
</v-card> </v-card>
<!-- 상단 툴바 -->
<v-sheet <v-sheet
class="bg-shades-transparent d-flex flex-wrap align-center mb-2" class="bg-shades-transparent d-flex flex-wrap align-center mb-2"
> >
@ -406,8 +519,8 @@ onMounted(() => {
class="d-flex align-center mr-3 mb-2 bg-shades-transparent" class="d-flex align-center mr-3 mb-2 bg-shades-transparent"
> >
<v-chip color="primary" <v-chip color="primary"
> {{ data.totalDataLength.toLocaleString() }} > {{ data.totalElements.toLocaleString() }}</v-chip
</v-chip> >
</v-sheet> </v-sheet>
<v-sheet class="bg-shades-transparent"> <v-sheet class="bg-shades-transparent">
<v-responsive max-width="140" min-width="140" class="mb-2"> <v-responsive max-width="140" min-width="140" class="mb-2">
@ -420,28 +533,20 @@ onMounted(() => {
variant="outlined" variant="outlined"
color="primary" color="primary"
hide-details hide-details
@update:model-value="changePageNum(1)" @update:model-value="changePageSize"
></v-select> />
</v-responsive> </v-responsive>
</v-sheet> </v-sheet>
</v-sheet> </v-sheet>
<v-sheet class="justify-end mb-2 mr-3" @click="handleTerminate">
<v-btn color="primary">Terminate </v-btn> <!-- <v-sheet class="justify-end mb-2">
</v-sheet> <v-btn color="primary" @click="openCreateModal"
<v-sheet class="justify-end mb-2 mr-3" @click="handleRetry"> >Create Experiment</v-btn
<v-btn color="primary">Retry </v-btn> >
</v-sheet> </v-sheet> -->
<v-sheet class="justify-end mb-2 mr-3" @click="handleClone">
<v-btn color="primary">Clone </v-btn>
</v-sheet>
<v-sheet class="justify-end mb-2 mr-3" @click="openComparePage">
<v-btn color="primary">Compare </v-btn>
</v-sheet>
<v-sheet class="justify-end mb-2" @click="openCreateExecution">
<v-btn color="primary">Execution </v-btn>
</v-sheet>
</v-sheet> </v-sheet>
<!-- 목록 -->
<v-card class="rounded-lg pa-8"> <v-card class="rounded-lg pa-8">
<v-col cols="12"> <v-col cols="12">
<v-sheet> <v-sheet>
@ -449,12 +554,9 @@ onMounted(() => {
density="comfortable" density="comfortable"
fixed-header fixed-header
height="625" height="625"
col-md-12
col-12
overflow-x-auto overflow-x-auto
> >
<colgroup> <colgroup>
<col style="width: 5%" />
<col <col
v-for="(item, i) in tableHeader" v-for="(item, i) in tableHeader"
:key="i" :key="i"
@ -463,20 +565,11 @@ onMounted(() => {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>
<v-checkbox
v-model="data.allSelected"
style="min-width: 36px"
:indeterminate="data.allSelected === true"
hide-details
@change="getSelectedAllData"
></v-checkbox>
</th>
<th <th
v-for="(item, i) in tableHeader" v-for="(item, i) in tableHeader"
:key="i" :key="i"
class="text-center font-weight-bold" class="text-center font-weight-bold"
:style="`${item.style}`" :style="item.style"
> >
{{ item.label }} {{ item.label }}
</th> </th>
@ -484,21 +577,12 @@ onMounted(() => {
</thead> </thead>
<tbody class="text-body-2"> <tbody class="text-body-2">
<tr <tr
v-for="(item, i) in pagedResults" v-for="(item, i) in data.results"
:key="item.run_id || item.no || i" :key="i"
class="text-center" class="text-center"
> >
<td> <td>{{ item.no }}</td>
<v-checkbox <td class="text-truncate">{{ item.name }}</td>
v-model="data.selected"
:value="{ deviceKey: item.deviceKey }"
hide-details
style="min-width: 36px"
/>
</td>
<td>{{ displayNo(i) }}</td>
<td>{{ item.name }}</td>
<td> <td>
<v-icon v-if="item.status === 'Succeeded'" color="green" <v-icon v-if="item.status === 'Succeeded'" color="green"
>mdi-check-circle</v-icon >mdi-check-circle</v-icon
@ -515,13 +599,20 @@ onMounted(() => {
<td>{{ item.registryStatus }}</td> <td>{{ item.registryStatus }}</td>
<td style="white-space: nowrap"> <td style="white-space: nowrap">
<IconInfoBtn @on-click="openInfoModal(item)" /> <IconInfoBtn @on-click="openInfoModal(item)" />
<IconModifyBtn @on-click="openModifyModal(item)" /> <IconDownloadBtn />
<IconDownloadBtn @on-click="openDownloadModal(item)" /> <IconDeleteBtn
@on-click="
removeData([
{ deviceKey: item.raw?.id ?? item.run_id },
])
"
/>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</v-table> </v-table>
</v-sheet> </v-sheet>
<v-card-actions class="text-center mt-8 justify-center"> <v-card-actions class="text-center mt-8 justify-center">
<v-pagination <v-pagination
v-model="data.params.pageNum" v-model="data.params.pageNum"
@ -530,33 +621,31 @@ onMounted(() => {
color="primary" color="primary"
rounded="circle" rounded="circle"
@update:model-value="changePageNum" @update:model-value="changePageNum"
></v-pagination> />
</v-card-actions> </v-card-actions>
</v-col> </v-col>
</v-card> </v-card>
</v-card> </v-card>
</v-card> </v-card>
<v-dialog v-model="execDialogOpen" max-width="800" persistent>
<ExecutionBaseDialog
:model-value="execDialogOpen"
:mode="execMode"
:selectedData="execSelected"
:workflowList="workflowList"
:executionTypes="executionTypes"
@update:modelValue="execDialogOpen = $event"
/>
</v-dialog>
</v-container> </v-container>
</div>
<div class="w-100" v-else-if="openCompare"> <!-- 생성 다이얼로그 -->
<CompareComponent @close="closeCompare" /> <v-dialog v-model="data.isCreateVisible" max-width="600" persistent>
<ExperimentCreateDialog
:edit-data="data.selectedData"
:mode="data.modalMode"
@close-modal="closeModal"
@saved="onSaved"
@handle-data="() => {}"
/>
</v-dialog>
</div> </div>
<div class="w-100" v-else-if="openView"> <div class="w-100" v-else>
<ViewComponent <ViewComponent
v-if="openView" v-if="openView"
:experimentInfo="execSelected" :id="execSelected.deviceKey"
@close="closeView" :onClose="closeView"
/> />
</div> </div>
</template> </template>

@ -0,0 +1,564 @@
<script setup lang="ts">
import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
import { computed, onMounted, ref, watch } from "vue";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
import CompareComponent from "@/components/templates/run/executions/CompareComponent.vue";
import ViewComponent from "@/components/templates/run/executions/ViewComponent.vue";
import ExecutionBaseDialog from "@/components/atoms/organisms/ExecutionBaseDialog.vue";
import { ExecutionsService } from "@/components/service/management/ExecutionsService";
// const store = commonStore();
const openCompare = ref(false);
const openView = ref(false);
const runsLoading = ref(false);
// 1
// ( 'Succeeded'/'Failed' )
function toUiStatus(state?: string) {
switch ((state || "").toUpperCase()) {
case "SUCCEEDED":
return "Succeeded";
case "FAILED":
return "Failed";
case "RUNNING":
return "Running";
case "PENDING":
return "Pending";
case "SKIPPED":
return "Skipped";
default:
return state || "-";
}
}
// /
function fmtStart(start?: string) {
if (!start) return "-";
const d = new Date(start);
if (isNaN(d.getTime())) return start;
const yyyy = d.getFullYear();
const MM = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${yyyy}-${MM}-${dd} ${hh}:${mi}`;
}
function fmtDuration(start?: string, end?: string) {
if (!start || !end) return "-";
const ms = new Date(end).getTime() - new Date(start).getTime();
if (!isFinite(ms) || ms < 0) return "-";
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, "0");
return `${h}:${pad(m)}:${pad(sec)}`;
}
const displayNo = (i: number) => {
const start = (data.value.params.pageNum - 1) * data.value.params.pageSize;
return data.value.totalDataLength - (start + i);
};
async function loadRunsAll() {
runsLoading.value = true;
try {
const all: any[] = [];
// 1
let resp = await ExecutionsService.search({
page_size: 500,
} as any);
all.push(...(resp?.data?.runs ?? []));
// (/ )
let token: string | undefined =
resp?.data?.next_page_token ?? resp?.data?.nextPageToken;
//
const seen = new Set<string>();
while (token && !seen.has(token)) {
seen.add(token);
// token (snake/camel )
resp = await ExecutionsService.search({
page_token: token,
pageToken: token,
page_size: 500,
} as any);
all.push(...(resp?.data?.runs ?? []));
token = resp?.data?.next_page_token ?? resp?.data?.nextPageToken;
}
//
const dedup = Array.from(
new Map(all.map((r: any) => [r?.run_id ?? r?.id ?? r?.name, r])).values(),
);
//
data.value.results = dedup.map((r: any, idx: number) => ({
no: idx + 1,
name: r?.display_name ?? r?.name ?? r?.run_id ?? "(no name)",
status: toUiStatus(r?.state),
duration: fmtDuration(r?.created_at, r?.finished_at),
experiment: r?.experiment_id ?? "-",
workflow:
r?.pipeline_version_reference?.pipeline_id ??
r?.pipeline_version_reference?.pipeline_version_id ??
"-",
startTime: fmtStart(r?.created_at),
registryStatus: r?.storage_state ?? "-",
run_id: r?.run_id,
raw: r,
}));
experimentOptions.value = Array.from(
new Set(data.value.results.map((r: any) => String(r.experiment || "-"))),
).filter((v) => v && v !== "-");
workflowOptions.value = Array.from(
new Set(data.value.results.map((r: any) => String(r.workflow || "-"))),
).filter((v) => v && v !== "-");
data.value.totalDataLength = data.value.results.length;
setPaginationLength();
} catch (e: any) {
console.error("[Runs] 호출 실패:", e?.response?.data ?? e);
} finally {
runsLoading.value = false;
}
}
const pagedResults = computed(() => {
const { pageNum, pageSize } = data.value.params;
const start = (pageNum - 1) * pageSize;
return data.value.results.slice(start, start + pageSize);
});
const tableHeader = [
{ label: "No", width: "5%", style: "word-break: keep-all;" },
{ label: "Execution Name", width: "20%", style: "word-break: keep-all;" },
{ label: "Status", width: "10%", style: "word-break: keep-all;" },
{ label: "Duration", width: "10%", style: "word-break: keep-all;" },
{ label: "Experiment", width: "15%", style: "word-break: keep-all;" },
{ label: "Workflow", width: "15%", style: "word-break: keep-all;" },
{ label: "Start Time", width: "15%", style: "word-break: keep-all;" },
{ label: "Registry Status", width: "10%", style: "word-break: keep-all;" },
{ label: "Action", width: "5%", style: "word-break: keep-all;" },
];
const searchOptions = [
{ searchType: "All", searchText: "" },
{ searchType: "Execution Name", searchText: "name" },
{ searchType: "Status", searchText: "status" },
{ searchType: "Duration", searchText: "duration" },
{ searchType: "Experiment", searchText: "experiment" },
{ searchType: "Workflow", searchText: "workflow" },
{ searchType: "Registry Status", searchText: "registryStatus" },
];
const experimentOptions = ref<string[]>([]);
const workflowOptions = ref<string[]>([]);
const execDialogOpen = ref(false);
const execMode = ref<"create" | "edit" | "clone">("create");
const execSelected = ref<any>(null);
const searchExperimentOptions = [{ searchType: "Experiment", searchText: "" }];
const searchWorkflowOptions = [{ searchType: "Workflow", searchText: "" }];
const workflowList = ref(["pipeline-a", "pipeline-b", "pipeline-c"]);
const executionTypes = ref(["One-off", "Recurring"]);
const pageSizeOptions = [
{ text: "10 페이지", value: 10 },
{ text: "50 페이지", value: 50 },
{ text: "100 페이지", value: 100 },
];
const data = ref({
params: {
pageNum: 1,
pageSize: 10,
searchType: "",
searchText: "",
experimentFilter: "",
workflowFilter: "",
},
results: [],
totalDataLength: 0,
pageLength: 0,
modalMode: "",
selectedData: null,
allSelected: false,
selected: [],
isModalVisible: false,
isConfirmDialogVisible: false,
userOption: [],
});
const filteredResults = computed(() => {
const { searchType, searchText, experimentFilter, workflowFilter } =
data.value.params;
let list = data.value.results;
//
if (experimentFilter) {
list = list.filter((r) => String(r.experiment).includes(experimentFilter));
}
if (workflowFilter) {
list = list.filter((r) => String(r.workflow).includes(workflowFilter));
}
//
const q = (searchText || "").trim().toLowerCase();
if (q) {
if (!searchType) {
// All: OR
list = list.filter((r) => {
const pool = [
r.name,
r.status,
r.duration,
r.experiment,
r.workflow,
r.registryStatus,
r.startTime,
];
return pool.some((v) =>
String(v ?? "")
.toLowerCase()
.includes(q),
);
});
} else {
list = list.filter((r) =>
String(r[searchType] ?? "")
.toLowerCase()
.includes(q),
);
}
}
return list;
});
const setPaginationLength = () => {
if (data.value.totalDataLength % data.value.params.pageSize === 0) {
data.value.pageLength =
data.value.totalDataLength / data.value.params.pageSize;
} else {
data.value.pageLength = Math.ceil(
data.value.totalDataLength / data.value.params.pageSize,
);
}
};
const handleTerminate = () => {
alert("Terminate 작업 진행중...");
};
const handleRetry = () => {
alert("Retry 작업 진행중...");
};
const handleClone = () => {
alert("Clone 작업 진행중...");
};
const changePageNum = (page) => {
data.value.params.pageNum = page;
};
const openCreateExecution = () => {
execMode.value = "create";
execSelected.value = null;
execDialogOpen.value = true;
};
const openComparePage = () => {
openCompare.value = true;
openView.value = false;
};
const openInfoModal = (item: any) => {
execSelected.value = item;
console.log("[Parent] 선택된 실행:", item);
openView.value = true;
openCompare.value = false;
};
const openModifyModal = (selectedItem) => {
execMode.value = "edit";
execDialogOpen.value = true;
};
const openDownloadModal = () => {
data.value.selectedData = null;
data.value.modalMode = "download";
};
function closeCompare() {
openCompare.value = false;
}
function closeView() {
openView.value = false;
}
const getSelectedAllData = () => {
data.value.selected = data.value.allSelected
? data.value.results.map((item) => {
return {
deviceKey: item.deviceKey,
};
})
: [];
};
onMounted(() => {
loadRunsAll();
});
</script>
<template>
<div class="w-100" v-if="!openCompare && !openView">
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card
flat
class="bg-shades-transparent d-flex flex-column align-center justify-center w-100"
>
<v-card flat class="bg-shades-transparent w-100">
<v-card-item class="text-h5 font-weight-bold pt-0 pa-5 pl-0">
<div class="d-flex flex-row justify-start align-center">
<div class="text-primary">Executions</div>
</div>
</v-card-item>
</v-card>
<v-card flat class="bg-shades-transparent w-100">
<v-card flat class="bg-shades-transparent mb-4">
<div class="d-flex justify-center flex-wrap align-center">
<v-responsive
max-width="180"
min-width="180"
class="mr-3 mt-3 mb-3"
>
<v-select
v-model="data.params.searchType"
label="검색조건"
density="compact"
:items="searchOptions"
item-title="searchType"
item-value="searchText"
hide-details
></v-select>
</v-responsive>
<v-responsive
max-width="180"
min-width="180"
class="mr-3 mt-3 mb-3"
>
<v-select
v-model="data.params.searchType"
label="검색조건"
density="compact"
:items="searchExperimentOptions"
item-title="searchType"
item-value="searchText"
hide-details
></v-select>
</v-responsive>
<v-responsive
max-width="180"
min-width="180"
class="mr-3 mt-3 mb-3"
>
<v-select
v-model="data.params.searchType"
label="검색조건"
density="compact"
:items="searchWorkflowOptions"
item-title="searchType"
item-value="searchText"
hide-details
></v-select>
</v-responsive>
<v-responsive min-width="540" max-width="540">
<v-text-field
v-model="data.params.searchText"
label="검색어"
density="compact"
clearable
required
class="mt-3 mb-3"
hide-details
@keyup.enter="changePageNum(1)"
></v-text-field>
</v-responsive>
<div class="ml-3">
<v-btn
size="large"
color="primary"
:rounded="5"
@click="changePageNum(1)"
>
<v-icon> mdi-magnify</v-icon>
</v-btn>
</div>
</div>
</v-card>
<v-sheet
class="bg-shades-transparent d-flex flex-wrap align-center mb-2"
>
<v-sheet class="d-flex flex-wrap me-auto bg-shades-transparent">
<v-sheet
class="d-flex align-center mr-3 mb-2 bg-shades-transparent"
>
<v-chip color="primary"
> {{ data.totalDataLength.toLocaleString() }}
</v-chip>
</v-sheet>
<v-sheet class="bg-shades-transparent">
<v-responsive max-width="140" min-width="140" class="mb-2">
<v-select
v-model="data.params.pageSize"
density="compact"
:items="pageSizeOptions"
item-title="text"
item-value="value"
variant="outlined"
color="primary"
hide-details
@update:model-value="changePageNum(1)"
></v-select>
</v-responsive>
</v-sheet>
</v-sheet>
<v-sheet class="justify-end mb-2 mr-3" @click="handleTerminate">
<v-btn color="primary">Terminate </v-btn>
</v-sheet>
<v-sheet class="justify-end mb-2 mr-3" @click="handleRetry">
<v-btn color="primary">Retry </v-btn>
</v-sheet>
<v-sheet class="justify-end mb-2 mr-3" @click="handleClone">
<v-btn color="primary">Clone </v-btn>
</v-sheet>
<v-sheet class="justify-end mb-2 mr-3" @click="openComparePage">
<v-btn color="primary">Compare </v-btn>
</v-sheet>
<v-sheet class="justify-end mb-2" @click="openCreateExecution">
<v-btn color="primary">Execution </v-btn>
</v-sheet>
</v-sheet>
<v-card class="rounded-lg pa-8">
<v-col cols="12">
<v-sheet>
<v-table
density="comfortable"
fixed-header
height="625"
col-md-12
col-12
overflow-x-auto
>
<colgroup>
<col style="width: 5%" />
<col
v-for="(item, i) in tableHeader"
:key="i"
:style="`width:${item.width}`"
/>
</colgroup>
<thead>
<tr>
<th>
<v-checkbox
v-model="data.allSelected"
style="min-width: 36px"
:indeterminate="data.allSelected === true"
hide-details
@change="getSelectedAllData"
></v-checkbox>
</th>
<th
v-for="(item, i) in tableHeader"
:key="i"
class="text-center font-weight-bold"
:style="`${item.style}`"
>
{{ item.label }}
</th>
</tr>
</thead>
<tbody class="text-body-2">
<tr
v-for="(item, i) in pagedResults"
:key="item.run_id || item.no || i"
class="text-center"
>
<td>
<v-checkbox
v-model="data.selected"
:value="{ deviceKey: item.deviceKey }"
hide-details
style="min-width: 36px"
/>
</td>
<td>{{ displayNo(i) }}</td>
<td>{{ item.name }}</td>
<td>
<v-icon v-if="item.status === 'Succeeded'" color="green"
>mdi-check-circle</v-icon
>
<v-icon v-else-if="item.status === 'Failed'" color="red"
>mdi-close-circle</v-icon
>
<v-icon v-else color="grey">mdi-loading</v-icon>
</td>
<td>{{ item.duration }}</td>
<td>{{ item.experiment }}</td>
<td>{{ item.workflow }}</td>
<td>{{ item.startTime }}</td>
<td>{{ item.registryStatus }}</td>
<td style="white-space: nowrap">
<IconInfoBtn @on-click="openInfoModal(item)" />
<IconModifyBtn @on-click="openModifyModal(item)" />
<IconDownloadBtn @on-click="openDownloadModal(item)" />
</td>
</tr>
</tbody>
</v-table>
</v-sheet>
<v-card-actions class="text-center mt-8 justify-center">
<v-pagination
v-model="data.params.pageNum"
:length="data.pageLength"
:total-visible="10"
color="primary"
rounded="circle"
@update:model-value="changePageNum"
></v-pagination>
</v-card-actions>
</v-col>
</v-card>
</v-card>
</v-card>
<v-dialog v-model="execDialogOpen" max-width="800" persistent>
<ExecutionBaseDialog
:model-value="execDialogOpen"
:mode="execMode"
:selectedData="execSelected"
:workflowList="workflowList"
:executionTypes="executionTypes"
@update:modelValue="execDialogOpen = $event"
/>
</v-dialog>
</v-container>
</div>
<div class="w-100" v-else-if="openCompare">
<CompareComponent @close="closeCompare" />
</div>
<div class="w-100" v-else-if="openView">
<ViewComponent
v-if="openView"
:experimentInfo="execSelected"
@close="closeView"
/>
</div>
</template>
<style scoped></style>
Loading…
Cancel
Save