Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
namespace Dotbot.Server;

/// <summary>
/// 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.
/// </summary>
Expand All @@ -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)
{
Expand Down
9 changes: 9 additions & 0 deletions src/server-dotnet/src/Dotbot.Server/Dotbot.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,13 @@
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

<!-- Fleet dashboard mock fixtures (#547). Read at runtime by MockFleetService from
ContentRootPath/Mock; copied to output/publish so they ship with the app.
Update (not Include) because the Web SDK already globs **/*.json as Content. -->
<ItemGroup>
<Content Update="Mock\*.json"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

</Project>
56 changes: 56 additions & 0 deletions src/server-dotnet/src/Dotbot.Server/Mock/fleet-alerts.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
95 changes: 95 additions & 0 deletions src/server-dotnet/src/Dotbot.Server/Mock/fleet-instances.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
25 changes: 25 additions & 0 deletions src/server-dotnet/src/Dotbot.Server/Models/FleetAlertDto.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
41 changes: 41 additions & 0 deletions src/server-dotnet/src/Dotbot.Server/Models/FleetInstanceDto.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
124 changes: 124 additions & 0 deletions src/server-dotnet/src/Dotbot.Server/Pages/Decisions.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
@page
@model Dotbot.Server.Pages.DecisionsModel
@{
Layout = "_LayoutDashboard";
ViewData["Title"] = "Decisions";
ViewData["UserName"] = Model.UserName;
ViewData["ActiveRail"] = "decisions";
}

<!-- Tab Bar (in-page filters; become filter chips in #606) -->
<div class="tab-bar-container">
<button class="tab active" data-tab="overview">Overview</button>
<button class="tab" data-tab="by-person">By Person</button>
<button class="tab" data-tab="by-project">By Project</button>
</div>

<!-- Main Content -->
<div class="main-content">
<!-- Overview Tab -->
<div class="tab-pane active" id="tab-overview">
<div class="stats-row">
<div class="stat-card">
<div class="stat-value" id="stat-total">0</div>
<div class="stat-label">Total</div>
</div>
<div class="stat-card">
<div class="stat-value stat-pending" id="stat-pending">0</div>
<div class="stat-label">Pending</div>
</div>
<div class="stat-card">
<div class="stat-value stat-sent" id="stat-sent">0</div>
<div class="stat-label">Sent</div>
</div>
<div class="stat-card">
<div class="stat-value stat-reminded" id="stat-reminded">0</div>
<div class="stat-label">Reminded</div>
</div>
<div class="stat-card">
<div class="stat-value stat-escalated" id="stat-escalated">0</div>
<div class="stat-label">Escalated</div>
</div>
<div class="stat-card">
<div class="stat-value stat-responded" id="stat-responded">0</div>
<div class="stat-label">Responded</div>
</div>
</div>

<div class="filter-row">
<input type="text" class="filter-input" id="filter-search" placeholder="Search..." />
<select class="filter-input" id="filter-status">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="scheduled">Scheduled</option>
<option value="sent">Sent</option>
<option value="reminded">Reminded</option>
<option value="escalated">Escalated</option>
<option value="responded">Responded</option>
<option value="failed">Failed</option>
</select>
<select class="filter-input" id="filter-project">
<option value="">All Projects</option>
</select>
<select class="filter-input" id="filter-channel">
<option value="">All Channels</option>
<option value="teams">Teams</option>
<option value="email">Email</option>
<option value="jira">Jira</option>
</select>
</div>

<div class="module">
<div class="module-header">
<span>INSTANCES <span class="instance-count-badge" id="instance-count">0</span></span>
</div>
<div class="sort-bar">
<button class="sort-btn active" data-sort="createdAt">Created <span class="sort-arrow">&#9660;</span></button>
<button class="sort-btn" data-sort="questionTitle">Question</button>
<button class="sort-btn" data-sort="projectName">Project</button>
<button class="sort-btn" data-sort="overallStatus">Status</button>
<button class="sort-btn" data-sort="respondedCount">Progress</button>
</div>
<div class="module-screen">
<div class="instance-list" id="instances-list"></div>
<div class="empty-state" id="empty-overview" style="display:none;">
No instances found.
</div>
</div>
</div>
</div>

<!-- By Person Tab -->
<div class="tab-pane" id="tab-by-person">
<div class="split-view">
<div class="list-panel">
<div class="list-panel-header">
<input type="text" class="filter-input" id="person-search" placeholder="Search people..." />
</div>
<div class="list-items" id="person-list"></div>
</div>
<div class="detail-panel" id="person-detail">
<div class="empty-state">Select a person to view their questions.</div>
</div>
</div>
</div>

<!-- By Project Tab -->
<div class="tab-pane" id="tab-by-project">
<div class="split-view">
<div class="list-panel">
<div class="list-panel-header">
<input type="text" class="filter-input" id="project-search" placeholder="Search projects..." />
</div>
<div class="list-items" id="project-list"></div>
</div>
<div class="detail-panel" id="project-detail">
<div class="empty-state">Select a project to view its instances.</div>
</div>
</div>
</div>
</div>

@section Scripts {
<script src="/js/dashboard.js"></script>
}
Loading
Loading