All checks were successful
Test Asgard Runner / test (push) Successful in 3s
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>
602 lines
16 KiB
Vue
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>
|