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
+47 -25
View File
@@ -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")]