feat : Metrics 비교 데이터 컴포넌트 추가 및 Deployment 버튼 추가

main
jschoi 8 months ago
parent 5e5dffcde9
commit 465b56fad8

4
components.d.ts vendored

@ -10,6 +10,7 @@ declare module 'vue' {
export interface GlobalComponents {
AppFooter: typeof import('./src/components/AppFooter.vue')['default']
CompareComponent: typeof import('./src/components/templates/run/executions/CompareComponent.vue')['default']
CompareRunDialog: typeof import('./src/components/atoms/organisms/CompareRunDialog.vue')['default']
copy: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog copy.vue')['default']
DatagroupBaseDoalog: typeof import('./src/components/atoms/organisms/DatagroupBaseDoalog.vue')['default']
DatasetBaseDoalog: typeof import('./src/components/atoms/organisms/DatasetBaseDoalog.vue')['default']
@ -20,10 +21,12 @@ declare module 'vue' {
ExecutionsViewComponent: typeof import('./src/components/templates/run/executions/ExecutionsViewComponent.vue')['default']
ExperimentCreateDialog: typeof import('./src/components/atoms/organisms/ExperimentCreateDialog.vue')['default']
ExperimentViewComponent: typeof import('./src/components/templates/run/experiment/ExperimentViewComponent.vue')['default']
ExternalDatasetDialog: typeof import('./src/components/atoms/organisms/ExternalDatasetDialog.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IconArrowDown: typeof import('./src/components/atoms/button/IconArrowDown.vue')['default']
IconArrowUp: typeof import('./src/components/atoms/button/IconArrowUp.vue')['default']
IconDeleteBtn: typeof import('./src/components/atoms/button/IconDeleteBtn.vue')['default']
IconDeployBtn: typeof import('./src/components/atoms/button/IconDeployBtn.vue')['default']
IconDeployment: typeof import('./src/components/atoms/button/IconDeployment.vue')['default']
IconDownloadBtn: typeof import('./src/components/atoms/button/IconDownloadBtn.vue')['default']
IconInfoBtn: typeof import('./src/components/atoms/button/IconInfoBtn.vue')['default']
@ -35,6 +38,7 @@ declare module 'vue' {
ListComponentback: typeof import('./src/components/templates/run/executions/ListComponentback.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
RunSearchBar: typeof import('./src/components/templates/run/executions/RunSearchBar.vue')['default']
SidebarHeader: typeof import('./src/components/common/SidebarHeader.vue')['default']
TrainingGroupBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingGroupBaseDoalog.vue')['default']
TrainingScriptBaseDoalog: typeof import('./src/components/atoms/organisms/TrainingScriptBaseDoalog.vue')['default']

@ -0,0 +1,26 @@
<script setup>
import { defineEmits } from "vue";
const emit = defineEmits(["onClick"]);
const onClick = () => {
emit("onClick");
};
</script>
<template>
<v-tooltip location="bottom" text="Deploy">
<template #activator="{ props }">
<v-btn
@click="onClick"
class="ma-1"
icon="mdi-rocket-launch"
color="info"
density="comfortable"
elevation="0"
size="small"
v-bind="props"
/>
</template>
</v-tooltip>
</template>

@ -0,0 +1,387 @@
<script setup lang="ts">
import {
computed,
ref,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import Plotly from "plotly.js-dist-min";
/** ===== Types ===== */
type MetricKV = { key: string; value: number };
type RunDetailType = {
info: any;
data: { metrics: MetricKV[]; params?: any[]; tags?: any[] };
};
/** ===== Props / Emits ===== */
const props = withDefaults(
defineProps<{
modelValue: boolean;
items: Array<{ value: string; label: string }>;
selectedRunIds: string[];
selectedMetricKeys: string[];
ensureRunDetail: (runId: string) => Promise<RunDetailType | null>;
runDetailCache: Map<string, RunDetailType>;
compareChartMode?: "byMetric" | "byRun";
normalizeValues?: boolean;
baselineRunId?: string | null;
}>(),
{
modelValue: false,
items: () => [],
selectedRunIds: () => [],
selectedMetricKeys: () => [],
compareChartMode: "byMetric",
normalizeValues: false,
baselineRunId: null,
},
);
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
(e: "update:selectedRunIds", v: string[]): void;
(e: "update:selectedMetricKeys", v: string[]): void;
}>();
/** ===== local refs ===== */
const dialogOpen = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const selectedRunIdsProxy = computed({
get: () => props.selectedRunIds,
set: (v: string[]) => emit("update:selectedRunIds", v),
});
const selectedMetricKeysProxy = computed({
get: () => props.selectedMetricKeys,
set: (v: string[]) => emit("update:selectedMetricKeys", v),
});
const elCompare = ref<HTMLDivElement | null>(null);
const loading = ref(false);
/** ===== helpers ===== */
const fmtNumber = (v: number | null, digits = 3) => {
if (v == null || !Number.isFinite(v)) return "";
const abs = Math.abs(v);
if (abs >= 1000) return v.toLocaleString();
if (abs >= 1) return v.toFixed(digits);
if (abs >= 0.01) return v.toFixed(digits + 1);
return v.toExponential(2);
};
const normalizeArray = (vals: (number | null)[]) => {
const xs = vals.filter((v): v is number => Number.isFinite(v as number));
if (xs.length === 0) return vals;
const min = Math.min(...xs),
max = Math.max(...xs);
if (max === min) return vals.map((v) => (v == null ? v : 1));
return vals.map((v) => (v == null ? v : (v - min) / (max - min)));
};
const labelOfRun = (r: RunDetailType) => r.info.run_name || r.info.run_id;
const valueOf = (run: RunDetailType, key: string): number | null => {
const m = run.data.metrics.find((x) => x.key === key)?.value;
return Number.isFinite(m as number) ? Number(m) : null;
};
/** ===== derived ===== */
const compareRuns = computed<RunDetailType[]>(
() =>
selectedRunIdsProxy.value
.map((id) => props.runDetailCache.get(id))
.filter(Boolean) as RunDetailType[],
);
const metricsKeySetByRun = computed(() =>
compareRuns.value.map((r) => new Set(r.data.metrics.map((m) => m.key))),
);
const commonMetricKeys = computed<string[]>(() => {
if (metricsKeySetByRun.value.length === 0) return [];
const [first, ...rest] = metricsKeySetByRun.value;
let inter = new Set(first);
rest.forEach((s) => (inter = new Set([...inter].filter((k) => s.has(k)))));
return Array.from(inter).sort();
});
const activeMetricKeys = computed<string[]>(() =>
selectedMetricKeysProxy.value.length
? selectedMetricKeysProxy.value
: commonMetricKeys.value,
);
/** ===== chart ===== */
const commonLayout: Partial<any> = {
autosize: true,
margin: { t: 28, r: 12, b: 56, l: 56 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
font: { color: "#E0E0E0" },
xaxis: { automargin: true, gridcolor: "rgba(255,255,255,0.08)" },
yaxis: {
rangemode: "tozero",
gridcolor: "rgba(255,255,255,0.08)",
automargin: true,
},
legend: {
orientation: "h",
x: 0,
y: 1.12,
yanchor: "bottom",
xanchor: "left",
},
};
function createTracesByMetric(metricKeys: string[], runsData: RunDetailType[]) {
const runLabels = runsData.map(labelOfRun);
return runsData.map((run, i) => {
const yRaw = metricKeys.map((k) => valueOf(run, k));
const y = props.normalizeValues ? normalizeArray(yRaw) : yRaw;
const text = yRaw.map((v) => fmtNumber(v));
return {
type: "bar",
name: runLabels[i],
x: metricKeys,
y,
text: props.normalizeValues ? undefined : text,
textposition: "auto",
customdata: text,
hovertemplate: `${runLabels[i]}<br>%{x}: %{customdata}<extra></extra>`,
};
});
}
function createTracesByRun(metricKeys: string[], runsData: RunDetailType[]) {
const rowLabels = runsData.map(labelOfRun);
return metricKeys.map((key) => {
const yRaw = rowLabels.map((_, i) => valueOf(runsData[i], key));
const y = props.normalizeValues ? normalizeArray(yRaw) : yRaw;
const text = yRaw.map((v) => fmtNumber(v));
return {
type: "bar",
name: key,
x: rowLabels,
y,
text: props.normalizeValues ? undefined : text,
textposition: "auto",
customdata: text,
hovertemplate: `${key}: %{customdata}<extra>%{x}</extra>`,
};
});
}
function drawCompareChart() {
if (!elCompare.value) return;
const metricKeys = activeMetricKeys.value;
const runsData = compareRuns.value;
if (!metricKeys.length || !runsData.length) {
elCompare.value.innerHTML =
'<div style="padding:16px;color:#999;text-align:center;">No Data</div>';
return;
}
const traces =
props.compareChartMode === "byMetric"
? createTracesByMetric(metricKeys, runsData)
: createTracesByRun(metricKeys, runsData);
if (props.compareChartMode === "byMetric") {
const varianceOrder = metricKeys
.map((k, idx) => {
const vals = traces
.map((t: any) => t.y[idx])
.filter((v: any) => v != null) as number[];
const min = Math.min(...vals),
max = Math.max(...vals);
return { k, spread: max - min };
})
.sort((a, b) => b.spread - a.spread)
.map((v) => v.k);
traces.forEach((t: any) => {
t.x = varianceOrder;
t.y = varianceOrder.map((mk: string) => t.y[metricKeys.indexOf(mk)]);
if (t.text)
t.text = varianceOrder.map(
(mk: string) => t.text[metricKeys.indexOf(mk)],
);
if (t.customdata)
t.customdata = varianceOrder.map(
(mk: string) => t.customdata[metricKeys.indexOf(mk)],
);
});
}
Plotly.react(
elCompare.value,
traces as any,
{ ...commonLayout, barmode: "group" },
{ displayModeBar: false, responsive: true },
);
}
/** ===== load & watch ===== */
async function loadCompareData() {
if (!dialogOpen.value) return;
loading.value = true;
try {
await Promise.all(selectedRunIdsProxy.value.map(props.ensureRunDetail));
if (selectedMetricKeysProxy.value.length === 0) {
selectedMetricKeysProxy.value = commonMetricKeys.value.slice(0, 6);
}
await nextTick();
drawCompareChart();
} finally {
loading.value = false;
}
}
watch(
() => dialogOpen.value,
(open) => {
if (open) loadCompareData();
},
);
watch([selectedRunIdsProxy, selectedMetricKeysProxy], async () => {
if (!dialogOpen.value) return;
await loadCompareData();
});
const onResize = () => {
if (elCompare.value) Plotly.Plots.resize(elCompare.value);
};
onMounted(() => window.addEventListener("resize", onResize));
onBeforeUnmount(() => window.removeEventListener("resize", onResize));
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="1000">
<v-card class="rounded-lg overflow-hidden">
<!-- 헤더: Deploy Model 스타일 적용 -->
<v-card-title
class="text-white font-weight-bold text-h6"
style="background-color: #1976d2"
>
Compare Runs
</v-card-title>
<!-- 상단: 선택 섹션 -->
<v-card-text class="pa-6">
<v-row dense class="mb-2">
<v-col cols="12">
<v-subheader class="font-weight-medium mb-2"
>Select Runs</v-subheader
>
<v-autocomplete
v-model="selectedRunIdsProxy"
:items="items"
item-title="label"
item-value="value"
chips
multiple
closable-chips
variant="outlined"
density="comfortable"
hide-details
:disabled="loading"
placeholder="Select runs to compare"
/>
</v-col>
</v-row>
<v-expand-transition>
<v-alert
v-if="!loading && selectedRunIdsProxy.length < 2"
type="info"
variant="tonal"
density="compact"
class="mt-2"
>
2 이상의 Run을 선택해 주세요.
</v-alert>
</v-expand-transition>
<v-row dense class="mt-4">
<v-col cols="12">
<v-subheader class="font-weight-medium mb-2">Metrics</v-subheader>
<v-autocomplete
v-model="selectedMetricKeysProxy"
:items="activeMetricKeys.length ? activeMetricKeys : []"
:menu-props="{ maxHeight: 360 }"
chips
multiple
closable-chips
variant="outlined"
density="comfortable"
hide-details
:disabled="loading || compareRuns.length === 0"
placeholder="Select metrics (empty = all common)"
/>
<div class="d-flex align-center mt-2 ga-2">
<v-chip size="small" color="primary" variant="tonal">
Mode:
{{
props.compareChartMode === "byMetric" ? "By Metric" : "By Run"
}}
</v-chip>
<v-chip size="small" color="secondary" variant="tonal">
{{ props.normalizeValues ? "Normalized" : "Raw values" }}
</v-chip>
<v-chip
v-if="props.baselineRunId"
size="small"
color="info"
variant="tonal"
>
Baseline: {{ props.baselineRunId }}
</v-chip>
</div>
</v-col>
</v-row>
</v-card-text>
<!-- 차트 & 테이블 섹션 ( 카드로 감싸기) -->
<v-card-text class="pt-0 px-6 pb-2">
<v-card variant="tonal" class="mb-4">
<v-card-title class="py-2 px-4">Metrics (grouped bar)</v-card-title>
<v-divider />
<v-card-text class="px-4">
<div v-if="loading" class="my-2">
<v-progress-linear indeterminate />
</div>
<div ref="elCompare" style="width: 100%; height: 420px"></div>
</v-card-text>
</v-card>
<v-card variant="tonal">
<v-card-title class="py-2 px-4">Comparison Table</v-card-title>
<v-divider />
<v-card-text class="px-0">
<v-table density="comfortable">
<thead>
<tr>
<th style="width: 28%">Run</th>
<th v-for="k in activeMetricKeys" :key="k">{{ k }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in compareRuns" :key="r.info.run_id">
<td class="text-no-wrap">
{{ r.info.run_name || r.info.run_id }}
</td>
<td v-for="k in activeMetricKeys" :key="k">
{{ r.data.metrics.find((m) => m.key === k)?.value ?? "—" }}
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</v-card-text>
<!-- 액션 -->
<v-card-actions class="justify-end">
<v-btn variant="text" class="text-white" @click="dialogOpen = false">
CLOSE
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped></style>

@ -1,168 +1,301 @@
<script setup lang="ts">
import IconArrowDown from "@/components/atoms/button/IconArrowDown.vue";
import IconArrowUp from "@/components/atoms/button/IconArrowUp.vue";
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
import { ref, watch } from "vue";
const steps = ref([
{ order: 1, stepName: "Data Load", type: "DataPrep", status: "Configured" },
{
order: 2,
stepName: "Preprocessing",
type: "Preprocess",
status: "Not Configured",
},
{
order: 3,
stepName: "Train Model",
type: "Train",
status: "Not Configured",
},
]);
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = defineProps({
editData: Object,
mode: String,
userOption: Array,
});
type PackageOption = { label: string; value: string; raw: any };
const emit = defineEmits(["handle-data", "close-modal"]);
const props = defineProps<{
packages: PackageOption[];
packagesLoading?: boolean;
packagesError?: string;
artifactPath?: string;
}>();
const visible = ref(true);
const emit = defineEmits<{
(e: "close-modal"): void;
(e: "handle-data", value: any): void;
}>();
const form = ref({
name: "",
description: "",
package_id: "",
sw_id: "",
sw_version: "",
software_name: "",
executed: true,
file_type: "bundle" as "bundle" | "single",
os: "" as "" | "Windows" | "Linux",
win_exe_name: "",
win_root_path: "",
linux_exe_name: "",
linux_root_path: "",
install_location: "",
private_only: false,
});
const submit = () => {
emit("handle-data", form.value);
};
const isWin = computed(() => form.value.os === "Windows");
const isLinux = computed(() => form.value.os === "Linux");
const selectedOption = computed(() =>
props.packages?.find((p) => p.value === form.value.package_id),
);
const selectedRaw = computed(() => selectedOption.value?.raw ?? null);
const readonlyFields = computed(() => ({
swTypeName: selectedRaw.value?.sw_type_name ?? "",
swGroupName: selectedRaw.value?.sw_group_name ?? "",
manufacturer: selectedRaw.value?.sw_manufacturer ?? "",
packageId: selectedRaw.value?.package_id ?? "",
}));
function onPickPackage() {
form.value.win_exe_name = selectedRaw.value?.window_exe_name ?? "";
form.value.win_root_path = selectedRaw.value?.window_root_location ?? "";
form.value.linux_exe_name = selectedRaw.value?.linux_exe_name ?? "";
form.value.linux_root_path = selectedRaw.value?.linux_root_location ?? "";
if (form.value.win_exe_name || form.value.win_root_path)
form.value.os = "Windows";
else if (form.value.linux_exe_name || form.value.linux_root_path)
form.value.os = "Linux";
}
watch(
() => props.packages,
() => {
if (!props.packages?.some((p) => p.value === form.value.package_id)) {
form.value.package_id = "";
form.value.win_exe_name = "";
form.value.win_root_path = "";
form.value.linux_exe_name = "";
form.value.linux_root_path = "";
form.value.os = "";
}
},
);
function submit() {
if (!form.value.package_id) return;
emit("handle-data", {
...form.value,
resolved: readonlyFields.value,
artifact_path: props.artifactPath ?? "",
});
}
function onEsc(e: KeyboardEvent) {
if (e.key === "Escape") emit("close-modal");
}
onMounted(() => window.addEventListener("keydown", onEsc));
onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
</script>
<template>
<v-card class="rounded-lg overflow-hidden">
<!-- 타이틀 영역 -->
<v-card rounded="lg" elevation="4" max-height="85vh" class="overflow-auto">
<v-card-title
class="text-white font-weight-bold text-h6"
style="background-color: #1976d2"
class="text-white text-h6 font-weight-bold"
style="background: #1976d2"
>
Deploy Model
차량 지능 SW 등록
</v-card-title>
<v-card-text class="pa-6">
<div class="text-subtitle-1 font-weight-medium mb-4">
Select Model : ImageClassifier
</div>
<VDivider class="my-2" />
<v-form @submit.prevent="submit">
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
>OTA
</label>
<v-row dense class="mb-6">
<v-col cols="12">
<v-subheader class="font-weight-medium white--text mb-2">
Select Package
</v-subheader>
<v-select
dense
hide-details
outlined
style="background: #1e1e1e; color: #fff"
/>
</v-col>
</v-row>
</div>
</v-form>
</v-card-text>
<v-card-text class="pt-6 pb-4 px-6">
<v-subheader class="font-weight-medium mb-2">
Package Preview
</v-subheader>
<v-sheet class="pa-4 mb-6" elevation="1" rounded>
<v-row dense>
<!-- Linux -->
<v-defaults-provider
:defaults="{
VTextField: {
density: 'compact',
variant: 'outlined',
hideDetails: true,
},
VSelect: { density: 'compact', variant: 'outlined', hideDetails: true },
VRadioGroup: { density: 'compact' },
VCheckbox: { density: 'compact' },
VAlert: { density: 'compact' },
}"
>
<!-- 패키지 선택 & 자동 표기 -->
<v-card-text class="pa-4">
<v-row dense class="mb-2">
<v-col cols="12">
<div class="text-body-2 font-weight-medium mb-1">SW 패키지</div>
<v-select
v-model="form.package_id"
:items="packages"
item-title="label"
item-value="value"
:loading="packagesLoading"
:disabled="packagesLoading"
placeholder="선택해주세요."
@update:model-value="onPickPackage"
/>
<v-alert
v-if="packagesError"
type="error"
variant="tonal"
class="mt-2"
>
{{ packagesError }}
</v-alert>
</v-col>
</v-row>
<!-- 자동 표기 (읽기 전용) -->
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<div class="font-weight-medium mb-2">Linux</div>
<v-text-field
label="File Name"
placeholder="4_EdgeInfra_Perception.sh"
dense
outlined
hide-details
label="SW 종류"
:model-value="readonlyFields.swTypeName"
disabled
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="File Path"
placeholder="/home/etri/TeslaSystem/EdgeInfraVision/RUN"
dense
outlined
hide-details
label="SW 상세 종류"
:model-value="readonlyFields.swGroupName"
disabled
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="제조사"
:model-value="readonlyFields.manufacturer"
disabled
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="패키지 번호"
:model-value="readonlyFields.packageId"
disabled
/>
</v-col>
</v-row>
</v-card-text>
<!-- Windows -->
<v-divider class="mx-4" />
<!-- 사용자 입력 -->
<v-card-text class="pa-4 pt-3">
<v-row dense class="mb-2">
<v-col cols="12" md="6">
<div class="font-weight-medium mb-2">Windows</div>
<v-text-field label="SW ID" v-model="form.sw_id" />
</v-col>
<v-col cols="12" md="6">
<v-text-field label="SW 버전" v-model="form.sw_version" />
</v-col>
<v-col cols="12">
<v-text-field
label="File Name"
placeholder="4_EdgeInfra_Perception.exe"
dense
outlined
hide-details
label="SW 명칭 (Software Name)"
v-model="form.software_name"
/>
</v-col>
</v-row>
<v-row dense class="mb-1">
<v-col cols="12" md="6">
<v-radio-group v-model="form.executed" inline>
<template #label
><span class="text-body-2">실행 여부</span></template
>
<v-radio class="mr-13" label="실행" :value="true" />
<v-radio label="미실행" :value="false" />
</v-radio-group>
</v-col>
<v-col cols="12" md="3">
<v-radio-group v-model="form.file_type" inline>
<template #label
><span class="text-body-2">파일 종류</span></template
>
<v-radio class="mr-13" label="묶음" value="bundle" />
<v-radio label="단일" value="single" />
</v-radio-group>
</v-col>
</v-row>
<v-row dense class="mb-1">
<v-col cols="12" md="6">
<v-radio-group v-model="form.os" inline>
<template #label><span class="text-body-2">OS</span></template>
<v-radio class="mr-4" label="Windows" value="Windows" />
<v-radio label="Linux" value="Linux" />
</v-radio-group>
</v-col>
</v-row>
<!-- Windows -->
<v-row dense v-if="isWin" class="mb-1">
<v-col cols="12"
><div class="text-body-2 font-weight-medium mb-1">
Windows
</div></v-col
>
<v-col cols="12" md="6">
<v-text-field
label="윈도우 실행 파일명"
v-model="form.win_exe_name"
disabled
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="윈도우 경로"
v-model="form.win_root_path"
disabled
/>
</v-col>
</v-row>
<!-- Linux -->
<v-row dense v-if="isLinux" class="mb-1">
<v-col cols="12"
><div class="text-body-2 font-weight-medium mb-1">Linux</div></v-col
>
<v-col cols="12" md="6">
<v-text-field
label="리눅스 실행 파일명"
v-model="form.linux_exe_name"
disabled
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
label="File Path"
placeholder="C:/etri/TeslaSystem/EdgeInfraVision/RUN"
dense
outlined
label="리눅스 경로"
v-model="form.linux_root_path"
disabled
/>
</v-col>
</v-row>
<!-- Artifact Path -->
<v-row dense class="mb-1">
<v-col cols="12">
<v-text-field
label="차량 SW 파일 (Artifact Path)"
:model-value="props.artifactPath || '-'"
disabled
/>
</v-col>
</v-row>
<!-- 설치 위치 & 등록인만 접근 -->
<v-row dense>
<v-col cols="12" md="8">
<v-text-field label="설치 위치" v-model="form.install_location" />
</v-col>
<v-col cols="12" md="4" class="d-flex align-center">
<v-checkbox
v-model="form.private_only"
label="등록인만 접근 여부"
hide-details
/>
</v-col>
</v-row>
</v-sheet>
<!-- Package Preview -->
<!-- Software Name / Version -->
<v-row dense class="mb-4">
<v-col cols="12">
<v-text-field
label="Software Name"
placeholder="Enter software Name"
dense
outlined
hide-details
/>
</v-col>
<v-col cols="12">
<v-text-field
label="Software Version"
placeholder="Enter software Version"
dense
outlined
hide-details
/>
</v-col>
</v-row>
<!-- Executed 여부 -->
<v-row dense class="mb-6">
<v-col cols="12">
<v-radio-group row>
<v-radio label="Executed" value="executed" />
<v-radio label="Not Executed" value="not_executed" />
</v-radio-group>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="justify-end" style="padding: 16px 24px">
<v-btn color="success" @click="submit">Save</v-btn>
<v-btn text class="white--text" @click="$emit('close-modal')"
>Close</v-btn
>
</v-card-actions>
</v-card-text>
<v-card-actions class="py-2 px-3">
<v-spacer />
<v-btn color="success" @click="submit">SAVE</v-btn>
<v-btn variant="text" @click="$emit('close-modal')">CANCEL</v-btn>
</v-card-actions>
</v-defaults-provider>
</v-card>
</template>

@ -0,0 +1,180 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import {
DatasetService,
type ExternalDatasetItem,
} from "@/components/service/management/DatasetService";
const props = defineProps<{
modelValue: boolean;
projectId: number;
refId?: number | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
(e: "close"): void;
(e: "saved"): void; //
}>();
const visible = ref(props.modelValue);
watch(
() => props.modelValue,
(v) => (visible.value = v),
);
watch(visible, (v) => emit("update:modelValue", v));
const loading = ref(false);
const errorMsg = ref("");
const rows = ref<ExternalDatasetItem[]>([]);
const searchKeyword = ref("");
const groupName = ref("");
function unwrap<T = any>(res: any): T {
return res && res.data ? res.data : res;
}
async function fetchList() {
if (!props.projectId) return;
loading.value = true;
errorMsg.value = "";
try {
const res = await DatasetService.listExternal({
ds_prj_idx: props.projectId,
search_keyword: searchKeyword.value || undefined, //
grp_name: groupName.value || undefined,
});
const body = unwrap<any>(res);
const arr: ExternalDatasetItem[] = Array.isArray(body?.data)
? body.data
: Array.isArray(body)
? body
: [];
// ds_dataset_name
const kw = (searchKeyword.value || "").trim().toLowerCase();
rows.value =
kw.length === 0
? arr
: arr.filter((r) =>
String(r.ds_dataset_name || "")
.toLowerCase()
.includes(kw),
);
} catch (e) {
errorMsg.value = "외부 데이터셋 조회에 실패했습니다.";
} finally {
loading.value = false;
}
}
function close() {
visible.value = false;
emit("close");
}
async function pick(item: ExternalDatasetItem) {
try {
// DatasetService.saveExternal(...) emit("saved")
// :
// await DatasetService.saveExternal({
// datasetName: item.ds_dataset_name,
// path: item.ds_dataset_base_path,
// refId: props.refId ?? 0,
// refType: "DATASET",
// title: item.ds_dataset_name,
// version: 1,
// description: "",
// regUserId: "currentUser",
// projectId: props.projectId,
// });
emit("saved");
} finally {
close();
}
}
watch(() => props.projectId, fetchList);
onMounted(fetchList);
</script>
<template>
<v-card rounded="lg" elevation="4">
<v-card-title
class="text-white text-h6 font-weight-bold"
style="background: #1976d2"
>
External Datasets
</v-card-title>
<v-card-text class="pa-4">
<!-- 🔧 검색바 정렬 -->
<div class="d-flex align-center ga-2 mb-3" style="flex-wrap: wrap">
<v-text-field
v-model="searchKeyword"
label="데이터셋 이름 (ds_dataset_name)"
density="compact"
hide-details
style="min-width: 240px; flex: 1 1 240px"
@keyup.enter="fetchList"
clearable
/>
<v-text-field
v-model="groupName"
label="그룹 이름"
density="compact"
hide-details
style="min-width: 240px; flex: 1 1 240px"
@keyup.enter="fetchList"
clearable
/>
<v-btn color="primary" :loading="loading" @click="fetchList"
>검색</v-btn
>
</div>
<v-alert
v-if="errorMsg"
type="error"
variant="tonal"
density="compact"
class="mb-2"
>
{{ errorMsg }}
</v-alert>
<v-table density="compact" fixed-header height="360">
<thead>
<tr>
<th class="text-left">Dataset Name</th>
<th class="text-left">Images</th>
<th class="text-left">Label Type</th>
<th class="text-left">Base Path</th>
<th class="text-left">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in rows" :key="i">
<td>{{ r.ds_dataset_name }}</td>
<td>{{ r.ds_dataset_image_count }}</td>
<td>{{ r.labelling_tool_kr }}</td>
<td>{{ r.ds_dataset_base_path }}</td>
<td>
<v-btn size="small" color="primary" @click="pick(r)"
>Select</v-btn
>
</td>
</tr>
<tr v-if="!loading && rows.length === 0">
<td colspan="5" class="text-center text-medium-emphasis">
No data
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions class="justify-end px-4 py-2">
<v-btn variant="text" @click="close">Close</v-btn>
</v-card-actions>
</v-card>
</template>

@ -148,6 +148,7 @@ function logOut() {
.catch(console.error)
.finally(() => {
localStorage.removeItem("autoflow-auth");
localStorage.removeItem("external-auth");
localStorage.removeItem("projectName");
localStorage.removeItem("projectId");
sessionStorage.removeItem("initialRedirectDone");
@ -206,9 +207,8 @@ onBeforeUnmount(() => {
<template>
<v-app>
<!-- ===== 상단 탑바 ===== -->
<v-app-bar flat height="75" class="topbar">
<!-- 좌측: (브랜드) 버튼 -->
<v-btn
<v-app-bar flat height="64" class="topbar">
<div
variant="text"
size="large"
class="brand-btn text-h5 text-primary"
@ -216,21 +216,59 @@ onBeforeUnmount(() => {
aria-label="Home"
>
AUTOFLOW WEB CONSOLE
</v-btn>
</div>
<!-- 중앙: 메뉴 그룹 (Settings / 분기) -->
<div class="center-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴: showAdminTabs 조건으로 표시 -->
<!-- 관리자 메뉴바: 기본 메뉴바와 1:1 동일 구조 -->
<template v-if="showAdminTabs">
<template v-for="(m, i) in adminMenus" :key="'am_' + i">
<!-- 드롭다운 있는 항목 -->
<v-menu
v-if="m.depth?.length"
open-on-hover
close-on-content-click
location="bottom"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="nav-btn"
:class="{
'nav-active': m.depth?.some((d: any) =>
isLinkActive(d.path),
),
}"
append-icon="mdi-chevron-down"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
<v-list density="compact" class="min-w-48">
<v-list-item
v-for="(d, j) in m.depth"
:key="'amd_' + j"
:title="d.title"
:to="d.path"
:active="isLinkActive(d.path)"
active-class="nav-active"
/>
</v-list>
</v-menu>
<!-- 드롭다운 없는 단일 항목 -->
<v-btn
v-else
variant="text"
class="nav-btn"
:prepend-icon="m.icon"
:to="m.path"
:color="isLinkActive(m.path) ? 'primary' : undefined"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click="m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
@ -244,12 +282,13 @@ onBeforeUnmount(() => {
open-on-hover
close-on-content-click
location="bottom"
transition="scale-transition"
>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="nav-btn"
class="nav-btn text-white"
:class="{
'nav-active': m.depth?.some((d: any) =>
isLinkActive(d.path),
@ -261,16 +300,56 @@ onBeforeUnmount(() => {
{{ m.title }}
</v-btn>
</template>
<v-list density="compact" class="min-w-48">
<v-list-item
v-for="(d, j) in m.depth"
:key="'d_' + j"
:title="d.title"
:to="d.path"
:active="isLinkActive(d.path)"
:color="isLinkActive(d.path) ? 'primary' : undefined"
/>
</v-list>
<!-- 여기부터: Run 전용 디자인 -->
<template
v-if="
(m.title && m.title.toLowerCase() === 'run') ||
(m.path && m.path.startsWith('/run'))
"
>
<v-card
rounded="lg"
elevation="12"
color="surface"
class="px-2 py-2"
>
<v-list density="comfortable" lines="one" class="min-w-48">
<template v-for="(d, j) in m.depth" :key="'run_' + j">
<v-hover v-slot="{ isHovering, props: liProps }">
<v-list-item
v-bind="liProps"
:title="d.title"
:to="d.path"
:active="isLinkActive(d.path)"
color="primary"
:rounded="'lg'"
:variant="
isHovering || isLinkActive(d.path)
? 'tonal'
: 'text'
"
class="mx-2 my-1 text-white"
/>
</v-hover>
</template>
</v-list>
</v-card>
</template>
<!-- 기본 하위메뉴 (Run 이외는 기존 그대로) -->
<template v-else>
<v-list density="compact" class="min-w-48 subnav-list">
<v-list-item
v-for="(d, j) in m.depth"
:key="'d_' + j"
:title="d.title"
:to="d.path"
class="submenu-item text-white"
:class="{ 'submenu-active': isLinkActive(d.path) }"
/>
</v-list>
</template>
</v-menu>
<v-btn
@ -315,7 +394,7 @@ onBeforeUnmount(() => {
v-bind="props"
aria-label="Project"
>
<v-icon>mdi-home</v-icon>
<v-icon>mdi-file-tree</v-icon>
</v-btn>
</template>
</v-tooltip>
@ -383,16 +462,42 @@ onBeforeUnmount(() => {
align-items: center;
}
/* 네비게이션 버튼 스타일 */
.nav-btn {
height: 40px;
text-transform: none;
border-radius: 10px;
padding: 0 10px;
font-size: 16px;
padding: 0 16px;
font-size: 14px;
color: #fff !important; /* 흰색 텍스트 통일 */
}
.nav-btn:hover {
background: rgba(59, 130, 246, 0.08);
}
.nav-active {
background: rgba(59, 130, 246, 0.15);
background: rgba(59, 130, 246, 0.22);
height: 46px;
color: #fff !important;
}
/* 드롭다운(하위 메뉴)도 동일 룩으로 */
.subnav-list {
background: transparent; /* 탑바 느낌 유지 */
}
.submenu-item {
color: #fff !important;
border-radius: 10px;
margin: 2px 8px;
}
.submenu-item:hover {
background: rgba(59, 130, 246, 0.08);
}
.submenu-active {
background: rgba(59, 130, 246, 0.22);
color: #fff !important;
}
.min-w-48 {
min-width: 12rem;

@ -0,0 +1,35 @@
// src/components/service/management/DatasetService.ts
import { request } from "@/components/service/index";
export interface ExternalListQuery {
ds_prj_idx: number;
search_keyword?: string;
grp_name?: string;
}
export interface ExternalDatasetItem {
ds_dataset_name: string;
ds_dataset_image_count: number;
labelling_tool_kr: string;
ds_dataset_base_path: string;
}
export interface SaveExternalDatasetPayload {
datasetName: string;
path: string;
refId: number;
refType: "DATASET";
title: string;
description?: string;
version: number;
regUserId: string;
projectId: number;
}
export const DatasetService = {
listExternal(query: ExternalListQuery) {
return request.get("/api/datasets/list", query as any);
},
saveExternal(payload: SaveExternalDatasetPayload) {
return request.post("/api/datasets/dataset/save", payload);
},
};

@ -0,0 +1,22 @@
import { request } from "@/components/service/index";
export const ExternalAuthControllerService = {
signIn: (id: string, password: string) => {
return request.post("/api/external-auth/signin", { id, password });
},
add: (token: string, edgePkgInfoVO: string, file: any) => {
return request.post("/api/external-auth/add", {
token,
edgePkgInfoVO,
file,
});
},
search: (id: string, token: string) => {
return request.get("/api/external-auth/edge-search", {
id,
token,
});
},
};

@ -6,6 +6,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { storage } from "@/utils/storage";
import ViewComponent from "@/components/templates/Datasets/ViewComponent.vue";
import DatasetBaseDoalog from "@/components/atoms/organisms/DatasetBaseDoalog.vue";
import ExternalDatasetDialog from "@/components/atoms/organisms/ExternalDatasetDialog.vue";
import { AttachmentsService } from "@/components/service/management/AttachmentsService";
import { commonStore } from "@/stores/commonStore";
import { useRoute, useRouter } from "vue-router";
@ -17,7 +18,7 @@ const activeRefName = computed(() => String(route.query.refName));
const store = commonStore();
const openView = ref(false);
const openModify = ref(false);
const externalVisible = ref(false);
const username = ref<string>("");
// ===== /( ) =====
@ -68,6 +69,7 @@ const data = ref({
allSelected: false,
selected: [] as Array<{ deviceKey: number }>,
isCreateVisible: false,
isExternalVisible: false,
isUploadVisible: false,
isModalVisible: false,
isConfirmDialogVisible: false,
@ -284,6 +286,14 @@ const removeData = (value?: Array<{ deviceKey: number }>) => {
}
};
function openExternal() {
externalVisible.value = true;
}
function onPickExternal(item: any) {
console.log("picked external dataset:", item);
}
const closeDetail = () => {
openView.value = false;
};
@ -329,6 +339,14 @@ const getSelectedAllData = () => {
? data.value.results.map((item: any) => ({ deviceKey: item.deviceKey }))
: [];
};
function onPickExternalSaved() {
fetchList();
store.setSnackbarMsg({
color: "success",
text: "외부 데이터셋이 등록되었습니다.",
result: 200,
});
}
onMounted(() => {
username.value = readUsernameFromStorage();
activeRefId.value = getRefIdFromRoute(route.query);
@ -356,7 +374,7 @@ watch(
<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">Datasets</div>
<div>Datasets</div>
</div>
</v-card-item>
</v-card>
@ -458,9 +476,12 @@ watch(
</v-sheet>
</v-sheet>
<v-sheet class="justify-end mb-2">
<v-sheet class="justify-end mb-2 mr-2">
<v-btn color="info" @click="openCreateModal">Add Dataset</v-btn>
</v-sheet>
<v-sheet class="justify-end mb-2">
<v-btn color="info" @click="openExternal">External Dataset</v-btn>
</v-sheet>
</v-sheet>
<!-- 목록 -->
@ -551,6 +572,14 @@ watch(
:user-option="data.userOption"
/>
</v-dialog>
<v-dialog v-model="externalVisible" max-width="900" persistent>
<ExternalDatasetDialog
:project-id="getProjectId()"
:ref-id="activeRefId"
@close="externalVisible = false"
@saved="onPickExternalSaved"
/>
</v-dialog>
</div>
<div class="w-100" v-else>

@ -170,7 +170,7 @@ watch(
<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">View Details</div>
<div>View Details</div>
</div>
</v-card-item>
</v-card>

@ -374,7 +374,7 @@ onMounted(fetchList);
<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">DataGroup</div>
<div>DataGroup</div>
</div>
</v-card-item>
</v-card>

@ -2,14 +2,28 @@
import IconDeleteBtn from "@/components/atoms/button/IconDeleteBtn.vue";
import IconModifyBtn from "@/components/atoms/button/IconModifyBtn.vue";
import IconSettingBtn from "@/components/atoms/button/IconSettingBtn.vue";
// import FormComponent from "@/components/device/FormComponent.vue";
import { onMounted, ref, watch } from "vue";
import { onMounted, ref, nextTick } from "vue";
import ViewComponent from "@/components/templates/deployment/ViewComponent.vue";
import DeploymentDialog from "@/components/atoms/organisms/DeploymentDialog.vue";
// const store = commonStore();
import { ExternalAuthControllerService } from "@/components/service/management/ExternalAuthControllerService";
const openView = ref(false);
const isEditVisible = ref(false);
const loginDialog = ref(false);
const loginLoading = ref(false);
const loginForm = ref({ id: "", password: "" });
const loginError = ref("");
const isAuthenticated = ref(false);
type ExternalAuth = { id: string; name: string; token: string };
const AUTH_KEY = "external-auth";
const externalAuth = ref<ExternalAuth | null>(null);
type PackageOption = { label: string; value: string; raw: any };
const packageOptions = ref<PackageOption[]>([]);
const packagesLoading = ref(false);
const packagesError = ref("");
const tableHeader = [
{ label: "No", width: "5%", style: "word-break: keep-all;" },
{ label: "Model Name", width: "20%", style: "word-break: keep-all;" },
@ -38,33 +52,138 @@ const pageSizeOptions = [
];
const data = ref({
params: {
pageNum: 1,
pageSize: 10,
searchType: "",
searchText: "",
},
results: [],
params: { pageNum: 1, pageSize: 10, searchType: "", searchText: "" },
results: [] as any[],
totalDataLength: 0,
pageLength: 0,
modalMode: "",
selectedData: null,
selectedData: null as any,
allSelected: false,
selected: [],
selected: [] as any[],
isEditVisible: false,
isModalVisible: false,
isConfirmDialogVisible: false,
userOption: [],
userOption: [] as any[],
});
const getCodeList = () => {
// UserService.search(data.value.params).then((d) => {
// if (d.status === 200) {
// data.value.userOption = d.data.userList;
// }
// });
const handleLogin = async () => {
try {
loginLoading.value = true;
loginError.value = "";
const { id, password } = loginForm.value;
if (!id || !password) {
loginError.value = "ID와 비밀번호를 입력하세요.";
return;
}
const res = await ExternalAuthControllerService.signIn(id, password);
const raw = res?.data ?? res;
if (raw == null) {
loginError.value = "로그인에 실패했습니다. (서버 응답이 비어있습니다)";
return;
}
const payload = Object.prototype.hasOwnProperty.call(raw, "data")
? raw.data
: raw;
if (!payload?.token) {
loginError.value = "로그인에 실패했습니다. 아이디/비밀번호를 확인하세요.";
return;
}
const toSave: ExternalAuth = {
id: payload.id ?? id,
name: payload.name ?? id,
token: payload.token,
};
localStorage.setItem(AUTH_KEY, JSON.stringify(toSave));
externalAuth.value = toSave;
isAuthenticated.value = true;
loginDialog.value = false;
loginForm.value = { id: "", password: "" };
await nextTick();
openDeploymentModal(); //
} catch (e: any) {
loginError.value =
e?.response?.data?.message || e?.message || "로그인에 실패했습니다.";
} finally {
loginLoading.value = false;
}
};
const handleLogout = () => {
localStorage.removeItem(AUTH_KEY);
isAuthenticated.value = false;
externalAuth.value = null;
packageOptions.value = [];
};
onMounted(() => {
const raw = localStorage.getItem(AUTH_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw) as ExternalAuth;
if (parsed?.token) {
isAuthenticated.value = true;
externalAuth.value = parsed;
}
} catch {}
}
getData();
getCodeList();
});
const openDeploymentModal = async () => {
if (!isAuthenticated.value) {
loginDialog.value = true;
return;
}
data.value.selectedData = null;
data.value.modalMode = "create";
isEditVisible.value = true;
packagesError.value = "";
packageOptions.value = [];
const auth =
externalAuth.value ||
(() => {
try {
const raw = localStorage.getItem(AUTH_KEY);
return raw ? (JSON.parse(raw) as ExternalAuth) : null;
} catch {
return null;
}
})();
if (!auth?.id || !auth?.token) {
packagesError.value = "로그인 정보가 없습니다.";
return;
}
try {
packagesLoading.value = true;
const res = await ExternalAuthControllerService.search(auth.id, auth.token);
const body = res?.data ?? res;
const list = body?.data?.data ?? body?.data ?? body?.result ?? body ?? [];
const arr = Array.isArray(list) ? list : [];
packageOptions.value = arr.map((it: any) => ({
label: it?.package_name || it?.package_id || "Unknown",
value: it?.package_id,
raw: it,
}));
} catch (e: any) {
packagesError.value =
e?.response?.data?.message || e?.message || "패키지 조회 실패";
} finally {
packagesLoading.value = false;
}
};
const getCodeList = () => {};
const getData = () => {
data.value.results = [
{
@ -156,101 +275,10 @@ const setPaginationLength = () => {
}
};
const saveData = (formData) => {
if (data.value.modalMode === "create") {
// DeviceService.add(formData).then((d) => {
// if (d.status === 200) {
// data.value.isModalVisible = false;
// store.setSnackbarMsg({
// text: " .",
// result: 200,
// });
// changePageNum(1);
// } else {
// store.setSnackbarMsg({
// text: d,
// result: 500,
// });
// }
// });
} else {
// DeviceService.update(formData.deviceKey, formData).then((d) => {
// if (d.status === 200) {
// data.value.isModalVisible = false;
// store.setSnackbarMsg({
// text: " .",
// result: 200,
// });
// changePageNum();
// } else {
// store.setSnackbarMsg({
// text: d,
// result: 500,
// });
// }
// });
}
};
const removeData = (value) => {
let removeList = value ? value : data.value.selected;
const remove = (code) => {
// return DeviceService.delete(code).then((d) => {
// if (d.status !== 200) {
// store.setSnackbarMsg({
// text: d,
// result: 500,
// });
// }
// });
};
if (removeList.length === 1) {
remove(removeList[0].deviceKey).then(() => {
// store.setSnackbarMsg({
// text: ".",
// result: 200,
// });
changePageNum();
data.value.isConfirmDialogVisible = false;
data.value.selected = [];
data.value.allSelected = false;
});
} else {
Promise.all(removeList.map((item) => remove(item.deviceKey))).finally(
() => {
// store.setSnackbarMsg({
// text: " .",
// result: 200,
// });
changePageNum();
data.value.isConfirmDialogVisible = false;
data.value.selected = [];
data.value.allSelected = false;
},
);
}
};
const handleRemoveData = () => {
if (data.value.selected.length === 0) {
// store.setSnackbarMsg({
// text: " . ",
// result: 500,
// });
return;
}
if (data.value.allSelected || data.value.selected.length !== 1) {
data.value.isConfirmDialogVisible = true;
return;
}
//
removeData(undefined);
};
const handleRefresh = () => {
alert("Refresh 작업 진행중...");
};
const saveData = (_: any) => {};
const removeData = (_: any) => {};
const handleRemoveData = () => {};
const handleRefresh = () => alert("Refresh 작업 진행중...");
const openInfoModal = () => {
data.value.modalMode = "info";
openView.value = true;
@ -258,69 +286,37 @@ const openInfoModal = () => {
const closeDetail = () => {
openView.value = false;
};
const changePageNum = (page) => {
const changePageNum = (page: number) => {
data.value.params.pageNum = page;
getData();
};
const openSettingModal = (selectedItem) => {
const openSettingModal = (selectedItem: any) => {
data.value.selectedData = selectedItem;
data.value.modalMode = "setting";
openView.value = true;
};
const openModifyModal = (selectedItem) => {
const openModifyModal = (selectedItem: any) => {
data.value.selectedData = selectedItem;
data.value.modalMode = "modify";
data.value.isModalVisible = true;
};
const openDeploymentModal = () => {
data.value.selectedData = null;
data.value.modalMode = "create";
isEditVisible.value = true;
};
const closeModal = () => {
data.value.isModalVisible = false;
data.value.selectedData = null;
};
const closeCreateModal = () => {
data.value.isModalVisible = false;
isEditVisible.value = null;
isEditVisible.value = false;
};
const getSelectedAllData = () => {
data.value.selected = data.value.allSelected
? data.value.results.map((item) => {
return {
deviceKey: item.deviceKey,
};
})
? data.value.results.map((item: any) => ({ deviceKey: item.deviceKey }))
: [];
};
onMounted(() => {
getData();
getCodeList();
});
</script>
<template>
<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-card
flat
@ -329,10 +325,11 @@ onMounted(() => {
<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">Deployment</div>
<div>Deployment</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">
@ -349,8 +346,9 @@ onMounted(() => {
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"
@ -361,7 +359,7 @@ onMounted(() => {
class="mt-3 mb-3"
hide-details
@keyup.enter="changePageNum(1)"
></v-text-field>
/>
</v-responsive>
<div class="ml-3">
@ -371,7 +369,7 @@ onMounted(() => {
:rounded="5"
@click="changePageNum(1)"
>
<v-icon> mdi-magnify</v-icon>
<v-icon>mdi-magnify</v-icon>
</v-btn>
</div>
</div>
@ -385,8 +383,8 @@ onMounted(() => {
class="d-flex align-center mr-3 mb-2 bg-shades-transparent"
>
<v-chip color="primary"
> {{ data.totalDataLength.toLocaleString() }}
</v-chip>
> {{ data.totalDataLength.toLocaleString() }}</v-chip
>
</v-sheet>
<v-sheet class="bg-shades-transparent">
<v-responsive max-width="140" min-width="140" class="mb-2">
@ -400,14 +398,26 @@ onMounted(() => {
color="primary"
hide-details
@update:model-value="changePageNum(1)"
></v-select>
/>
</v-responsive>
</v-sheet>
</v-sheet>
<v-sheet class="justify-end mb-2">
<v-btn color="info" class="mr-4" @click="handleRefresh"
>Refresh
<v-btn
class="mr-4"
color="primary"
:rounded="5"
@click="isAuthenticated ? handleLogout() : (loginDialog = true)"
>
<v-icon start>{{
isAuthenticated ? "mdi-logout" : "mdi-login"
}}</v-icon>
{{ isAuthenticated ? "Logout" : "Login" }}
</v-btn>
<v-btn color="info" class="mr-4" @click="handleRefresh"
>Refresh</v-btn
>
<v-btn color="info" @click="openDeploymentModal"
>Deployment</v-btn
>
@ -442,7 +452,7 @@ onMounted(() => {
:indeterminate="data.allSelected === true"
hide-details
@change="getSelectedAllData"
></v-checkbox>
/>
</th>
<th
v-for="(item, i) in tableHeader"
@ -472,41 +482,35 @@ onMounted(() => {
<td class="text-start">{{ item.modelName }}</td>
<td>{{ item.version }}</td>
<td>{{ item.duration }}</td>
<td>
<v-icon
v-if="item.deployStatus === 'success'"
color="success"
>mdi-checkbox-marked-circle</v-icon
>
mdi-checkbox-marked-circle
</v-icon>
<v-icon
v-else-if="item.deployStatus === 'in_progress'"
spin
>mdi-loading</v-icon
>
mdi-loading
</v-icon>
<v-icon v-else color="error"> mdi-close-circle </v-icon>
<v-icon v-else color="error">mdi-close-circle</v-icon>
</td>
<td>
<v-icon
v-if="item.downloadStatus === 'success'"
color="success"
>mdi-checkbox-marked-circle</v-icon
>
mdi-checkbox-marked-circle
</v-icon>
<v-icon
v-else-if="item.downloadStatus === 'in_progress'"
spin
>mdi-loading</v-icon
>
mdi-loading
</v-icon>
<v-icon
v-else-if="item.downloadStatus === 'failure'"
color="error"
>mdi-close-circle</v-icon
>
mdi-close-circle
</v-icon>
<span v-else>-</span>
</td>
<td>{{ item.deployedAt }}</td>
@ -518,6 +522,7 @@ onMounted(() => {
</tbody>
</v-table>
</v-sheet>
<v-card-actions class="text-center mt-8 justify-center">
<v-pagination
v-model="data.params.pageNum"
@ -526,23 +531,88 @@ onMounted(() => {
color="primary"
rounded="circle"
@update:model-value="getData"
></v-pagination>
/>
</v-card-actions>
</v-col>
</v-card>
</v-card>
</v-card>
<v-dialog v-model="isEditVisible" max-width="800" persistent>
<DeploymentDialog
:edit-data="data.selectedData"
:mode="data.modalMode"
:packages="packageOptions"
:packages-loading="packagesLoading"
:packages-error="packagesError"
@close-modal="closeCreateModal"
@handle-data="saveData"
:user-option="data.userOption"
/>
</v-dialog>
<v-dialog v-model="loginDialog" max-width="450" persistent>
<v-card>
<v-card-title
class="text-white font-weight-bold text-h6"
style="background-color: #1976d2"
>외부 로그인</v-card-title
>
<v-card-text class="pa-6">
<v-form @submit.prevent="handleLogin">
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
>ID</label
>
<v-text-field
v-model="loginForm.id"
variant="outlined"
:disabled="loginLoading"
dense
hide-details="auto"
autocomplete="username"
required
@keyup.enter.prevent="handleLogin"
/>
</div>
<div class="mb-5">
<label class="text-subtitle-2 font-weight-medium mb-1 d-block"
>Password</label
>
<v-text-field
v-model="loginForm.password"
variant="outlined"
:disabled="loginLoading"
dense
hide-details="auto"
type="password"
autocomplete="current-password"
required
@keyup.enter.prevent="handleLogin"
/>
</div>
<div v-if="loginError" class="mt-3 text-error">
{{ loginError }}
</div>
</v-form>
</v-card-text>
<v-card-actions class="justify-end" style="padding: 16px 24px">
<v-btn color="success" :loading="loginLoading" @click="handleLogin"
>Login</v-btn
>
<v-btn
text
class="white--text"
:disabled="loginLoading"
@click="loginDialog = false"
>Close</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
<div class="w-100" v-else>
<ViewComponent @close="closeDetail" />
</div>

@ -236,7 +236,7 @@ onMounted(() => {
<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">View Details</div>
<div>View Details</div>
</div>
</v-card-item>
</v-card>

@ -511,7 +511,7 @@ onMounted(async () => {
<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">Projects</div>
<div>Projects</div>
</div>
</v-card-item>
</v-card>

@ -264,7 +264,7 @@ onMounted(() => {
<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">Compare Executions</div>
<div>Compare Executions</div>
</div>
</v-card-item>
</v-card>

@ -569,7 +569,7 @@ onMounted(() => {
<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>Executions</div>
</div>
</v-card-item>
</v-card>

@ -1,563 +0,0 @@
<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;
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>

File diff suppressed because it is too large Load Diff

@ -302,7 +302,7 @@ onMounted(() => {
<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">Experiment</div>
<div>Experiment</div>
</div>
</v-card-item>
</v-card>

@ -185,7 +185,7 @@ watch(
<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">View Details</div>
<div>View Details</div>
</div>
</v-card-item>
</v-card>

@ -317,7 +317,7 @@ onMounted(() => {
<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">Workflows Step Config</div>
<div>Workflows Step Config</div>
</div>
</v-card-item>
</v-card>

@ -96,7 +96,7 @@ watch(
<v-card flat class="bg-shades-transparent w-100 mb-6">
<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">View Details</div>
<div>View Details</div>
</div>
</v-card-item>
</v-card>

@ -355,7 +355,7 @@ watch(
<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">TrainingScript</div>
<div>TrainingScript</div>
</div>
</v-card-item>
</v-card>

@ -110,7 +110,7 @@ onBeforeUnmount(() => {
<v-card flat class="bg-shades-transparent w-100 mb-6">
<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">View Details</div>
<div>View Details</div>
</div>
</v-card-item>
</v-card>

@ -374,7 +374,7 @@ onMounted(fetchList);
<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">TrainingScriptGroup</div>
<div>TrainingScriptGroup</div>
</div>
</v-card-item>
</v-card>

@ -401,7 +401,7 @@ onMounted(async () => {
<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">Users</div>
<div>Users</div>
</div>
</v-card-item>
</v-card>

@ -347,7 +347,7 @@ onMounted(() => {
<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">Workflows</div>
<div>Workflows</div>
</div>
</v-card-item>
</v-card>

@ -204,7 +204,7 @@ const steps = ref<
<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">View Details</div>
<div>View Details</div>
</div>
</v-card-item>
</v-card>

@ -23,12 +23,12 @@ export const menuUtils = {
{ title: "Executions", path: "/run/executions" },
],
},
{
title: "Deployment",
path: "/deployment",
value: "deployment",
icon: "mdi-folder-search",
},
// {
// title: "Deployment",
// path: "/deployment",
// value: "deployment",
// icon: "mdi-folder-search",
// },
{
title: "TrainingScriptGroup",
path: "/TrainingScriptGroup",

Loading…
Cancel
Save