fix: 배포

main
jschoi 9 months ago
parent 4f344c930f
commit 7244a01bfa

@ -1,3 +1,3 @@
NODE_ENV = "dev"
VITE_APP_API_SERVER_URL = "http://localhost:80"
VITE_APP_API_SERVER_URL = "http://localhost:8080"
VITE_ROOT_PATH = ""

@ -6,14 +6,12 @@ type RunPayload = {
display_name: string;
description?: string;
pipeline_version_reference: { pipeline_id: string };
//
// runtime_config?: { parameters?: Record<string, any> };
// service_account?: string;
runtime_config?: { parameters?: Record<string, any> };
service_account?: string;
experiment_id?: string;
};
const props = defineProps<{
pipelineId?: string | number | null; // pipelineId
}>();
const props = defineProps<{ pipelineId?: string | number | null }>();
const emit = defineEmits<{
(e: "close-modal"): void;
@ -24,10 +22,17 @@ const form = ref({
display_name: "",
description: "",
pipeline_id: "",
experiment_id: "" as string,
});
const loading = ref(false);
const errorMsg = ref("");
//
type ExperimentOption = { label: string; value: string; created?: string };
const experimentOptions = ref<ExperimentOption[]>([]);
const expLoading = ref(false);
const isValid = computed(
() => !!form.value.display_name.trim() && !!form.value.pipeline_id.trim(),
);
@ -44,6 +49,44 @@ function onEsc(e: KeyboardEvent) {
onMounted(() => window.addEventListener("keydown", onEsc));
onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
/** 모든 experiments 수집해서 드롭다운 채우기 (콘솔 출력 제거) */
async function loadExperimentsAll() {
expLoading.value = true;
try {
const first = await kubeflowService.experiments({ pageSize: 500 });
const all: any[] = [...(first.data?.experiments ?? [])];
let token: string | undefined = first.data?.next_page_token;
if (token) {
const { data } = await kubeflowService.experiments({
pageSize: 500,
pageToken: token,
});
all.push(...(data?.experiments ?? []));
token = data?.next_page_token;
}
experimentOptions.value = all.map((x: any) => ({
label: x?.display_name ?? x?.name ?? "(no name)",
value: x?.experiment_id ?? x?.id ?? x?.name,
created: x?.created_at ?? x?.create_time,
}));
if (!form.value.experiment_id && experimentOptions.value.length > 0) {
form.value.experiment_id = experimentOptions.value[0].value;
}
} catch (e: any) {
errorMsg.value =
e?.response?.data?.message ||
e?.response?.data?.error ||
e?.message ||
"Experiments 로드에 실패했습니다.";
} finally {
expLoading.value = false;
}
}
onMounted(loadExperimentsAll);
async function submitRun() {
errorMsg.value = "";
if (!isValid.value) {
@ -53,11 +96,14 @@ async function submitRun() {
const payload: RunPayload = {
display_name: form.value.display_name.trim(),
// description
...(form.value.description.trim() && {
description: form.value.description.trim(),
}),
pipeline_version_reference: { pipeline_id: form.value.pipeline_id.trim() },
service_account: "pipeline-runner",
...(form.value.experiment_id && {
experiment_id: form.value.experiment_id,
}),
};
try {
@ -66,7 +112,6 @@ async function submitRun() {
emit("submitted", data);
emit("close-modal");
} catch (e: any) {
console.error("Run 생성 실패:", e);
errorMsg.value =
e?.response?.data?.message ||
e?.response?.data?.error ||
@ -89,41 +134,70 @@ async function submitRun() {
<v-card-text class="pa-6">
<v-form @submit.prevent="submitRun">
<!-- 제목 -->
<div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Run Title (display_name)
</label>
<v-text-field
v-model="form.display_name"
variant="outlined"
:disabled="loading"
hide-details
density="comfortable"
hide-details="auto"
required
/>
</div>
<!-- 내용 -->
<div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Run Description
</label>
<v-textarea
v-model="form.description"
variant="outlined"
:disabled="loading"
hide-details
rows="3"
density="comfortable"
hide-details="auto"
/>
</div>
<!-- pipeline_id (읽기 전용) -->
<div class="mb-2">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
pipeline_id
</label>
<v-text-field
v-model="form.pipeline_id"
variant="outlined"
:disabled="true"
hide-details
density="comfortable"
hide-details="auto"
required
/>
</div>
<!-- Experiment 선택 -->
<div class="mb-2">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Experiment
</label>
<v-select
v-model="form.experiment_id"
:items="experimentOptions"
item-title="label"
item-value="value"
variant="outlined"
:disabled="loading || expLoading"
hide-details="auto"
:loading="expLoading"
clearable
placeholder="Select experiment"
/>
</div>
<div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
</v-form>
</v-card-text>

@ -14,6 +14,9 @@ export const request = {
get: (uri: string, param: any): any => {
return axios.get(`${API_URL}${uri}`, { params: param });
},
getsize: (uri: string): any => {
return axios.get(`${API_URL}${uri}`);
},
delete: (uri: string, param: any): any => {
return axios.delete(`${API_URL}${uri}`, param);
},

@ -1,7 +1,9 @@
import { RunsSearchParams } from "@/components/models/management/Exeucutios";
import { request } from "@/components/service/index";
export const ExecutionsService = {
search: (payload: RunsSearchParams) => {
return request.get("/api/runs/runs", { params: payload });
},
search: (params?: {
pageToken?: string;
pageSize?: number;
sortBy?: string;
filter?: string;
}) => request.get("/api/runs/runs", params),
};

@ -0,0 +1,24 @@
import { request } from "@/components/service/index";
export type KubeflowRunSearchParams = {
experimentId?: string; // 실험 ID
page?: number; // 페이지 번호 (0부터 시작)
size?: number; // 한 페이지당 출력 건수
keyword?: string; // 공통 키워드 검색
searchType?: string; // 검색 유형 (전체, 제목, 작성자 등)
startDate?: string; // 등록일자 검색 시작 (yyyy-MM-dd)
endDate?: string; // 등록일자 검색 종료 (yyyy-MM-dd)
sortField?: string; // 정렬 기준 필드명
sortDirection?: "ASC" | "DESC"; // 정렬 방향
};
export const kubeflowRunService = {
getAll: () => {
return request.get("/api/kubeflow/runs", {});
},
singleData: (runId: number) => {
return request.get(`/api/kubeflow/runs/${runId}`, {});
},
search: (params?: KubeflowRunSearchParams) => {
return request.get("/api/kubeflow/runs", params);
},
};

@ -7,7 +7,9 @@ export const kubeflowService = {
run: (payload: kubeflow) => {
return request.post("/pipelines/runs", payload);
},
kubeflowSize: (payload: kubeflow) => {
return request.post("/pipelines/experiments", payload);
},
experiments: (params?: {
namespace?: string;
pageSize?: number;
pageToken?: string;
}) => request.get("/api/kubeflow/experiments", params),
};

@ -2,6 +2,8 @@
import { onMounted, ref, computed, watch } from "vue";
import Plotly from "plotly.js-dist-min";
import { WorkflowService } from "@/components/service/management/WorkflowService";
import { ExecutionsService } from "@/components/service/management/ExecutionsService";
import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { useAutoflowStore } from "@/stores/autoflowStore";
const store = useAutoflowStore();
@ -10,29 +12,234 @@ const currentProjectId = computed(() => store.projectId);
const pieChartRef = ref<HTMLElement | null>(null);
const workflows = ref<any[]>([]);
const recentLimit = 10;
const runsLoading = ref(false);
const recentRuns = ref<
{
name: string;
status: "success" | "failed" | "running" | "pending";
time: string;
}[]
>([]);
// dataset
const dsLoading = ref(false);
type DsItem = {
name: string;
version: number;
rows: number;
last?: string;
pct: number;
};
const dsItems = ref<DsItem[]>([]);
const dsWindowDays = ref<7 | 30 | 90>(30);
//
const toTime = (x?: string) => new Date(x ?? 0).getTime();
const fmtYmd = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "-");
async function loadDatasetActivity() {
dsLoading.value = true;
try {
const payload = {
projectId: currentProjectId.value,
page: 0,
size: 1000,
refType: "DATASET",
sortField: "id",
sortDirection: "DESC",
};
const res = await AttachmentsService.search(payload as any);
const list: any[] = res?.data?.content ?? res?.data ?? [];
const map = new Map<
string,
{ name: string; version: number; rows: number; last?: string }
>();
for (const a of list) {
const name = String(a?.title ?? a?.originalName ?? "(no name)");
const v = Number(a?.version) || 1; // null
const reg = (a?.modDt ?? a?.regDt ?? a?.createdAt) as string | undefined;
const cur = map.get(name) ?? {
name,
version: 0,
rows: 0,
last: undefined,
};
cur.rows += 1;
cur.version = Math.max(cur.version, v); //
if (!cur.last || (reg && toTime(reg) > toTime(cur.last))) cur.last = reg;
map.set(name, cur);
}
const arr = Array.from(map.values());
const maxVer = Math.max(1, ...arr.map((i) => i.version));
dsItems.value = arr
.map((i) => ({ ...i, pct: Math.round((i.version / maxVer) * 100) }))
.sort((a, b) => toTime(b.last) - toTime(a.last) || b.version - a.version);
} catch (e) {
console.error("[Home] loadDatasetActivity error:", e);
dsItems.value = [];
} finally {
dsLoading.value = false;
}
}
function toUiStatus(state?: string) {
switch ((state || "").toUpperCase()) {
case "SUCCEEDED":
return "success";
case "FAILED":
return "failed";
case "RUNNING":
return "running";
case "PENDING":
return "pending";
default:
return "pending";
}
}
function fmtYmdHm(iso?: string) {
if (!iso) return "-";
const d = new Date(iso);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
async function loadRecentRuns() {
runsLoading.value = true;
try {
const all: any[] = [];
const seen = new Set<string>();
//
let page = await ExecutionsService.search({ pageSize: 200 });
all.push(...(page?.data?.runs ?? []));
let token = page?.data?.next_page_token ?? page?.data?.nextPageToken;
// ( )
let guard = 0;
while (token && !seen.has(token) && guard < 20) {
seen.add(token);
// snake_case
page = await ExecutionsService.search({
pageToken: token,
pageSize: 200,
} as any);
all.push(...(page?.data?.runs ?? []));
token = page?.data?.next_page_token ?? page?.data?.nextPageToken;
guard++;
}
//
const dedup = Array.from(
new Map(all.map((r) => [r?.run_id ?? r?.id ?? r?.name, r])).values(),
);
// ()
// const filtered = dedup.filter(r => String(r?.projectId ?? "") === String(currentProjectId.value));
const latest = dedup
.sort(
(a, b) =>
new Date(b?.created_at ?? 0).getTime() -
new Date(a?.created_at ?? 0).getTime(),
)
.slice(0, recentLimit)
.map((r) => ({
name: r?.display_name ?? r?.name ?? r?.run_id ?? "(no name)",
status: toUiStatus(r?.state),
time: fmtYmdHm(r?.created_at),
}));
recentRuns.value = latest;
} catch (e) {
console.error("[Home] loadRecentRuns error:", e);
recentRuns.value = [];
} finally {
runsLoading.value = false;
}
}
// ---- kubeflowStatus ( , ) ----
function renderStatusPie() {
if (!pieChartRef.value) return;
// 1)
const COLOR = {
SUCCEEDED: "#2ecc71",
FAILED: "#e74c3c",
RUNNING: "#3498db",
PENDING: "#f1c40f",
CREATED: "#3498db",
SKIPPED: "#95a5a6",
UNKNOWN: "#7f8c8d",
NODATA: "#4b4f55",
};
// 2)
const counts = new Map<string, number>();
for (const wf of workflows.value ?? []) {
const status =
String(wf?.kubeflowStatus ?? wf?.kubeflow_status ?? "Unknown").trim() ||
"Unknown";
const raw = (wf?.kubeflowStatus ?? wf?.kubeflow_status ?? "UNKNOWN") + "";
const status = raw.toUpperCase().trim();
counts.set(status, (counts.get(status) || 0) + 1);
}
// 3) No Data ( + / )
if (counts.size === 0) {
const trace: Partial<Plotly.PlotData> = {
values: [1],
labels: ["No Data"],
type: "pie",
hole: 0.55,
textinfo: "none",
marker: { colors: [COLOR.NODATA] },
hoverinfo: "skip",
};
const layout: Partial<Plotly.Layout> = {
paper_bgcolor: "#1e1e1e",
plot_bgcolor: "#1e1e1e",
showlegend: false,
margin: { t: 20, b: 40, l: 0, r: 0 },
annotations: [
{
text: "<b>No Data</b>",
showarrow: false,
font: { color: "#ffffff", size: 16 },
},
],
};
Plotly.react(pieChartRef.value, [trace], layout, { displayModeBar: false });
return;
}
// 4) :
const labels = Array.from(counts.keys());
const values = Array.from(counts.values());
const colors = labels.map((s) => {
if (s.includes("SUCCEED")) return COLOR.SUCCEEDED;
if (s.includes("FAIL")) return COLOR.FAILED;
if (s.includes("RUN")) return COLOR.RUNNING;
if (s.includes("CREATE")) return COLOR.CREATED;
if (s.includes("PEND")) return COLOR.PENDING;
if (s.includes("SKIP")) return COLOR.SKIPPED;
return COLOR.UNKNOWN;
});
const trace: Partial<Plotly.PlotData> = {
values: values.length ? values : [1],
labels: labels.length ? labels : ["No Data"],
values,
labels,
type: "pie",
textinfo: "label+percent",
textfont: { color: "#fff", size: 14 },
hole: 0.4,
textinfo: "label+percent",
textfont: { color: "#fff", size: 13 },
marker: { colors },
hovertemplate: "%{label}: %{percent}<extra></extra>",
};
const layout: Partial<Plotly.Layout> = {
@ -52,13 +259,6 @@ function renderStatusPie() {
Plotly.react(pieChartRef.value, [trace], layout, { displayModeBar: false });
}
// ---- ( ) ----
const recentRuns = [
{ name: "Model A - v1", status: "success", time: "2025-05-12 09:12" },
{ name: "Model B - tuning", status: "success", time: "2025-05-14 08:59" },
{ name: "Model C - test run", status: "failed", time: "2025-05-13 18:13" },
];
const datasetUpdates = [
{ name: "DrivingLog2025", count: 7 },
{ name: "CameraFrames", count: 3 },
@ -174,9 +374,10 @@ async function loadWorkflows() {
}
onMounted(async () => {
// ,
renderStatusPie();
await loadRecentRuns();
await loadWorkflows();
await loadDatasetActivity();
});
//
@ -246,32 +447,77 @@ watch(currentProjectId, () => loadWorkflows());
<div style="padding: 16px; border-bottom: 1px solid #ccc">
<h3 class="text-subtitle-1 font-weight-bold mb-0">Recent Run</h3>
</div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<v-list density="comfortable">
<!-- 로딩 표시 -->
<v-skeleton-loader
v-if="runsLoading"
type="list-item-two-line"
class="mb-2"
v-for="i in 4"
:key="i"
/>
<!-- 데이터 있을 -->
<v-list v-else density="comfortable">
<v-list-item
v-for="(run, idx) in recentRuns"
:key="idx"
class="py-2"
>
<div class="d-flex align-center">
<v-avatar
size="28"
:color="
run.status === 'success'
? 'green lighten-1'
: 'red lighten-1'
"
>
<v-icon size="20" color="white">
{{ run.status === "success" ? "mdi-check" : "mdi-close" }}
</v-icon>
</v-avatar>
<!-- 기존 div 대신 블록으로 교체 -->
<div class="d-flex align-center justify-space-between w-100">
<!-- 왼쪽: 상태칩 + (선택) 아이콘 -->
<div class="d-flex align-center ga-2">
<!-- 아이콘을 계속 쓰고 싶으면 유지, 아니면 v-avatar는 삭제해도 -->
<v-avatar
size="28"
:color="
run.status === 'success'
? 'green-lighten-1'
: run.status === 'failed'
? 'red-lighten-1'
: run.status === 'running'
? 'blue-lighten-1'
: 'grey-darken-1'
"
>
<v-icon size="20" color="white">
{{
run.status === "success"
? "mdi-check"
: run.status === "failed"
? "mdi-close"
: run.status === "running"
? "mdi-progress-clock"
: "mdi-clock-outline"
}}
</v-icon>
</v-avatar>
<v-chip
size="small"
:color="
run.status === 'success'
? 'success'
: run.status === 'failed'
? 'error'
: run.status === 'running'
? 'info'
: 'grey'
"
variant="tonal"
class="text-uppercase"
>
{{ run.status }}
</v-chip>
</div>
<!-- 오른쪽: 이름/시간(오른쪽 정렬) -->
<div
class="d-flex flex-column text-right ml-4"
style="flex: 1"
class="d-flex flex-column text-right"
style="min-width: 220px"
>
<span class="font-weight-medium text-body-2">
<span class="font-weight-medium text-body-2 truncate">
{{ run.name }}
</span>
<span class="text-caption text-grey-darken-1">
@ -280,6 +526,14 @@ watch(currentProjectId, () => loadWorkflows());
</div>
</div>
</v-list-item>
<!-- 상태 -->
<v-list-item v-if="!runsLoading && recentRuns.length === 0">
<template #title>
<div class="text-caption text-grey">
최근 실행 데이터가 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-card>
@ -287,23 +541,65 @@ watch(currentProjectId, () => loadWorkflows());
<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">
Dataset Update Activity
</h3>
<div class="d-flex align-center justify-space-between w-100">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
Dataset Update Activity
</h3>
</div>
</div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<v-list dense>
<v-list-item v-for="(data, idx) in datasetUpdates" :key="idx">
<v-list-item-title>{{ data.name }}</v-list-item-title>
<v-skeleton-loader
v-if="dsLoading"
type="list-item-three-line"
class="mb-2"
v-for="i in 5"
:key="i"
/>
<v-list v-else density="comfortable">
<v-list-item v-for="it in dsItems" :key="it.name" class="py-2">
<div class="d-flex justify-space-between align-center w-100">
<span class="text-body-2 font-weight-medium"
>{{ it.name }}
<v-chip
size="small"
color="primary"
variant="tonal"
class="py-2"
>
v{{ it.version }}
</v-chip></span
>
<!-- 최신 버전 -->
<div class="d-flex align-center ga-2">
<span class="text-caption text-medium-emphasis">
Last: {{ fmtYmd(it.last) }}
</span>
</div>
</div>
<v-progress-linear
:model-value="data.count * 10"
:model-value="it.pct"
height="8"
color="primary"
class="mt-1"
/>
<v-list-item-subtitle
>{{ data.count }} updates</v-list-item-subtitle
>
<div class="text-caption text-grey mt-1">
{{ it.version }} Update{{ it.version > 1 ? "s" : "" }}
<span v-if="it.rows && it.rows !== it.version">
{{ it.rows }} Rows</span
>
</div>
</v-list-item>
<v-list-item v-if="!dsLoading && dsItems.length === 0">
<template #title>
<div class="text-caption text-grey">
표시할 데이터셋이 없습니다.
</div>
</template>
</v-list-item>
</v-list>
</div>

@ -1,28 +1,27 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import tz from "dayjs/plugin/timezone";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconInfoBtn from "@/components/atoms/button/IconInfoBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.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";
import type { RunsSearchParams } from "@/components/models/management/Exeucutios";
dayjs.extend(utc);
dayjs.extend(tz);
const KST = "Asia/Seoul";
import { onMounted, ref } 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/KubefliwRunService";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
/* ===== UI 상태 ===== */
const openCompare = ref(false);
const store = commonStore();
const openView = ref(false);
const username = ref<string>("");
const selectedExperiment = ref<{
name: string;
description: string;
createdDate: string;
createdID: string;
deviceKey: number;
} | null>(null);
// ===== =====
const tableHeader = [
{ label: "No", width: "5%", style: "word-break: keep-all;" },
{ label: "Execution Name", width: "20%", style: "word-break: keep-all;" },
@ -32,26 +31,23 @@ const tableHeader = [
{ 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;" },
{ label: "Action", width: "10%", style: "word-break: keep-all;" },
];
// ===== / (/ '') =====
type SearchType = "전체" | "제목" | "작성자";
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" },
{ label: "전체", value: "전체" as SearchType },
{ label: "제목", value: "제목" as SearchType },
{ label: "작성자", value: "작성자" as SearchType },
];
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 SEARCH_TYPE_MAP: Record<SearchType | "", "ALL" | "TITLE" | "AUTHOR"> = {
"": "ALL",
전체: "ALL",
제목: "TITLE",
작성자: "AUTHOR",
};
const pageSizeOptions = [
{ text: "10 페이지", value: 10 },
@ -59,289 +55,391 @@ const pageSizeOptions = [
{ text: "100 페이지", value: 100 },
];
type UiRow = {
no: number;
name: string;
status: string;
duration: string;
experiment: string;
workflow: string;
startTime: string;
registryStatus: string;
deviceKey: string; // run_id
};
// ===== =====
const data = ref({
params: {
pageNum: 1,
pageSize: 10,
searchType: "",
searchType: "전체" as SearchType,
searchText: "",
},
results: [] as UiRow[],
totalDataLength: 0,
results: [] as any[],
totalElements: 0,
pageLength: 0,
modalMode: "",
modalMode: "" as "create" | "edit" | "",
selectedData: null as any,
allSelected: false,
selected: [] as Array<{ deviceKey: string }>,
selected: [] as any[],
isCreateVisible: false,
isModalVisible: false,
isConfirmDialogVisible: false,
userOption: [] as any[],
});
/* ===== page_token 기반 페이지네이션 캐시 ===== */
const pageTokens = ref<string[]>([""]); // index=0 ( )
const nextPageToken = ref<string | undefined>();
/* ===== 헬퍼 ===== */
const mapState = (s?: string) => {
switch ((s || "").toUpperCase()) {
case "SUCCEEDED":
return "Succeeded";
case "FAILED":
return "Failed";
case "RUNNING":
return "Running";
case "PENDING":
return "Pending";
default:
return s || "";
// ===== =====
function readUsernameFromStorage(): string {
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 formatRegistry = (v?: string) => {
const s = (v || "").toUpperCase();
if (s === "AVAILABLE") return "Registered";
if (s === "ARCHIVED") return "Archived";
return v || "";
};
const kst = (v?: string | number | Date) =>
v ? dayjs(v).tz(KST).format("YYYY-MM-DD HH:mm:ss") : "";
const duration = (start?: string, end?: string) => {
if (!start) return "";
const s = dayjs(start);
const e = end ? dayjs(end) : dayjs();
const diff = dayjs.duration(e.diff(s));
const h = Math.floor(diff.asHours());
const m = String(diff.minutes()).padStart(2, "0");
const sec = String(diff.seconds()).padStart(2, "0");
return `${h}:${m}:${sec}`;
};
const toRow = (run: any, no: number): UiRow => ({
no,
name: run?.display_name ?? run?.name ?? run?.run_id ?? "-",
status: mapState(run?.state),
duration: duration(run?.created_at, run?.finished_at),
experiment: run?.experiment_id ?? "",
workflow:
run?.pipeline_version_reference?.pipeline_id ??
run?.pipeline_version_reference?.pipeline_version_id ??
"",
startTime: kst(run?.created_at),
registryStatus: formatRegistry(run?.storage_state),
deviceKey: run?.run_id,
});
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)}`;
};
/* ===== 모든 페이지 콘솔 덤프 + 첫 페이지(최대 1000건) 테이블 반영 ===== */
const dumpAllRunsToConsole = async () => {
let token: string | undefined = undefined;
let page = 1;
const pageSize = 10;
const all: any[] = [];
let totalSizeFromServer: number | undefined;
do {
const payload: RunsSearchParams = {
page_token: token,
page_size: pageSize,
sort_by: "created_at_desc",
};
const res = await ExecutionsService.search(payload);
const body = res?.data ?? {};
const runs: any[] = Array.isArray(body.runs) ? body.runs : [];
const next = body.next_page_token as string | undefined;
if (page === 1) {
totalSizeFromServer =
Number(body.total_size ?? runs.length) || runs.length;
console.log(`[Runs] total_size (server): ${totalSizeFromServer}`);
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 || "-";
}
};
console.log(
`[Runs] page ${page} — count: ${runs.length}, next_page_token: ${
next ? "YES" : "NO"
}`,
);
console.table(
runs.map((r: any, i: number) => ({
i,
run_id: r.run_id,
name: r.display_name ?? r.name,
state: r.state,
created_at: r.created_at,
finished_at: r.finished_at,
})),
);
all.push(...runs);
token = next;
page += 1;
} while (token);
console.log(`[Runs] aggregated count (fetched): ${all.length}`);
if (typeof totalSizeFromServer === "number") {
console.log(
`[Runs] server total_size vs fetched: ${totalSizeFromServer} vs ${all.length}`,
);
}
const { pageNum, pageSize } = data.value.params;
// ( 1000)
const firstPage = all.slice(0, pageSize);
const firstNoStart = totalSizeFromServer ?? all.length;
data.value.results = firstPage.map((r, i) => toRow(r, firstNoStart - i));
data.value.totalDataLength = totalSizeFromServer ?? all.length;
data.value.pageLength = Math.max(
1,
Math.ceil((totalSizeFromServer ?? all.length) / pageSize),
);
// ( )
pageTokens.value = [""];
nextPageToken.value = undefined;
};
return {
no: (pageNum - 1) * pageSize + (idx + 1),
name: r.displayName ?? r.name ?? r.runId ?? "(no name)",
status: toUiStatus(r.state),
duration: fmtDuration(r.createdAt, r.finishedAt),
experiment: r.experimentId ?? "-",
/* ===== (옵션) 단일 페이지 가져오기 — 테이블 재조회 필요 시 사용 ===== */
const fetchPage = async (pageIndex: number) => {
const token = pageTokens.value[pageIndex - 1] || undefined;
workflow: r.pipelineId ?? r.pipelineVersionId ?? "-",
startTime: fmtStart(r.createdAt),
registryStatus: r.storageState ?? "-",
run_id: r.runId,
raw: r,
};
};
const payload: RunsSearchParams = {
page_token: token,
page_size: data.value.params.pageSize, // 1000
sort_by: "created_at_desc",
// async function fetchList() {
// const { pageNum, pageSize, searchType, searchText } = data.value.params;
// const mapped = SEARCH_TYPE_MAP[searchType] || "ALL";
// const keyword = (searchText || "").trim();
// const needLocalFilter = mapped !== "ALL" && keyword.length > 0;
// // (: 1000) +
// // 0-based
// const reqPage = needLocalFilter ? 0 : pageNum ;
// const reqSize = needLocalFilter ? 1000 : pageSize;
// const payload = {
// projectId: getProjectId(),
// page: reqPage, // : reqPage
// size: reqSize, // : reqSize
// keyword,
// searchType: mapped,
// 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",
};
const res = await ExecutionsService.search(payload);
const body = res?.data ?? {};
const runs: any[] = Array.isArray(body.runs) ? body.runs : [];
const total = Number(body.total_size ?? runs.length) || runs.length;
console.log(
`[Runs] total_size: ${total}, pageIndex: ${pageIndex}, page_size: ${data.value.params.pageSize}, this_page: ${runs.length}, next_page_token:`,
body.next_page_token || "(none)",
);
console.table(
runs.map((r, i) => ({
i,
run_id: r.run_id,
name: r.display_name ?? r.name,
state: r.state,
created_at: r.created_at,
finished_at: r.finished_at,
})),
);
const pageOffset = (pageIndex - 1) * data.value.params.pageSize;
const firstNo = Math.max(total - pageOffset, 1);
data.value.results = runs.map((r, i) => toRow(r, firstNo - i));
data.value.totalDataLength = total;
data.value.pageLength = Math.max(
1,
Math.ceil(total / data.value.params.pageSize),
);
nextPageToken.value = body.next_page_token;
if (nextPageToken.value && !pageTokens.value[pageIndex]) {
pageTokens.value[pageIndex] = nextPageToken.value;
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 {
list = [];
}
if (!isServerPaged) {
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.results = slice.map((r, i) => toRow(r, i));
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 changePageNum = (page: number) => {
data.value.params.pageNum = page;
fetchList();
};
const changePageSize = (size: number) => {
data.value.params.pageSize = size;
data.value.params.pageNum = 1;
fetchList();
};
/* ===== 템플릿 이벤트에 맞춘 함수들 ===== */
const getData = async (newPage?: number) => {
if (typeof newPage === "number") data.value.params.pageNum = newPage;
// / ( )
const removeData = (value?: Array<{ deviceKey: number }>) => {
const removeList = value ?? data.value.selected;
if (!removeList || removeList.length === 0) return;
const ids = removeList.map((x) => x.deviceKey);
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;
}
// page_size 1000 1
await fetchPage(1);
};
fetchList();
data.value.isConfirmDialogVisible = false;
data.value.selected = [];
data.value.allSelected = false;
};
const changePageNum = async (page: number) => {
data.value.params.pageNum = page;
await getData(page);
// /
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 openCreateExecution = () => {
execMode.value = "create";
execSelected.value = null;
execDialogOpen.value = true;
};
const openComparePage = () => {
openCompare.value = true;
// ===== & ( ) =====
const closeDetail = () => {
openView.value = false;
selectedExperiment.value = null;
};
const openInfoModal = () => {
const onSaved = () => fetchList();
const openDetailModal = (selectedItem: any) => {
console.log("[Experiment/List] row clicked:", selectedItem);
if (!selectedItem?.deviceKey) {
console.warn("[Experiment/List] deviceKey 없음!", selectedItem);
}
data.value.selectedData = selectedItem;
openView.value = true;
openCompare.value = false;
};
const openModifyModal = () => {
execMode.value = "edit";
execDialogOpen.value = true;
const openCreateModal = () => {
data.value.modalMode = "create";
data.value.selectedData = {
username: username.value,
projectId: getProjectId(),
};
data.value.isCreateVisible = true;
};
const openDownloadModal = () => {
const closeModal = () => {
data.value.isCreateVisible = false;
data.value.selectedData = null;
data.value.modalMode = "download";
};
const handleTerminate = () => alert("Terminate 작업 진행중...");
const handleRetry = () => alert("Retry 작업 진행중...");
const handleClone = () => alert("Clone 작업 진행중...");
const getSelectedAllData = () => {
data.value.selected = data.value.allSelected
? data.value.results.map((x) => ({ deviceKey: x.deviceKey }))
: [];
};
// 1000
watch(
() => data.value.params.pageSize,
async (v) => {
if (v !== 1000) data.value.params.pageSize = 1000;
pageTokens.value = [""];
nextPageToken.value = undefined;
await getData(1);
},
);
/* 초기 로드 */
onMounted(async () => {
// 1)
await dumpAllRunsToConsole();
// 2) () getData(1)
// await getData(1);
onMounted(() => {
username.value = readUsernameFromStorage();
fetchList();
});
</script>
<template>
<div class="w-100" v-if="!openCompare && !openView">
<!-- <v-dialog v-model="data.isModalVisible" max-width="600" persistent>
<FormComponent
:edit-data="data.selectedData"
:mode="data.modalMode"
@close-modal="closeModal"
@handle-data="saveData"
:user-option="data.userOption"
/>
</v-dialog>
<v-dialog v-model="data.isConfirmDialogVisible" persistent max-width="300">
<ConfirmDialogComponent
@cancel="data.isConfirmDialogVisible = false"
@delete="removeData(undefined)"
@init="((data.selected = []), (data.allSelected = false))"
/>
</v-dialog> -->
<div class="w-100" v-if="!openView">
<v-container fluid class="h-100 pa-5 d-flex flex-column align-center">
<v-card
flat
@ -354,7 +452,9 @@ onMounted(async () => {
</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
@ -367,41 +467,12 @@ onMounted(async () => {
label="검색조건"
density="compact"
:items="searchOptions"
item-title="searchType"
item-value="searchText"
item-title="label"
item-value="value"
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"
@ -411,8 +482,8 @@ onMounted(async () => {
required
class="mt-3 mb-3"
hide-details
@keyup.enter="changePageNum(1)"
></v-text-field>
@keyup.enter="doSearch"
/>
</v-responsive>
<div class="ml-3">
@ -420,14 +491,15 @@ onMounted(async () => {
size="large"
color="primary"
:rounded="5"
@click="changePageNum(1)"
@click="doSearch"
>
<v-icon> mdi-magnify</v-icon>
<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"
>
@ -436,8 +508,8 @@ onMounted(async () => {
class="d-flex align-center mr-3 mb-2 bg-shades-transparent"
>
<v-chip color="primary"
> {{ data.totalDataLength.toLocaleString() }}
</v-chip>
> {{ data.totalElements.toLocaleString() }}</v-chip
>
</v-sheet>
<v-sheet class="bg-shades-transparent">
<v-responsive max-width="140" min-width="140" class="mb-2">
@ -450,28 +522,20 @@ onMounted(async () => {
variant="outlined"
color="primary"
hide-details
@update:model-value="changePageNum(1)"
></v-select>
@update:model-value="changePageSize"
/>
</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 class="justify-end mb-2">
<v-btn color="primary" @click="openCreateModal"
>Create Experiment</v-btn
>
</v-sheet> -->
</v-sheet>
<!-- 목록 -->
<v-card class="rounded-lg pa-8">
<v-col cols="12">
<v-sheet>
@ -479,12 +543,9 @@ onMounted(async () => {
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"
@ -493,20 +554,11 @@ onMounted(async () => {
</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}`"
:style="item.style"
>
{{ item.label }}
</th>
@ -514,20 +566,12 @@ onMounted(async () => {
</thead>
<tbody class="text-body-2">
<tr
v-for="item in data.results"
:key="item.no"
v-for="(item, i) in data.results"
:key="i"
class="text-center"
>
<td>
<v-checkbox
v-model="data.selected"
:value="{ deviceKey: item.deviceKey }"
hide-details
style="min-width: 36px"
/>
</td>
<td>{{ item.no }}</td>
<td>{{ item.name }}</td>
<td class="text-truncate">{{ item.name }}</td>
<td>
<v-icon v-if="item.status === 'Succeeded'" color="green"
>mdi-check-circle</v-icon
@ -543,14 +587,20 @@ onMounted(async () => {
<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)" />
<IconDownloadBtn />
<IconDeleteBtn
@on-click="
removeData([
{ deviceKey: item.raw?.id ?? item.run_id },
])
"
/>
</td>
</tr>
</tbody>
</v-table>
</v-sheet>
<v-card-actions class="text-center mt-8 justify-center">
<v-pagination
v-model="data.params.pageNum"
@ -558,31 +608,33 @@ onMounted(async () => {
:total-visible="10"
color="primary"
rounded="circle"
@update:model-value="getData"
></v-pagination>
@update:model-value="changePageNum"
/>
</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" />
<!-- 생성 다이얼로그 -->
<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 class="w-100" v-else-if="openView">
<ViewComponent @close="closeView" />
<div class="w-100" v-else>
<ViewComponent
v-if="data.selectedData"
:id="data.selectedData.deviceKey"
@close="closeDetail"
/>
</div>
</template>

@ -2,228 +2,100 @@
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
// import FormComponent from "@/components/device/FormComponent.vue";
import { onMounted, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
// const store = commonStore();
const experimentInfo = ref({
executionsName: "run-batch32-lr0.001",
status: "Succeeded",
duration: "0:00:21",
experiment: "Baseline Model Training",
workflow: "baseline_train_pipeline",
startTime: "2025-05-20 10:12",
registryStatus: "Registered",
});
const props = defineProps<{
experimentInfo: any;
}>();
const otaInfo = ref({
packageName: "자율주행 타차량 예측",
os: "Linux",
packageFileName: "4_EdgeInfra_Perception.sh",
packageFilePath: "/home/etri/TeslaSystem/EdgeInfraVision/RUN",
softwareName: "4_EdgeInfra_Perception.sh",
softwareVersion: "v2.0",
execute: "Not Executed",
});
const emit = defineEmits<{ (e: "close"): void }>();
const history = computed(() =>
(props.experimentInfo.raw?.state_history ?? [])
.slice()
.sort(
(a: any, b: any) =>
new Date(a.update_time).getTime() - new Date(b.update_time).getTime(),
),
);
const data = ref({
params: {
pageNum: 1,
pageSize: 10,
searchType: "",
searchText: "",
},
results: [],
totalDataLength: 0,
pageLength: 0,
modalMode: "",
selectedData: null,
allSelected: false,
selected: [],
isModalVisible: false,
isConfirmDialogVisible: false,
userOption: [],
//
const hPending = computed(() =>
history.value.find((h) => (h.state || "").toUpperCase() === "PENDING"),
);
const hRunning = computed(() =>
history.value.find((h) => (h.state || "").toUpperCase() === "RUNNING"),
);
const hTerminal = computed(() => {
const t = history.value
.slice()
.reverse()
.find((h) =>
["SUCCEEDED", "FAILED"].includes((h.state || "").toUpperCase()),
);
return t ?? null;
});
const getCodeList = () => {
// UserService.search(data.value.params).then((d) => {
// if (d.status === 200) {
// data.value.userOption = d.data.userList;
// }
// });
};
// 3
const steps = computed(() => {
const lastLabel = (hTerminal.value?.state || "COMPLETED").toUpperCase();
const getData = () => {
const params = { ...data.value.params };
if (params.searchType === "" || params.searchText === "") {
delete params.searchType;
delete params.searchText;
}
data.value.results = [
{
name: "run-batch32-lr0.001",
status: "Succeeded",
Duration: "0:00:21",
configProgress: "0/2",
Pipeline: "baseline_train_pipeline",
registDt: "2025-06-10T00:00:00Z",
},
{
name: "run-batch64-lr0.001",
status: "Failed",
Duration: "0:00:21",
configProgress: "1/3",
Pipeline: "baseline_train_pipeline",
registDt: "2025-06-09T00:00:00Z",
},
return [
{
name: "run-batch32-lr0.0005",
status: "Succeeded",
Duration: "0:00:21",
configProgress: "0/3",
Pipeline: "baseline_train_pipeline",
registDt: "2025-06-01T00:00:00Z",
key: "PENDING",
label: "PENDING",
active: !!(hPending.value || hRunning.value || hTerminal.value),
color: "primary",
icon: "mdi-clock-outline",
ts: hPending.value?.update_time,
},
{
name: "run-batch64-lr0.0005",
status: "Running",
Duration: "0:00:21",
configProgress: "1/3",
Pipeline: "baseline_train_pipeline",
registDt: "2025-05-29T00:00:00Z",
key: "RUNNING",
label: "RUNNING",
active: !!(hRunning.value || hTerminal.value),
color: "info",
icon: "mdi-progress-clock",
ts: hRunning.value?.update_time,
},
{
name: "run-augmented-data",
status: "Succeeded",
Duration: "0:00:21",
configProgress: "0/3",
Pipeline: "baseline_train_pipeline",
registDt: "2025-05-31T00:00:00Z",
key: "TERMINAL",
label: ["SUCCEEDED", "FAILED"].includes(lastLabel)
? lastLabel
: "COMPLETED",
active: !!hTerminal.value,
color:
lastLabel === "FAILED"
? "error"
: lastLabel === "SUCCEEDED"
? "success"
: "surface-variant",
icon:
lastLabel === "FAILED"
? "mdi-close"
: lastLabel === "SUCCEEDED"
? "mdi-check"
: "mdi-dots-horizontal",
ts: hTerminal.value?.update_time,
},
];
data.value.totalDataLength = 5;
setPaginationLength();
// DeviceService.search(params).then((d) => {
// if (d.status === 200) {
// data.value.results = d.data.deviceList;
// data.value.totalDataLength = d.data.totalCount;
// setTimeout(() => {
// setPaginationLength();
// }, 200);
// } else {
// store.setSnackbarMsg({
// text: " ",
// color: "error",
// });
// }
// });
// DeviceService.search().then((d) => {
// data.value.totalDataLength = d.data.totalCount;
// setTimeout(() => {
// setPaginationLength();
// }, 200);
// });
};
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 saveData = (formData) => {
if (data.value.modalMode === "create") {
// DeviceService.add(formData).then((d) => {
// if (d.status === 200) {
// data.value.isModalVisible = false;
// store.setSnackbarMsg({
// text: " .",
// result: 200,
// });
// changePageNum(1);
// } else {
// store.setSnackbarMsg({
// text: d,
// result: 500,
// });
// }
// });
} else {
// DeviceService.update(formData.deviceKey, formData).then((d) => {
// if (d.status === 200) {
// data.value.isModalVisible = false;
// store.setSnackbarMsg({
// text: " .",
// result: 200,
// });
// changePageNum();
// } else {
// store.setSnackbarMsg({
// text: d,
// result: 500,
// });
// }
// });
}
};
const removeData = (value) => {
let removeList = value ? value : data.value.selected;
const remove = (code) => {
// return DeviceService.delete(code).then((d) => {
// if (d.status !== 200) {
// store.setSnackbarMsg({
// text: d,
// result: 500,
// });
// }
// });
};
});
if (removeList.length === 1) {
remove(removeList[0].deviceKey).then(() => {
// store.setSnackbarMsg({
// text: ".",
// result: 200,
// });
changePageNum();
data.value.isConfirmDialogVisible = false;
data.value.selected = [];
data.value.allSelected = false;
});
} else {
Promise.all(removeList.map((item) => remove(item.deviceKey))).finally(
() => {
// store.setSnackbarMsg({
// text: " .",
// result: 200,
// });
changePageNum();
data.value.isConfirmDialogVisible = false;
data.value.selected = [];
data.value.allSelected = false;
},
);
}
};
// 3 /
const nSteps = 3;
const activeIndex = computed(
() => steps.value.map((s) => s.active).lastIndexOf(true), // -1 X
);
const leftPct = (i: number) => (i / (nSteps - 1)) * 100;
const segWidthPct = () => 100 / (nSteps - 1);
const changePageNum = (page) => {
data.value.params.pageNum = page;
getData();
};
const emit = defineEmits<{
(e: "close"): void;
}>();
//
const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
//
onMounted(() => {
getData();
getCodeList();
console.log("[Child] 받은 데이터:", props.experimentInfo);
});
</script>
@ -243,97 +115,204 @@ onMounted(() => {
<v-card flat class="bordered-box mb-6 w-100 rounded-lg pa-8">
<v-card-title class="grey lighten-4 py-2 px-4">
<span class="font-weight-bold">Deploy Model Information </span>
<span class="font-weight-bold">Execution Information</span>
</v-card-title>
<v-card-text class="px-6 pb-6 pt-4">
<!-- Experiment Name -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Executions Name
</v-col>
<v-col cols="9" class="pa-2">{{
experimentInfo.executionsName
}}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold">Name</v-col>
<v-col cols="9" class="pa-2">{{ props.experimentInfo.name }}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- Project Name -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Status</v-col>
<v-col cols="9" class="pa-2">
<v-icon v-if="experimentInfo.status === 'Succeeded'" color="green"
<v-icon
v-if="props.experimentInfo.status === 'Succeeded'"
color="green"
>mdi-check-circle</v-icon
>
<v-icon v-else-if="experimentInfo.status === 'Failed'" color="red"
<v-icon
v-else-if="props.experimentInfo.status === 'Failed'"
color="red"
>mdi-close-circle</v-icon
>
<v-icon v-else color="grey">mdi-loading</v-icon></v-col
>
<v-icon v-else color="grey">mdi-loading</v-icon>
{{ props.experimentInfo.status }}
</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Duration </v-col>
<v-col cols="9" class="pa-2">{{ experimentInfo.duration }}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold">Duration</v-col>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.duration
}}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Experiment ID</v-col
>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.experiment
}}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- Created Date / ID -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Experiment</v-col>
<v-col cols="9" class="pa-2">{{ experimentInfo.experiment }}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold">Workflow</v-col>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.workflow
}}</v-col>
</v-row>
<VDivider class="my-2" />
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Workflow </v-col>
<v-col cols="3" class="pa-2">{{ experimentInfo.workflow }}</v-col>
<v-col cols="3" class="text-h6 font-weight-bold">Start Time</v-col>
<v-col cols="3" class="pa-2">{{ experimentInfo.startTime }}</v-col>
<v-col cols="9" class="pa-2">{{
props.experimentInfo.startTime
}}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- Description -->
<v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold"
>Registry Status</v-col
>
<v-col cols="9" class="pa-2">{{
experimentInfo.registryStatus
props.experimentInfo.registryStatus
}}</v-col>
</v-row>
<VDivider class="my-2" />
<!-- 🔹 state_history 출력 -->
<!-- 🔹 State History -->
<v-row align="center" class="py-6">
<v-col cols="3" class="text-h6 font-weight-bold"
>State History</v-col
>
<v-col cols="9" class="pa-2">
<div class="history-rail">
<!-- 기본(회색) 레일 -->
<div class="history-rail__line" />
<!-- 진행 세그먼트 2 고정 -->
<template v-for="i in 2" :key="'seg-' + i">
<div
class="history-rail__seg"
:style="{
left: `calc(${leftPct(i - 1)}% + var(--dot-size)/2)`,
width: `calc(${segWidthPct()}% - var(--dot-size))`,
background:
i - 1 < activeIndex
? 'var(--color-active)'
: 'var(--color-idle)',
}"
/>
</template>
<!-- /아이콘 + 라벨/시간 : 3 고정 -->
<template v-for="(s, i) in steps" :key="'dot-' + s.key">
<div
class="history-rail__dot"
:style="{ left: leftPct(i) + '%' }"
>
<v-avatar
:color="s.active ? s.color : 'surface-variant'"
size="28"
class="elev-1"
>
<v-icon size="18">{{ s.icon }}</v-icon>
</v-avatar>
</div>
<div
class="history-rail__label"
:style="{ left: leftPct(i) + '%' }"
>
<v-chip
size="small"
:color="s.active ? s.color : undefined"
variant="tonal"
class="mb-1 text-uppercase font-weight-medium"
>
{{ s.label }}
</v-chip>
<div class="text-caption text-medium-emphasis">
{{ fmt(s.ts) }}
</div>
</div>
</template>
</div>
</v-col>
</v-row>
</v-card-text>
<v-sheet class="d-flex justify-end mb-2">
<v-btn color="primary" @click="emit('close')"> Back to List </v-btn>
<v-btn color="primary" @click="emit('close')">Back to List</v-btn>
</v-sheet>
</v-card>
</v-card>
</v-container>
</template>
<style scoped>
.v-card-text {
width: 100% !important;
border-collapse: collapse;
/* 전체 테이블 1px 테두리 */
:root {
--dot-size: 28px;
}
.v-card-text th {
font-size: 20px;
min-width: 400px;
border: 1px solid rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.05);
font-weight: 600;
text-align: center;
white-space: nowrap;
/* 레일 컨테이너: 한 줄 정렬 + 여백 */
.history-rail {
position: relative;
width: 40%;
padding: 12px 0 34px; /* 위: 점 여유 / 아래: 라벨 공간 */
--color-rail: rgba(255, 255, 255, 0.12);
--color-idle: rgba(255, 255, 255, 0.12);
--color-active: rgb(
98,
0,
238
); /* theme primary(보라) 느낌, 필요시 바꿔도 OK */
}
.v-card-text td {
font-size: 16px;
min-width: 600px;
padding: 12px 16px;
text-align: left;
border: 1px solid rgba(255, 255, 255, 0.12);
/* 가로 기준선 */
.history-rail__line {
position: absolute;
left: 0;
right: 0;
top: 14px; /* 점 중앙과 자연스럽게 맞춤 */
height: 4px;
background: var(--color-rail);
border-radius: 2px;
}
.v-card-text tr:nth-child(odd) {
background-color: rgba(255, 255, 255, 0.02);
/* 진행 세그먼트(점~점 사이) */
.history-rail__seg {
position: absolute;
top: 14px;
height: 4px;
border-radius: 2px;
transition:
width 240ms ease,
background 240ms ease;
}
/* 점 아이콘 */
.history-rail__dot {
position: absolute;
top: 0; /* 컨테이너 안에서 수직 정렬 */
transform: translateX(-50%);
}
/* 라벨+시간(점 아래) */
.history-rail__label {
position: absolute;
top: 28px;
transform: translateX(-50%);
text-align: center;
white-space: nowrap;
}
</style>

@ -3,6 +3,7 @@ import { onMounted, ref, watch, onBeforeUnmount } from "vue";
import * as monaco from "monaco-editor";
import "monaco-editor/min/vs/editor/editor.main.css";
import { WorkflowService } from "@/components/service/management/WorkflowService";
import { AttachmentsService } from "@/components/service/management/AttachmentsService"; //
type TabKey = "details" | "yaml";
@ -15,7 +16,20 @@ const activeTab = ref<TabKey>("details");
const editorRef = ref<HTMLDivElement | null>(null);
let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null;
// ( )
function ensureEditor() {
if (editorInstance || !editorRef.value) return;
editorInstance = monaco.editor.create(editorRef.value, {
value: defaultYaml,
language: "yaml",
theme: "vs-dark",
readOnly: true,
automaticLayout: true,
minimap: { enabled: false },
lineNumbers: "on",
});
}
//
const detail = ref({
name: "",
version: "",
@ -41,7 +55,7 @@ spec:
args: ["echo hello"]
`;
// (ISO/T )
//
function formatDateTime(raw?: string): string {
if (!raw) return "-";
const s = String(raw).replace("T", " ");
@ -49,13 +63,30 @@ function formatDateTime(raw?: string): string {
return m ? m[1] : s.slice(0, 19);
}
// ===== =====
/** ⬅️ storagePath(object key)로부터 YAML 읽기 */
async function loadYamlFromStoragePath(objectName?: string) {
const key = (objectName || "").trim();
if (!key) return false;
try {
const res = await AttachmentsService.readTextByPath(key);
const text =
typeof res?.data === "string" ? res.data : String(res?.data ?? "");
ensureEditor();
editorInstance?.setValue(text || defaultYaml);
return true;
} catch (e) {
console.warn("[Workflow Detail] readTextByPath failed:", e);
return false;
}
}
/** 상세 조회 → YAML 문자열 우선 → storagePath 후보들 시도 → 실패 시 기본 YAML */
async function fetchDetail(id: number | string) {
try {
const res = await WorkflowService.view(Number(id));
const d = res?.data ?? {};
// (/)
// (/ )
detail.value = {
name: d.name ?? d.workflowName ?? "",
version: String(d.version ?? ""),
@ -66,7 +97,9 @@ async function fetchDetail(id: number | string) {
regDt: formatDateTime(d.reg_dt ?? d.regDt ?? d.regDate),
};
// YAML ( , )
ensureEditor();
// 1) YAML
const yamlFromServer =
d.workflowYaml ||
d.yaml ||
@ -74,27 +107,45 @@ async function fetchDetail(id: number | string) {
d.specYaml ||
d.yamlStr ||
"";
if (editorInstance) {
editorInstance.setValue(yamlFromServer || defaultYaml);
if (yamlFromServer) {
editorInstance!.setValue(yamlFromServer);
return;
}
// 2) object key(=storagePath )
const objectKeyCandidates = [
d.yamlStoragePath,
d.yaml_object_name,
d.yamlObjectName,
d.storagePath,
d.storedName,
d.objectName,
d.object_key,
d.yamlPath,
d.filePath,
d.yamlFile,
d.yamlFilePath,
].filter(Boolean) as string[];
// 3)
for (const key of objectKeyCandidates) {
const ok = await loadYamlFromStoragePath(key);
if (ok) return;
}
// 4) YAML
editorInstance!.setValue(defaultYaml);
} catch (e) {
console.error("[Workflow Detail] view API failed:", e);
ensureEditor();
editorInstance!.setValue(defaultYaml);
}
}
// ===== & =====
onMounted(() => {
if (editorRef.value) {
editorInstance = monaco.editor.create(editorRef.value, {
value: defaultYaml,
language: "yaml",
theme: "vs-dark",
readOnly: true,
automaticLayout: true,
minimap: { enabled: false },
lineNumbers: "on",
});
}
// ok (automaticLayout)
ensureEditor();
});
watch(
@ -107,14 +158,20 @@ watch(
{ immediate: true },
);
// YAML
watch(
() => activeTab.value,
(tab) => {
if (tab === "yaml") ensureEditor();
},
);
onBeforeUnmount(() => {
if (editorInstance) {
editorInstance.dispose();
editorInstance = null;
}
editorInstance?.dispose();
editorInstance = null;
});
// ===== ( ) Step =====
// ( ) Step
const stepHeaders = [
{ title: "Order", key: "order", width: "10%", align: "center" },
{ title: "Step Name", key: "name", width: "40%", align: "center" },

Loading…
Cancel
Save