* 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.
1611 lines
54 KiB
HTML
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>
|