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 +
+
+ + + + + +
+
+
+ +
+
+
+ + +
+
+
+
+ +
+
+
+
+
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). *@ - -
- -
-
-
-
0
-
Total
-
-
-
0
-
Pending
-
-
-
0
-
Sent
-
-
-
0
-
Reminded
-
-
-
0
-
Escalated
-
-
-
0
-
Responded
-
-
+ + -
- - - - -
+ +
+
+
0
+
Instances
+
+
+
0
+
Online
+
+
+
0
+
Stale
+
+
+
0
+
Error
+
+
+
0
+
Drones
+
+
+
0
+
Active Alerts
+
+
-
-
- INSTANCES 0 -
-
- - - - - -
-
-
- -
-
-
+ +
+
+ INSTANCES 0 +
+
+
+ +
+
- -
-
-
-
- -
-
-
-
-
Select a person to view their questions.
-
-
-
+ +
+
+ ALERTS 0 +
+
+
+ +
+
- -
-
-
-
- -
-
-
-
-
Select a project to view its instances.
-
-
-
+ +
+
+ ALERT HISTORY 0 +
+
+
+ +
+
@section Scripts { - + } diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml.cs b/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml.cs index 88d7b976..18c82869 100644 --- a/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml.cs +++ b/src/server-dotnet/src/Dotbot.Server/Pages/Index.cshtml.cs @@ -4,6 +4,9 @@ namespace Dotbot.Server.Pages; +// The Mothership landing surface (/) — the Fleet dashboard (#547). Thin model: +// resolves the signed-in identity for the shell topbar; Fleet data is fetched +// client-side by /js/fleet.js from /api/fleet/*. [Authorize] public class IndexModel : PageModel { diff --git a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml index 07edec10..914329dd 100644 --- a/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml +++ b/src/server-dotnet/src/Dotbot.Server/Pages/Shared/_LayoutDashboard.cshtml @@ -27,10 +27,15 @@ SIGN OUT - @* Rail shows only destinations that exist; Fleet (#547), People (#601) - and Settings slots are added as their pages land — Q5 pattern. *@ + @* Rail shows only destinations that exist; People (#601) and Settings slots + are added as their pages land — Q5 pattern. Active item is driven by + ViewData["ActiveRail"], set per page. *@ + @{ + var activeRail = ViewData["ActiveRail"] as string; + } diff --git a/src/server-dotnet/src/Dotbot.Server/Program.cs b/src/server-dotnet/src/Dotbot.Server/Program.cs index 817b73f1..211df0d8 100644 --- a/src/server-dotnet/src/Dotbot.Server/Program.cs +++ b/src/server-dotnet/src/Dotbot.Server/Program.cs @@ -167,6 +167,10 @@ // Administrator service builder.Services.AddSingleton(); + // Fleet dashboard (#547): mock-data source. #598 swaps this line for the live + // HTTP-backed implementation (config-selected, per the attachment-storage precedent). + builder.Services.AddSingleton(); + // Azure AD authentication for dashboard (reuses bot app registration) if (environmentName != "Development") { @@ -884,6 +888,15 @@ context.Response.Redirect("/"); }); + // ── Fleet API endpoints (#547, mock data) ────────────────────────────── + // Admin-gated by DashboardAuthMiddleware (same as /api/dashboard/*). #598 swaps + // the backing IFleetService to live data; these signatures stay fixed. + app.MapGet("/api/fleet/instances", async (IFleetService fleet) => + Results.Ok(await fleet.GetInstancesAsync())); + + app.MapGet("/api/fleet/alerts", async (IFleetService fleet, string? status) => + Results.Ok(await fleet.GetAlertsAsync(status))); + // Health check endpoint app.MapGet("/api/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow })); diff --git a/src/server-dotnet/src/Dotbot.Server/Services/IFleetService.cs b/src/server-dotnet/src/Dotbot.Server/Services/IFleetService.cs new file mode 100644 index 00000000..ce34cc89 --- /dev/null +++ b/src/server-dotnet/src/Dotbot.Server/Services/IFleetService.cs @@ -0,0 +1,14 @@ +using Dotbot.Server.Models; + +namespace Dotbot.Server.Services; + +// Data source for the Fleet dashboard (#547). Async so the live HTTP-backed +// implementation (#598) drops in without changing this contract or its callers. +public interface IFleetService +{ + Task> GetInstancesAsync(); + + // status: "active" | "cleared" | null (all). Active feeds the banner/rail; + // cleared feeds the alert-history view. + Task> GetAlertsAsync(string? status = null); +} diff --git a/src/server-dotnet/src/Dotbot.Server/Services/MockFleetService.cs b/src/server-dotnet/src/Dotbot.Server/Services/MockFleetService.cs new file mode 100644 index 00000000..e5ab6296 --- /dev/null +++ b/src/server-dotnet/src/Dotbot.Server/Services/MockFleetService.cs @@ -0,0 +1,50 @@ +using System.Text.Json; +using Dotbot.Server.Models; + +namespace Dotbot.Server.Services; + +// Serves Fleet dashboard data (#547) from static JSON fixtures under +// ContentRootPath/Mock, loaded once at construction (fail-fast on a missing or +// malformed fixture). #598 replaces this with a live HTTP-backed IFleetService; +// the DTO contract and endpoint signatures stay fixed across that swap. +public sealed class MockFleetService : IFleetService +{ + private readonly IReadOnlyList _instances; + private readonly IReadOnlyList _alerts; + + public MockFleetService(IWebHostEnvironment env) + : this(Path.Combine(env.ContentRootPath, "Mock")) + { + } + + // Path-based ctor for unit tests (see InternalsVisibleTo in the csproj). + internal MockFleetService(string mockDirectory) + { + _instances = Load(Path.Combine(mockDirectory, "fleet-instances.json")); + _alerts = Load(Path.Combine(mockDirectory, "fleet-alerts.json")); + } + + public Task> GetInstancesAsync() => + Task.FromResult(_instances); + + public Task> GetAlertsAsync(string? status = null) + { + if (string.IsNullOrWhiteSpace(status)) + return Task.FromResult(_alerts); + + IReadOnlyList filtered = _alerts + .Where(a => string.Equals(a.Status, status, StringComparison.OrdinalIgnoreCase)) + .ToList(); + return Task.FromResult(filtered); + } + + private static IReadOnlyList Load(string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"Fleet mock fixture not found: {path}", path); + + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize>(json, JsonSerializerOptions.Web) + ?? throw new InvalidOperationException($"Fleet mock fixture is empty or invalid: {path}"); + } +} diff --git a/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css b/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css index 498e2a7c..9b1e05a3 100644 --- a/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css +++ b/src/server-dotnet/src/Dotbot.Server/wwwroot/css/dashboard.css @@ -1330,3 +1330,178 @@ select.filter-input option { margin-left: 0; } } + +/* ============================================================ + FLEET DASHBOARD (#547) + ============================================================ */ + +/* Stat-value tints for fleet node states / alerts */ +.stat-online { color: var(--color-success); text-shadow: 0 0 15px var(--success-30); } +.stat-stale { color: var(--color-warning); text-shadow: 0 0 15px var(--primary-30); } +.stat-error { color: var(--color-error); text-shadow: 0 0 15px var(--error-30); } +.stat-alerts { color: var(--color-error); text-shadow: 0 0 15px var(--error-30); } + +/* Instance health cards — grid of vertical cards (overrides .instance-card's row flex) */ +.fleet-instance-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 10px; +} +.fleet-card { + flex-direction: column; + align-items: stretch; + gap: 8px; + padding: 12px 14px; + cursor: default; +} +.fleet-card.status-online { border-left-color: var(--color-success); } +.fleet-card.status-stale { border-left-color: var(--color-warning); } +.fleet-card.status-error { border-left-color: var(--color-error); } + +/* Status LEDs for fleet node states */ +.led-online { background: var(--color-success); box-shadow: 0 0 8px var(--success-30); } +.led-stale { background: var(--color-warning); box-shadow: 0 0 8px var(--primary-50); } +.led-error { background: var(--color-error); box-shadow: 0 0 8px var(--error-30); } + +.fleet-kind-badge { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); + border: 1px solid var(--bezel-edge); + border-radius: 3px; + padding: 1px 6px; +} +/* Stale-detection badge — distinct amber pill so stale nodes stand out at a glance */ +.fleet-stale-badge { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-warning); + background: var(--warning-15); + border: 1px solid var(--color-warning); + border-radius: 3px; + padding: 1px 6px; +} + +.fleet-card-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; +} +.fleet-meta { + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-primary-dim); +} +.fleet-meta-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-muted); +} + +/* Drone utilization sub-block */ +.fleet-drone-util { + border-top: 1px solid var(--bezel-edge); + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} +.fleet-util-row { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--color-primary-dim); +} +.fleet-util-row .progress-bar-sm { flex: 1; } +.fleet-util-stats { display: flex; gap: 14px; } + +/* Active-alert banner */ +.fleet-alert-banner { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + border-radius: 4px; + padding: 10px 14px; + margin-bottom: 16px; + border: 1px solid var(--color-warning); + border-left-width: 3px; + background: var(--warning-15); + color: var(--color-warning); +} +.fleet-alert-banner[data-severity="critical"] { + border-color: var(--color-error); + background: var(--error-15); + color: var(--color-error); +} +.fleet-alert-banner[data-severity="info"] { + border-color: var(--color-secondary); + background: var(--secondary-10); + color: var(--color-secondary); +} + +/* Alert rows — notification rail + history */ +.fleet-alert-list { display: flex; flex-direction: column; gap: 4px; } +.fleet-alert-row { + background: var(--bg-panel); + border-left: 3px solid var(--color-bezel); + border-radius: 0 4px 4px 0; + padding: 10px 12px; +} +.fleet-alert-row[data-severity="critical"] { border-left-color: var(--color-error); } +.fleet-alert-row[data-severity="warning"] { border-left-color: var(--color-warning); } +.fleet-alert-row[data-severity="info"] { border-left-color: var(--color-secondary); } +.fleet-alert-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} +.fleet-severity-badge { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + border-radius: 3px; + padding: 1px 6px; +} +.fleet-severity-badge.sev-critical { color: var(--color-error); background: var(--error-15); } +.fleet-severity-badge.sev-warning { color: var(--color-warning); background: var(--warning-15); } +.fleet-severity-badge.sev-info { color: var(--color-secondary); background: var(--secondary-10); } +.fleet-alert-instance { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--color-primary); +} +.fleet-alert-time { + margin-left: auto; + font-family: var(--font-mono); + font-size: 9px; + color: var(--color-muted); +} +.fleet-alert-msg { + font-family: var(--font-ui); + font-size: 12px; + color: var(--color-primary-dim); + line-height: 1.4; +} +.fleet-alert-resolved { + margin-top: 4px; + font-family: var(--font-mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-success); +} diff --git a/src/server-dotnet/src/Dotbot.Server/wwwroot/js/fleet.js b/src/server-dotnet/src/Dotbot.Server/wwwroot/js/fleet.js new file mode 100644 index 00000000..8aa1ec1f --- /dev/null +++ b/src/server-dotnet/src/Dotbot.Server/wwwroot/js/fleet.js @@ -0,0 +1,250 @@ +// ============================================================ +// DOTBOT FLEET DASHBOARD (#547) — Vanilla JS +// Hydrates the Fleet surface from /api/fleet/* (mock data now; live in #598). +// ============================================================ + +(function () { + 'use strict'; + + const _dtFormatter = new Intl.DateTimeFormat('en-US', { + weekday: 'short', month: 'short', day: 'numeric', + year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false + }); + const _timeFormatter = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', minute: '2-digit', hour12: false + }); + + let refreshTimer = null; + let appReadySignalled = false; + + // --- Init --- + document.addEventListener('DOMContentLoaded', () => { + fetchData(); + refreshTimer = setInterval(fetchData, 30000); + }); + + // --- Data fetching --- + async function fetchData() { + try { + const [instances, alerts] = await Promise.all([ + fetchJson('/api/fleet/instances'), + fetchJson('/api/fleet/alerts') + ]); + if (instances === null || alerts === null) return; // auth reload in flight + renderInstances(instances); + renderAlerts(alerts); + updateRefreshTime(); + signalAppReady(); + } catch (err) { + console.error('Failed to fetch fleet data:', err); + } + } + + async function fetchJson(url) { + const resp = await fetch(url); + if (!resp.ok) { + if (resp.status === 401 || resp.status === 403) { + window.location.reload(); + return null; + } + throw new Error(`HTTP ${resp.status} for ${url}`); + } + return resp.json(); + } + + // --- Instances --- + function renderInstances(instances) { + const list = instances || []; + + const counts = { + total: list.length, + online: list.filter(i => i.status === 'online').length, + stale: list.filter(i => i.status === 'stale').length, + error: list.filter(i => i.status === 'error').length, + drones: list.filter(i => i.kind === 'drone').length + }; + setText('stat-total', counts.total); + setText('stat-online', counts.online); + setText('stat-stale', counts.stale); + setText('stat-error', counts.error); + setText('stat-drones', counts.drones); + setText('fleet-instance-count', counts.total); + + const container = document.getElementById('fleet-instances-list'); + const empty = document.getElementById('fleet-instances-empty'); + container.innerHTML = list.map(renderInstanceCard).join(''); + empty.style.display = list.length === 0 ? '' : 'none'; + } + + function renderInstanceCard(inst) { + const status = inst.status || 'online'; + const staleBadge = status === 'stale' + ? `STALE` : ''; + const meta = [ + metaItem('status', status), + metaItem('uptime', formatUptime(inst.uptimeSeconds)), + metaItem('workflow', inst.activeWorkflow || '—'), + metaItem('tasks', inst.taskCount != null ? inst.taskCount : 0), + metaItem('last seen', timeAgo(inst.lastHeartbeatAt)) + ].join(''); + + return `
` + + `
` + + ` ${esc(inst.name)}` + + `${esc(inst.kind)}${staleBadge}
` + + `
${meta}
` + + renderDroneUtil(inst.droneUtilization) + + `
`; + } + + function renderDroneUtil(util) { + if (!util) return ''; + const max = util.maxConcurrent || 0; + const pct = max > 0 ? Math.round((util.load / max) * 100) : 0; + const success = Math.round((util.successRate || 0) * 100); + return `
` + + `
load ` + + `${util.load}/${max}` + + `
` + + `
` + + `success ${success}%` + + `avg ${formatDuration(util.avgDurationSeconds)}` + + `
`; + } + + function metaItem(label, value) { + return `${esc(label)} ${esc(String(value))}`; + } + + // --- Alerts --- + function renderAlerts(alerts) { + const all = alerts || []; + const active = all.filter(a => a.status === 'active'); + const cleared = all.filter(a => a.status === 'cleared'); + + setText('stat-alerts', active.length); + renderAlertBanner(active); + + renderAlertList('fleet-alerts-active', 'fleet-alerts-empty', 'fleet-alerts-count', active, false); + renderAlertList('fleet-alerts-history', 'fleet-history-empty', 'fleet-history-count', cleared, true); + } + + function renderAlertBanner(active) { + const banner = document.getElementById('fleet-alert-banner'); + if (active.length === 0) { + banner.style.display = 'none'; + banner.textContent = ''; + return; + } + const critical = active.filter(a => a.severity === 'critical').length; + const topSeverity = critical > 0 ? 'critical' + : (active.some(a => a.severity === 'warning') ? 'warning' : 'info'); + const detail = critical > 0 ? ` — ${critical} critical` : ''; + banner.dataset.severity = topSeverity; + banner.textContent = `${active.length} active alert${active.length === 1 ? '' : 's'}${detail}`; + banner.style.display = ''; + } + + function renderAlertList(listId, emptyId, countId, alerts, isHistory) { + setText(countId, alerts.length); + const container = document.getElementById(listId); + const empty = document.getElementById(emptyId); + container.innerHTML = alerts.map(a => renderAlertRow(a, isHistory)).join(''); + empty.style.display = alerts.length === 0 ? '' : 'none'; + } + + function renderAlertRow(alert, isHistory) { + const sev = alert.severity || 'info'; + const resolved = isHistory && alert.resolvedAt + ? `
resolved ${esc(formatDashboardDateTime(alert.resolvedAt))}
` + : ''; + return `
` + + `
` + + `${esc(sev)}` + + `${esc(alert.instanceId)}` + + `${esc(formatDashboardDateTime(alert.createdAt))}` + + `
` + + `
${esc(alert.message)}
` + + resolved + + `
`; + } + + // --- Readiness signal (mirrors the Outpost shell; enables deterministic tests) --- + function signalAppReady() { + if (appReadySignalled) return; + document.body.dataset.appReady = '1'; + appReadySignalled = true; + } + + // --- Helpers --- + function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; + } + + function formatUptime(seconds) { + if (!seconds || seconds <= 0) return '—'; + const mins = Math.floor(seconds / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ${mins % 60}m`; + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; + } + + function formatDuration(seconds) { + if (!seconds || seconds <= 0) return '—'; + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + return `${mins}m ${seconds % 60}s`; + } + + function timeAgo(dateStr) { + if (!dateStr) return '-'; + const now = Date.now(); + const then = new Date(dateStr).getTime(); + if (isNaN(then)) return '-'; + const secs = Math.floor((now - then) / 1000); + if (secs < 60) return 'just now'; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; + } + + function formatDashboardDateTime(date) { + try { + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return String(date); + const parts = _dtFormatter.formatToParts(d); + const get = (t) => (parts.find(p => p.type === t) || {}).value || ''; + return `${get('month')} ${get('day')} ${get('hour')}:${get('minute')}`; + } catch (e) { return String(date); } + } + + function formatDashboardTime(date) { + try { + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + const parts = _timeFormatter.formatToParts(d); + const get = (t) => (parts.find(p => p.type === t) || {}).value || ''; + return `${get('hour')}:${get('minute')}`; + } catch (e) { return ''; } + } + + function esc(str) { + if (str === null || str === undefined) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function updateRefreshTime() { + const el = document.getElementById('last-refresh'); + if (el) el.textContent = `Last refresh: ${formatDashboardTime(new Date())}`; + } +})(); diff --git a/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetApiTests.cs b/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetApiTests.cs new file mode 100644 index 00000000..e58d3af0 --- /dev/null +++ b/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetApiTests.cs @@ -0,0 +1,92 @@ +using Dotbot.Server.Models; +using Dotbot.Server.Services; +using Dotbot.Server.Tests.Integration.TestDoubles; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Net; +using System.Text.Json; + +namespace Dotbot.Server.Tests.Integration; + +// End-to-end coverage of the Fleet mock endpoints through the running app, which +// also validates that the shipped Mock/*.json fixtures deserialize correctly. +// WebApplicationFactory runs in the Development environment, so DevelopmentAuthMiddleware +// injects an authenticated synthetic user; the AlwaysAdministratorService lets it clear +// DashboardAuthMiddleware's admin gate (the base factory's default denies everyone). +public class FleetApiTests : IClassFixture +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + private readonly HttpClient _client; + + public FleetApiTests(DotbotApiFactory factory) + { + _client = factory + .WithWebHostBuilder(builder => builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + })) + .CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + } + + [Fact] + public async Task GetInstances_ReturnsMockFleet() + { + var resp = await _client.GetAsync("/api/fleet/instances"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var instances = await Deserialize>(resp); + Assert.Equal(6, instances.Count); + Assert.Contains(instances, i => i.InstanceId == "atlas-prod" && i.Kind == "outpost"); + Assert.Contains(instances, i => i.Kind == "drone" && i.DroneUtilization != null); + Assert.Contains(instances, i => i.Status == "stale"); + Assert.Contains(instances, i => i.Status == "error"); + } + + [Fact] + public async Task GetAlerts_Active_ReturnsOnlyActive() + { + var resp = await _client.GetAsync("/api/fleet/alerts?status=active"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var alerts = await Deserialize>(resp); + Assert.NotEmpty(alerts); + Assert.All(alerts, a => Assert.Equal("active", a.Status)); + } + + [Fact] + public async Task GetAlerts_Cleared_ReturnsHistoryWithResolvedAt() + { + var resp = await _client.GetAsync("/api/fleet/alerts?status=cleared"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var alerts = await Deserialize>(resp); + Assert.NotEmpty(alerts); + Assert.All(alerts, a => + { + Assert.Equal("cleared", a.Status); + Assert.NotNull(a.ResolvedAt); + }); + } + + [Fact] + public async Task GetAlerts_NoFilter_ReturnsActiveAndCleared() + { + var resp = await _client.GetAsync("/api/fleet/alerts"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + + var alerts = await Deserialize>(resp); + Assert.Contains(alerts, a => a.Status == "active"); + Assert.Contains(alerts, a => a.Status == "cleared"); + } + + private static async Task Deserialize(HttpResponseMessage resp) + { + var json = await resp.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(json, JsonOpts)!; + } +} diff --git a/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetPageTests.cs b/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetPageTests.cs new file mode 100644 index 00000000..4e8a53ca --- /dev/null +++ b/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/FleetPageTests.cs @@ -0,0 +1,58 @@ +using Dotbot.Server.Services; +using Dotbot.Server.Tests.Integration.TestDoubles; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using System.Net; + +namespace Dotbot.Server.Tests.Integration; + +// Drives the Razor rendering for the re-homed surfaces: Fleet is the landing (/), +// the Q&A dashboard moved to /decisions, and the shell rail marks the active item +// from ViewData["ActiveRail"]. Browser-only behaviour (fleet.js/CSS) is out of scope. +public class FleetPageTests : IClassFixture +{ + private readonly HttpClient _client; + + public FleetPageTests(DotbotApiFactory factory) + { + _client = factory + .WithWebHostBuilder(builder => builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(); + })) + .CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + [Fact] + public async Task Root_RendersFleetLanding_WithFleetRailActive() + { + var resp = await _client.GetAsync("/"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var html = await resp.Content.ReadAsStringAsync(); + + Assert.Contains("Fleet - Dotbot", html); + Assert.Contains("id=\"fleet-instances-list\"", html); + Assert.Contains("/js/fleet.js", html); + // Fleet rail item is the active destination. + Assert.Contains("href=\"/\" aria-current=\"page\"", html); + // Decisions rail item is present but not active. + Assert.Contains("href=\"/decisions\"", html); + Assert.DoesNotContain("href=\"/decisions\" aria-current=\"page\"", html); + } + + [Fact] + public async Task Decisions_RendersQaDashboard_WithDecisionsRailActive() + { + var resp = await _client.GetAsync("/decisions"); + Assert.Equal(HttpStatusCode.OK, resp.StatusCode); + var html = await resp.Content.ReadAsStringAsync(); + + Assert.Contains("id=\"instances-list\"", html); + Assert.Contains("/js/dashboard.js", html); + Assert.Contains("href=\"/decisions\" aria-current=\"page\"", html); + // Fleet rail item present but not active on this page. + Assert.DoesNotContain("href=\"/\" aria-current=\"page\"", html); + } +} diff --git a/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/TestDoubles/AlwaysAdministratorService.cs b/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/TestDoubles/AlwaysAdministratorService.cs new file mode 100644 index 00000000..edf5b789 --- /dev/null +++ b/src/server-dotnet/tests/Dotbot.Server.Tests/Integration/TestDoubles/AlwaysAdministratorService.cs @@ -0,0 +1,13 @@ +using Dotbot.Server.Services; + +namespace Dotbot.Server.Tests.Integration.TestDoubles; + +// Grants admin to every caller. Used by dashboard-route tests: WebApplicationFactory +// runs in the Development environment, so DevelopmentAuthMiddleware injects an +// authenticated synthetic user; this lets that user clear DashboardAuthMiddleware's +// admin gate (the default NullAdministratorService denies everyone). +internal sealed class AlwaysAdministratorService : IAdministratorService +{ + public Task SeedIfEmptyAsync() => Task.CompletedTask; + public Task IsAdministratorAsync(string email) => Task.FromResult(true); +} diff --git a/src/server-dotnet/tests/Dotbot.Server.Tests/Unit/MockFleetServiceTests.cs b/src/server-dotnet/tests/Dotbot.Server.Tests/Unit/MockFleetServiceTests.cs new file mode 100644 index 00000000..71b63512 --- /dev/null +++ b/src/server-dotnet/tests/Dotbot.Server.Tests/Unit/MockFleetServiceTests.cs @@ -0,0 +1,108 @@ +using Dotbot.Server.Services; + +namespace Dotbot.Server.Tests.Unit; + +// Exercises MockFleetService against controlled fixtures written to a temp dir +// (constructed via the internal path-based ctor). The real shipped fixtures are +// validated end-to-end by FleetApiTests through the running app. +public class MockFleetServiceTests : IDisposable +{ + private readonly string _dir; + + public MockFleetServiceTests() + { + _dir = Path.Combine(Path.GetTempPath(), "fleet-mock-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "fleet-instances.json"), InstancesJson); + File.WriteAllText(Path.Combine(_dir, "fleet-alerts.json"), AlertsJson); + } + + public void Dispose() => Directory.Delete(_dir, recursive: true); + + private MockFleetService Service() => new(_dir); + + [Fact] + public async Task GetInstances_ReturnsAll_WithDroneUtilizationOnlyForDrones() + { + var instances = await Service().GetInstancesAsync(); + Assert.Equal(3, instances.Count); + + var outpost = instances.Single(i => i.InstanceId == "op-online"); + Assert.Equal("outpost", outpost.Kind); + Assert.Equal("online", outpost.Status); + Assert.Null(outpost.DroneUtilization); + + Assert.Equal("stale", instances.Single(i => i.InstanceId == "op-stale").Status); + + var drone = instances.Single(i => i.InstanceId == "dr-online"); + Assert.Equal("drone", drone.Kind); + Assert.Equal(3, drone.TaskCount); + Assert.NotNull(drone.DroneUtilization); + Assert.Equal(2, drone.DroneUtilization!.Load); + Assert.Equal(4, drone.DroneUtilization.MaxConcurrent); + Assert.Equal(0.96, drone.DroneUtilization.SuccessRate, 3); + Assert.Equal(312, drone.DroneUtilization.AvgDurationSeconds); + } + + [Fact] + public async Task GetAlerts_NoFilter_ReturnsActiveAndCleared() + { + var all = await Service().GetAlertsAsync(); + Assert.Equal(3, all.Count); + } + + [Fact] + public async Task GetAlerts_Active_ReturnsOnlyActive() + { + var active = await Service().GetAlertsAsync("active"); + Assert.Equal(2, active.Count); + Assert.All(active, a => Assert.Equal("active", a.Status)); + Assert.All(active, a => Assert.Null(a.ResolvedAt)); + } + + [Fact] + public async Task GetAlerts_Cleared_ReturnsOnlyClearedWithResolvedAt() + { + var cleared = await Service().GetAlertsAsync("cleared"); + var alert = Assert.Single(cleared); + Assert.Equal("cleared", alert.Status); + Assert.NotNull(alert.ResolvedAt); + } + + [Fact] + public async Task GetAlerts_IsCaseInsensitive() + { + var active = await Service().GetAlertsAsync("ACTIVE"); + Assert.Equal(2, active.Count); + } + + [Fact] + public async Task GetAlerts_UnknownStatus_ReturnsEmpty() + { + Assert.Empty(await Service().GetAlertsAsync("bogus")); + } + + [Fact] + public void Constructor_MissingFixture_ThrowsFileNotFound() + { + var emptyDir = Path.Combine(_dir, "empty"); + Directory.CreateDirectory(emptyDir); + Assert.Throws(() => new MockFleetService(emptyDir)); + } + + private const string InstancesJson = """ + [ + { "instanceId": "op-online", "name": "op-online", "kind": "outpost", "status": "online", "orgId": "default", "uptimeSeconds": 1000, "lastHeartbeatAt": "2026-07-13T18:00:00Z", "activeWorkflow": "wf", "taskCount": 1, "version": "4.1.0", "droneUtilization": null }, + { "instanceId": "op-stale", "name": "op-stale", "kind": "outpost", "status": "stale", "orgId": "default", "uptimeSeconds": 500, "lastHeartbeatAt": "2026-07-13T17:00:00Z", "activeWorkflow": null, "taskCount": 0, "version": "4.1.0", "droneUtilization": null }, + { "instanceId": "dr-online", "name": "dr-online", "kind": "drone", "status": "online", "orgId": "default", "uptimeSeconds": 2000, "lastHeartbeatAt": "2026-07-13T18:00:00Z", "activeWorkflow": "wf", "taskCount": 3, "version": "4.1.0", "droneUtilization": { "load": 2, "maxConcurrent": 4, "successRate": 0.96, "avgDurationSeconds": 312 } } + ] + """; + + private const string AlertsJson = """ + [ + { "id": "a1", "severity": "critical", "instanceId": "op-stale", "message": "m", "createdAt": "2026-07-13T18:00:00Z", "status": "active", "resolvedAt": null }, + { "id": "a2", "severity": "warning", "instanceId": "dr-online", "message": "m", "createdAt": "2026-07-13T18:01:00Z", "status": "active", "resolvedAt": null }, + { "id": "a3", "severity": "info", "instanceId": "op-online", "message": "m", "createdAt": "2026-07-13T10:00:00Z", "status": "cleared", "resolvedAt": "2026-07-13T10:05:00Z" } + ] + """; +}