parent
5e5dffcde9
commit
465b56fad8
@ -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-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">
|
||||
<v-subheader class="font-weight-medium white--text mb-2">
|
||||
Select Package
|
||||
</v-subheader>
|
||||
<div class="text-body-2 font-weight-medium mb-1">SW 패키지</div>
|
||||
<v-select
|
||||
dense
|
||||
hide-details
|
||||
outlined
|
||||
style="background: #1e1e1e; color: #fff"
|
||||
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>
|
||||
</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-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>
|
||||
|
||||
<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">
|
||||
<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="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">
|
||||
<div class="font-weight-medium mb-2">Windows</div>
|
||||
<v-text-field
|
||||
label="File Name"
|
||||
placeholder="4_EdgeInfra_Perception.exe"
|
||||
dense
|
||||
outlined
|
||||
hide-details
|
||||
label="윈도우 실행 파일명"
|
||||
v-model="form.win_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
|
||||
hide-details
|
||||
label="윈도우 경로"
|
||||
v-model="form.win_root_path"
|
||||
disabled
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-sheet>
|
||||
<!-- Package Preview 끝 -->
|
||||
|
||||
<!-- Software Name / Version -->
|
||||
<v-row dense class="mb-4">
|
||||
<v-col cols="12">
|
||||
<!-- 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="Software Name"
|
||||
placeholder="Enter software Name"
|
||||
dense
|
||||
outlined
|
||||
hide-details
|
||||
label="리눅스 실행 파일명"
|
||||
v-model="form.linux_exe_name"
|
||||
disabled
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
label="Software Version"
|
||||
placeholder="Enter software Version"
|
||||
dense
|
||||
outlined
|
||||
hide-details
|
||||
label="리눅스 경로"
|
||||
v-model="form.linux_root_path"
|
||||
disabled
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Executed 여부 -->
|
||||
<v-row dense class="mb-6">
|
||||
<!-- Artifact Path -->
|
||||
<v-row dense class="mb-1">
|
||||
<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-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-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 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>
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -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
Loading…
Reference in new issue