feat: unified rail shell, robust live sync, task board performance
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped

Shell:
- One shared NexusLayout for ALL routes (dashboard + pages): compact 68px
  icon rail with hover-expand overlay, replaces both old sidebars + topbars
- Single flat nav source (railNav) — same menu everywhere incl. Settings
- Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup,
  NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView

Live updates:
- Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and
  ChannelReader.ReadAsync were re-invoked while pending — every published
  update threw InvalidOperationException and killed ALL live streams
- liveSync store: treat graceful stream close as disconnect (was stuck
  connected=true with polling stopped -> page frozen until manual reload),
  fast first retry, heartbeat watchdog (65s), reconnect on online/visibility
- One app-wide SSE connection owned by the layout instead of per-view
  connect/disconnect churn; removed duplicate live-sync.ts store

Performance:
- Board endpoint: drop nested childTasks duplication (counts stay) — payload
  104KB -> 65KB; SSE snapshots shrink equally
- nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire);
  text/event-stream excluded to keep SSE unbuffered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:25:26 +02:00
parent 86ceb2bcce
commit f564ecfbc7
19 changed files with 540 additions and 2057 deletions
+25 -3
View File
@@ -236,15 +236,25 @@ public class DashboardController(
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested)
{
// PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
// Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
// mit einer InvalidOperationException.
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
try
{
while (!ct.IsCancellationRequested)
{
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
{
var envelope = await readTask;
readTask = subscription.Reader.ReadAsync(ct).AsTask();
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
@@ -263,12 +273,24 @@ public class DashboardController(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else if (await heartbeatTask)
else
{
var ticked = await heartbeatTask;
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
if (!ticked) break;
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
}
}
}
catch (OperationCanceledException)
{
// Client hat die Verbindung beendet — normal.
}
catch (System.Threading.Channels.ChannelClosedException)
{
// Subscription serverseitig geschlossen — Stream regulär beenden.
}
}
[HttpPatch("tasks/{id:guid}/move")]
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
+11 -5
View File
@@ -431,7 +431,9 @@ public sealed class TaskService(
foreach (var task in all)
{
var dto = MapToDtoWithChildren(task, all, activity);
// Ohne verschachtelte Child-DTOs: Children sind als eigene Karten im Board,
// die Nested-Duplikate haben die Payload nur verdoppelt (Counts bleiben).
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: false);
switch (task.State.ToLowerInvariant())
{
case "backlog": offen.Add(dto); break;
@@ -513,22 +515,26 @@ public sealed class TaskService(
return all.Where(e => e.TaskId == taskId).ToList();
}
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
{
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
// includeChildren=false (Board/SSE-Snapshot): Children erscheinen dort ohnehin
// als eigene Karten — verschachtelte Child-DTOs verdoppeln nur die Payload.
var childDtos = includeChildren
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
: null;
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
ChildTaskCount = childTasks.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
};
}
+9
View File
@@ -5,6 +5,15 @@ server {
root /usr/share/nginx/html;
index index.html;
# Kompression für Bundle + API-JSON (Board-Payload ~100 KB → wenige KB).
# text/event-stream bewusst NICHT in gzip_types: gzip würde den SSE-Stream puffern.
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types application/json application/javascript text/css text/javascript image/svg+xml;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always;
+7 -149
View File
@@ -1,156 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Activity } from '@lucide/vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { useOperationsStore } from './stores/operations'
import { useAuthStore } from './stores/auth'
import AppSidebar from './components/layout/AppSidebar.vue'
import AppHeader from './components/layout/AppHeader.vue'
import GalaxyBackground from './components/background/GalaxyBackground.vue'
import ModuleView from './components/ModuleView.vue'
/**
* App — nur noch Router-Einstieg + Toasts.
* Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
* und umschließt alle Seiten außer dem Login.
*/
import { RouterView } from 'vue-router'
import ToastContainer from './components/ui/ToastContainer.vue'
const store = useOperationsStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const activeView = computed(() => {
if (route.name === 'Settings') return 'Settings'
if (route.name === 'ProjectDetail') return 'ProjectDetail'
return String(route.name ?? 'Dashboard')
})
const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
}
const navigate = (label: string) => {
mobileNavOpen.value = false
return router.push(routePaths[label] ?? '/dashboard')
}
const mobileNavOpen = ref(false)
const standaloneViews = computed(() => {
if (route.name === 'Dashboard') return true
if (route.meta?.standalone) return true
return false
})
onMounted(() => {
if (auth.isAuthenticated) store.refresh()
})
</script>
<template>
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
<div v-else class="shell">
<GalaxyBackground />
<AppSidebar
:active-view="activeView"
:mobile-nav-open="mobileNavOpen"
:queued-tasks="store.snapshot.metrics.queuedTasks"
:incidents="store.snapshot.metrics.incidents"
@navigate="navigate"
/>
<main>
<AppHeader
:connected="store.connected"
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
/>
<section class="content">
<RouterView v-if="standaloneViews" />
<template v-else>
<div class="page-heading">
<div>
<span class="eyebrow">MISSION CONTROL</span>
<h1>{{ activeView }}</h1>
<p>System overview and operational intelligence across Noveria.</p>
</div>
<button class="refresh" @click="store.refresh()">
<Activity :size="15" :class="{ spin: store.loading }" />
Refresh
</button>
</div>
<ModuleView
:view="activeView"
:snapshot="store.snapshot"
:routing="store.routing"
@create-project="store.createProject"
@create-task="store.createTask"
@update-task-state="store.updateTaskState"
/>
</template>
</section>
</main>
<RouterView />
<ToastContainer />
</div>
</template>
<style scoped>
.shell {
display: flex;
height: 100vh;
overflow: hidden;
}
main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
}
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
}
.page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
.eyebrow {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--nx-accent);
text-transform: uppercase;
}
.refresh {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 6px 11px;
border: 1px solid var(--nx-line);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim);
font-size: 9px;
cursor: pointer;
transition: background .15s;
}
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 860px) {
.kanban { grid-template-columns: 1fr; }
}
</style>
-741
View File
@@ -1,741 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { TASK_STATES } from '../types'
import { apiFetch } from '../services/api'
import { useAuthStore } from '../stores/auth'
import { useOperationsStore, type PendingApprovalTask } from '../stores/operations'
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
const emit = defineEmits<{
createProject: [name: string]
createTask: [title: string, priority: string]
updateTaskState: [id: string, state: string]
}>()
const store = useOperationsStore()
const auth = useAuthStore()
const agents = ref<AgentInfo[]>([])
const agentsLoading = ref(false)
const pendingApprovals = ref<PendingApprovalTask[]>([])
const pendingApprovalsLoading = ref(false)
const pendingApprovalsError = ref('')
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
async function loadAgents() {
if (agentsLoading.value) return
agentsLoading.value = true
agents.value = await store.fetchAgents()
agentsLoading.value = false
}
async function loadPendingApprovals() {
if (!canModerateApprovals.value) {
pendingApprovals.value = []
pendingApprovalsError.value = ''
return
}
pendingApprovalsLoading.value = true
pendingApprovalsError.value = ''
try {
pendingApprovals.value = await store.fetchPendingApprovals()
} catch (e) {
pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals'
} finally {
pendingApprovalsLoading.value = false
}
}
onMounted(() => {
if (props.view === 'Agents') loadAgents()
if (props.view === 'Task Board') void loadPendingApprovals()
})
watch(() => props.view, (v) => {
if (v === 'Agents') loadAgents()
if (v === 'Task Board') void loadPendingApprovals()
})
watch(canModerateApprovals, (value) => {
if (props.view !== 'Task Board') return
if (value) {
void loadPendingApprovals()
return
}
pendingApprovals.value = []
pendingApprovalsError.value = ''
})
const newProject = ref('')
const newTask = ref('')
const message = ref('')
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
const chatPending = ref(false)
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
localStorage.setItem('nexus-conversation-id', conversationId.value)
// Task editing state
const editingTaskId = ref<string | null>(null)
// Task approval / rejection state
const approvingTaskId = ref<string | null>(null)
const taskActionError = ref('')
async function handleApproveTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.approveTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
} finally {
approvingTaskId.value = null
}
}
async function handleRejectTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.rejectTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
} finally {
approvingTaskId.value = null
}
}
// Task deletion state
const deletingTaskId = ref<string | null>(null)
const deleteError = ref('')
async function confirmDeleteTask(id: string) {
deleteError.value = ''
try {
await store.deleteTask(id)
deletingTaskId.value = null
} catch (e) {
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
}
}
function cancelDeleteTask() {
deletingTaskId.value = null
deleteError.value = ''
}
const editTaskTitle = ref('')
const editTaskPriority = ref('')
const editTaskProjectId = ref<string | null>(null)
// Activity filtering and pagination
const activityTypeFilter = ref('')
const activitySort = ref('newest')
const activityPage = ref(1)
const activityPageSize = 20
const activityTotalPages = ref(1)
const activityTotalCount = ref(0)
const columns = computed(() =>
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
const availableTypes = computed(() => {
const types = new Set(props.snapshot.activity.map(e => e.type))
return Array.from(types)
})
const filteredActivity = computed(() => {
let items = [...props.snapshot.activity]
if (activityTypeFilter.value) {
items = items.filter(e => e.type === activityTypeFilter.value)
}
if (activitySort.value === 'oldest') {
items.reverse()
}
const total = items.length
activityTotalCount.value = total
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
const start = (activityPage.value - 1) * activityPageSize
return items.slice(start, start + activityPageSize)
})
watch(activityTypeFilter, () => { activityPage.value = 1 })
watch(activitySort, () => { activityPage.value = 1 })
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
editingTaskId.value = task.id
editTaskTitle.value = task.title
editTaskPriority.value = task.priority
editTaskProjectId.value = task.projectId ?? null
}
async function saveEditTask(id: string) {
try {
await store.updateTask(id, {
title: editTaskTitle.value.trim() || undefined,
priority: editTaskPriority.value || undefined,
projectId: editTaskProjectId.value || undefined,
})
editingTaskId.value = null
} catch (e) {
console.error('Failed to update task', e)
}
}
function cancelEditTask() {
editingTaskId.value = null
}
async function sendMessage() {
const value = message.value.trim()
if (!value || chatPending.value) return
chatMessages.value.push({ role: 'owner', content: value })
message.value = ''
chatPending.value = true
try {
const response = await apiFetch('/api/v1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
conversationId.value = payload.conversationId
localStorage.setItem('nexus-conversation-id', payload.conversationId)
chatMessages.value.push({ role: 'iris', content: payload.content })
} catch (error) {
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
} finally {
chatPending.value = false
}
}
</script>
<template>
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
<div v-if="view === 'Projects'" class="module-grid">
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
</article>
</div>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
<header class="approval-strip-head">
<div>
<span class="kicker">Owner approvals</span>
<h3>Pending approvals</h3>
</div>
<span class="badge">{{ pendingApprovals.length }}</span>
</header>
<p v-if="pendingApprovalsLoading" class="approval-strip-note">Loading owner approval queue</p>
<p v-else-if="pendingApprovalsError" class="approval-strip-note error">{{ pendingApprovalsError }}</p>
<p v-else-if="!pendingApprovals.length" class="approval-strip-note">No tasks are waiting for Bao approval.</p>
<div v-else class="approval-list">
<article v-for="task in pendingApprovals" :key="task.id" class="approval-card">
<div>
<strong>{{ task.title }}</strong>
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
</div>
<div class="approval-actions">
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
</div>
</article>
</div>
<p v-if="taskActionError" class="approval-strip-note error">{{ taskActionError }}</p>
</section>
<div v-if="view === 'Task Board'" class="kanban">
<section v-for="column in columns" :key="column.name" class="kanban-column">
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
<article v-for="task in column.items" :key="task.id" class="task-card">
<template v-if="editingTaskId === task.id">
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
<div class="task-edit-row">
<select v-model="editTaskPriority">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Normal">Normal</option>
<option value="Low">Low</option>
</select>
<select v-model="editTaskProjectId">
<option :value="null">No project</option>
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="task-edit-actions">
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
</div>
</template>
<template v-else>
<div class="task-card-head">
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
<div class="task-card-actions">
<template v-if="task.state === 'In progress' && canModerateApprovals">
<button
class="task-approve-btn"
title="Approve"
:disabled="approvingTaskId === task.id"
@click="handleApproveTask(task.id)"
><CheckCircle2 :size="13" /></button>
<button
class="task-reject-btn"
title="Reject"
:disabled="approvingTaskId === task.id"
@click="handleRejectTask(task.id)"
><X :size="13" /></button>
</template>
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
<button
v-if="task.state === 'Done' || task.state === 'Backlog'"
class="task-delete-btn"
title="Delete task"
@click="deletingTaskId = task.id; deleteError = ''"
><Trash2 :size="12" /></button>
</div>
</div>
<h3>{{ task.title }}</h3>
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
</select>
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
</template>
</article>
<div v-if="!column.items.length" class="empty-state">No tasks</div>
</section>
</div>
<div v-else-if="view === 'Agents'" class="module-grid">
<div v-if="agentsLoading" class="loading-agents">Loading agents</div>
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
<Zap v-else :size="22" />
</div>
<div>
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
<h3>{{ agent.name }}</h3>
<p>{{ agent.description || agent.model }}</p>
</div>
<div class="agent-status-group">
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
</div>
</article>
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
</div>
<div v-else-if="view === 'Models'" class="module-list panel">
<div v-for="model in routing" :key="model.model" class="model-detail">
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
</div>
</div>
<div v-else-if="view === 'Activity'" class="activity-panel panel">
<div class="activity-filters">
<div class="filter-group">
<label>Type</label>
<select v-model="activityTypeFilter">
<option value="">All types</option>
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="filter-group">
<label>Sort</label>
<select v-model="activitySort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</div>
</div>
<div class="timeline">
<article v-for="event in filteredActivity" :key="event.message + event.at">
<div :class="['timeline-icon', event.type]">
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
<ShieldAlert v-else :size="15" />
</div>
<div>
<span class="kicker">{{ event.type }}</span>
<h3>{{ event.message }}</h3>
<p>{{ new Date(event.at).toLocaleString() }}</p>
</div>
</article>
</div>
<div v-if="activityTotalPages > 1" class="activity-pagination">
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
</div>
</div>
<div v-else-if="view === 'Settings'" class="settings-redirect">
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
</div>
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
</div>
<!-- Task deletion confirmation dialog -->
<Teleport to="body">
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
<div class="delete-dialog">
<h3>Delete Task?</h3>
<p>This action cannot be undone. The task will be permanently removed.</p>
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
<div class="delete-actions">
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.approval-strip {
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid var(--line, #1e2030);
border-radius: 14px;
background: rgba(255,255,255,.025);
}
.approval-strip-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.approval-strip-head h3 {
margin: 2px 0 0;
}
.approval-strip-note {
margin: 10px 0 0;
color: #8e96a8;
}
.approval-strip-note.error {
color: #e16e75;
}
.approval-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.approval-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(255,255,255,.06);
border-radius: 12px;
background: rgba(8, 10, 18, .35);
}
.approval-card p {
margin: 4px 0 0;
color: #8e96a8;
font-size: 12px;
}
.approval-actions {
display: flex;
gap: 8px;
}
.task-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.task-card-actions {
display: flex;
gap: 0.15rem;
align-items: center;
}
.task-edit-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s;
}
.task-card:hover .task-edit-btn {
opacity: 1;
}
.task-edit-btn:hover {
color: var(--nx-accent);
}
.task-delete-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-delete-btn {
opacity: 1;
}
.task-delete-btn:hover {
color: var(--danger, #e74c3c);
}
.task-edit-input {
width: 100%;
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
color: var(--text-primary);
margin-bottom: 0.4rem;
}
.task-edit-row {
display: flex;
gap: 0.35rem;
margin-bottom: 0.4rem;
}
.task-edit-row select {
flex: 1;
padding: 0.25rem 0.4rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text-primary);
}
.task-edit-actions {
display: flex;
gap: 0.35rem;
}
.task-edit-save {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.task-edit-cancel {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.activity-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.filter-group label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
}
.filter-group select {
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-primary);
}
.activity-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.activity-pagination button {
display: flex;
align-items: center;
padding: 0.3rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.activity-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.activity-pagination span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.project-card {
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.project-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.settings-redirect {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.settings-redirect a {
color: var(--nx-accent);
text-decoration: none;
}
.settings-redirect a:hover {
text-decoration: underline;
}
/* Task deletion confirmation */
.delete-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.delete-dialog {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
max-width: 380px;
width: 90%;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.delete-dialog h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.delete-dialog p {
margin: 0 0 1rem;
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.4;
}
.delete-error {
color: var(--danger, #e74c3c) !important;
font-weight: 600;
}
.delete-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.delete-cancel {
padding: 0.45rem 1rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
}
.delete-confirm {
padding: 0.45rem 1rem;
background: var(--danger, #e74c3c);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.delete-confirm:hover {
opacity: 0.9;
}
/* Agent card enhancements */
.agent-status-group {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.3rem;
}
.agent-model-tag {
font-size: 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1rem 0.35rem;
color: var(--text-muted);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-agents {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
/* Task approve/reject buttons */
.task-approve-btn,
.task-reject-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.15rem;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-approve-btn,
.task-card:hover .task-reject-btn {
opacity: 1;
}
.task-approve-btn {
color: var(--success, #27ae60);
}
.task-approve-btn:hover {
color: var(--success, #27ae60);
filter: brightness(1.2);
}
.task-reject-btn {
color: var(--warning, #f39c12);
}
.task-reject-btn:hover {
color: var(--danger, #e74c3c);
}
.task-approve-btn:disabled,
.task-reject-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
@@ -1,105 +0,0 @@
<script setup lang="ts">
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
defineProps<{
connected: boolean
}>()
defineEmits<{
toggleMobileNav: []
}>()
</script>
<template>
<header class="topbar">
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
<Command :size="19" />
</button>
<div class="search">
<Search :size="16" />
<span>Search operations</span>
<kbd> K</kbd>
</div>
<div class="top-actions">
<span :class="['connection', connected ? 'live' : 'preview']">
<CircleDot :size="13" />
{{ connected ? 'Live' : 'Preview data' }}
</span>
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
</div>
</header>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 22px;
border-bottom: 1px solid var(--line);
background: rgba(8, 6, 20, 0.5);
backdrop-filter: blur(14px);
}
.mobile-menu { display: none; }
.search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
max-width: 560px;
padding: 8px 14px;
border: 1px solid var(--line);
border-radius: 11px;
background: rgba(124, 108, 255, 0.06);
color: var(--tx-3);
font-size: 11px;
}
.search kbd {
margin-left: auto;
padding: 1px 4px;
border: 1px solid var(--line-2);
border-radius: 4px;
font-size: 8px;
color: var(--tx-3);
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.connection {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
padding: 4px 11px;
border-radius: 20px;
border: 1px solid var(--line-2);
background: rgba(124, 108, 255, 0.07);
color: var(--tx-2);
}
.connection.live { color: var(--st-work); }
.connection.preview { color: var(--st-queue); }
.ask {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border: none;
border-radius: 10px;
background: var(--grad);
box-shadow: var(--glow-purple);
color: #fff;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: filter .16s;
}
.ask:hover { filter: brightness(1.08); }
@media (max-width: 860px) {
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--line-2); border-radius: 9px; background: transparent; color: var(--tx-2); cursor: pointer; }
}
</style>
@@ -1,233 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import {
Activity, Bell, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useNotificationStore } from '../../stores/notifications'
import { initials } from '../../utils/format'
const props = defineProps<{
activeView: string
mobileNavOpen: boolean
queuedTasks: number
incidents: number
}>()
const emit = defineEmits<{
navigate: [label: string]
}>()
const auth = useAuthStore()
const router = useRouter()
const notificationStore = useNotificationStore()
onMounted(() => {
notificationStore.startPolling()
})
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen },
{ label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo },
{ label: 'Incidents', icon: AlertTriangle },
{ separator: true },
{ label: 'Notifications', icon: Bell },
{ label: 'Calendar', icon: Calendar },
{ label: 'Agents', icon: Bot },
{ label: 'Models', icon: SlidersHorizontal },
{ label: 'Activity', icon: Activity },
{ label: 'Mobile Chat', icon: MessageSquareText },
]
function onNavigate(label: string) {
emit('navigate', label)
}
async function logout() {
await auth.logout()
await router.replace('/login')
}
</script>
<template>
<aside :class="['sidebar', { open: mobileNavOpen }]">
<div class="brand">
<div class="brand-mark"><Command :size="18" /></div>
<div>
<strong>NEXUS</strong>
<span>Noveria Operations</span>
</div>
</div>
<nav class="nav">
<template v-for="item in navigation" :key="item.label ?? 'sep'">
<div v-if="item.separator" class="nav-separator"></div>
<button
v-else
:class="{ active: activeView === item.label }"
@click="onNavigate(item.label)"
>
<component :is="item.icon" :size="17" />
<span>{{ item.label }}</span>
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
</button>
</template>
</nav>
<div class="sidebar-bottom">
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
<button class="owner" type="button" title="Sign out" @click="logout">
<div class="avatar">{{ ownerInitials }}</div>
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
<LogOut :size="15" />
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 210px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
flex-shrink: 0;
padding: 0 8px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 10px 12px;
}
.brand-mark {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--grad);
box-shadow: var(--glow-purple);
color: #fff;
}
.brand div strong { display: block; font-family: 'Space Grotesk', sans-serif; font-size: 10px; letter-spacing: .12em; }
.brand div span { font-size: 8px; color: var(--tx-3); }
.nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 0;
overflow-y: auto;
}
.nav button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--tx-2);
font-size: 10.5px;
text-align: left;
cursor: pointer;
transition: background .15s, color .15s;
}
.nav button:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.nav button.active {
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);
color: #fff;
font-weight: 600;
}
.nav button i {
margin-left: auto;
background: rgba(124,108,255,.14);
border: 1px solid var(--line-2);
color: var(--tx);
font-family: 'JetBrains Mono', monospace;
font-style: normal;
font-size: 8px;
font-weight: 700;
padding: 1px 6px;
border-radius: 20px;
line-height: 1.4;
}
.nav button i.badge-red {
background: rgba(251,113,133,.18);
border-color: rgba(251,113,133,.4);
color: var(--st-block);
}
.nav-separator {
height: 1px;
margin: 6px 10px;
background: var(--line);
}
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line); }
.sidebar-bottom > button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--tx-2);
font-size: 10.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.sidebar-bottom > button:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.sidebar-bottom > button.active {
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);
color: #fff;
font-weight: 600;
}
.owner {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.owner div strong { display: block; font-size: 9px; color: var(--tx); }
.owner div span { font-size: 7.5px; color: var(--tx-3); text-transform: capitalize; }
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
.owner:hover > svg:last-child { opacity: 1; }
.avatar {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
font-size: 9px;
font-weight: 700;
}
@media (max-width: 860px) {
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
.sidebar.open { transform: translateX(0); }
}
</style>
@@ -1,36 +0,0 @@
<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
@@ -1,126 +0,0 @@
<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>
+280 -159
View File
@@ -1,139 +1,180 @@
<script setup lang="ts">
/**
* Sidebar — kompakte Icon-Rail (V2-Shell, alle Seiten)
*
* Collapsed 68px, expandiert bei Hover auf 232px als Overlay
* (kein Layout-Shift im Content). Ersetzt Sidebar + Topbar.
* Mobile: als Drawer über mobileOpen/close.
*/
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents'
import { useTaskStore } from '../../stores/tasks'
import { navigation, icons } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons'
import { useNotificationStore } from '../../stores/notifications'
import { useLiveSyncStore } from '../../stores/liveSync'
import { railNav, railFooterNav, svg } from '../../composables/icons'
import { initials } from '../../utils/format'
defineProps<{
mobileOpen?: boolean
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const agentStore = useAgentStore()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
const liveSync = useLiveSyncStore()
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
function logout() {
auth.logout()
router.replace('/login')
function isActive(itemRoute?: string): boolean {
if (!itemRoute) return false
if (route.path === itemRoute) return true
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
return route.path.startsWith(itemRoute + '/')
}
/**
* Dynamische Nav-Item-Counts aus den Stores.
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
*/
const dynamicNavigation = computed<NavGroupDef[]>(() => {
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
return navigation.map(group => ({
...group,
items: group.items.map(item => {
let dynamicCount: string | undefined
switch (item.label) {
case 'Agenten':
case 'Hosts · OpenClaw':
dynamicCount = String(agentStore.agentList.length)
break
case 'Task Board':
dynamicCount = String(taskStore.taskList.length)
break
case 'Kosten & Tokens':
dynamicCount = agentStore.todayCost
break
case 'Docs & .md':
dynamicCount = '0'
break
case 'Incidents':
dynamicCount = '0'
break
function navigate(itemRoute?: string) {
if (!itemRoute) return
emit('close')
router.push(itemRoute)
}
return {
...item,
count: dynamicCount ?? item.count,
async function logout() {
await auth.logout()
await router.replace('/login')
}
}),
}))
const statusLabel = computed(() => {
if (liveSync.connected) return 'Live'
if (liveSync.connecting) return 'Verbinde…'
return 'Polling'
})
</script>
<template>
<aside :class="['sidebar', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<aside :class="['rail', { open: mobileOpen }]">
<!-- 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>
<button class="rail-brand" @click="navigate('/dashboard')">
<span class="brand-mark" v-html="svg('command')"></span>
<span class="rail-label brand-label">NEXUS</span>
</button>
<!-- Navigation -->
<nav class="nav">
<NavGroup
v-for="(group, idx) in dynamicNavigation"
:key="idx"
:label="group.group"
:items="group.items"
/>
<nav class="rail-nav v2-scroll">
<button
v-for="item in railNav"
:key="item.route"
:class="['rail-item', { active: isActive(item.route) }]"
:title="item.label"
@click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-dot"
></span>
<span class="rail-label">{{ item.label }}</span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-count"
>{{ notificationStore.unreadCount }}</span>
</button>
</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 class="rail-foot">
<div class="rail-item static" :title="statusLabel">
<span class="rail-icon">
<span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
</span>
<span class="rail-label dim">{{ statusLabel }}</span>
</div>
<button
v-for="item in railFooterNav"
:key="item.route"
:class="['rail-item', { active: isActive(item.route) }]"
:title="item.label"
@click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span class="rail-label">{{ item.label }}</span>
</button>
<div class="rail-owner">
<span class="avatar">{{ ownerInitials }}</span>
<span class="rail-label owner-label">
<span class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</span>
<span class="owner-role">{{ auth.user?.role ?? 'Owner' }}</span>
</span>
<button class="logout-btn rail-label" title="Abmelden" @click="logout" v-html="svg('logout')"></button>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 248px;
flex: 0 0 248px;
height: 100vh;
.rail {
position: absolute;
inset: 0 auto 0 0;
width: 68px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
padding: 0;
position: relative;
z-index: 2;
overflow: hidden;
transition: width .18s ease, box-shadow .18s ease;
z-index: 100;
}
.side-top {
.rail:hover {
width: 232px;
box-shadow: 24px 0 60px -30px rgba(0, 0, 0, .8);
}
/* Labels: unsichtbar bis die Rail expandiert */
.rail-label {
opacity: 0;
white-space: nowrap;
transition: opacity .14s ease .04s;
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.rail:hover .rail-label,
.rail.open .rail-label {
opacity: 1;
}
/* ── Brand ── */
.rail-brand {
display: flex;
align-items: center;
gap: 11px;
padding: 18px 18px 16px;
gap: 13px;
padding: 15px;
border: none;
background: transparent;
cursor: pointer;
}
.brand-mark {
width: 38px;
height: 38px;
flex: 0 0 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) {
@@ -142,108 +183,154 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
color: #fff;
}
.brand-name {
.brand-label {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 17px;
font-size: 16px;
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);
}
.sidebar-close {
display: none;
}
@media (max-width: 767px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 100;
height: 100vh;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 18px;
right: 12px;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
z-index: 1;
}
.sidebar-close:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.sidebar-close :deep(svg) {
/* ── Nav ── */
.rail-nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 12px;
overflow-y: auto;
overflow-x: hidden;
}
.rail-item {
position: relative;
display: flex;
align-items: center;
gap: 13px;
height: 42px;
padding: 0 13px;
flex: 0 0 auto;
border: none;
border-radius: 11px;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background .15s, color .15s;
}
.rail-item:not(.static):hover {
background: rgba(124, 108, 255, .08);
color: var(--tx);
}
.rail-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);
}
.rail-item.static {
cursor: default;
}
.rail-icon {
width: 18px;
height: 18px;
flex: 0 0 18px;
display: grid;
place-items: center;
opacity: .9;
}
.rail-icon :deep(svg) {
width: 18px;
height: 18px;
}
.rail-item .rail-label {
flex: 1;
}
/* Ungelesen-Punkt am Icon (collapsed sichtbar) */
.rail-dot {
position: absolute;
left: 24px;
top: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--st-block);
box-shadow: 0 0 8px rgba(251, 113, 133, .8);
}
.rail-count {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(251, 113, 133, .16);
border: 1px solid rgba(251, 113, 133, .35);
color: var(--st-block);
}
/* ── Footer ── */
.rail-foot {
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 12px 10px;
border-top: 1px solid var(--line);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on {
background: var(--st-work);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.rail-label.dim {
color: var(--tx-3);
font-size: 12px;
}
.rail-owner {
display: flex;
align-items: center;
gap: 13px;
padding: 7px 5px 2px;
}
.avatar {
width: 34px;
height: 34px;
flex: 0 0 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;
font-size: 12px;
color: var(--tx);
flex-shrink: 0;
}
.owner-info {
min-width: 0;
.owner-label {
flex: 1;
display: flex;
flex-direction: column;
}
.owner-name {
@@ -258,7 +345,41 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
.owner-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize;
}
.logout-btn {
border: none;
background: transparent;
color: var(--tx-3);
cursor: pointer;
padding: 6px;
border-radius: 8px;
display: grid;
place-items: center;
}
.logout-btn:hover {
color: var(--st-block);
background: rgba(251, 113, 133, .1);
}
.logout-btn :deep(svg) {
width: 16px;
height: 16px;
}
/* ── Mobile: Drawer ── */
@media (max-width: 767px) {
.rail {
position: fixed;
width: 232px;
transform: translateX(-100%);
transition: transform .22s ease;
}
.rail.open {
transform: translateX(0);
}
}
</style>
-210
View File
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
defineProps<{
connected?: boolean
statusLabel?: string
}>()
defineEmits<{
'toggle-sidebar': []
}>()
</script>
<template>
<header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<!-- 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 ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span>
<!-- Ask Iris Button -->
<button class="btn btn-primary ask-iris-btn">
<span class="btn-icon" v-html="icons.spark || ''"></span>
<span class="ask-label">Ask Iris</span>
</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;
}
.hamburger {
display: none;
}
@media (max-width: 767px) {
.topbar {
padding: 0 14px;
}
.search {
flex: 1;
max-width: none;
}
.hamburger {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
flex: 0 0 auto;
}
.hamburger:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.hamburger :deep(svg) {
width: 20px;
height: 20px;
}
.pill {
display: none;
}
.ask-iris-btn {
width: 32px;
height: 32px;
padding: 0;
display: grid;
place-items: center;
border-radius: 9px;
flex: 0 0 auto;
}
.ask-label {
display: none;
}
.ask-iris-btn .btn-icon {
display: flex;
}
}
</style>
+16 -37
View File
@@ -26,6 +26,9 @@ export const icons: Record<string, string> = {
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"/>`,
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
bell: `<path d="M18 9a6 6 0 1 0-12 0c0 6-2.5 7-2.5 7h17S18 15 18 9M10.3 20a2 2 0 0 0 3.4 0"/>`,
calendar: `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>`,
logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>`,
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"/>`,
@@ -51,46 +54,22 @@ export interface NavGroupDef {
}
/**
* Navigation structure matching NEXUS.nav from agents.js
* Rail-Navigation — EINE Quelle für alle Seiten (Dashboard + Rest).
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
* unten im Fußbereich (railFooterNav).
*/
export const navigation: NavGroupDef[] = [
{
group: 'Operations',
items: [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
export const railNav: NavItemDef[] = [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard' },
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
{ icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
],
},
{
group: 'Knowledge',
items: [
{ icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
{ icon: 'search', label: 'Research', route: '/research' },
],
},
{
group: 'Infrastructure',
items: [
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
{ 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: 'doc', label: 'Docs', route: '/docs' },
{ icon: 'calendar', label: 'Kalender', route: '/calendar' },
{ icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
],
},
{
group: 'System',
items: [
{ icon: 'gear', label: 'Settings', route: '/settings' },
],
},
{ icon: 'shield', label: 'Security', route: '/security' },
]
export const railFooterNav: NavItemDef[] = [
{ icon: 'gear', label: 'Einstellungen', route: '/settings' },
]
+113 -27
View File
@@ -1,49 +1,79 @@
<script setup lang="ts">
/**
* NexusLayout — V2 Dashboard Shell
* Flex row, 100vh, overflow hidden.
* Sidebar (248px) + Main (flex:1, flex-column)
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
* NexusLayout — gemeinsame Shell für ALLE Seiten
*
* Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
* Content bekommt die volle restliche Fläche:
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
* - alle anderen: scrollbarer Container mit Seiten-Padding
*
* Die Live-Verbindung (SSE) gehört der Shell — EINE Verbindung für die
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
*/
import { ref } from 'vue'
import { RouterView } from 'vue-router'
import { useDashboardStore } from '../stores/dashboard'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useLiveSyncStore } from '../stores/liveSync'
import { useNotificationStore } from '../stores/notifications'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue'
import { svg } from '../composables/icons'
const dashboardStore = useDashboardStore()
const route = useRoute()
const auth = useAuthStore()
const liveSync = useLiveSyncStore()
const notificationStore = useNotificationStore()
/* ── Mobile Sidebar State ───────────────────────── */
const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
/* ── Mobile Drawer ─────────────────────────────── */
const mobileMenuOpen = ref(false)
function closeMobileMenu() {
mobileMenuOpen.value = false
}
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
function onVisibilityChange() {
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
liveSync.connect(liveUser.value)
}
}
function onOnline() {
liveSync.reconnectNow()
}
onMounted(() => {
liveSync.connect(liveUser.value)
notificationStore.startPolling()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('online', onOnline)
})
onUnmounted(() => {
liveSync.disconnect()
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('online', onOnline)
})
</script>
<template>
<div class="nexus-layout">
<GalaxyBackground />
<Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
/>
<!-- Mobile Backdrop -->
<div
v-if="mobileMenuOpen"
class="mobile-backdrop"
@click="closeMobileMenu"
></div>
<div class="rail-slot">
<Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
</div>
<!-- Mobile: Hamburger + Backdrop -->
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
<main class="nexus-main">
<Topbar
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/>
<div class="nexus-content">
<div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
<RouterView />
</div>
</main>
@@ -59,6 +89,15 @@ function closeMobileMenu() {
position: relative;
}
/* Platzhalter in der Flex-Reihe — die Rail selbst liegt absolut darüber
und kann expandieren, ohne den Content zu verschieben. */
.rail-slot {
width: 68px;
flex: 0 0 68px;
position: relative;
z-index: 2;
}
.nexus-main {
flex: 1;
display: flex;
@@ -70,19 +109,62 @@ function closeMobileMenu() {
.nexus-content {
flex: 1;
overflow: hidden;
min-height: 0;
}
.nexus-content.full-bleed {
overflow: hidden;
display: flex;
flex-direction: column;
}
.nexus-content.full-bleed > :deep(*) {
flex: 1;
min-height: 0;
}
.nexus-content.page-scroll {
overflow-y: auto;
padding: 24px 28px 64px;
}
.mobile-toggle,
.mobile-backdrop {
display: none;
}
@media (max-width: 767px) {
.rail-slot {
width: 0;
flex: 0 0 0;
}
.nexus-main {
width: 100%;
}
.mobile-toggle {
display: grid;
place-items: center;
position: fixed;
top: 12px;
left: 12px;
z-index: 90;
width: 40px;
height: 40px;
border: 1px solid var(--line-2);
border-radius: 12px;
background: var(--glass);
backdrop-filter: blur(12px);
color: var(--tx-2);
cursor: pointer;
}
.mobile-toggle :deep(svg) {
width: 19px;
height: 19px;
}
.mobile-backdrop {
display: block;
position: fixed;
@@ -90,5 +172,9 @@ function closeMobileMenu() {
z-index: 99;
background: rgba(0, 0, 0, 0.5);
}
.nexus-content.page-scroll {
padding: 60px 16px 48px;
}
}
</style>
+15 -19
View File
@@ -19,31 +19,27 @@ const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
{ path: '/', redirect: '/dashboard' },
// V2 Dashboard (neues NexusLayout + FlowBoard)
// Eine Shell für alle Seiten (Rail-Navigation, app-weiter Live-Sync)
{
path: '/dashboard',
path: '/',
component: NexusLayout,
children: [
{ path: '', name: 'Dashboard', component: FlowBoard },
{ path: 'dashboard', name: 'Dashboard', component: FlowBoard, meta: { fullBleed: true } },
{ path: 'agents', name: 'Agents', component: AgentsIndexView },
{ path: 'agents/:id', name: 'AgentDetail', component: AgentDetailView },
{ path: 'tasks', name: 'Task Board', component: TaskBoardView },
{ path: 'tasks/:id', name: 'TaskDetail', component: TaskDetailView },
{ path: 'memory', name: 'Memory', component: MemoryView },
{ path: 'docs', name: 'Docs', component: DocsView },
{ path: 'calendar', name: 'Calendar', component: CalendarView },
{ path: 'notifications', name: 'Notifications', component: NotificationsView },
{ path: 'incidents', name: 'Incidents', component: IncidentsView },
{ path: 'security', name: 'Security', component: SecurityView },
{ path: 'projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
{ path: 'settings', name: 'Settings', component: SettingsView },
],
},
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
{ path: '/projects', name: 'Projects', component: { template: '' } },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
{ path: '/models', name: 'Models', component: { template: '' } },
{ path: '/activity', name: 'Activity', component: { template: '' } },
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
]
-172
View File
@@ -1,172 +0,0 @@
import { defineStore } from 'pinia'
import { openDashboardLiveStream } from '../services/live'
import type { BoardGroup, DashboardTaskDto } from './tasks'
import type { NotificationItem } from './notifications'
import type { TaskItem } from '../components/dashboard/v2/types'
import { useTaskStore } from './tasks'
import { useNotificationStore } from './notifications'
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
interface NotificationSnapshotDto {
notifications: NotificationItem[]
unreadCount: number
forUser: string
}
interface DashboardLiveSnapshotDto {
board: BoardGroup
notifications: NotificationSnapshotDto
cursor: LiveCursorDto
}
function isBoardGroup(value: unknown): value is BoardGroup {
const v = value as BoardGroup
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
}
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
}
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
detail: t.detail,
source: t.source,
}
}
export const useLiveSyncStore = defineStore('liveSync', {
state: () => ({
connected: false,
connecting: false,
lastEventAt: null as string | null,
lastHeartbeatAt: null as string | null,
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
}),
getters: {
liveIndicatorLabel: (state) => {
if (state.connecting) return 'Verbinde…'
if (state.connected) return `Live · #${state.lastSequence}`
return state.mode === 'polling' ? 'Polling' : 'Offline'
},
connectionHealth: (state) => {
if (state.connected) return 'healthy'
if (state.connecting) return 'connecting'
return 'degraded'
},
},
actions: {
async connect(forUser = 'bao') {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.controller = new AbortController()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
try {
const stream = await openDashboardLiveStream((event, data) => {
this.lastEventAt = new Date().toISOString()
if (event === 'heartbeat') {
const cursor = data as LiveCursorDto
this.lastHeartbeatAt = cursor.timestamp
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
return
}
if (event === 'snapshot') {
const snapshot = data as DashboardLiveSnapshotDto
taskStore.board = snapshot.board
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
notificationStore.notifications = snapshot.notifications.notifications
notificationStore.unreadCount = snapshot.notifications.unreadCount
this.lastSequence = snapshot.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
return
}
const eventDto = data as DashboardLiveEventDto
this.applyEnvelope(eventDto.envelope, forUser)
this.lastSequence = eventDto.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
}
},
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
taskStore.board = envelope.payload
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
return
}
if (envelope.type === 'notifications.snapshot') {
const snapshot = envelope.payload as NotificationSnapshotDto
if (snapshot.forUser !== forUser) return
notificationStore.notifications = snapshot.notifications
notificationStore.unreadCount = snapshot.unreadCount
}
},
disconnect() {
this.controller?.abort()
this.controller = null
this.connected = false
this.connecting = false
this.mode = 'polling'
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
},
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(forUser)
}, delay)
},
},
})
+46 -7
View File
@@ -50,9 +50,11 @@ export const useLiveSyncStore = defineStore('liveSync', {
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
watchdogTimer: null as ReturnType<typeof setInterval> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
forUser: 'bao',
}),
getters: {
@@ -73,7 +75,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.forUser = forUser
this.controller = new AbortController()
this.startWatchdog()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
@@ -113,19 +117,51 @@ export const useLiveSyncStore = defineStore('liveSync', {
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
// Stream „sauber" beendet (Proxy-Timeout, Server-Neustart, Netzwechsel):
// muss genauso wie ein Fehler behandelt werden — sonst bleibt connected=true
// hängen, Polling ist gestoppt und die Seite erhält nie wieder Updates.
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
if (!this.controller?.signal.aborted) {
console.warn('[liveSync] stream failed', error)
this.error = 'Live updates unavailable'
}
} finally {
this.connecting = false
}
if (this.controller?.signal.aborted) return
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
},
/** Erzwingt einen frischen Stream (Watchdog / visibilitychange / online). */
reconnectNow() {
const forUser = this.forUser
this.disconnect()
this.connect(forUser)
},
startWatchdog() {
if (this.watchdogTimer) return
this.watchdogTimer = setInterval(() => {
if (!this.connected || !this.lastEventAt) return
// Heartbeat kommt alle 20s — >65s Stille heißt: Verbindung ist tot,
// auch wenn der Browser den fetch-Stream noch für offen hält.
const silentMs = Date.now() - new Date(this.lastEventAt).getTime()
if (silentMs > 65000) {
console.warn('[liveSync] heartbeat timeout, reconnecting')
this.reconnectNow()
}
}, 15000)
},
stopWatchdog() {
if (this.watchdogTimer) {
clearInterval(this.watchdogTimer)
this.watchdogTimer = null
}
},
@@ -148,6 +184,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
},
disconnect() {
this.stopWatchdog()
this.controller?.abort()
this.controller = null
this.connected = false
@@ -161,7 +198,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
// Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
// danach sanft hochstaffeln bis 30s.
const delay = this.reconnectAttempts === 0 ? 1000 : Math.min(30000, 5000 * this.reconnectAttempts)
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
@@ -18,7 +18,6 @@ import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat'
import { useDashboardStore } from '../../stores/dashboard'
import { useTaskStore } from '../../stores/tasks'
import { useLiveSyncStore } from '../../stores/liveSync'
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
@@ -31,7 +30,6 @@ const agentStore = useAgentStore()
const chatStore = useChatStore()
const dashboardStore = useDashboardStore()
const taskStore = useTaskStore()
const liveSyncStore = useLiveSyncStore()
const router = useRouter()
const {
@@ -70,7 +68,6 @@ onMounted(() => {
dashboardStore.startPolling()
taskStore.startPolling()
taskStore.startBoardPolling()
liveSyncStore.connect()
})
onUnmounted(() => {
@@ -79,7 +76,6 @@ onUnmounted(() => {
dashboardStore.stopPolling()
taskStore.stopPolling()
taskStore.stopBoardPolling()
liveSyncStore.disconnect()
})
</script>
-4
View File
@@ -2,12 +2,10 @@
import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '../stores/notifications'
import { useLiveSyncStore } from '../stores/liveSync'
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
const store = useNotificationStore()
const router = useRouter()
const liveSyncStore = useLiveSyncStore()
const sortedNotifications = computed(() => {
return [...store.notifications].sort(
@@ -55,12 +53,10 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
onMounted(() => {
store.startListPolling()
liveSyncStore.connect()
})
onUnmounted(() => {
store.stopListPolling()
liveSyncStore.disconnect()
})
</script>
+1 -3
View File
@@ -17,7 +17,7 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTaskStore } from '../stores/tasks'
import { useLiveSyncStore } from '../stores/live-sync'
import { useLiveSyncStore } from '../stores/liveSync'
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
type BoardTask = ReturnType<typeof flattenBoard>[number]
@@ -393,7 +393,6 @@ let agentOverviewInterval: ReturnType<typeof setInterval> | null = null
onMounted(() => {
taskStore.startBoardPolling()
taskStore.fetchAgentOverview()
liveSyncStore.connect()
window.addEventListener('keydown', onGlobalKeydown)
agentOverviewInterval = setInterval(() => taskStore.fetchAgentOverview(), 30000)
})
@@ -404,7 +403,6 @@ onBeforeUnmount(() => {
onUnmounted(() => {
taskStore.stopBoardPolling()
liveSyncStore.disconnect()
if (agentOverviewInterval) clearInterval(agentOverviewInterval)
window.removeEventListener('keydown', onGlobalKeydown)
})