feat: unified rail shell, robust live sync, task board performance
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:
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user