Files
corrosion-admin-panel/frontend/src/components/layout/DashboardLayout.vue
Vantz Stockwell a8722a7a07
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
fix(audit): kill fake install cmds + dead demo CTA; production fonts; scoped error boundary; admin bootstrap seed
Full-site fake-data audit findings:
- SetupWizard showed a curl|sh installer (get.corrosionmgmt.com) and a
  'corrosion-agent' binary that don't exist -> real host-agent commands
- 'View live demo' CTA on 5 marketing pages linked to a login, not a
  demo -> honest 'Sign in'
- Google Fonts @import was silently dropped from the production CSS
  bundle (mid-bundle @import) -> <link> tags in index.html; prod was
  shipping system fallback fonts
- App-root ErrorBoundary bricked the entire SPA (incl. marketing) on a
  single failed fetch until manual reload -> resets on route change +
  content-scoped boundary inside DashboardLayout so nav chrome survives
- Status page KPIs showed fake zeros while the fetch failed -> em dash
- Login lacked the forgot-password link (flow already existed end-to-end)
- AdminSeedService: fresh DB had schema but no login possible; seeds
  super-admin + license from ADMIN_* env when users table is empty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:23:44 -04:00

602 lines
16 KiB
Vue

<script setup lang="ts">
/**
* DashboardLayout — game-aware app shell (Phase C redesign).
* Nav is driven by GAME_PROFILES[activeGame].nav — switching the GameSwitcher
* visibly changes nav items, labels, and sections per game.
* Preserves: permission gating, super-admin section, logout, mobile sidebar,
* GameSwitcher, agent-health footer, topbar.
*/
import { ref, computed } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useServerStore } from '@/stores/server'
import { useThemeGame } from '@/composables/useThemeGame'
import { useGameProfile } from '@/config/gameProfiles'
import type { NavSection, NavItemDef } from '@/config/gameProfiles'
import { safeDate } from '@/utils/formatters'
import ErrorBoundary from '@/components/ErrorBoundary.vue'
import Logo from '@/components/ds/brand/Logo.vue'
import Badge from '@/components/ds/core/Badge.vue'
import StatusDot from '@/components/ds/core/StatusDot.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Button from '@/components/ds/core/Button.vue'
import Avatar from '@/components/ds/data/Avatar.vue'
import NavItem from '@/components/ds/navigation/NavItem.vue'
import GameSwitcher from '@/components/ds/navigation/GameSwitcher.vue'
import type { GameOption } from '@/components/ds/navigation/GameSwitcher.vue'
import type { ActiveGame } from '@/composables/useThemeGame'
// ---- Stores / composables ----
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const server = useServerStore()
const { theme, activeGame, setActiveGame, toggleTheme } = useThemeGame()
// ---- Mobile sidebar ----
const sidebarOpen = ref(false)
function closeSidebar() { sidebarOpen.value = false }
// ---- App version ----
const APP_VERSION = __APP_VERSION__
// ---- Game switcher ----
const GAME_OPTIONS: GameOption[] = [
{ key: 'all', label: 'All games', icon: 'layers' },
{ key: 'rust', label: 'Rust', icon: 'box' },
{ key: 'dune', label: 'Dune', icon: 'sun' },
{ key: 'conan', label: 'Conan Exiles', icon: 'swords' },
{ key: 'soulmask', label: 'Soulmask', icon: 'drama' },
]
const GAME_LABEL: Record<string, string> = {
all: 'All games', rust: 'Rust', dune: 'Dune',
conan: 'Conan Exiles', soulmask: 'Soulmask',
}
function onActiveGame(val: string) {
setActiveGame(val as ActiveGame)
}
// ---- Navigation — driven by the game profile registry ----
/**
* For 'all', fall back to rust (superset nav). For a specific game, look up
* its profile. noUncheckedIndexedAccess-safe: always ?? GAME_PROFILES.rust.
*/
const activeNavSections = computed<NavSection[]>(() => {
const game = activeGame.value === 'all' ? 'rust' : activeGame.value
return (useGameProfile(game)).nav
})
const adminNavItems = [
{ name: 'Admin home', path: '/admin', icon: 'shield' },
{ name: 'Licenses', path: '/admin/licenses', icon: 'key' },
{ name: 'Subscriptions', path: '/admin/subscriptions', icon: 'credit-card' },
{ name: 'Users', path: '/admin/users', icon: 'users' },
{ name: 'Server fleet', path: '/admin/servers', icon: 'server' },
]
function isActive(path: string): boolean {
if (path === '/' || path === '/admin') return route.path === path
return route.path.startsWith(path)
}
function navigate(path: string) {
router.push(path)
closeSidebar()
}
function canShowNavItem(item: NavItemDef): boolean {
if (!item.permission) return true
return auth.hasPermission(item.permission)
}
function hasVisibleItems(section: NavSection): boolean {
return section.items.some(canShowNavItem)
}
// ---- Agent health ----
const hasAgent = computed(() => server.connection !== null)
const agentTone = computed(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'online' as const
if (cs === 'degraded') return 'warn' as const
return 'offline' as const
})
const agentLabel = computed(() => {
const cs = server.connection?.connection_status
if (cs === 'connected') return 'Healthy'
if (cs === 'degraded') return 'Degraded'
return 'Offline'
})
const agentName = computed(() => server.connection?.server_ip ?? 'Host agent')
const agentMetaLine = computed(() => {
const cs = server.connection?.connection_status
let line = cs === 'connected' ? 'Connected' : server.connection?.companion_last_seen
? `Last seen ${safeDate(server.connection.companion_last_seen)}`
: 'Awaiting first heartbeat'
if (server.stats) {
line += ` · ${server.stats.player_count}/${server.stats.max_players} players`
}
return line
})
// ---- Topbar ----
const serverName = computed(() => auth.license?.server_name ?? 'Your servers')
const userName = computed(() => auth.user?.username ?? '')
const themeIcon = computed(() => theme.value === 'dark' ? 'sun' : 'moon')
</script>
<template>
<!-- Outer app grid: sidebar | main -->
<div class="app">
<!-- ===================================================== SIDEBAR ===== -->
<!-- Mobile overlay -->
<div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="closeSidebar"
/>
<aside
class="app__sidebar"
:class="sidebarOpen ? 'app__sidebar--open' : ''"
>
<!-- Brand -->
<div class="side__brand">
<Logo :size="22" />
<Badge tone="neutral" :mono="true" class="side__ver">{{ APP_VERSION }}</Badge>
</div>
<!-- Active game switcher -->
<div class="side__game">
<div class="t-eyebrow side__lbl">
Active game · {{ GAME_LABEL[activeGame] ?? 'All games' }}
</div>
<GameSwitcher
:model-value="activeGame"
:games="GAME_OPTIONS"
:show-labels="false"
@update:model-value="onActiveGame"
/>
</div>
<!-- Navigation sections driven by GAME_PROFILES[activeGame].nav -->
<nav class="side__nav">
<template v-for="section in activeNavSections" :key="section.label">
<template v-if="hasVisibleItems(section)">
<div class="side__sec">
<div v-if="section.label" class="t-eyebrow side__lbl">{{ section.label }}</div>
<NavItem
v-for="item in section.items"
v-show="canShowNavItem(item)"
:key="item.route"
:icon="item.icon"
:label="item.label"
:active="isActive(item.route)"
@click="navigate(item.route)"
/>
</div>
</template>
</template>
<!-- Platform admin section (super-admin only) -->
<div v-if="auth.isSuperAdmin" class="side__sec">
<div class="t-eyebrow side__lbl side__lbl--platform">Platform</div>
<NavItem
v-for="item in adminNavItems"
:key="item.path"
:icon="item.icon"
:label="item.name"
:active="isActive(item.path)"
@click="navigate(item.path)"
/>
</div>
</nav>
<!-- Host agent footer -->
<div class="side__foot">
<!-- Connected: real IP + status badge + meta line -->
<div v-if="hasAgent" class="agent">
<div class="agent__row">
<StatusDot :tone="agentTone" :pulse="agentTone === 'online'" />
<span class="agent__name">{{ agentName }}</span>
<Badge :tone="agentTone" size="md">{{ agentLabel }}</Badge>
</div>
<div class="agent__meta">{{ agentMetaLine }}</div>
</div>
<!-- Not connected: honest empty state -->
<div v-else class="agent agent--empty">
<div class="agent__row">
<StatusDot tone="offline" />
<span class="agent__name agent__name--muted">No host agent connected</span>
</div>
<div class="agent__meta">Install the Corrosion host agent from the Server page</div>
</div>
<!-- User / logout row -->
<div class="side__user">
<span class="side__user-name">{{ auth.user?.username ?? '' }}</span>
<button
type="button"
class="side__logout"
title="Sign out"
@click="() => { auth.logout(); router.push('/login') }"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</div>
</aside>
<!-- ======================================================= MAIN ===== -->
<div class="app__main">
<!-- Topbar -->
<header class="app__topbar">
<!-- Mobile hamburger (left of topbar on small screens) -->
<button
class="topbar-hamburger"
type="button"
aria-label="Open navigation"
@click="sidebarOpen = true"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<!-- Breadcrumb -->
<div class="top__crumbs">
<span class="crumb">Corrosion</span>
<span class="crumb__sep">/</span>
<span class="crumb crumb--cluster">{{ serverName }}</span>
</div>
<!-- Search -->
<div class="top__search">
<svg class="top__search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input placeholder="Search servers, players, configs…" readonly />
<span class="top__kbd">
<kbd class="cc-kbd"></kbd><kbd class="cc-kbd">K</kbd>
</span>
</div>
<!-- Actions -->
<div class="top__actions">
<IconButton
:icon="themeIcon"
label="Toggle theme"
@click="toggleTheme"
/>
<IconButton icon="bell" label="Alerts" @click="router.push('/alerts')" />
<Button size="sm" icon="rocket">Deploy server</Button>
<Avatar
:name="userName"
:size="30"
status="online"
/>
</div>
</header>
<!-- Page content boundary keeps sidebar/topbar alive when a view fails -->
<main class="app__content">
<ErrorBoundary variant="content">
<RouterView />
</ErrorBoundary>
</main>
</div>
</div>
</template>
<style>
/* ============================================================ SHELL ===== */
html, body { height: 100%; }
body { margin: 0; overflow: hidden; }
.app {
display: grid;
grid-template-columns: var(--sidebar-w, 228px) 1fr;
height: 100vh;
background: var(--surface-canvas, #0a0a0b);
}
/* ---- Sidebar ---- */
.app__sidebar {
background: var(--surface-base);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
min-height: 0;
z-index: 50;
}
.side__brand {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 16px 12px;
}
.side__ver {
font-family: var(--font-mono);
}
.side__game {
padding: 2px 14px 13px;
border-bottom: 1px solid var(--border-subtle);
}
.side__lbl {
margin-bottom: 7px;
}
.side__lbl--platform {
color: var(--accent-text);
}
.side__nav {
flex: 1;
overflow-y: auto;
padding: 13px 10px;
display: flex;
flex-direction: column;
gap: 15px;
}
.side__sec {
display: flex;
flex-direction: column;
gap: 2px;
}
.side__sec .t-eyebrow {
margin: 0 0 5px 10px;
}
.side__foot {
padding: 11px;
border-top: 1px solid var(--border-subtle);
}
.agent {
background: var(--surface-raised);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
padding: 9px 11px;
}
.agent__row {
display: flex;
align-items: center;
gap: 8px;
}
.agent__name {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-primary);
flex: 1;
}
.agent__meta {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
margin-top: 5px;
padding-left: 16px;
}
.agent--empty { opacity: 0.7; }
.agent__name--muted {
color: var(--text-tertiary);
font-style: italic;
}
.side__user {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
padding: 4px 2px 0;
}
.side__user-name {
font-size: var(--text-xs);
color: var(--text-tertiary);
font-family: var(--font-mono);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.side__logout {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
background: transparent;
color: var(--text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition-colors);
flex: none;
}
.side__logout:hover { background: var(--surface-hover); color: var(--text-primary); }
/* ---- Main ---- */
.app__main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.app__topbar {
height: var(--topbar-h, 52px);
flex: none;
display: flex;
align-items: center;
gap: 18px;
padding: 0 18px;
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-base);
}
.top__crumbs {
display: flex;
align-items: center;
gap: 9px;
font-size: var(--text-sm);
}
.crumb { color: var(--text-tertiary); }
.crumb__sep { color: var(--text-muted); }
.crumb--cluster {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
font-weight: 600;
background: var(--surface-raised-2);
border: 0;
box-shadow: var(--ring-default);
height: 30px;
padding: 0 10px;
border-radius: var(--radius-md);
font-family: var(--font-sans);
}
.top__search {
flex: 1;
max-width: 440px;
display: flex;
align-items: center;
gap: 9px;
height: 34px;
padding: 0 11px;
background: var(--surface-inset);
border-radius: var(--radius-md);
box-shadow: var(--ring-default);
color: var(--text-tertiary);
}
.top__search-icon { flex: none; }
.top__search input {
flex: 1;
min-width: 0;
background: transparent;
border: 0;
outline: 0;
color: var(--text-primary);
font-family: var(--font-sans);
font-size: var(--text-sm);
cursor: default;
}
.top__search input::placeholder { color: var(--text-muted); }
.top__kbd { display: flex; gap: 3px; }
.top__actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.app__content {
flex: 1;
overflow-y: auto;
padding: 22px 24px 40px;
}
/* ---- Mobile hamburger ---- */
.topbar-hamburger {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius-md);
cursor: pointer;
flex: none;
}
.topbar-hamburger:hover { background: var(--surface-hover); color: var(--text-primary); }
/* ---- Sidebar overlay (mobile) ---- */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 49;
}
/* ---- Kbd styling ---- */
.cc-kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--surface-active);
border-radius: var(--radius-xs, 3px);
box-shadow: var(--ring-default);
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1;
}
/* ---- Responsive ---- */
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
}
.app__sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 228px;
transform: translateX(-100%);
transition: transform 220ms cubic-bezier(0.2, 0, 0, 1);
}
.app__sidebar--open {
transform: translateX(0);
}
.sidebar-overlay {
display: block;
}
.topbar-hamburger {
display: inline-flex;
}
.top__search {
display: none;
}
}
</style>