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 @@