Files
gastown/internal/web/templates/convoy.html
Clay Cantrell aca753296b feat(web): comprehensive dashboard control panel with 13 data panels (#931)
* feat(dashboard): comprehensive control panel with expand/collapse

- Add 13 panels: Convoys, Polecats, Sessions, Activity, Mail, Merge Queue,
  Escalations, Rigs, Dogs, System Health, Open Issues, Hooks, Queues
- Add Mayor status banner and Summary/Alerts section
- Implement instant client-side expand/collapse (no page reload)
- Add responsive grid layout for different window sizes
- Parallel data fetching for faster load times
- Color-coded mail by sender, chronological ordering
- Full titles visible in expanded views (no truncation)
- Auto-refresh every 10 seconds via HTMX

* fix(web): update tests and lint for dashboard control panel

- Update MockConvoyFetcher with 11 new interface methods
- Update MockConvoyFetcherWithErrors with matching methods
- Update test assertions for new template structure:
  - Section headers ("Gas Town Convoys" -> "Convoys")
  - Work status badges (badge-green, badge-yellow, badge-red)
  - CI/merge status display text
  - Empty state messages ("No active convoys")
- Fix linting: explicit _, _ = for fmt.Sscanf returns

Tests and linting now pass with the new dashboard features.

* perf(web): add timeouts and error logging to dashboard

Performance and reliability improvements:

- Add 8-second overall fetch timeout to prevent stuck requests
- Add per-command timeouts: 5s for bd/sqlite3, 10s for gh, 2s for tmux
- Add helper functions runCmd() and runBdCmd() with context timeout
- Add error logging for all 14 fetch operations
- Handler now returns partial data if timeout occurs

This addresses slow loading and "stuck" dashboard issues by ensuring
commands cannot hang indefinitely.
2026-01-25 18:00:46 -08:00

1611 lines
54 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gas Town Dashboard</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
:root {
--bg-dark: #0f1419;
--bg-card: #1a1f26;
--bg-card-hover: #242b33;
--text-primary: #e6e1cf;
--text-secondary: #6c7680;
--text-muted: #4a5159;
--border: #2d363f;
--border-accent: #3d4752;
--green: #c2d94c;
--yellow: #ffb454;
--red: #f07178;
--blue: #59c2ff;
--purple: #d2a6ff;
--cyan: #95e6cb;
--orange: #ff8f40;
--pink: #ff79c6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
background: var(--bg-dark);
color: var(--text-primary);
padding: 16px;
min-height: 100vh;
font-size: 13px;
line-height: 1.5;
}
.dashboard {
width: 100%;
max-width: 100%;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
h1 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.refresh-info {
color: var(--text-muted);
font-size: 0.75rem;
}
/* Grid layout for panels - auto-fit responsive */
.panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 16px;
margin-bottom: 16px;
width: 100%;
}
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.panel-full {
grid-column: 1 / -1;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: var(--bg-dark);
border-bottom: 1px solid var(--border);
}
.panel-header h2 {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
}
.panel-header .count {
font-size: 0.75rem;
color: var(--text-muted);
background: var(--border);
padding: 2px 8px;
border-radius: 10px;
}
.panel-header .count-alert {
background: var(--red);
color: var(--bg-dark);
}
.panel-body {
max-height: 280px;
overflow-y: auto;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 6px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
font-weight: 500;
color: var(--text-muted);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--bg-card);
position: sticky;
top: 0;
}
tr:hover {
background: var(--bg-card-hover);
}
/* Status badges */
.badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-green { background: var(--green); color: var(--bg-dark); }
.badge-yellow { background: var(--yellow); color: var(--bg-dark); }
.badge-red { background: var(--red); color: var(--bg-dark); }
.badge-blue { background: var(--blue); color: var(--bg-dark); }
.badge-purple { background: var(--purple); color: var(--bg-dark); }
.badge-cyan { background: var(--cyan); color: var(--bg-dark); }
.badge-orange { background: var(--orange); color: var(--bg-dark); }
.badge-muted { background: var(--border-accent); color: var(--text-secondary); }
/* Activity dots */
.activity-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.activity-green .activity-dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.activity-yellow .activity-dot { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
.activity-red .activity-dot { background: var(--red); box-shadow: 0 0 6px var(--red); }
.activity-unknown .activity-dot { background: var(--text-muted); }
/* Convoy-specific styles */
.convoy-row {
cursor: pointer;
}
.convoy-id {
font-weight: 600;
color: var(--blue);
}
.convoy-title {
color: var(--text-secondary);
margin-left: 8px;
}
.progress-bar {
width: 60px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
.progress-fill {
height: 100%;
background: var(--green);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Expandable issues */
.tracked-issues {
background: var(--bg-dark);
border-top: 1px solid var(--border);
}
.tracked-issue {
display: flex;
align-items: center;
padding: 6px 12px 6px 32px;
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
}
.tracked-issue:last-child {
border-bottom: none;
}
.issue-status-icon {
width: 16px;
margin-right: 8px;
text-align: center;
}
.issue-status-icon.open { color: var(--text-muted); }
.issue-status-icon.in_progress { color: var(--yellow); }
.issue-status-icon.closed { color: var(--green); }
.issue-id {
color: var(--text-secondary);
margin-right: 8px;
font-size: 0.75rem;
}
.issue-title {
flex: 1;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.issue-assignee {
color: var(--purple);
font-size: 0.75rem;
margin-left: 8px;
}
/* Polecat styles */
.polecat-name {
font-weight: 600;
color: var(--cyan);
}
.polecat-rig {
color: var(--text-muted);
}
.polecat-work {
color: var(--text-secondary);
font-size: 0.8rem;
}
.status-hint {
color: var(--text-muted);
font-size: 0.75rem;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Polecat issue styles */
.polecat-issue {
font-size: 0.8rem;
max-width: 180px;
}
.polecat-issue .issue-id {
color: var(--blue);
font-weight: 600;
margin-right: 4px;
}
.polecat-issue .issue-title {
color: var(--text-secondary);
}
.polecat-issue .no-issue {
color: var(--text-muted);
}
/* Polecat status row highlights */
tr.polecat-working { }
tr.polecat-stale { background: rgba(255, 180, 84, 0.05); }
tr.polecat-stuck { background: rgba(240, 113, 120, 0.08); }
tr.polecat-idle { opacity: 0.7; }
/* Mail styles */
.mail-unread {
background: var(--bg-card-hover);
}
.mail-from, .mail-to {
font-size: 0.8rem;
}
.mail-to {
color: var(--text-secondary);
}
/* Sender color classes - consistent color per sender */
.sender-cyan .mail-from { color: var(--cyan); }
.sender-purple .mail-from { color: var(--purple); }
.sender-green .mail-from { color: var(--green); }
.sender-yellow .mail-from { color: var(--yellow); }
.sender-orange .mail-from { color: var(--orange); }
.sender-blue .mail-from { color: var(--blue); }
.sender-red .mail-from { color: var(--red); }
.sender-pink .mail-from { color: var(--pink); }
.sender-default .mail-from { color: var(--text-secondary); }
/* Sender color stripe on left */
.sender-cyan { border-left: 3px solid var(--cyan); }
.sender-purple { border-left: 3px solid var(--purple); }
.sender-green { border-left: 3px solid var(--green); }
.sender-yellow { border-left: 3px solid var(--yellow); }
.sender-orange { border-left: 3px solid var(--orange); }
.sender-blue { border-left: 3px solid var(--blue); }
.sender-red { border-left: 3px solid var(--red); }
.sender-pink { border-left: 3px solid var(--pink); }
.sender-default { border-left: 3px solid var(--border); }
.mail-subject {
color: var(--text-primary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mail-time {
color: var(--text-muted);
font-size: 0.75rem;
}
.priority-urgent { color: var(--red); font-weight: bold; }
.priority-high { color: var(--orange); }
.priority-normal { color: var(--text-secondary); }
.priority-low { color: var(--text-muted); }
/* PR styles */
.pr-link {
color: var(--blue);
text-decoration: none;
}
.pr-link:hover {
text-decoration: underline;
}
.pr-title {
color: var(--text-secondary);
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mq-green { background: rgba(194, 217, 76, 0.08); }
.mq-yellow { background: rgba(255, 180, 84, 0.08); }
.mq-red { background: rgba(240, 113, 120, 0.08); }
/* Severity styles */
.severity-critical { color: var(--red); font-weight: bold; }
.severity-high { color: var(--orange); }
.severity-medium { color: var(--yellow); }
.severity-low { color: var(--text-muted); }
/* Dog state styles */
.dog-idle { color: var(--green); }
.dog-working { color: var(--yellow); }
/* Session role styles */
.role-deacon { color: var(--purple); }
.role-witness { color: var(--cyan); }
.role-refinery { color: var(--orange); }
.role-polecat { color: var(--green); }
.role-crew { color: var(--blue); }
/* Health status */
.health-good { color: var(--green); }
.health-warning { color: var(--yellow); }
.health-bad { color: var(--red); }
/* Health panel specific */
.health-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px;
}
.health-item {
padding: 8px 12px;
background: var(--bg-dark);
border-radius: 4px;
}
.health-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: 4px;
}
.health-value {
font-size: 1rem;
font-weight: 600;
}
.health-value.good { color: var(--green); }
.health-value.warning { color: var(--yellow); }
.health-value.bad { color: var(--red); }
/* Rig styles */
.rig-name {
font-weight: 600;
color: var(--blue);
}
.rig-url {
color: var(--text-muted);
font-size: 0.75rem;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-icons {
display: flex;
gap: 4px;
}
.agent-icon {
font-size: 0.8rem;
opacity: 0.3;
}
.agent-icon.active {
opacity: 1;
}
/* Hook styles */
.hook-id {
font-weight: 600;
color: var(--purple);
}
.hook-title {
color: var(--text-secondary);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hook-agent {
color: var(--cyan);
font-size: 0.8rem;
}
tr.hook-stale {
background: rgba(255, 180, 84, 0.08);
}
tr.hook-stale .hook-id {
color: var(--orange);
}
/* Issue styles */
.issue-id {
font-weight: 600;
color: var(--blue);
}
.issue-title {
color: var(--text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.issue-type {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: lowercase;
}
tr.priority-1 {
background: rgba(240, 113, 120, 0.08);
}
tr.priority-2 {
background: rgba(255, 180, 84, 0.05);
}
/* Activity feed styles */
.activity-feed {
padding: 8px !important;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
}
.feed-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
font-size: 0.8rem;
}
.feed-icon {
font-size: 0.9rem;
flex-shrink: 0;
}
.feed-summary {
flex: 1;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.feed-time {
color: var(--text-muted);
font-size: 0.7rem;
flex-shrink: 0;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 24px;
color: var(--text-muted);
}
.empty-state p {
font-size: 0.8rem;
}
/* htmx loading indicator */
.htmx-request .htmx-indicator {
opacity: 1;
}
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--border-accent);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Mayor status banner */
.mayor-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
margin-bottom: 16px;
background: var(--bg-elevated);
border-radius: 8px;
border: 1px solid var(--border);
}
.mayor-banner.attached {
border-color: var(--green);
background: rgba(166, 209, 137, 0.08);
}
.mayor-banner.detached {
border-color: var(--text-muted);
opacity: 0.7;
}
.mayor-info {
display: flex;
align-items: center;
gap: 12px;
}
.mayor-icon {
font-size: 1.5rem;
}
.mayor-title {
font-weight: 600;
color: var(--text-primary);
}
.mayor-status {
display: flex;
align-items: center;
gap: 16px;
}
.mayor-stat {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.mayor-stat-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
}
.mayor-stat-value {
font-size: 0.85rem;
font-weight: 500;
}
.mayor-stat-value.active {
color: var(--green);
}
.mayor-stat-value.idle {
color: var(--yellow);
}
/* Summary & Alerts Banner */
.summary-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
margin-bottom: 16px;
background: var(--bg-elevated);
border-radius: 8px;
border: 1px solid var(--border);
gap: 20px;
}
.summary-stats {
display: flex;
gap: 24px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 1.4rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.stat-label {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 4px;
}
.summary-alerts {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.alert-item {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.alert-red {
background: rgba(240, 113, 120, 0.2);
color: var(--red);
}
.alert-orange {
background: rgba(255, 180, 84, 0.2);
color: var(--orange);
}
.alert-yellow {
background: rgba(255, 214, 102, 0.2);
color: var(--yellow);
}
.alert-green {
background: rgba(166, 209, 137, 0.15);
color: var(--green);
}
/* Responsive - adapt to different screen sizes */
/* Medium screens */
@media (max-width: 900px) {
.summary-stats {
gap: 16px;
}
.stat-value {
font-size: 1.2rem;
}
.panel-body {
padding: 10px;
}
table {
font-size: 0.75rem;
}
th, td {
padding: 6px 8px;
}
}
/* Expand mode (client-side JS) */
.panel-header {
display: flex;
align-items: center;
}
.expand-btn {
margin-left: auto;
font-size: 0.7rem;
color: var(--text-muted);
cursor: pointer;
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: none;
}
.expand-btn:hover {
color: var(--text-secondary);
border-color: var(--text-muted);
}
.panel.expanded {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
margin: 0;
border-radius: 0;
overflow: auto;
background: #0f1419; /* Solid opaque background */
}
.panel.expanded .panel-body {
max-height: calc(100vh - 50px);
overflow: auto;
}
.panel.expanded table {
font-size: 0.95rem;
}
.panel.expanded .expand-btn {
color: var(--red);
border-color: var(--red);
}
/* Don't truncate text when expanded */
.panel.expanded .pr-title,
.panel.expanded .convoy-id,
.panel.expanded .polecat-issue,
.panel.expanded .issue-title,
.panel.expanded .hook-title,
.panel.expanded .mail-subject,
.panel.expanded .feed-summary,
.panel.expanded td {
max-width: none;
white-space: normal;
overflow: visible;
text-overflow: unset;
}
/* Small screens (< 600px) */
@media (max-width: 600px) {
body {
padding: 8px;
}
.panels {
grid-template-columns: 1fr;
}
header {
flex-direction: column;
gap: 8px;
text-align: center;
}
header h1 {
font-size: 1.2rem;
}
.mayor-banner {
flex-direction: column;
gap: 12px;
text-align: center;
}
.mayor-status {
justify-content: center;
}
.summary-banner {
flex-direction: column;
gap: 12px;
}
.summary-stats {
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.summary-alerts {
justify-content: center;
}
.stat-value {
font-size: 1.1rem;
}
.stat-label {
font-size: 0.65rem;
}
.alert-item {
font-size: 0.7rem;
padding: 3px 8px;
}
}
</style>
</head>
<body>
<div class="dashboard" hx-get="/" hx-trigger="every 10s" hx-swap="outerHTML">
<header>
<h1>🚚 Gas Town Control Center</h1>
<span class="refresh-info">
Auto-refresh: 10s
<span class="htmx-indicator"></span>
</span>
</header>
<!-- Mayor Status Banner -->
<div class="mayor-banner {{if .Mayor}}{{if .Mayor.IsAttached}}attached{{else}}detached{{end}}{{else}}detached{{end}}">
<div class="mayor-info">
<span class="mayor-icon">🎩</span>
<span class="mayor-title">The Mayor</span>
{{if .Mayor}}
{{if .Mayor.IsAttached}}
<span class="badge badge-green">Attached</span>
{{else}}
<span class="badge badge-muted">Detached</span>
{{end}}
{{else}}
<span class="badge badge-muted">Unknown</span>
{{end}}
</div>
{{if .Mayor}}{{if .Mayor.IsAttached}}
<div class="mayor-status">
<div class="mayor-stat">
<span class="mayor-stat-label">Activity</span>
<span class="mayor-stat-value {{if .Mayor.IsActive}}active{{else}}idle{{end}}">
{{.Mayor.LastActivity}}
</span>
</div>
<div class="mayor-stat">
<span class="mayor-stat-label">Runtime</span>
<span class="mayor-stat-value">{{.Mayor.Runtime}}</span>
</div>
</div>
{{end}}{{end}}
</div>
<!-- Summary & Alerts Banner -->
{{if .Summary}}
<div class="summary-banner">
<div class="summary-stats">
<div class="stat">
<span class="stat-value">{{.Summary.PolecatCount}}</span>
<span class="stat-label">🦨 Polecats</span>
</div>
<div class="stat">
<span class="stat-value">{{.Summary.HookCount}}</span>
<span class="stat-label">🪝 Hooks</span>
</div>
<div class="stat">
<span class="stat-value">{{.Summary.IssueCount}}</span>
<span class="stat-label">📿 Issues</span>
</div>
<div class="stat">
<span class="stat-value">{{.Summary.ConvoyCount}}</span>
<span class="stat-label">🚚 Convoys</span>
</div>
<div class="stat">
<span class="stat-value">{{.Summary.EscalationCount}}</span>
<span class="stat-label">⚠️ Escalations</span>
</div>
</div>
{{if .Summary.HasAlerts}}
<div class="summary-alerts">
{{if .Summary.StuckPolecats}}
<span class="alert-item alert-red">💀 {{.Summary.StuckPolecats}} stuck</span>
{{end}}
{{if .Summary.StaleHooks}}
<span class="alert-item alert-yellow">⏰ {{.Summary.StaleHooks}} stale hooks</span>
{{end}}
{{if .Summary.UnackedEscalations}}
<span class="alert-item alert-orange">🔔 {{.Summary.UnackedEscalations}} unacked</span>
{{end}}
{{if .Summary.HighPriorityIssues}}
<span class="alert-item alert-red">🔥 {{.Summary.HighPriorityIssues}} P1/P2</span>
{{end}}
{{if .Summary.DeadSessions}}
<span class="alert-item alert-red">☠️ {{.Summary.DeadSessions}} dead</span>
{{end}}
</div>
{{else}}
<div class="summary-alerts">
<span class="alert-item alert-green">✓ All clear</span>
</div>
{{end}}
</div>
{{end}}
<div class="panels">
<!-- Row 1: Convoys, Polecats, Sessions -->
<!-- Convoys Panel -->
<div class="panel">
<div class="panel-header">
<h2>🚚 Convoys</h2>
<span class="count">{{len .Convoys}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Convoys}}
<table>
<thead>
<tr>
<th>Status</th>
<th>Convoy</th>
<th>Progress</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
{{range .Convoys}}
<tr class="convoy-row">
<td>
{{if eq .WorkStatus "complete"}}
<span class="badge badge-green"></span>
{{else if eq .WorkStatus "active"}}
<span class="badge badge-green">Active</span>
{{else if eq .WorkStatus "stale"}}
<span class="badge badge-yellow">Stale</span>
{{else if eq .WorkStatus "stuck"}}
<span class="badge badge-red">Stuck</span>
{{else}}
<span class="badge badge-muted">Wait</span>
{{end}}
</td>
<td>
<span class="convoy-id">{{.ID}}</span>
</td>
<td>
{{.Progress}}
{{if .Total}}
<div class="progress-bar">
<div class="progress-fill" style="width: {{progressPercent .Completed .Total}}%;"></div>
</div>
{{end}}
</td>
<td class="{{activityClass .LastActivity}}">
<span class="activity-dot"></span>
{{.LastActivity.FormattedAge}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No active convoys</p>
</div>
{{end}}
</div>
</div>
<!-- Polecats Panel -->
<div class="panel">
<div class="panel-header">
<h2>🐾 Polecats</h2>
<span class="count">{{len .Polecats}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Polecats}}
<table>
<thead>
<tr>
<th>Worker</th>
<th>Rig</th>
<th>Working On</th>
<th>Status</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
{{range .Polecats}}
<tr class="{{polecatStatusClass .WorkStatus}}">
<td><span class="polecat-name">{{.Name}}</span></td>
<td><span class="polecat-rig">{{.Rig}}</span></td>
<td class="polecat-issue">
{{if .IssueID}}
<span class="issue-id">{{.IssueID}}</span>
<span class="issue-title">{{.IssueTitle}}</span>
{{else}}
<span class="no-issue"></span>
{{end}}
</td>
<td>
{{if eq .WorkStatus "working"}}
<span class="badge badge-green">Working</span>
{{else if eq .WorkStatus "stale"}}
<span class="badge badge-yellow">Stale</span>
{{else if eq .WorkStatus "stuck"}}
<span class="badge badge-red">Stuck</span>
{{else}}
<span class="badge badge-muted">Idle</span>
{{end}}
</td>
<td class="{{activityClass .LastActivity}}">
<span class="activity-dot"></span>
{{.LastActivity.FormattedAge}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No active workers</p>
</div>
{{end}}
</div>
</div>
<!-- Sessions Panel -->
<div class="panel">
<div class="panel-header">
<h2>📟 Sessions</h2>
<span class="count">{{len .Sessions}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Sessions}}
<table>
<thead>
<tr>
<th>Role</th>
<th>Rig</th>
<th>Worker</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
{{range .Sessions}}
<tr>
<td>
<span class="role-{{.Role}}">{{.Role}}</span>
</td>
<td>{{.Rig}}</td>
<td>{{.Worker}}</td>
<td>{{.Activity}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No active sessions</p>
</div>
{{end}}
</div>
</div>
<!-- Activity Feed Panel -->
<div class="panel">
<div class="panel-header">
<h2>📜 Activity</h2>
<span class="count">{{len .Activity}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body activity-feed">
{{if .Activity}}
<div class="feed-list">
{{range .Activity}}
<div class="feed-item">
<span class="feed-icon">{{.Icon}}</span>
<span class="feed-summary">{{.Summary}}</span>
<span class="feed-time">{{.Time}}</span>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state">
<p>No recent activity</p>
</div>
{{end}}
</div>
</div>
<!-- Row 2: Mail, Merge Queue, Escalations -->
<!-- Mail Panel -->
<div class="panel">
<div class="panel-header">
<h2>✉️ Mail</h2>
<span class="count">{{len .Mail}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Mail}}
<table>
<thead>
<tr>
<th>From</th>
<th>To</th>
<th>Subject</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{{range .Mail}}
<tr class="{{senderColorClass .FromRaw}}{{if not .Read}} mail-unread{{end}}">
<td class="mail-from">{{.From}}</td>
<td class="mail-to">{{.To}}</td>
<td>
{{if eq .Priority "urgent"}}<span class="priority-urgent"></span>{{end}}
{{if eq .Priority "high"}}<span class="priority-high">!</span>{{end}}
<span class="mail-subject">{{.Subject}}</span>
</td>
<td class="mail-time">{{.Age}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No recent mail</p>
</div>
{{end}}
</div>
</div>
<!-- Merge Queue Panel -->
<div class="panel">
<div class="panel-header">
<h2>🔀 Merge Queue</h2>
<span class="count">{{len .MergeQueue}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .MergeQueue}}
<table>
<thead>
<tr>
<th>PR</th>
<th>Repo</th>
<th>Title</th>
<th>CI</th>
<th>Merge</th>
</tr>
</thead>
<tbody>
{{range .MergeQueue}}
<tr class="{{.ColorClass}}">
<td><a href="{{.URL}}" target="_blank" class="pr-link">#{{.Number}}</a></td>
<td>{{.Repo}}</td>
<td class="pr-title">{{.Title}}</td>
<td>
{{if eq .CIStatus "pass"}}<span class="badge badge-green">CI Pass</span>
{{else if eq .CIStatus "fail"}}<span class="badge badge-red">CI Fail</span>
{{else}}<span class="badge badge-yellow">CI Running</span>{{end}}
</td>
<td>
{{if eq .Mergeable "ready"}}<span class="badge badge-green">Ready</span>
{{else if eq .Mergeable "conflict"}}<span class="badge badge-red">Conflict</span>
{{else}}<span class="badge badge-muted">Pending</span>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No PRs in queue</p>
</div>
{{end}}
</div>
</div>
<!-- Escalations Panel -->
<div class="panel">
<div class="panel-header">
<h2>🚨 Escalations</h2>
<span class="count{{if .Escalations}} count-alert{{end}}">{{len .Escalations}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Escalations}}
<table>
<thead>
<tr>
<th>Severity</th>
<th>Issue</th>
<th>From</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{{range .Escalations}}
<tr>
<td>
{{if eq .Severity "critical"}}<span class="badge badge-red">CRIT</span>
{{else if eq .Severity "high"}}<span class="badge badge-orange">HIGH</span>
{{else if eq .Severity "medium"}}<span class="badge badge-yellow">MED</span>
{{else}}<span class="badge badge-muted">LOW</span>{{end}}
</td>
<td>
<span class="severity-{{.Severity}}">{{.Title}}</span>
{{if .Acked}}<span class="badge badge-cyan" style="margin-left: 4px;">ACK</span>{{end}}
</td>
<td>{{.EscalatedBy}}</td>
<td>{{.Age}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No escalations</p>
</div>
{{end}}
</div>
</div>
<!-- Row 3: Rigs, Dogs, Health -->
<!-- Rigs Panel -->
<div class="panel">
<div class="panel-header">
<h2>🏗️ Rigs</h2>
<span class="count">{{len .Rigs}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Rigs}}
<table>
<thead>
<tr>
<th>Name</th>
<th>Polecats</th>
<th>Crew</th>
<th>Agents</th>
</tr>
</thead>
<tbody>
{{range .Rigs}}
<tr>
<td><span class="rig-name">{{.Name}}</span></td>
<td>{{.PolecatCount}}</td>
<td>{{.CrewCount}}</td>
<td class="agent-icons">
<span class="agent-icon{{if .HasWitness}} active{{end}}" title="Witness">👁</span>
<span class="agent-icon{{if .HasRefinery}} active{{end}}" title="Refinery">⚗️</span>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No rigs configured</p>
</div>
{{end}}
</div>
</div>
<!-- Dogs Panel -->
<div class="panel">
<div class="panel-header">
<h2>🐕 Dogs</h2>
<span class="count">{{len .Dogs}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Dogs}}
<table>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Work</th>
<th>Activity</th>
</tr>
</thead>
<tbody>
{{range .Dogs}}
<tr>
<td><span class="polecat-name">{{.Name}}</span></td>
<td>
{{if eq .State "idle"}}<span class="badge badge-green">Idle</span>
{{else}}<span class="badge badge-yellow">Working</span>{{end}}
</td>
<td class="status-hint">{{.Work}}</td>
<td>{{.LastActive}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No dogs in kennel</p>
</div>
{{end}}
</div>
</div>
<!-- Health Panel -->
<div class="panel">
<div class="panel-header">
<h2>💓 System Health</h2>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Health}}
<div class="health-grid">
<div class="health-item">
<div class="health-label">Deacon Heartbeat</div>
<div class="health-value {{if .Health.HeartbeatFresh}}good{{else}}bad{{end}}">
{{.Health.DeaconHeartbeat}}
</div>
</div>
<div class="health-item">
<div class="health-label">Cycle</div>
<div class="health-value">{{.Health.DeaconCycle}}</div>
</div>
<div class="health-item">
<div class="health-label">Healthy Agents</div>
<div class="health-value good">{{.Health.HealthyAgents}}</div>
</div>
<div class="health-item">
<div class="health-label">Unhealthy</div>
<div class="health-value {{if .Health.UnhealthyAgents}}bad{{else}}good{{end}}">
{{.Health.UnhealthyAgents}}
</div>
</div>
{{if .Health.IsPaused}}
<div class="health-item" style="grid-column: 1 / -1; background: rgba(240, 113, 120, 0.1);">
<div class="health-label">⚠️ Deacon Paused</div>
<div class="health-value bad">{{.Health.PauseReason}}</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state">
<p>Health data unavailable</p>
</div>
{{end}}
</div>
</div>
<!-- Queues Panel (optional, only show if there are queues) -->
{{if .Queues}}
<div class="panel">
<div class="panel-header">
<h2>📋 Queues</h2>
<span class="count">{{len .Queues}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>Queue</th>
<th>Status</th>
<th>Avail</th>
<th>Proc</th>
<th>Done</th>
<th>Fail</th>
</tr>
</thead>
<tbody>
{{range .Queues}}
<tr>
<td>{{.Name}}</td>
<td>
{{if eq .Status "active"}}<span class="badge badge-green">Active</span>
{{else if eq .Status "paused"}}<span class="badge badge-yellow">Paused</span>
{{else}}<span class="badge badge-muted">Closed</span>{{end}}
</td>
<td>{{.Available}}</td>
<td>{{.Processing}}</td>
<td>{{.Completed}}</td>
<td>{{if .Failed}}<span class="severity-high">{{.Failed}}</span>{{else}}0{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
<!-- Open Issues Panel -->
<div class="panel">
<div class="panel-header">
<h2>📿 Open Issues</h2>
<span class="count">{{len .Issues}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Issues}}
<table>
<thead>
<tr>
<th>Pri</th>
<th>ID</th>
<th>Title</th>
<th>Type</th>
<th>Age</th>
</tr>
</thead>
<tbody>
{{range .Issues}}
<tr class="issue-row priority-{{.Priority}}">
<td>
{{if eq .Priority 1}}<span class="badge badge-red">P1</span>
{{else if eq .Priority 2}}<span class="badge badge-orange">P2</span>
{{else if eq .Priority 3}}<span class="badge badge-yellow">P3</span>
{{else}}<span class="badge badge-muted">P4</span>{{end}}
</td>
<td><span class="issue-id">{{.ID}}</span></td>
<td class="issue-title">{{.Title}}</td>
<td class="issue-type">{{.Type}}</td>
<td>{{.Age}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No open issues</p>
</div>
{{end}}
</div>
</div>
<!-- Hooks Panel -->
<div class="panel">
<div class="panel-header">
<h2>🪝 Hooks</h2>
<span class="count{{if .Hooks}} {{end}}">{{len .Hooks}}</span>
<button class="expand-btn">Expand</button>
</div>
<div class="panel-body">
{{if .Hooks}}
<table>
<thead>
<tr>
<th>Bead</th>
<th>Title</th>
<th>Agent</th>
<th>Hooked</th>
</tr>
</thead>
<tbody>
{{range .Hooks}}
<tr class="{{if .IsStale}}hook-stale{{end}}">
<td><span class="hook-id">{{.ID}}</span></td>
<td class="hook-title">{{.Title}}</td>
<td class="hook-agent">{{.Agent}}</td>
<td>
{{if .IsStale}}
<span class="badge badge-yellow">{{.Age}}</span>
{{else}}
{{.Age}}
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No hooked work</p>
</div>
{{end}}
</div>
</div>
</div>
</div>
<script>
(function() {
// Use event delegation - ONE handler, no re-attachment needed
document.addEventListener('click', function(e) {
const btn = e.target.closest('.expand-btn');
if (!btn) return;
e.preventDefault();
const panel = btn.closest('.panel');
if (!panel) return;
if (panel.classList.contains('expanded')) {
// Collapse
panel.classList.remove('expanded');
btn.textContent = 'Expand';
} else {
// Collapse any other expanded panel first
document.querySelectorAll('.panel.expanded').forEach(p => {
p.classList.remove('expanded');
const b = p.querySelector('.expand-btn');
if (b) b.textContent = 'Expand';
});
// Expand this one
panel.classList.add('expanded');
btn.textContent = '✕ Close';
}
});
// Escape to close
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.panel.expanded').forEach(p => {
p.classList.remove('expanded');
const btn = p.querySelector('.expand-btn');
if (btn) btn.textContent = 'Expand';
});
}
});
// After HTMX swap, close any expanded panels (fresh state)
document.body.addEventListener('htmx:afterSwap', function() {
document.querySelectorAll('.panel.expanded').forEach(p => {
p.classList.remove('expanded');
});
});
})();
</script>
</body>
</html>