Merge branch 'feature/main-js' of http://192.168.10.110/Autoflow/autoflow-web-console into feature/main-js

main
bjkim 9 months ago
commit 4ee9ebde11

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

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,19 +1,17 @@
<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;
description?: string; description?: string;
pipeline_version_reference: { pipeline_id: string }; pipeline_version_reference: { pipeline_id: string };
// runtime_config?: { parameters?: Record<string, any> };
// runtime_config?: { parameters?: Record<string, any> }; service_account?: string;
// service_account?: string; experiment_id?: string;
}; };
const props = defineProps<{ const props = defineProps<{ pipelineId?: string | number | null }>();
pipelineId?: string | number | null; // pipelineId
}>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "close-modal"): void; (e: "close-modal"): void;
@ -24,10 +22,17 @@ const form = ref({
display_name: "", display_name: "",
description: "", description: "",
pipeline_id: "", pipeline_id: "",
experiment_id: "" as string,
}); });
const loading = ref(false); const loading = ref(false);
const errorMsg = ref(""); const errorMsg = ref("");
//
type ExperimentOption = { label: string; value: string; created?: string };
const experimentOptions = ref<ExperimentOption[]>([]);
const expLoading = ref(false);
const isValid = computed( const isValid = computed(
() => !!form.value.display_name.trim() && !!form.value.pipeline_id.trim(), () => !!form.value.display_name.trim() && !!form.value.pipeline_id.trim(),
); );
@ -44,6 +49,44 @@ function onEsc(e: KeyboardEvent) {
onMounted(() => window.addEventListener("keydown", onEsc)); onMounted(() => window.addEventListener("keydown", onEsc));
onBeforeUnmount(() => window.removeEventListener("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() { async function submitRun() {
errorMsg.value = ""; errorMsg.value = "";
if (!isValid.value) { if (!isValid.value) {
@ -53,20 +96,22 @@ async function submitRun() {
const payload: RunPayload = { const payload: RunPayload = {
display_name: form.value.display_name.trim(), display_name: form.value.display_name.trim(),
// description
...(form.value.description.trim() && { ...(form.value.description.trim() && {
description: form.value.description.trim(), description: form.value.description.trim(),
}), }),
pipeline_version_reference: { pipeline_id: form.value.pipeline_id.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 { 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) {
console.error("Run 생성 실패:", e);
errorMsg.value = errorMsg.value =
e?.response?.data?.message || e?.response?.data?.message ||
e?.response?.data?.error || e?.response?.data?.error ||
@ -89,41 +134,70 @@ async function submitRun() {
<v-card-text class="pa-6"> <v-card-text class="pa-6">
<v-form @submit.prevent="submitRun"> <v-form @submit.prevent="submitRun">
<!-- 제목 -->
<div class="mb-4"> <div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"> <label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Run Title (display_name) Run Title (display_name)
</label> </label>
<v-text-field <v-text-field
v-model="form.display_name" v-model="form.display_name"
variant="outlined"
:disabled="loading" :disabled="loading"
hide-details density="comfortable"
hide-details="auto"
required required
/> />
</div> </div>
<!-- 내용 -->
<div class="mb-4"> <div class="mb-4">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"> <label class="text-subtitle-2 font-weight-medium mb-1 d-block">
Run Description Run Description
</label> </label>
<v-textarea <v-textarea
v-model="form.description" v-model="form.description"
variant="outlined"
:disabled="loading" :disabled="loading"
hide-details rows="3"
density="comfortable"
hide-details="auto"
/> />
</div> </div>
<!-- pipeline_id (읽기 전용) -->
<div class="mb-2"> <div class="mb-2">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"> <label class="text-subtitle-2 font-weight-medium mb-1 d-block">
pipeline_id pipeline_id
</label> </label>
<v-text-field <v-text-field
v-model="form.pipeline_id" v-model="form.pipeline_id"
variant="outlined"
:disabled="true" :disabled="true"
hide-details density="comfortable"
hide-details="auto"
required required
/> />
</div> </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> <div v-if="errorMsg" class="mt-3 text-error">{{ errorMsg }}</div>
</v-form> </v-form>
</v-card-text> </v-card-text>

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

@ -1,7 +1,9 @@
import { RunsSearchParams } from "@/components/models/management/Exeucutios";
import { request } from "@/components/service/index"; import { request } from "@/components/service/index";
export const ExecutionsService = { export const ExecutionsService = {
search: (payload: RunsSearchParams) => { search: (params?: {
return request.get("/api/runs/runs", { params: payload }); 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);
},
};

@ -1,13 +1,15 @@
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);
}, },
run: (payload: kubeflow) => { run: (payload: kubeflow) => {
return request.post("/pipelines/runs", payload); return request.post("/pipelines/runs", payload);
}, },
kubeflowSize: (payload: kubeflow) => { experiments: (params?: {
return request.post("/pipelines/experiments", payload); namespace?: string;
}, pageSize?: number;
pageToken?: string;
}) => request.get("/api/kubeflow/experiments", params),
}; };

@ -2,37 +2,322 @@
import { onMounted, ref, computed, watch } from "vue"; import { onMounted, ref, computed, watch } from "vue";
import Plotly from "plotly.js-dist-min"; import Plotly from "plotly.js-dist-min";
import { WorkflowService } from "@/components/service/management/WorkflowService"; 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"; 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);
const pieChartRef = ref<HTMLElement | null>(null); 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 kfRunsLoading = ref(false);
const isRefreshing = 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() : "-");
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() {
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;
}
}
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;
}
}
// ---- kubeflowStatus ( , ) ----
function renderStatusPie() { function renderStatusPie() {
if (!pieChartRef.value) return; 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>(); const counts = new Map<string, number>();
for (const wf of workflows.value ?? []) { for (const wf of workflows.value ?? []) {
const status = const raw = (wf?.kubeflowStatus ?? wf?.kubeflow_status ?? "UNKNOWN") + "";
String(wf?.kubeflowStatus ?? wf?.kubeflow_status ?? "Unknown").trim() || const status = raw.toUpperCase().trim();
"Unknown";
counts.set(status, (counts.get(status) || 0) + 1); 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 labels = Array.from(counts.keys());
const values = Array.from(counts.values()); 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> = { const trace: Partial<Plotly.PlotData> = {
values: values.length ? values : [1], values,
labels: labels.length ? labels : ["No Data"], labels,
type: "pie", type: "pie",
textinfo: "label+percent",
textfont: { color: "#fff", size: 14 },
hole: 0.4, hole: 0.4,
textinfo: "label+percent",
textfont: { color: "#fff", size: 13 },
marker: { colors },
hovertemplate: "%{label}: %{percent}<extra></extra>",
}; };
const layout: Partial<Plotly.Layout> = { const layout: Partial<Plotly.Layout> = {
@ -52,13 +337,6 @@ function renderStatusPie() {
Plotly.react(pieChartRef.value, [trace], layout, { displayModeBar: false }); 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 = [ const datasetUpdates = [
{ name: "DrivingLog2025", count: 7 }, { name: "DrivingLog2025", count: 7 },
{ name: "CameraFrames", count: 3 }, { name: "CameraFrames", count: 3 },
@ -106,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 }))
@ -174,37 +470,126 @@ async function loadWorkflows() {
} }
onMounted(async () => { onMounted(async () => {
// ,
renderStatusPie(); renderStatusPie();
await loadRecentRuns();
await loadWorkflows(); await loadWorkflows();
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-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">
<h3 class="text-subtitle-1 font-weight-bold mb-0"> <h3 class="text-subtitle-1 font-weight-bold mb-0">Recent Run</h3>
Workflow Success Rate
</h3>
</div> </div>
<div style="overflow-y: auto; padding: 8px 16px">
<div ref="pieChartRef" style="height: 280px"></div> <div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<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 justify-space-between w-100">
<div class="d-flex align-center ga-2">
<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"
style="min-width: 220px"
>
<span class="font-weight-medium text-body-2 truncate">
{{ run.name }}
</span>
<span class="text-caption text-grey-darken-1">
{{ run.time }}
</span>
</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> </div>
</v-card> </v-card>
</v-col> </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">
@ -244,42 +629,86 @@ watch(currentProjectId, () => loadWorkflows());
<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">
<h3 class="text-subtitle-1 font-weight-bold mb-0">Recent Run</h3> <h3 class="text-subtitle-1 font-weight-bold mb-0">Kubeflow Runs</h3>
</div> </div>
<div style="overflow-y: auto; max-height: 300px; padding: 8px 16px">
<v-list density="comfortable"> <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-list-item
v-for="(run, idx) in recentRuns" v-for="(r, idx) in kfRuns"
:key="idx" :key="r.runId || idx"
class="py-2" class="py-2"
> >
<div class="d-flex align-center"> <div class="d-flex align-center justify-space-between w-100">
<v-avatar <!-- 왼쪽: 상태칩 + 이름 -->
size="28" <div class="d-flex align-center ga-2">
:color=" <v-chip
run.status === 'success' size="small"
? 'green lighten-1' :color="
: 'red lighten-1' r.status === 'success'
" ? 'success'
> : r.status === 'failed'
<v-icon size="20" color="white"> ? 'error'
{{ run.status === "success" ? "mdi-check" : "mdi-close" }} : r.status === 'running'
</v-icon> ? 'info'
</v-avatar> : '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="d-flex flex-column text-right ml-4" <div class="text-right" style="min-width: 200px">
style="flex: 1" <div class="text-caption">
> <strong>Experiment</strong>:
<span class="font-weight-medium text-body-2"> <span class="text-medium-emphasis">{{
{{ run.name }} r.experimentId || "-"
</span> }}</span>
<span class="text-caption text-grey-darken-1"> </div>
{{ run.time }} <div class="text-caption">
</span> <strong>Pipeline</strong>:
<span class="text-medium-emphasis">{{
r.pipelineName || "-"
}}</span>
</div>
</div> </div>
</div> </div>
</v-list-item> </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> </v-list>
</div> </div>
</v-card> </v-card>
@ -287,23 +716,65 @@ watch(currentProjectId, () => loadWorkflows());
<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">
<h3 class="text-subtitle-1 font-weight-bold mb-0"> <div class="d-flex align-center justify-space-between w-100">
Dataset Update Activity <h3 class="text-subtitle-1 font-weight-bold mb-0">
</h3> Dataset Update Activity
</h3>
</div>
</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-list dense> <v-skeleton-loader
<v-list-item v-for="(data, idx) in datasetUpdates" :key="idx"> v-if="dsLoading"
<v-list-item-title>{{ data.name }}</v-list-item-title> 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 <v-progress-linear
:model-value="data.count * 10" :model-value="it.pct"
height="8" height="8"
color="primary" color="primary"
class="mt-1" class="mt-1"
/> />
<v-list-item-subtitle <div class="text-caption text-grey mt-1">
>{{ data.count }} updates</v-list-item-subtitle {{ 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-item>
</v-list> </v-list>
</div> </div>

@ -1,28 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from "vue"; import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.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 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 CompareComponent from "@/components/templates/run/executions/CompareComponent.vue"; import { storage } from "@/utils/storage";
import ViewComponent from "@/components/templates/run/executions/ViewComponent.vue"; import ViewComponent from "@/components/templates/run/experiment/ViewComponent.vue";
import ExecutionBaseDialog from "@/components/atoms/organisms/ExecutionBaseDialog.vue"; import ExperimentCreateDialog from "@/components/atoms/organisms/ExperimentCreateDialog.vue";
import { ExperimentService } from "@/components/service/management/ExperimentService";
import { ExecutionsService } from "@/components/service/management/ExecutionsService"; import { commonStore } from "@/stores/commonStore";
import type { RunsSearchParams } from "@/components/models/management/Exeucutios"; import { KubeflowRunService } from "@/components/service/management/KubeflowRunService";
import IconDownloadBtn from "@/components/atoms/button/IconDownloadBtn.vue";
dayjs.extend(utc);
dayjs.extend(tz);
const KST = "Asia/Seoul";
/* ===== UI 상태 ===== */ const store = commonStore();
const openCompare = ref(false);
const openView = ref(false); const openView = ref(false);
const username = ref<string>("");
const openCompare = ref(false);
const execSelected = ref<any>(null);
const selectedExperiment = ref<{
name: string;
description: string;
createdDate: string;
createdID: string;
deviceKey: number;
} | null>(null);
// ===== =====
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;" },
@ -32,26 +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 execDialogOpen = ref(false); const SEARCH_TYPE_MAP: Record<SearchType | "", "ALL" | "TITLE" | "AUTHOR"> = {
const execMode = ref<"create" | "edit" | "clone">("create"); "": "ALL",
const execSelected = ref<any>(null); 전체: "ALL",
const searchExperimentOptions = [{ searchType: "Experiment", searchText: "" }]; 제목: "TITLE",
const searchWorkflowOptions = [{ searchType: "Workflow", searchText: "" }]; 작성자: "AUTHOR",
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 },
@ -59,289 +57,400 @@ const pageSizeOptions = [
{ text: "100 페이지", value: 100 }, { 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({ const data = ref({
params: { params: {
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 10,
searchType: "", searchType: "전체" as SearchType,
searchText: "", searchText: "",
}, },
results: [] as UiRow[], results: [] as any[],
totalDataLength: 0, totalElements: 0,
pageLength: 0, pageLength: 0,
modalMode: "", modalMode: "" as "create" | "edit" | "",
selectedData: null as any, selectedData: null as any,
allSelected: false, allSelected: false,
selected: [] as Array<{ deviceKey: string }>, selected: [] as any[],
isCreateVisible: false,
isModalVisible: false, isModalVisible: false,
isConfirmDialogVisible: false, isConfirmDialogVisible: false,
userOption: [] as any[], userOption: [] as any[],
}); });
/* ===== page_token 기반 페이지네이션 캐시 ===== */ // ===== =====
const pageTokens = ref<string[]>([""]); // index=0 ( ) function readUsernameFromStorage(): string {
const nextPageToken = ref<string | undefined>(); try {
const raw =
/* ===== 헬퍼 ===== */ storage?.get?.("autoflow-auth") ??
const mapState = (s?: string) => { storage?.getAuth?.() ??
switch ((s || "").toUpperCase()) { localStorage.getItem("autoflow-auth") ??
case "SUCCEEDED": null;
return "Succeeded"; const auth = typeof raw === "string" ? JSON.parse(raw) : raw;
case "FAILED": const u1 = auth?.userInfo?.username;
return "Failed"; const u2 = auth?.username;
case "RUNNING": const u3 = auth?.userInfo?.userName;
return "Running"; const u4 = auth?.userInfo?.email?.split?.("@")?.[0];
case "PENDING": return (u1 || u2 || u3 || u4 || "").toString();
return "Pending"; } catch {
default: return "";
return s || "";
} }
}
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 fmtDuration = (start?: string, end?: string) => {
const s = (v || "").toUpperCase(); if (!start || !end) return "-";
if (s === "AVAILABLE") return "Registered"; const ms = new Date(end).getTime() - new Date(start).getTime();
if (s === "ARCHIVED") return "Archived"; if (!isFinite(ms) || ms < 0) return "-";
return v || ""; const s = Math.floor(ms / 1000);
}; const h = Math.floor(s / 3600);
const kst = (v?: string | number | Date) => const m = Math.floor((s % 3600) / 60);
v ? dayjs(v).tz(KST).format("YYYY-MM-DD HH:mm:ss") : ""; const sec = s % 60;
const duration = (start?: string, end?: string) => { const pad = (n: number) => String(n).padStart(2, "0");
if (!start) return ""; return `${h}:${pad(m)}:${pad(sec)}`;
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,
});
/* ===== 모든 페이지 콘솔 덤프 + 첫 페이지(최대 1000건) 테이블 반영 ===== */ const toUiStatus = (state?: string) => {
const dumpAllRunsToConsole = async () => { switch ((state || "").toUpperCase()) {
let token: string | undefined = undefined; case "SUCCEEDED":
let page = 1; return "Succeeded";
const pageSize = 10; case "FAILED":
return "Failed";
const all: any[] = []; case "RUNNING":
let totalSizeFromServer: number | undefined; return "Running";
case "PENDING":
do { return "Pending";
const payload: RunsSearchParams = { case "SKIPPED":
page_token: token, return "Skipped";
page_size: pageSize, default:
sort_by: "created_at_desc", return state || "-";
};
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}`);
} }
};
console.log( const { pageNum, pageSize } = data.value.params;
`[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}`,
);
}
// ( 1000) return {
const firstPage = all.slice(0, pageSize); no: (pageNum - 1) * pageSize + (idx + 1),
const firstNoStart = totalSizeFromServer ?? all.length; name: r.displayName ?? r.name ?? r.runId ?? "(no name)",
data.value.results = firstPage.map((r, i) => toRow(r, firstNoStart - i)); status: toUiStatus(r.state),
data.value.totalDataLength = totalSizeFromServer ?? all.length; duration: fmtDuration(r.createdAt, r.finishedAt),
data.value.pageLength = Math.max( experiment: r.experimentId ?? "-",
1,
Math.ceil((totalSizeFromServer ?? all.length) / pageSize),
);
// ( )
pageTokens.value = [""];
nextPageToken.value = undefined;
};
/* ===== (옵션) 단일 페이지 가져오기 — 테이블 재조회 필요 시 사용 ===== */ workflow: r.pipelineId ?? r.pipelineVersionId ?? "-",
const fetchPage = async (pageIndex: number) => { startTime: fmtStart(r.createdAt),
const token = pageTokens.value[pageIndex - 1] || undefined; registryStatus: r.storageState ?? "-",
run_id: r.runId,
raw: r,
};
};
const payload: RunsSearchParams = { // async function fetchList() {
page_token: token, // const { pageNum, pageSize, searchType, searchText } = data.value.params;
page_size: data.value.params.pageSize, // 1000
sort_by: "created_at_desc", // 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); try {
const body = res?.data ?? {}; const res = await KubeflowRunService.search(payload as any);
const runs: any[] = Array.isArray(body.runs) ? body.runs : []; const result = res?.data ?? res;
const total = Number(body.total_size ?? runs.length) || runs.length;
// 1)
console.log( // - Page : { content, totalElements, totalPages }
`[Runs] total_size: ${total}, pageIndex: ${pageIndex}, page_size: ${data.value.params.pageSize}, this_page: ${runs.length}, next_page_token:`, // - runs : { runs }
body.next_page_token || "(none)", // - : []
); // - data : { data: [] }
console.table( let list: any[] = [];
runs.map((r, i) => ({ let totalElements: number | undefined;
i, let totalPages: number | undefined;
run_id: r.run_id, let isServerPaged = false;
name: r.display_name ?? r.name,
state: r.state, if (Array.isArray(result)) {
created_at: r.created_at, //
finished_at: r.finished_at, list = result;
})), } else if (Array.isArray(result?.data)) {
); // data
list = result.data;
const pageOffset = (pageIndex - 1) * data.value.params.pageSize; } else if (Array.isArray(result?.content)) {
const firstNo = Math.max(total - pageOffset, 1); // (Page)
list = result.content;
data.value.results = runs.map((r, i) => toRow(r, firstNo - i)); totalElements = result.totalElements;
data.value.totalDataLength = total; totalPages = result.totalPages;
data.value.pageLength = Math.max( isServerPaged = true;
1, } else if (Array.isArray(result?.runs)) {
Math.ceil(total / data.value.params.pageSize), list = result.runs;
); } else {
list = [];
nextPageToken.value = body.next_page_token; }
if (nextPageToken.value && !pageTokens.value[pageIndex]) {
pageTokens.value[pageIndex] = nextPageToken.value; 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) => { const removeData = (value?: Array<{ deviceKey: number }>) => {
if (typeof newPage === "number") data.value.params.pageNum = newPage; 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 fetchList();
await fetchPage(1); data.value.isConfirmDialogVisible = false;
}; data.value.selected = [];
data.value.allSelected = false;
};
const changePageNum = async (page: number) => { // /
data.value.params.pageNum = page; if (ids.length === 1) {
await getData(page); 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"; const closeDetail = () => {
execSelected.value = null;
execDialogOpen.value = true;
};
const openComparePage = () => {
openCompare.value = true;
openView.value = false; openView.value = false;
selectedExperiment.value = null;
}; };
const openInfoModal = () => { const openInfoModal = (item: any) => {
execSelected.value = item;
console.log("[Parent] 선택된 실행:", item);
openView.value = true; openView.value = true;
openCompare.value = false; openCompare.value = false;
}; };
const openModifyModal = () => { function closeView() {
execMode.value = "edit"; openView.value = false;
execDialogOpen.value = true; }
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;
}; };
const openDownloadModal = () => { const openCreateModal = () => {
data.value.selectedData = null; data.value.modalMode = "create";
data.value.modalMode = "download"; data.value.selectedData = {
username: username.value,
projectId: getProjectId(),
};
data.value.isCreateVisible = true;
}; };
const closeModal = () => {
const handleTerminate = () => alert("Terminate 작업 진행중..."); data.value.isCreateVisible = false;
const handleRetry = () => alert("Retry 작업 진행중..."); data.value.selectedData = null;
const handleClone = () => alert("Clone 작업 진행중...");
const getSelectedAllData = () => {
data.value.selected = data.value.allSelected
? data.value.results.map((x) => ({ deviceKey: x.deviceKey }))
: [];
}; };
// 1000 onMounted(() => {
watch( username.value = readUsernameFromStorage();
() => data.value.params.pageSize, fetchList();
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);
}); });
</script> </script>
<template> <template>
<div class="w-100" v-if="!openCompare && !openView"> <div class="w-100" v-if="!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> -->
<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
@ -354,7 +463,9 @@ onMounted(async () => {
</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
@ -367,41 +478,12 @@ onMounted(async () => {
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"
@ -411,8 +493,8 @@ onMounted(async () => {
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">
@ -420,14 +502,15 @@ onMounted(async () => {
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"
> >
@ -436,8 +519,8 @@ onMounted(async () => {
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">
@ -450,28 +533,20 @@ onMounted(async () => {
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>
@ -479,12 +554,9 @@ onMounted(async () => {
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"
@ -493,20 +565,11 @@ onMounted(async () => {
</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>
@ -514,20 +577,12 @@ onMounted(async () => {
</thead> </thead>
<tbody class="text-body-2"> <tbody class="text-body-2">
<tr <tr
v-for="item in data.results" v-for="(item, i) in data.results"
:key="item.no" :key="i"
class="text-center" 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.no }}</td>
<td>{{ item.name }}</td> <td class="text-truncate">{{ 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
@ -544,13 +599,20 @@ onMounted(async () => {
<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"
@ -558,31 +620,33 @@ onMounted(async () => {
:total-visible="10" :total-visible="10"
color="primary" color="primary"
rounded="circle" rounded="circle"
@update:model-value="getData" @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 @close="closeView" /> <ViewComponent
v-if="openView"
:id="execSelected.deviceKey"
: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>

@ -2,228 +2,100 @@
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue"; import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue"; import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
// import FormComponent from "@/components/device/FormComponent.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 store = commonStore();
const experimentInfo = ref({ const props = defineProps<{
executionsName: "run-batch32-lr0.001", experimentInfo: any;
status: "Succeeded", }>();
duration: "0:00:21",
experiment: "Baseline Model Training",
workflow: "baseline_train_pipeline",
startTime: "2025-05-20 10:12",
registryStatus: "Registered",
});
const otaInfo = ref({ const emit = defineEmits<{ (e: "close"): void }>();
packageName: "자율주행 타차량 예측",
os: "Linux", const history = computed(() =>
packageFileName: "4_EdgeInfra_Perception.sh", (props.experimentInfo.raw?.state_history ?? [])
packageFilePath: "/home/etri/TeslaSystem/EdgeInfraVision/RUN", .slice()
softwareName: "4_EdgeInfra_Perception.sh", .sort(
softwareVersion: "v2.0", (a: any, b: any) =>
execute: "Not Executed", new Date(a.update_time).getTime() - new Date(b.update_time).getTime(),
}); ),
);
const data = ref({ //
params: { const hPending = computed(() =>
pageNum: 1, history.value.find((h) => (h.state || "").toUpperCase() === "PENDING"),
pageSize: 10, );
searchType: "", const hRunning = computed(() =>
searchText: "", history.value.find((h) => (h.state || "").toUpperCase() === "RUNNING"),
}, );
results: [], const hTerminal = computed(() => {
totalDataLength: 0, const t = history.value
pageLength: 0, .slice()
modalMode: "", .reverse()
selectedData: null, .find((h) =>
allSelected: false, ["SUCCEEDED", "FAILED"].includes((h.state || "").toUpperCase()),
selected: [], );
isModalVisible: false, return t ?? null;
isConfirmDialogVisible: false,
userOption: [],
}); });
const getCodeList = () => { // 3
// UserService.search(data.value.params).then((d) => { const steps = computed(() => {
// if (d.status === 200) { const lastLabel = (hTerminal.value?.state || "COMPLETED").toUpperCase();
// data.value.userOption = d.data.userList;
// }
// });
};
const getData = () => { return [
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",
},
{ {
name: "run-batch32-lr0.0005", key: "PENDING",
status: "Succeeded", label: "PENDING",
Duration: "0:00:21", active: !!(hPending.value || hRunning.value || hTerminal.value),
configProgress: "0/3", color: "primary",
Pipeline: "baseline_train_pipeline", icon: "mdi-clock-outline",
registDt: "2025-06-01T00:00:00Z", ts: hPending.value?.update_time,
}, },
{ {
name: "run-batch64-lr0.0005", key: "RUNNING",
status: "Running", label: "RUNNING",
Duration: "0:00:21", active: !!(hRunning.value || hTerminal.value),
configProgress: "1/3", color: "info",
Pipeline: "baseline_train_pipeline", icon: "mdi-progress-clock",
registDt: "2025-05-29T00:00:00Z", ts: hRunning.value?.update_time,
}, },
{ {
name: "run-augmented-data", key: "TERMINAL",
status: "Succeeded", label: ["SUCCEEDED", "FAILED"].includes(lastLabel)
Duration: "0:00:21", ? lastLabel
configProgress: "0/3", : "COMPLETED",
Pipeline: "baseline_train_pipeline", active: !!hTerminal.value,
registDt: "2025-05-31T00:00:00Z", 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) { // 3 /
remove(removeList[0].deviceKey).then(() => { const nSteps = 3;
// store.setSnackbarMsg({ const activeIndex = computed(
// text: ".", () => steps.value.map((s) => s.active).lastIndexOf(true), // -1 X
// result: 200, );
// }); const leftPct = (i: number) => (i / (nSteps - 1)) * 100;
changePageNum(); const segWidthPct = () => 100 / (nSteps - 1);
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;
},
);
}
};
const changePageNum = (page) => { //
data.value.params.pageNum = page; const fmt = (iso?: string) => (iso ? new Date(iso).toLocaleString() : "—");
getData();
};
const emit = defineEmits<{
(e: "close"): void;
}>();
//
onMounted(() => { onMounted(() => {
getData(); console.log("[Child] 받은 데이터:", props.experimentInfo);
getCodeList();
}); });
</script> </script>
@ -243,97 +115,204 @@ onMounted(() => {
<v-card flat class="bordered-box mb-6 w-100 rounded-lg pa-8"> <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"> <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-title>
<v-card-text class="px-6 pb-6 pt-4"> <v-card-text class="px-6 pb-6 pt-4">
<!-- Experiment Name -->
<v-row align="center" class="py-2"> <v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold" <v-col cols="3" class="text-h6 font-weight-bold">Name</v-col>
>Executions Name <v-col cols="9" class="pa-2">{{ props.experimentInfo.name }}</v-col>
</v-col>
<v-col cols="9" class="pa-2">{{
experimentInfo.executionsName
}}</v-col>
</v-row> </v-row>
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- Project Name -->
<v-row align="center" class="py-2"> <v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Status</v-col> <v-col cols="3" class="text-h6 font-weight-bold">Status</v-col>
<v-col cols="9" class="pa-2"> <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 >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 >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> </v-row>
<VDivider class="my-2" /> <VDivider class="my-2" />
<v-row align="center" class="py-2"> <v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Duration </v-col> <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="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> </v-row>
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- Created Date / ID -->
<v-row align="center" class="py-2"> <v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold">Experiment</v-col> <v-col cols="3" class="text-h6 font-weight-bold">Workflow</v-col>
<v-col cols="9" class="pa-2">{{ experimentInfo.experiment }}</v-col> <v-col cols="9" class="pa-2">{{
props.experimentInfo.workflow
}}</v-col>
</v-row> </v-row>
<VDivider class="my-2" /> <VDivider class="my-2" />
<v-row align="center" class="py-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="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> </v-row>
<VDivider class="my-2" /> <VDivider class="my-2" />
<!-- Description -->
<v-row align="center" class="py-2"> <v-row align="center" class="py-2">
<v-col cols="3" class="text-h6 font-weight-bold" <v-col cols="3" class="text-h6 font-weight-bold"
>Registry Status</v-col >Registry Status</v-col
> >
<v-col cols="9" class="pa-2">{{ <v-col cols="9" class="pa-2">{{
experimentInfo.registryStatus props.experimentInfo.registryStatus
}}</v-col> }}</v-col>
</v-row> </v-row>
<VDivider class="my-2" /> <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-card-text>
<v-sheet class="d-flex justify-end mb-2"> <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-sheet>
</v-card> </v-card>
</v-card> </v-card>
</v-container> </v-container>
</template> </template>
<style scoped> <style scoped>
.v-card-text { :root {
width: 100% !important; --dot-size: 28px;
border-collapse: collapse;
/* 전체 테이블 1px 테두리 */
} }
.v-card-text th { /* 레일 컨테이너: 한 줄 정렬 + 여백 */
font-size: 20px; .history-rail {
min-width: 400px; position: relative;
border: 1px solid rgba(255, 255, 255, 0.12); width: 40%;
background-color: rgba(255, 255, 255, 0.05); padding: 12px 0 34px; /* 위: 점 여유 / 아래: 라벨 공간 */
font-weight: 600; --color-rail: rgba(255, 255, 255, 0.12);
text-align: center; --color-idle: rgba(255, 255, 255, 0.12);
white-space: nowrap; --color-active: rgb(
98,
0,
238
); /* theme primary(보라) 느낌, 필요시 바꿔도 OK */
} }
.v-card-text td {
font-size: 16px; /* 가로 기준선 */
min-width: 600px; .history-rail__line {
padding: 12px 16px; position: absolute;
text-align: left; left: 0;
border: 1px solid rgba(255, 255, 255, 0.12); 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> </style>

@ -3,6 +3,7 @@ import { onMounted, ref, watch, onBeforeUnmount } from "vue";
import * as monaco from "monaco-editor"; import * as monaco from "monaco-editor";
import "monaco-editor/min/vs/editor/editor.main.css"; import "monaco-editor/min/vs/editor/editor.main.css";
import { WorkflowService } from "@/components/service/management/WorkflowService"; import { WorkflowService } from "@/components/service/management/WorkflowService";
import { AttachmentsService } from "@/components/service/management/AttachmentsService"; //
type TabKey = "details" | "yaml"; type TabKey = "details" | "yaml";
@ -15,7 +16,20 @@ const activeTab = ref<TabKey>("details");
const editorRef = ref<HTMLDivElement | null>(null); const editorRef = ref<HTMLDivElement | null>(null);
let editorInstance: monaco.editor.IStandaloneCodeEditor | 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({ const detail = ref({
name: "", name: "",
version: "", version: "",
@ -41,7 +55,7 @@ spec:
args: ["echo hello"] args: ["echo hello"]
`; `;
// (ISO/T ) //
function formatDateTime(raw?: string): string { function formatDateTime(raw?: string): string {
if (!raw) return "-"; if (!raw) return "-";
const s = String(raw).replace("T", " "); const s = String(raw).replace("T", " ");
@ -49,13 +63,30 @@ function formatDateTime(raw?: string): string {
return m ? m[1] : s.slice(0, 19); 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) { async function fetchDetail(id: number | string) {
try { try {
const res = await WorkflowService.view(Number(id)); const res = await WorkflowService.view(Number(id));
const d = res?.data ?? {}; const d = res?.data ?? {};
// (/) // (/ )
detail.value = { detail.value = {
name: d.name ?? d.workflowName ?? "", name: d.name ?? d.workflowName ?? "",
version: String(d.version ?? ""), version: String(d.version ?? ""),
@ -66,7 +97,9 @@ async function fetchDetail(id: number | string) {
regDt: formatDateTime(d.reg_dt ?? d.regDt ?? d.regDate), regDt: formatDateTime(d.reg_dt ?? d.regDt ?? d.regDate),
}; };
// YAML ( , ) ensureEditor();
// 1) YAML
const yamlFromServer = const yamlFromServer =
d.workflowYaml || d.workflowYaml ||
d.yaml || d.yaml ||
@ -74,27 +107,45 @@ async function fetchDetail(id: number | string) {
d.specYaml || d.specYaml ||
d.yamlStr || d.yamlStr ||
""; "";
if (editorInstance) { if (yamlFromServer) {
editorInstance.setValue(yamlFromServer || defaultYaml); 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) { } catch (e) {
console.error("[Workflow Detail] view API failed:", e); console.error("[Workflow Detail] view API failed:", e);
ensureEditor();
editorInstance!.setValue(defaultYaml);
} }
} }
// ===== & ===== // ===== & =====
onMounted(() => { onMounted(() => {
if (editorRef.value) { // ok (automaticLayout)
editorInstance = monaco.editor.create(editorRef.value, { ensureEditor();
value: defaultYaml,
language: "yaml",
theme: "vs-dark",
readOnly: true,
automaticLayout: true,
minimap: { enabled: false },
lineNumbers: "on",
});
}
}); });
watch( watch(
@ -107,14 +158,20 @@ watch(
{ immediate: true }, { immediate: true },
); );
// YAML
watch(
() => activeTab.value,
(tab) => {
if (tab === "yaml") ensureEditor();
},
);
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (editorInstance) { editorInstance?.dispose();
editorInstance.dispose(); editorInstance = null;
editorInstance = null;
}
}); });
// ===== ( ) Step ===== // ( ) Step
const stepHeaders = [ const stepHeaders = [
{ title: "Order", key: "order", width: "10%", align: "center" }, { title: "Order", key: "order", width: "10%", align: "center" },
{ title: "Step Name", key: "name", width: "40%", align: "center" }, { title: "Step Name", key: "name", width: "40%", align: "center" },

@ -122,16 +122,13 @@ const router = createRouter({
routes, routes,
}); });
router.beforeEach((to) => { router.beforeEach((to, from) => {
const authed = !!( const authed = !!(
typeof storage.getToken === "function" && storage.getToken() typeof storage.getToken === "function" && storage.getToken()
); );
const isLogin = to.name === "login";
const isLogin = to.name === "login" || to.path === "/login"; const isSignup = to.name === "signup";
const isSignup = to.name === "signup" || to.path === "/signup"; const isSelect = to.name === "select";
const isSelect = to.name === "select" || to.path === "/select";
const bootDone = sessionStorage.getItem("initialRedirectDone") === "1";
if (!authed) { if (!authed) {
if (!isLogin && !isSignup) { if (!isLogin && !isSignup) {
@ -140,32 +137,26 @@ router.beforeEach((to) => {
return true; return true;
} }
if (!bootDone && !isSelect && !isLogin && !isSignup) { const hasProject = !!localStorage.getItem("projectId"); // ✅ 프로젝트 선택 여부
sessionStorage.setItem("initialRedirectDone", "1"); const bootDone = sessionStorage.getItem("initialRedirectDone") === "1";
return { name: "select", replace: true, query: { redirect: to.fullPath } };
}
if (to.matched.some((r) => r.meta?.requiresAdmin)) {
try {
const raw =
typeof storage?.getAuth === "function"
? storage.getAuth()
: JSON.parse(localStorage.getItem("autoflow-auth") || "null");
const roles = raw?.userInfo?.roles ?? raw?.roles ?? [];
const authCd = raw?.userInfo?.authCd ?? raw?.authCd ?? raw?.auth;
const isAdmin = // 이미 프로젝트 선택됨 → 어떤 화면이든 통과
(Array.isArray(roles) if (hasProject) return true;
? roles.includes("ROLE_ADMIN")
: roles === "ROLE_ADMIN") || authCd === "ADMIN";
if (!isAdmin) { // 아직 프로젝트 미선택
return { name: "home", replace: true }; if (!bootDone) {
} // ✅ 선택 화면에 "들어온 순간"을 부트 완료로 간주 (여기서 한 번만 세팅)
} catch { if (isSelect) {
return { name: "home", replace: true }; sessionStorage.setItem("initialRedirectDone", "1");
return true;
} }
// ✅ select 로 1회만 보냄
return { name: "select", replace: true, query: { redirect: to.fullPath } };
}
// 부트 완료인데 여전히 프로젝트 미선택이면, select만 허용
if (!isSelect) {
return { name: "select", replace: true, query: { redirect: to.fullPath } };
} }
return true; return true;

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, computed } from "vue"; import { onMounted, onBeforeUnmount, ref, computed } from "vue";
import { useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useAutoflowStore } from "@/stores/autoflowStore"; import { useAutoflowStore } from "@/stores/autoflowStore";
import type { import type {
@ -21,6 +21,7 @@ const DEFAULT_PERMISSIONS: Permission[] = [
/** ===== 라우터 & 스토어 ===== */ /** ===== 라우터 & 스토어 ===== */
const router = useRouter(); const router = useRouter();
const route = useRoute();
const autoflowStore = useAutoflowStore(); const autoflowStore = useAutoflowStore();
/** ===== 상태 ===== */ /** ===== 상태 ===== */
@ -265,11 +266,20 @@ const closeDialog = () => {
const selectProject = (index: number) => { const selectProject = (index: number) => {
const selected = pagedProjects.value[index]; const selected = pagedProjects.value[index];
if (!selected) return;
// 1)
localStorage.setItem("projectId", String(selected.id));
localStorage.setItem("projectName", selected.title);
// ( )
autoflowStore.setProjectId(selected.id); autoflowStore.setProjectId(selected.id);
autoflowStore.setProjectName(selected.title); autoflowStore.setProjectName(selected.title);
router.push("/home");
};
// 2) redirect , /home
const dest = (route.query.redirect as string) || "/home";
router.replace(dest);
};
const grantDefaultPermissions = async ( const grantDefaultPermissions = async (
projectId: number, projectId: number,
usernames: string[], usernames: string[],

Loading…
Cancel
Save