feat(v2): NexusLayout, Sidebar, NavGroup, NavItem, Topbar, FlowBoard placeholder
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s

This commit is contained in:
2026-06-12 00:20:27 +02:00
parent f378d7aed4
commit 3672e56994
9 changed files with 725 additions and 1 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ onMounted(() => {
</script>
<template>
<RouterView v-if="route.name === 'Login'" />
<RouterView v-if="route.name === 'Login' || route.name === 'DashboardV2'" />
<div v-else class="shell">
<AppSidebar
:active-view="activeView"
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { NavItemDef } from '../../composables/icons'
import NavItem from './NavItem.vue'
defineProps<{
label: string
items: NavItemDef[]
}>()
</script>
<template>
<div class="nav-group">
<div class="nav-group-label">{{ label }}</div>
<NavItem
v-for="item in items"
:key="item.label"
:icon="item.icon"
:label="item.label"
:route="item.route"
:count="item.count"
:active="item.active"
/>
</div>
</template>
<style scoped>
.nav-group-label {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
padding: 16px 10px 7px;
font-family: 'Manrope', sans-serif;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { icons } from '../../composables/icons'
const props = defineProps<{
icon: string
label: string
route?: string
count?: string
active?: boolean
}>()
const router = useRouter()
const route = useRoute()
const isActive = computed(() => {
if (props.active) return true
if (props.route && route.path === props.route) return true
return false
})
function navigate() {
if (props.route) {
router.push(props.route)
}
}
</script>
<template>
<button
:class="['nav-item', { active: isActive }]"
@click="navigate"
>
<!-- Icon -->
<span class="nav-icon" v-html="icons[icon] || ''"></span>
<!-- Label -->
<span class="nav-label">{{ label }}</span>
<!-- Count badge -->
<span v-if="count !== undefined" class="count">{{ count }}</span>
</button>
</template>
<style scoped>
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: background .16s, color .16s;
text-decoration: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: rgba(124,108,255,.08);
color: var(--tx);
}
.nav-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 3px;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
flex: 0 0 auto;
opacity: .85;
}
.nav-icon :deep(svg) {
width: 17px;
height: 17px;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(124,108,255,.16);
color: var(--tx);
line-height: 1.4;
flex-shrink: 0;
}
</style>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { navigation, icons } from '../../composables/icons'
import NavGroup from './NavGroup.vue'
const auth = useAuthStore()
const router = useRouter()
const ownerInitials = computed(() =>
auth.user?.displayName?.split(' ').map(p => p[0]).join('').slice(0, 2).toUpperCase()
?? 'OW'
)
function logout() {
auth.logout()
router.replace('/login')
}
</script>
<template>
<aside class="sidebar">
<!-- Brand -->
<div class="side-top">
<div class="brand-mark" v-html="icons.command || ''"></div>
<div>
<div class="brand-name">NEXUS</div>
<div class="brand-sub">Mission Control</div>
</div>
</div>
<!-- Navigation -->
<nav class="nav">
<NavGroup
v-for="(group, idx) in navigation"
:key="idx"
:label="group.group"
:items="group.items"
/>
</nav>
<!-- Footer -->
<div class="side-foot">
<div class="avatar">{{ ownerInitials }}</div>
<div class="owner-info">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 248px;
flex: 0 0 248px;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
padding: 0;
position: relative;
z-index: 2;
}
.side-top {
display: flex;
align-items: center;
gap: 11px;
padding: 18px 18px 16px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--grad);
box-shadow: var(--glow-purple);
flex: 0 0 auto;
}
.brand-mark :deep(svg) {
width: 20px;
height: 20px;
color: #fff;
}
.brand-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 17px;
letter-spacing: .14em;
line-height: 1;
}
.brand-sub {
font-size: 10.5px;
color: var(--tx-3);
letter-spacing: .05em;
margin-top: 3px;
}
.nav {
flex: 1;
overflow-y: auto;
padding: 6px 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.side-foot {
padding: 12px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background .15s;
}
.side-foot:hover {
background: rgba(124,108,255,.06);
}
.avatar {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
color: var(--tx);
flex-shrink: 0;
}
.owner-info {
min-width: 0;
}
.owner-name {
font-size: 12px;
font-weight: 600;
color: var(--tx);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.owner-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize;
}
</style>
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
defineProps<{
connected?: boolean
}>()
</script>
<template>
<header class="topbar">
<!-- Search -->
<div class="search">
<span class="search-icon" v-html="icons.search || ''"></span>
<span class="search-placeholder">Operationen, Agents oder Tasks suchen</span>
</div>
<!-- Spacer -->
<div class="spacer"></div>
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? 'Verbunden' : 'Preview' }}
</span>
<!-- Ask Iris Button -->
<button class="btn btn-primary">
<span class="btn-icon" v-html="icons.spark || ''"></span>
Ask Iris
</button>
</header>
</template>
<style scoped>
.topbar {
height: 62px;
flex: 0 0 62px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: rgba(8,6,20,.5);
backdrop-filter: blur(14px);
}
.search {
flex: 1;
max-width: 560px;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 14px;
border-radius: 11px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
color: var(--tx-3);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
}
.search-icon :deep(svg) {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.search-placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
font-family: 'Manrope', sans-serif;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-dot.on {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-family: 'Manrope', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
border: none;
transition: filter .16s;
}
.btn-primary {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
@keyframes pulse-work {
0% { box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
70% { box-shadow: 0 0 0 7px rgba(61,220,151,0); }
100% { box-shadow: 0 0 0 0 rgba(61,220,151,0); }
}
</style>
+89
View File
@@ -0,0 +1,89 @@
/**
* Inline SVG icons for Nexus V2
* All stroke-based, currentColor, viewBox 0 0 24 24
*/
export const icons: Record<string, string> = {
grid: `<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>`,
cpu: `<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>`,
list: `<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>`,
flow: `<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.5 7.5 11 16M16.5 7.5 13 16"/>`,
brain: `<path d="M9 3a3 3 0 0 0-3 3 3 3 0 0 0-1 5.8A3 3 0 0 0 8 17a3 3 0 0 0 4 1 3 3 0 0 0 4-1 3 3 0 0 0 3-5.2A3 3 0 0 0 18 6a3 3 0 0 0-3-3 3 3 0 0 0-3 1.5A3 3 0 0 0 9 3Z"/>`,
doc: `<path d="M14 3v5h5M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>`,
search: `<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>`,
server: `<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>`,
model: `<path d="M12 2 3 7l9 5 9-5-9-5ZM3 12l9 5 9-5M3 17l9 5 9-5"/>`,
activity: `<path d="M3 12h4l3 8 4-16 3 8h4"/>`,
coin: `<circle cx="12" cy="12" r="9"/><path d="M12 7v10M9.5 9.5h4a1.5 1.5 0 0 1 0 3h-3a1.5 1.5 0 0 0 0 3h4"/>`,
shield: `<path d="M12 3 5 6v5c0 4 3 7 7 9 4-2 7-5 7-9V6z"/>`,
alert: `<path d="M12 3 2 20h20zM12 9v5M12 17h.01"/>`,
send: `<path d="M22 2 11 13M22 2 15 22l-4-9-9-4z"/>`,
spark: `<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>`,
expand: `<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>`,
bot: `<rect x="4" y="7" width="16" height="12" rx="3"/><path d="M12 7V4M9 13h.01M15 13h.01M8 19v2M16 19v2"/>`,
clock: `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`,
target: `<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/>`,
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
plus: `<path d="M12 5v14M5 12h14"/>`,
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
}
export function svg(name: string, cls = ''): string {
const inner = icons[name]
if (!inner) return ''
return `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
}
export interface NavItemDef {
icon: string
label: string
route?: string
count?: string
active?: boolean
}
export interface NavGroupDef {
group: string
items: NavItemDef[]
}
/**
* Navigation structure matching NEXUS.nav from agents.js
*/
export const navigation: NavGroupDef[] = [
{
group: 'Operations',
items: [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard/v2', active: true },
{ icon: 'cpu', label: 'Agenten', route: '/agents', count: '6' },
{ icon: 'list', label: 'Task Board', route: '/tasks', count: '7' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
],
},
{
group: 'Knowledge',
items: [
{ icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'doc', label: 'Docs & .md', route: '/docs', count: '12' },
{ icon: 'search', label: 'Research', route: '/research' },
],
},
{
group: 'Infrastructure',
items: [
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts', count: '1' },
{ icon: 'model', label: 'Modelle', route: '/models' },
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
],
},
{
group: 'Governance',
items: [
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
{ icon: 'shield', label: 'Security', route: '/security' },
{ icon: 'alert', label: 'Incidents', route: '/incidents', count: '1' },
],
},
]
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
/**
* NexusLayout — V2 Dashboard Shell
* Flex row, 100vh, overflow hidden.
* Sidebar (248px) + Main (flex:1, flex-column)
*/
import { RouterView } from 'vue-router'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue'
</script>
<template>
<div class="nexus-layout">
<GalaxyBackground />
<Sidebar />
<main class="nexus-main">
<Topbar />
<div class="nexus-content">
<RouterView />
</div>
</main>
</div>
</template>
<style scoped>
.nexus-layout {
display: flex;
flex-direction: row;
height: 100vh;
overflow: hidden;
position: relative;
}
.nexus-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
}
.nexus-content {
flex: 1;
overflow-y: auto;
padding: 18px 20px;
}
</style>
+14
View File
@@ -10,11 +10,25 @@ import SecurityView from './views/SecurityView.vue'
import IncidentsView from './views/IncidentsView.vue'
import CalendarView from './views/CalendarView.vue'
import DashboardView from './views/DashboardView.vue'
import NexusLayout from './layouts/NexusLayout.vue'
import FlowBoard from './views/Dashboard/FlowBoard.vue'
const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
{ path: '/', redirect: '/dashboard' },
// V1 Dashboard (altes Layout bleibt erhalten)
{ path: '/dashboard', name: 'Dashboard', component: DashboardView },
// V2 Dashboard (neues NexusLayout)
{
path: '/dashboard/v2',
component: NexusLayout,
children: [
{ path: '', name: 'DashboardV2', component: FlowBoard },
],
},
{ path: '/memory', name: 'Memory', component: MemoryView },
{ path: '/docs', name: 'Docs', component: DocsView },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
/**
* FlowBoard Das neue V2 Dashboard
* Main content area rendered inside NexusLayout.
*/
import { useOperationsStore } from '../../stores/operations'
const store = useOperationsStore()
</script>
<template>
<div class="flow-board">
<!-- Placeholder: V2 Dashboard Content -->
<div class="board-hero">
<h2 class="board-title">Flow Board</h2>
<p class="board-subtitle">Real-time Agent Operations Dashboard</p>
</div>
<!-- Metrics Row -->
<div class="metrics-row">
<div class="metric-card">
<span class="metric-label">Connected Agents</span>
<strong class="metric-value">{{ store.snapshot.metrics.runningAgents ?? 0 }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">Queued Tasks</span>
<strong class="metric-value">{{ store.snapshot.metrics.queuedTasks ?? 0 }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">Incidents</span>
<strong class="metric-value">{{ store.snapshot.metrics.incidents ?? 0 }}</strong>
</div>
</div>
</div>
</template>
<style scoped>
.flow-board {
animation: fade-in 0.35s ease-out;
height: 100%;
display: flex;
flex-direction: column;
gap: 18px;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.board-hero {
padding-bottom: 8px;
}
.board-title {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 24px;
margin: 0;
color: var(--tx);
}
.board-subtitle {
font-size: 13px;
color: var(--tx-3);
margin: 4px 0 0;
}
.metrics-row {
display: flex;
gap: 14px;
}
.metric-card {
flex: 1;
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--r);
backdrop-filter: blur(12px);
padding: 16px 18px;
}
.metric-label {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: .08em;
color: var(--tx-3);
text-transform: uppercase;
margin-bottom: 6px;
}
.metric-value {
font-family: 'JetBrains Mono', monospace;
font-size: 28px;
font-weight: 600;
color: var(--tx);
font-variant-numeric: tabular-nums;
}
</style>