feat:Deployment 버튼 분리

main
jschoi 8 months ago
parent 96b3718493
commit 482b35da8d

@ -21,6 +21,8 @@ const props = defineProps<{
packagesError?: string;
artifactPath?: string;
token: string;
hideArtifactPath?: boolean;
hideUploadFile?: boolean;
}>();
const emit = defineEmits<{
@ -74,7 +76,9 @@ const remoteError = ref("");
const pkgOptions = computed<PackageOption[]>(() =>
remotePackages.value.length ? remotePackages.value : (props.packages ?? []),
);
/* ========================= 표시/숨김 스위치 ========================= */
const showArtifactPath = computed(() => !!props.artifactPath);
const showUploadFile = computed(() => !showArtifactPath.value);
function getCurrentUserId(): string {
// id ,
try {
@ -258,7 +262,16 @@ async function submit() {
sw_type: sourceType.value === "edge" ? 1 : 0,
creation_datetime: new Date().toISOString(),
};
if (props.hideArtifactPath) {
// Upload
if (!file.value) return (errorMsg.value = "업로드 파일을 선택하세요.");
}
if (props.hideUploadFile) {
// Artifact artifactPath ,
if (file.value) clearFile();
if (!props.artifactPath)
return (errorMsg.value = "Artifact 경로가 없습니다.");
}
try {
saving.value = true;
let res: any;
@ -501,7 +514,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
</v-row>
<!-- Artifact Path -->
<v-row dense class="mb-1">
<v-row dense class="mb-1" v-if="!props.hideArtifactPath">
<v-col cols="12">
<v-text-field
label="차량 SW 파일 (Artifact Path)"
@ -512,16 +525,16 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onEsc));
</v-row>
<!-- 파일 업로드 -->
<v-row dense class="mb-1">
<v-row dense class="mb-1" v-if="!props.hideUploadFile">
<v-col cols="12">
<div class="text-body-2 font-weight-medium mb-1">업로드 파일</div>
<div class="d-flex align-center ga-3">
<v-btn size="small" color="primary" @click="fileInput?.click()"
>파일 선택</v-btn
>
<span v-if="file" class="text-body-2"
>{{ file.name }} ({{ file.size.toLocaleString() }} bytes)</span
>
<span v-if="file" class="text-body-2">
{{ file.name }} ({{ file.size.toLocaleString() }} bytes)
</span>
<v-btn
v-if="file"
size="x-small"

@ -5,7 +5,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storage } from "@/utils/storage.js";
import { UserManagerService } from "@/components/service/management/userManagerService";
import { UserManagerService } from "@/components/service/management/UserManagerService";
import { menuUtils } from "@/utils/menuUtils";
/* ================================
@ -20,7 +20,7 @@ type MenuItem = {
};
/* ================================
* Router / Base states
* Router / Reactive base state
* ================================ */
const route = useRoute();
const router = useRouter();
@ -29,9 +29,12 @@ const username = ref<string>("");
const projectName = ref<string>(localStorage.getItem("projectName") || "");
const isAdmin = ref<boolean>(false);
const adminMode = ref<boolean>(false);
const adminMode = ref<boolean>(false); // Settings /
const lastNonAdminPath = ref<string>("/home");
/* ================================
* Auth / Role helpers
* ================================ */
function readAuth() {
try {
const raw =
@ -43,6 +46,7 @@ function readAuth() {
return null;
}
}
function computeIsAdmin() {
const auth = readAuth();
const roles = auth?.userInfo?.roles ?? auth?.roles ?? [];
@ -52,6 +56,7 @@ function computeIsAdmin() {
: roles === "ROLE_ADMIN";
isAdmin.value = inRoles || authCd === "ADMIN";
}
function updateUsername() {
const auth = readAuth();
username.value = auth?.userInfo?.username ?? auth?.username ?? "";
@ -59,6 +64,8 @@ function updateUsername() {
/* ================================
* Derived route state
* - /select
* - 관리자 표시 조건: adminMode ON || 관리자 라우트
* ================================ */
const hideAllMenus = computed<boolean>(() => route.path.startsWith("/select"));
@ -71,16 +78,18 @@ const isAdminRoute = computed<boolean>(() => {
const hitMeta = route.matched.some((r) => r.meta?.requiresAdmin);
return hitPath || hitMeta;
});
const showAdminTabs = computed<boolean>(
() => adminMode.value || isAdminRoute.value,
);
/* ================================
* Menus
* Menus (기본/관리자)
* ================================ */
const baseMenus = computed<MenuItem[]>(
() => (menuUtils?.menuItem ?? []) as MenuItem[],
);
const adminMenus = computed<MenuItem[]>(() => {
const fromUtil = (menuUtils?.adminMenuItem ?? []) as MenuItem[];
return fromUtil.length
@ -90,75 +99,50 @@ const adminMenus = computed<MenuItem[]>(() => {
{ title: "Users", icon: "mdi-account-multiple", path: "/users" },
];
});
const isLinkActive = (path?: string) => !!path && route.path.startsWith(path);
/* ================================
* 사용자 메뉴 (우측)
* Header dropdown menu
* ================================ */
const menu = ref<MenuItem[]>([]);
const menuItems: MenuItem[] = [
{ title: "Select Project", click: () => goSelect() },
{ title: "Change Password", click: () => {} },
{
title: "Change Password",
click: () => {
/* open modal */
},
},
{ title: "Logout", icon: "mdi-logout", click: () => logOut() },
];
/* ================================
* 상단 Hover 하위 메뉴 스트립
* ================================ */
type DepthItem = { title: string; path: string };
const hoverBar = ref<{
open: boolean;
items: DepthItem[];
}>({ open: false, items: [] });
let hideTimer: number | null = null;
function showHoverStrip(m: MenuItem) {
if (!m.depth?.length) return;
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
hoverBar.value = {
open: true,
items: m.depth,
};
}
function scheduleHideStrip() {
if (hideTimer) window.clearTimeout(hideTimer);
hideTimer = window.setTimeout(() => {
hoverBar.value.open = false;
}, 140);
}
function keepStrip() {
if (hideTimer) {
window.clearTimeout(hideTimer);
hideTimer = null;
}
}
/* ================================
* Navigation
* Navigation actions
* ================================ */
function goMain() {
adminMode.value = false;
router.push("/home");
}
function goSelect() {
adminMode.value = false;
router.push("/select");
}
function toggleAdmin() {
if (!isAdmin.value) return;
if (adminMode.value) {
//
adminMode.value = false;
router.push("/home");
} else {
adminMode.value = true;
//
if (!isAdminRoute.value) router.push("/project");
}
}
function logOut() {
UserManagerService.signOut()
.catch(console.error)
@ -181,16 +165,18 @@ function logOut() {
function refreshProjectName() {
projectName.value = localStorage.getItem("projectName") || "";
}
//
watch(
() => route.fullPath,
() => {
refreshProjectName();
//
hoverBar.value.open = false;
if (!isAdminRoute.value) lastNonAdminPath.value = route.fullPath || "/home";
},
{ immediate: true },
);
// storage
function onStorage(e: StorageEvent) {
if (!e.key || e.key === "projectName") {
refreshProjectName();
@ -212,9 +198,9 @@ onMounted(() => {
menu.value = menuItems;
window.addEventListener("storage", onStorage);
});
onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage);
if (hideTimer) window.clearTimeout(hideTimer);
});
</script>
@ -232,24 +218,54 @@ onBeforeUnmount(() => {
AUTOFLOW WEB CONSOLE
</div>
<!-- 여기 스페이서를 '브랜드 다음' 둬서 오른쪽으로 밀기 -->
<v-spacer />
<!-- 메뉴는 우측 정렬 -->
<div class="right-nav d-none d-md-flex" v-if="!hideAllMenus">
<!-- 관리자 메뉴 -->
<!-- 중앙: 메뉴 그룹 (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)) ||
isLinkActive(m.path),
'nav-active': m.depth?.some((d: any) =>
isLinkActive(d.path),
),
}"
@mouseenter="showHoverStrip(m)"
@mouseleave="scheduleHideStrip"
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"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click="m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
@ -261,17 +277,87 @@ onBeforeUnmount(() => {
<!-- 기본 메뉴 -->
<template v-else>
<template v-for="(m, i) in baseMenus" :key="'m_' + i">
<v-menu
v-if="m.depth?.length"
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 text-white"
:class="{
'nav-active':
m.depth?.some((d: any) => isLinkActive(d.path)) ||
isLinkActive(m.path),
'nav-active': m.depth?.some((d: any) =>
isLinkActive(d.path),
),
}"
@mouseenter="showHoverStrip(m)"
@mouseleave="scheduleHideStrip"
@click="!m.depth?.length && m.path && router.push(m.path)"
append-icon="mdi-chevron-down"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
</v-btn>
</template>
<!-- 여기부터: 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
v-else
variant="text"
class="nav-btn"
:class="{ 'nav-active': isLinkActive(m.path) }"
@click="m.path && router.push(m.path)"
>
<v-icon start :icon="m.icon" class="mr-1" />
{{ m.title }}
@ -279,7 +365,10 @@ onBeforeUnmount(() => {
</template>
</template>
</div>
<!-- 우측 아이콘들 -->
<v-spacer />
<!-- 우측: 기존 기능 유지 -->
<v-tooltip v-if="isAdmin" location="bottom" text="Settings">
<template #activator="{ props }" v-if="!hideAllMenus">
<v-btn
@ -337,37 +426,6 @@ onBeforeUnmount(() => {
</v-menu>
</v-app-bar>
<!-- ===== 상단 하위 메뉴 스트립 (호버 표시) ===== -->
<v-slide-y-transition>
<v-sheet
v-if="hoverBar.open"
class="hover-strip"
elevation="8"
color="surface"
@mouseenter="keepStrip"
@mouseleave="scheduleHideStrip"
>
<v-container class="py-2" :fluid="true">
<!-- 중앙 정렬 -->
<v-row class="g-2" no-gutters justify="center" align="center">
<v-col class="d-flex flex-wrap justify-center" cols="12">
<v-btn
v-for="d in hoverBar.items"
:key="d.path"
size="small"
class="mx-1 my-1 strip-chip"
:variant="isLinkActive(d.path) ? 'tonal' : 'text'"
:color="isLinkActive(d.path) ? 'primary' : undefined"
@click="router.push(d.path)"
>
{{ d.title }}
</v-btn>
</v-col>
</v-row>
</v-container>
</v-sheet>
</v-slide-y-transition>
<!-- 본문 -->
<v-main>
<v-container
@ -388,70 +446,64 @@ onBeforeUnmount(() => {
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
/* 브랜드 */
/* 더 커진 홈(브랜드) 버튼 */
.brand-btn {
font-weight: 800;
letter-spacing: 0.08em;
padding: 0 14px;
}
.right-nav {
display: flex;
/* 중앙 고정 네비게이션 */
.center-nav {
position: absolute;
left: 50%;
transform: translateX(-50%);
gap: 8px;
align-items: center;
gap: 8px; /* 버튼 간격 */
justify-content: flex-end;
}
.nav-btn {
text-transform: none;
border-radius: 10px;
padding: 0 16px;
font-size: 14px;
color: #fff !important;
color: #fff !important; /* 흰색 텍스트 통일 */
}
.nav-btn:hover {
background: rgba(59, 130, 246, 0.08);
}
.nav-active {
background: rgba(59, 130, 246, 0.22);
height: 46px;
color: #fff !important;
}
.userbox {
min-width: 180px;
/* 드롭다운(하위 메뉴)도 동일 룩으로 */
.subnav-list {
background: transparent; /* 탑바 느낌 유지 */
}
/* ===== 호버 스트립 (상단 바로 아래, 이미지 스타일) ===== */
.hover-strip {
position: fixed;
top: var(--v-layout-top, 64px); /* app-bar 바로 아래 */
left: 0;
right: 0;
z-index: 2500;
/* 다크 배경 + 살짝 투명 + 경계 */
background: rgba(32, 32, 32, 0.96);
border-bottom: 1px solid rgb(145, 61, 61);
backdrop-filter: blur(6px);
.submenu-item {
color: #fff !important;
border-radius: 10px;
margin: 2px 8px;
}
/* 버튼(알약) 다크에서도 비활성 글자/테두리 선명 */
.strip-chip {
border-radius: 9999px !important;
text-transform: none;
font-weight: 600;
letter-spacing: 0;
height: 30px;
padding: 0 14px;
color: #e5e7eb !important; /* 비활성도 흐려 보이지 않게 */
.submenu-item:hover {
background: rgba(59, 130, 246, 0.08);
}
.strip-chip.v-btn--variant-text {
/* text 변형일 때도 흐릿하지 않게 약한 테두리 */
border: 1px solid rgba(255, 255, 255, 0.14) !important;
background: transparent !important;
.submenu-active {
background: rgba(59, 130, 246, 0.22);
color: #fff !important;
}
.min-w-48 {
min-width: 12rem;
}
.strip-chip:hover {
background: rgba(255, 255, 255, 0.06) !important;
.userbox {
min-width: 180px;
}
</style>

@ -60,6 +60,8 @@ const packageOptions = ref<PackageOption[]>([]);
const packagesLoading = ref(false);
const packagesError = ref("");
const shouldOpenDeploymentAfterLogin = ref(false);
const dlgHideArtifactPath = ref(false);
const dlgHideUploadFile = ref(false);
/* ========= MLflow State ========= */
const runs = ref<any[]>([]);
const loadingRuns = ref(false);
@ -72,7 +74,7 @@ const runDetailCache = ref<Map<string, RunDetailType>>(new Map());
/* ========= UI Tabs / Compare ========= */
const mainTab = ref<"details" | "viz" | "artifacts">("details");
const vizTab = ref<"metrics" | "scatter" | "box" | "contour">("metrics");
const deployOpenMode = ref<"artifact" | "upload">("artifact");
const compareDialog = ref(false);
const compareLoading = ref(false);
const compareChartMode = ref<"byMetric" | "byRun">("byMetric");
@ -649,19 +651,35 @@ const handleLogin = async () => {
};
/* ========= Deployment Modal ========= */
const openDeploymentModal = async (fullPath?: string) => {
const openDeploymentModal = async (
fullPath?: string,
mode: "upload" | "artifact" = fullPath ? "artifact" : "upload",
) => {
if (fullPath) {
const uri = buildArtifactUri(fullPath);
lastArtifactUri.value = uri;
pendingArtifactPath.value = uri;
} else {
lastArtifactUri.value = "";
pendingArtifactPath.value = null;
}
if (!isAuthenticated.value) {
shouldOpenDeploymentAfterLogin.value = true;
loginDialog.value = true;
return;
}
if (mode === "artifact") {
dlgHideArtifactPath.value = false;
dlgHideUploadFile.value = true;
if (!fullPath && pendingArtifactPath.value)
lastArtifactUri.value = pendingArtifactPath.value;
} else {
dlgHideArtifactPath.value = true;
dlgHideUploadFile.value = false;
lastArtifactUri.value = "";
}
isEditVisible.value = true;
packagesError.value = "";
@ -699,6 +717,7 @@ const openDeploymentModal = async (fullPath?: string) => {
packagesLoading.value = false;
}
};
const closeCreateModal = () => {
isEditVisible.value = false;
};
@ -1259,6 +1278,15 @@ const artifactsLoading = ref(false);
<v-icon start size="16">mdi-check-decagram</v-icon>
{{ externalAuth?.name || externalAuth?.id }}
</v-chip>
<v-btn
size="small"
color="primary"
variant="text"
@click="openDeploymentModal(undefined, 'upload')"
title="업로드 파일로 등록(Artifact Path 숨김)"
>
<v-icon start size="16">mdi-rocket-launch</v-icon> Deploy
</v-btn>
<v-btn
v-if="isAuthenticated"
size="small"
@ -1379,7 +1407,9 @@ const artifactsLoading = ref(false);
? 'Deploy'
: 'Login required'
"
@onClick="openDeploymentModal(it.path)"
@onClick="
openDeploymentModal(it.path, 'artifact')
"
/>
</template>
<template v-else>
@ -1428,6 +1458,8 @@ const artifactsLoading = ref(false);
:packages-error="packagesError"
:artifact-path="lastArtifactUri"
:token="externalToken"
:hide-artifact-path="dlgHideArtifactPath"
:hide-upload-file="dlgHideUploadFile"
@close-modal="closeCreateModal"
@handle-data="saveData"
:user-option="[]"

Loading…
Cancel
Save