From 3438674e6eaec2fb9b78605b0f78283da0ffc87c Mon Sep 17 00:00:00 2001
From: Enmanuel Jimenez <34482837+EnmaJim@users.noreply.github.com>
Date: Mon, 13 Jul 2026 19:01:54 -0400
Subject: [PATCH] feat(server): add Fleet dashboard surface with mock data
(#547)
---
.../Dotbot.Server/DashboardAuthMiddleware.cs | 7 +-
.../src/Dotbot.Server/Dotbot.Server.csproj | 9 +
.../src/Dotbot.Server/Mock/fleet-alerts.json | 56 ++++
.../Dotbot.Server/Mock/fleet-instances.json | 95 +++++++
.../src/Dotbot.Server/Models/FleetAlertDto.cs | 25 ++
.../Dotbot.Server/Models/FleetInstanceDto.cs | 41 +++
.../src/Dotbot.Server/Pages/Decisions.cshtml | 124 +++++++++
.../Dotbot.Server/Pages/Decisions.cshtml.cs | 25 ++
.../src/Dotbot.Server/Pages/Index.cshtml | 176 +++++-------
.../src/Dotbot.Server/Pages/Index.cshtml.cs | 3 +
.../Pages/Shared/_LayoutDashboard.cshtml | 11 +-
.../src/Dotbot.Server/Program.cs | 13 +
.../Dotbot.Server/Services/IFleetService.cs | 14 +
.../Services/MockFleetService.cs | 50 ++++
.../Dotbot.Server/wwwroot/css/dashboard.css | 175 ++++++++++++
.../src/Dotbot.Server/wwwroot/js/fleet.js | 250 ++++++++++++++++++
.../Integration/FleetApiTests.cs | 92 +++++++
.../Integration/FleetPageTests.cs | 58 ++++
.../TestDoubles/AlwaysAdministratorService.cs | 13 +
.../Unit/MockFleetServiceTests.cs | 108 ++++++++
20 files changed, 1233 insertions(+), 112 deletions(-)
create mode 100644 src/server-dotnet/src/Dotbot.Server/Mock/fleet-alerts.json
create mode 100644 src/server-dotnet/src/Dotbot.Server/Mock/fleet-instances.json
create mode 100644 src/server-dotnet/src/Dotbot.Server/Models/FleetAlertDto.cs
create mode 100644 src/server-dotnet/src/Dotbot.Server/Models/FleetInstanceDto.cs
create mode 100644 src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml
create mode 100644 src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml.cs
create mode 100644 src/server-dotnet/src/Dotbot.Server/Services/IFleetService.cs
create mode 100644 src/server-dotnet/src/Dotbot.Server/Services/MockFleetService.cs
create mode 100644 src/server-dotnet/src/Dotbot.Server/wwwroot/js/fleet.js
create mode 100644 src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetApiTests.cs
create mode 100644 src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetPageTests.cs
create mode 100644 src/server-dotnet/tests/Dotbot.Server.Tests/Integration/TestDoubles/AlwaysAdministratorService.cs
create mode 100644 src/server-dotnet/tests/Dotbot.Server.Tests/Unit/MockFleetServiceTests.cs
diff --git a/src/server-dotnet/src/Dotbot.Server/DashboardAuthMiddleware.cs b/src/server-dotnet/src/Dotbot.Server/DashboardAuthMiddleware.cs
index 410147a1..272d6b34 100644
--- a/src/server-dotnet/src/Dotbot.Server/DashboardAuthMiddleware.cs
+++ b/src/server-dotnet/src/Dotbot.Server/DashboardAuthMiddleware.cs
@@ -4,7 +4,8 @@
namespace Dotbot.Server;
///
-/// Intercepts requests to / and /api/dashboard/* routes.
+/// Intercepts requests to the admin dashboard routes (/, /decisions,
+/// /api/dashboard/* and /api/fleet/*).
/// After authentication, checks the user's email against the administrator list.
/// Returns 403 if the user is not an administrator.
///
@@ -22,7 +23,9 @@ public async Task InvokeAsync(HttpContext context, IAdministratorService adminSe
var path = context.Request.Path.Value ?? "";
var isDashboardRoute = path == "/"
- || path.StartsWith("/api/dashboard/", StringComparison.OrdinalIgnoreCase);
+ || path == "/decisions"
+ || path.StartsWith("/api/dashboard/", StringComparison.OrdinalIgnoreCase)
+ || path.StartsWith("/api/fleet/", StringComparison.OrdinalIgnoreCase);
if (!isDashboardRoute)
{
diff --git a/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj b/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj
index f0529b6c..f47830be 100644
--- a/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj
+++ b/src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj
@@ -39,4 +39,13 @@
CopyToPublishDirectory="PreserveNewest" />
+
+
+
+
+
diff --git a/src/server-dotnet/src/Dotbot.Server/Mock/fleet-alerts.json b/src/server-dotnet/src/Dotbot.Server/Mock/fleet-alerts.json
new file mode 100644
index 00000000..c514744a
--- /dev/null
+++ b/src/server-dotnet/src/Dotbot.Server/Mock/fleet-alerts.json
@@ -0,0 +1,56 @@
+[
+ {
+ "id": "alert-1001",
+ "severity": "critical",
+ "instanceId": "orion-dev",
+ "message": "Outpost orion-dev reported a fatal runtime error and stopped heartbeating.",
+ "createdAt": "2026-07-13T17:58:20Z",
+ "status": "active",
+ "resolvedAt": null
+ },
+ {
+ "id": "alert-1002",
+ "severity": "warning",
+ "instanceId": "beacon-staging",
+ "message": "Outpost beacon-staging is stale — no heartbeat for over 5 minutes.",
+ "createdAt": "2026-07-13T18:24:10Z",
+ "status": "active",
+ "resolvedAt": null
+ },
+ {
+ "id": "alert-1003",
+ "severity": "warning",
+ "instanceId": "drone-charlie",
+ "message": "Drone drone-charlie is stale — no heartbeat for over 2 minutes.",
+ "createdAt": "2026-07-13T18:29:45Z",
+ "status": "active",
+ "resolvedAt": null
+ },
+ {
+ "id": "alert-1004",
+ "severity": "info",
+ "instanceId": "drone-bravo",
+ "message": "Drone drone-bravo reached maximum concurrency (4/4).",
+ "createdAt": "2026-07-13T18:15:00Z",
+ "status": "active",
+ "resolvedAt": null
+ },
+ {
+ "id": "alert-0990",
+ "severity": "critical",
+ "instanceId": "atlas-prod",
+ "message": "Work queue depth exceeded threshold (24 > 20).",
+ "createdAt": "2026-07-13T14:02:30Z",
+ "status": "cleared",
+ "resolvedAt": "2026-07-13T14:41:12Z"
+ },
+ {
+ "id": "alert-0985",
+ "severity": "warning",
+ "instanceId": "drone-alpha",
+ "message": "Drone drone-alpha assignment retried after a transient failure.",
+ "createdAt": "2026-07-13T11:20:00Z",
+ "status": "cleared",
+ "resolvedAt": "2026-07-13T11:22:48Z"
+ }
+]
diff --git a/src/server-dotnet/src/Dotbot.Server/Mock/fleet-instances.json b/src/server-dotnet/src/Dotbot.Server/Mock/fleet-instances.json
new file mode 100644
index 00000000..b39b37e8
--- /dev/null
+++ b/src/server-dotnet/src/Dotbot.Server/Mock/fleet-instances.json
@@ -0,0 +1,95 @@
+[
+ {
+ "instanceId": "atlas-prod",
+ "name": "atlas-prod",
+ "kind": "outpost",
+ "status": "online",
+ "orgId": "default",
+ "uptimeSeconds": 486300,
+ "lastHeartbeatAt": "2026-07-13T18:31:40Z",
+ "activeWorkflow": "start-from-jira",
+ "taskCount": 3,
+ "version": "4.1.0",
+ "droneUtilization": null
+ },
+ {
+ "instanceId": "beacon-staging",
+ "name": "beacon-staging",
+ "kind": "outpost",
+ "status": "stale",
+ "orgId": "default",
+ "uptimeSeconds": 91820,
+ "lastHeartbeatAt": "2026-07-13T18:19:05Z",
+ "activeWorkflow": "start-from-prompt",
+ "taskCount": 1,
+ "version": "4.1.0",
+ "droneUtilization": null
+ },
+ {
+ "instanceId": "orion-dev",
+ "name": "orion-dev",
+ "kind": "outpost",
+ "status": "error",
+ "orgId": "default",
+ "uptimeSeconds": 0,
+ "lastHeartbeatAt": "2026-07-13T17:58:11Z",
+ "activeWorkflow": null,
+ "taskCount": 0,
+ "version": "4.0.2",
+ "droneUtilization": null
+ },
+ {
+ "instanceId": "drone-alpha",
+ "name": "drone-alpha",
+ "kind": "drone",
+ "status": "online",
+ "orgId": "default",
+ "uptimeSeconds": 205400,
+ "lastHeartbeatAt": "2026-07-13T18:31:52Z",
+ "activeWorkflow": "start-from-jira",
+ "taskCount": 2,
+ "version": "4.1.0",
+ "droneUtilization": {
+ "load": 2,
+ "maxConcurrent": 4,
+ "successRate": 0.96,
+ "avgDurationSeconds": 312
+ }
+ },
+ {
+ "instanceId": "drone-bravo",
+ "name": "drone-bravo",
+ "kind": "drone",
+ "status": "online",
+ "orgId": "default",
+ "uptimeSeconds": 205280,
+ "lastHeartbeatAt": "2026-07-13T18:31:48Z",
+ "activeWorkflow": "start-from-repo",
+ "taskCount": 4,
+ "version": "4.1.0",
+ "droneUtilization": {
+ "load": 4,
+ "maxConcurrent": 4,
+ "successRate": 0.88,
+ "avgDurationSeconds": 540
+ }
+ },
+ {
+ "instanceId": "drone-charlie",
+ "name": "drone-charlie",
+ "kind": "drone",
+ "status": "stale",
+ "orgId": "default",
+ "uptimeSeconds": 48120,
+ "lastHeartbeatAt": "2026-07-13T18:27:30Z",
+ "activeWorkflow": null,
+ "taskCount": 0,
+ "version": "4.1.0",
+ "droneUtilization": {
+ "load": 0,
+ "maxConcurrent": 2,
+ "successRate": 0.71,
+ "avgDurationSeconds": 605
+ }
+ }
+]
diff --git a/src/server-dotnet/src/Dotbot.Server/Models/FleetAlertDto.cs b/src/server-dotnet/src/Dotbot.Server/Models/FleetAlertDto.cs
new file mode 100644
index 00000000..b1e50e8a
--- /dev/null
+++ b/src/server-dotnet/src/Dotbot.Server/Models/FleetAlertDto.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+
+namespace Dotbot.Server.Models;
+
+// UI-facing wire shape for a fleet health alert (#547). Active vs history is a filter
+// on Status, not two separate stores. Served from mock fixtures now; #598 swaps the
+// backing IFleetService to the live AlertService feed (#95).
+public sealed class FleetAlertDto
+{
+ public string Id { get; set; } = "";
+
+ // critical | warning | info
+ public string Severity { get; set; } = "";
+
+ public string InstanceId { get; set; } = "";
+ public string Message { get; set; } = "";
+ public DateTime CreatedAt { get; set; }
+
+ // active | cleared
+ public string Status { get; set; } = "";
+
+ // Set when the alert has been cleared/resolved.
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DateTime? ResolvedAt { get; set; }
+}
diff --git a/src/server-dotnet/src/Dotbot.Server/Models/FleetInstanceDto.cs b/src/server-dotnet/src/Dotbot.Server/Models/FleetInstanceDto.cs
new file mode 100644
index 00000000..7d24397d
--- /dev/null
+++ b/src/server-dotnet/src/Dotbot.Server/Models/FleetInstanceDto.cs
@@ -0,0 +1,41 @@
+using System.Text.Json.Serialization;
+
+namespace Dotbot.Server.Models;
+
+// UI-facing wire shape for a registered fleet node (outpost or drone) shown on the
+// Fleet dashboard (#547). Scaffolding is served from mock fixtures; #598 swaps the
+// backing IFleetService to the live registry, mapping its snake_case fields onto
+// this contract, which stays fixed across the swap.
+public sealed class FleetInstanceDto
+{
+ public string InstanceId { get; set; } = "";
+ public string Name { get; set; } = "";
+
+ // outpost | drone
+ public string Kind { get; set; } = "";
+
+ // online | stale | error
+ public string Status { get; set; } = "";
+
+ public string OrgId { get; set; } = "";
+ public long UptimeSeconds { get; set; }
+ public DateTime LastHeartbeatAt { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? ActiveWorkflow { get; set; }
+
+ public int TaskCount { get; set; }
+ public string Version { get; set; } = "";
+
+ // Present for drones, null for outposts.
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public DroneUtilizationDto? DroneUtilization { get; set; }
+}
+
+public sealed class DroneUtilizationDto
+{
+ public int Load { get; set; }
+ public int MaxConcurrent { get; set; }
+ public double SuccessRate { get; set; }
+ public int AvgDurationSeconds { get; set; }
+}
diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml b/src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml
new file mode 100644
index 00000000..af12753e
--- /dev/null
+++ b/src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml
@@ -0,0 +1,124 @@
+@page
+@model Dotbot.Server.Pages.DecisionsModel
+@{
+ Layout = "_LayoutDashboard";
+ ViewData["Title"] = "Decisions";
+ ViewData["UserName"] = Model.UserName;
+ ViewData["ActiveRail"] = "decisions";
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0
+
Total
+
+
+
0
+
Pending
+
+
+
0
+
Sent
+
+
+
0
+
Reminded
+
+
+
0
+
Escalated
+
+
+
0
+
Responded
+
+
+
+
+
+
+
+
+
+
+
+
+ INSTANCES 0
+
+
+
+
+
+
+
+
+
+
+
+ No instances found.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a person to view their questions.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a project to view its instances.
+
+
+
+
+
+@section Scripts {
+
+}
diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml.cs b/src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml.cs
new file mode 100644
index 00000000..a4df090a
--- /dev/null
+++ b/src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml.cs
@@ -0,0 +1,25 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.Security.Claims;
+
+namespace Dotbot.Server.Pages;
+
+// The cross-outpost Q&A dashboard — the "Decisions" rail slot. Re-homed from the
+// site root (/) to /decisions in #547 so Fleet can become the landing surface.
+[Authorize]
+public class DecisionsModel : PageModel
+{
+ public string UserEmail { get; private set; } = "";
+ public string UserName { get; private set; } = "";
+
+ public void OnGet()
+ {
+ UserEmail = User.FindFirstValue(ClaimTypes.Email)
+ ?? User.FindFirstValue("preferred_username")
+ ?? User.FindFirstValue("email")
+ ?? "unknown";
+ UserName = User.FindFirstValue(ClaimTypes.Name)
+ ?? User.FindFirstValue("name")
+ ?? UserEmail;
+ }
+}
diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml b/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml
index 6920999e..2e9f78fa 100644
--- a/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml
+++ b/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml
@@ -2,122 +2,84 @@
@model Dotbot.Server.Pages.IndexModel
@{
Layout = "_LayoutDashboard";
- ViewData["Title"] = "Dashboard";
+ ViewData["Title"] = "Fleet";
ViewData["UserName"] = Model.UserName;
+ ViewData["ActiveRail"] = "fleet";
}
-
-
-
-
-
-
+@* Fleet dashboard (#547) — the Mothership landing surface. Static skeleton hydrated
+ by /js/fleet.js from /api/fleet/* (mock data now; live wiring in #598). *@
-
-