From f564ecfbc708b8290e74cd440217d55facdc260e Mon Sep 17 00:00:00 2001 From: DevOps Date: Fri, 10 Jul 2026 13:25:26 +0200 Subject: [PATCH] feat: unified rail shell, robust live sync, task board performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/Controllers/DashboardController.cs | 72 +- backend/Services/TaskService.cs | 16 +- frontend/nginx.conf | 9 + frontend/src/App.vue | 158 +--- frontend/src/components/ModuleView.vue | 741 ------------------ frontend/src/components/layout/AppHeader.vue | 105 --- frontend/src/components/layout/AppSidebar.vue | 233 ------ frontend/src/components/layout/NavGroup.vue | 36 - frontend/src/components/layout/NavItem.vue | 126 --- frontend/src/components/layout/Sidebar.vue | 413 ++++++---- frontend/src/components/layout/Topbar.vue | 210 ----- frontend/src/composables/icons.ts | 61 +- frontend/src/layouts/NexusLayout.vue | 140 +++- frontend/src/router.ts | 34 +- frontend/src/stores/live-sync.ts | 172 ---- frontend/src/stores/liveSync.ts | 59 +- frontend/src/views/Dashboard/FlowBoard.vue | 4 - frontend/src/views/NotificationsView.vue | 4 - frontend/src/views/TaskBoardView.vue | 4 +- 19 files changed, 540 insertions(+), 2057 deletions(-) delete mode 100644 frontend/src/components/ModuleView.vue delete mode 100644 frontend/src/components/layout/AppHeader.vue delete mode 100644 frontend/src/components/layout/AppSidebar.vue delete mode 100644 frontend/src/components/layout/NavGroup.vue delete mode 100644 frontend/src/components/layout/NavItem.vue delete mode 100644 frontend/src/components/layout/Topbar.vue delete mode 100644 frontend/src/stores/live-sync.ts diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index f345bf9..738f5dd 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -236,38 +236,60 @@ 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 { - var readTask = subscription.Reader.ReadAsync(ct).AsTask(); - var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask(); - var completed = await Task.WhenAny(readTask, heartbeatTask); - - if (completed == readTask) + while (!ct.IsCancellationRequested) { - var envelope = await readTask; - if (envelope.Type == "notifications.snapshot") - { - var snapshot = envelope.Payload as NotificationSnapshotDto - ?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct); - if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase)) - continue; - envelope = envelope with { Payload = snapshot }; - } + var completed = await Task.WhenAny(readTask, heartbeatTask); - if (envelope.Type == "tasks.board.snapshot") + if (completed == readTask) { - envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) }; - } + var envelope = await readTask; + readTask = subscription.Reader.ReadAsync(ct).AsTask(); - await WriteEventAsync("update", new DashboardLiveEventDto( - envelope, - new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live"))); - } - else if (await heartbeatTask) - { - await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live")); + if (envelope.Type == "notifications.snapshot") + { + var snapshot = envelope.Payload as NotificationSnapshotDto + ?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct); + if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase)) + continue; + envelope = envelope with { Payload = snapshot }; + } + + if (envelope.Type == "tasks.board.snapshot") + { + envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) }; + } + + await WriteEventAsync("update", new DashboardLiveEventDto( + envelope, + new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live"))); + } + 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")] diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index 9344362..3b2f691 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -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 allTasks, IEnumerable activity) + private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList allTasks, IEnumerable 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 }; } diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4b90039..af6b469 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 04e8da9..b961814 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,156 +1,14 @@ - - diff --git a/frontend/src/components/ModuleView.vue b/frontend/src/components/ModuleView.vue deleted file mode 100644 index e79c6c6..0000000 --- a/frontend/src/components/ModuleView.vue +++ /dev/null @@ -1,741 +0,0 @@ - - - - - diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue deleted file mode 100644 index 5eef86e..0000000 --- a/frontend/src/components/layout/AppHeader.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - - diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue deleted file mode 100644 index 2f3a9cb..0000000 --- a/frontend/src/components/layout/AppSidebar.vue +++ /dev/null @@ -1,233 +0,0 @@ - - - - - diff --git a/frontend/src/components/layout/NavGroup.vue b/frontend/src/components/layout/NavGroup.vue deleted file mode 100644 index 74d5604..0000000 --- a/frontend/src/components/layout/NavGroup.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - diff --git a/frontend/src/components/layout/NavItem.vue b/frontend/src/components/layout/NavItem.vue deleted file mode 100644 index e612204..0000000 --- a/frontend/src/components/layout/NavItem.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - - - diff --git a/frontend/src/components/layout/Sidebar.vue b/frontend/src/components/layout/Sidebar.vue index 7d8603a..88124e3 100644 --- a/frontend/src/components/layout/Sidebar.vue +++ b/frontend/src/components/layout/Sidebar.vue @@ -1,139 +1,180 @@ diff --git a/frontend/src/components/layout/Topbar.vue b/frontend/src/components/layout/Topbar.vue deleted file mode 100644 index fba0091..0000000 --- a/frontend/src/components/layout/Topbar.vue +++ /dev/null @@ -1,210 +0,0 @@ - - - - - diff --git a/frontend/src/composables/icons.ts b/frontend/src/composables/icons.ts index d2a358d..aa14e67 100644 --- a/frontend/src/composables/icons.ts +++ b/frontend/src/composables/icons.ts @@ -26,6 +26,9 @@ export const icons: Record = { plus: ``, command: ``, gear: ``, + bell: ``, + calendar: ``, + logout: ``, chevron_left: ``, chevron_right: ``, dots: ``, @@ -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 }, - { 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: 'alert', label: 'Incidents', route: '/incidents' }, - ], - }, - { - group: 'System', - items: [ - { icon: 'gear', label: 'Settings', route: '/settings' }, - ], - }, +export const railNav: NavItemDef[] = [ + { icon: 'grid', label: 'Dashboard', route: '/dashboard' }, + { icon: 'cpu', label: 'Agenten', route: '/agents' }, + { icon: 'list', label: 'Task Board', route: '/tasks' }, + { icon: 'brain', label: 'Memory', route: '/memory' }, + { icon: 'doc', label: 'Docs', route: '/docs' }, + { icon: 'calendar', label: 'Kalender', route: '/calendar' }, + { icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' }, + { icon: 'alert', label: 'Incidents', route: '/incidents' }, + { icon: 'shield', label: 'Security', route: '/security' }, +] + +export const railFooterNav: NavItemDef[] = [ + { icon: 'gear', label: 'Einstellungen', route: '/settings' }, ] diff --git a/frontend/src/layouts/NexusLayout.vue b/frontend/src/layouts/NexusLayout.vue index ccab2d8..457e995 100644 --- a/frontend/src/layouts/NexusLayout.vue +++ b/frontend/src/layouts/NexusLayout.vue @@ -1,49 +1,79 @@