@sienklogic/plan-build-run 2.27.2 → 2.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dashboard/bin/cli.js +3 -3
- package/dashboard/package.json +8 -5
- package/dashboard/public/css/layout.css +470 -252
- package/dashboard/public/css/tokens.css +67 -61
- package/dashboard/public/js/sse-client.js +52 -82
- package/dashboard/public/js/theme-toggle.js +19 -14
- package/dashboard/src/components/Layout.tsx +93 -0
- package/dashboard/src/global.d.ts +10 -0
- package/dashboard/src/index.tsx +132 -0
- package/dashboard/src/middleware/current-phase.ts +25 -0
- package/dashboard/src/routes/index.routes.tsx +36 -0
- package/dashboard/src/services/sse.service.ts +34 -0
- package/dashboard/src/services/watcher.service.ts +35 -0
- package/dashboard/src/sse-handler.tsx +37 -0
- package/dashboard/src/watcher-setup.ts +25 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/dashboard/src/services/sse.service.js +0 -58
- package/dashboard/src/services/watcher.service.js +0 -48
|
@@ -1,42 +1,56 @@
|
|
|
1
1
|
/* ============================================
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
PBR Dashboard — Design Tokens
|
|
3
|
+
Semantic CSS variables backed by Open Props.
|
|
4
|
+
Open Props must be loaded before this file.
|
|
5
5
|
============================================ */
|
|
6
6
|
|
|
7
7
|
/* --- Light Mode (Default) --- */
|
|
8
8
|
:root {
|
|
9
|
-
/*
|
|
10
|
-
--color-surface:
|
|
11
|
-
--color-surface-raised:
|
|
12
|
-
--color-surface-hover:
|
|
13
|
-
--color-border:
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
--color-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
--
|
|
22
|
-
--
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
--
|
|
28
|
-
--
|
|
29
|
-
--
|
|
9
|
+
/* Surface colors */
|
|
10
|
+
--color-surface: var(--gray-0);
|
|
11
|
+
--color-surface-raised: var(--gray-1);
|
|
12
|
+
--color-surface-hover: var(--gray-2);
|
|
13
|
+
--color-border: var(--gray-3);
|
|
14
|
+
|
|
15
|
+
/* Text colors */
|
|
16
|
+
--color-text: var(--gray-9);
|
|
17
|
+
--color-text-dim: var(--gray-6);
|
|
18
|
+
--color-text-muted: var(--gray-5);
|
|
19
|
+
|
|
20
|
+
/* Accent */
|
|
21
|
+
--color-accent: var(--blue-6);
|
|
22
|
+
--color-accent-hover: var(--blue-7);
|
|
23
|
+
|
|
24
|
+
/* Spacing — mapped from Open Props size scale */
|
|
25
|
+
--space-xs: var(--size-1);
|
|
26
|
+
--space-sm: var(--size-2);
|
|
27
|
+
--space-md: var(--size-4);
|
|
28
|
+
--space-lg: var(--size-6);
|
|
29
|
+
--space-xl: var(--size-8);
|
|
30
|
+
--space-2xl: var(--size-10);
|
|
31
|
+
|
|
32
|
+
/* Border radii */
|
|
33
|
+
--radius-sm: var(--radius-2);
|
|
34
|
+
--radius-md: var(--radius-3);
|
|
35
|
+
--radius-lg: var(--radius-4);
|
|
30
36
|
|
|
31
37
|
/* Shadows */
|
|
32
|
-
--shadow-sm:
|
|
33
|
-
--shadow-md:
|
|
38
|
+
--shadow-sm: var(--shadow-2);
|
|
39
|
+
--shadow-md: var(--shadow-4);
|
|
40
|
+
|
|
41
|
+
/* Typography */
|
|
42
|
+
--font-sans: var(--font-sans);
|
|
43
|
+
--font-mono: var(--font-mono);
|
|
44
|
+
|
|
45
|
+
/* Layout dimensions */
|
|
46
|
+
--size-sidebar: 220px;
|
|
47
|
+
--size-header: 3.5rem;
|
|
34
48
|
|
|
35
49
|
/* Transitions */
|
|
36
|
-
--transition-fast: 0.12s
|
|
37
|
-
--transition-base: 0.2s
|
|
50
|
+
--transition-fast: var(--ease-3) 0.12s;
|
|
51
|
+
--transition-base: var(--ease-3) 0.2s;
|
|
38
52
|
|
|
39
|
-
/*
|
|
53
|
+
/* Component tokens — derived from semantic tokens above */
|
|
40
54
|
--card-bg: var(--color-surface-raised);
|
|
41
55
|
--card-border: var(--color-border);
|
|
42
56
|
--card-radius: var(--radius-md);
|
|
@@ -44,52 +58,44 @@
|
|
|
44
58
|
--card-padding: var(--space-md);
|
|
45
59
|
--card-header-padding: var(--space-sm) var(--space-lg);
|
|
46
60
|
|
|
47
|
-
/* Tables */
|
|
48
61
|
--table-cell-padding: var(--space-xs) var(--space-sm);
|
|
49
|
-
--table-cell-padding-y: var(--space-xs);
|
|
50
|
-
--table-cell-padding-x: var(--space-sm);
|
|
51
62
|
|
|
52
|
-
/* Badges */
|
|
53
63
|
--badge-padding-sm: 0.1rem 0.4rem;
|
|
54
64
|
--badge-padding-base: 0.15rem 0.55rem;
|
|
55
65
|
--badge-padding-lg: 0.25rem 0.75rem;
|
|
56
66
|
--badge-font-size-sm: 0.6875rem;
|
|
57
67
|
--badge-font-size-base: 0.75rem;
|
|
58
68
|
--badge-font-size-lg: 0.8125rem;
|
|
69
|
+
}
|
|
59
70
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
--
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
--
|
|
66
|
-
--overlay-bg-heavy: rgba(0, 0, 0, 0.7);
|
|
67
|
-
--color-dark-surface: #1a1a2e;
|
|
68
|
-
--transition-transform: transform 0.25s ease;
|
|
71
|
+
/* --- Dark Mode (explicit [data-theme="dark"] attribute) --- */
|
|
72
|
+
[data-theme="dark"] {
|
|
73
|
+
--color-surface: var(--gray-9);
|
|
74
|
+
--color-surface-raised: var(--gray-8);
|
|
75
|
+
--color-surface-hover: var(--gray-7);
|
|
76
|
+
--color-border: var(--gray-7);
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
--
|
|
72
|
-
--
|
|
73
|
-
}
|
|
78
|
+
--color-text: var(--gray-0);
|
|
79
|
+
--color-text-dim: var(--gray-4);
|
|
80
|
+
--color-text-muted: var(--gray-5);
|
|
74
81
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
--color-surface: #13131a;
|
|
78
|
-
--color-surface-raised: rgba(255, 255, 255, 0.03);
|
|
79
|
-
--color-surface-hover: rgba(255, 255, 255, 0.06);
|
|
80
|
-
--color-border: rgba(255, 255, 255, 0.08);
|
|
81
|
-
--color-text-dim: rgba(255, 255, 255, 0.5);
|
|
82
|
-
--color-text: #e8e8f0;
|
|
82
|
+
--color-accent: var(--blue-4);
|
|
83
|
+
--color-accent-hover: var(--blue-3);
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/* --- Dark Mode (system preference, unless light forced) --- */
|
|
86
87
|
@media (prefers-color-scheme: dark) {
|
|
87
|
-
:root:not([data-
|
|
88
|
-
--color-surface:
|
|
89
|
-
--color-surface-raised:
|
|
90
|
-
--color-surface-hover:
|
|
91
|
-
--color-border:
|
|
92
|
-
|
|
93
|
-
--color-text:
|
|
88
|
+
:root:not([data-theme="light"]) {
|
|
89
|
+
--color-surface: var(--gray-9);
|
|
90
|
+
--color-surface-raised: var(--gray-8);
|
|
91
|
+
--color-surface-hover: var(--gray-7);
|
|
92
|
+
--color-border: var(--gray-7);
|
|
93
|
+
|
|
94
|
+
--color-text: var(--gray-0);
|
|
95
|
+
--color-text-dim: var(--gray-4);
|
|
96
|
+
--color-text-muted: var(--gray-5);
|
|
97
|
+
|
|
98
|
+
--color-accent: var(--blue-4);
|
|
99
|
+
--color-accent-hover: var(--blue-3);
|
|
94
100
|
}
|
|
95
101
|
}
|
|
@@ -1,100 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* SSE client with exponential backoff reconnection.
|
|
3
|
+
* Listens for file-change events and triggers HTMX refresh.
|
|
3
4
|
*/
|
|
4
5
|
(function () {
|
|
5
6
|
'use strict';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
this.maxBackoff = 30000;
|
|
13
|
-
this.reconnectTimer = null;
|
|
14
|
-
this.es = null;
|
|
15
|
-
this.connect();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
connect() {
|
|
19
|
-
if (this.es) {
|
|
20
|
-
this.es.close();
|
|
21
|
-
}
|
|
8
|
+
var SSE_URL = '/api/events/stream';
|
|
9
|
+
var backoff = 1000;
|
|
10
|
+
var maxBackoff = 30000;
|
|
11
|
+
var reconnectTimer = null;
|
|
12
|
+
var es = null;
|
|
22
13
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
function updateStatus(connected) {
|
|
15
|
+
var dot = document.getElementById('sse-status');
|
|
16
|
+
if (dot) {
|
|
17
|
+
dot.setAttribute('data-connected', String(connected));
|
|
18
|
+
dot.setAttribute('title', connected ? 'Live updates: connected' : 'Live updates: disconnected');
|
|
19
|
+
dot.setAttribute('aria-label', connected ? 'Live updates: connected' : 'Live updates: disconnected');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
28
22
|
|
|
29
|
-
|
|
23
|
+
function scheduleReconnect() {
|
|
24
|
+
if (reconnectTimer) {
|
|
25
|
+
clearTimeout(reconnectTimer);
|
|
26
|
+
}
|
|
27
|
+
// Add jitter to avoid thundering herd
|
|
28
|
+
var jitter = backoff * (0.5 + Math.random() * 0.5);
|
|
29
|
+
reconnectTimer = setTimeout(connect, jitter);
|
|
30
|
+
backoff = Math.min(backoff * 2, maxBackoff);
|
|
31
|
+
}
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
function connect() {
|
|
34
|
+
if (es) {
|
|
35
|
+
es.close();
|
|
36
|
+
es = null;
|
|
37
|
+
}
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
this.es.close();
|
|
38
|
-
this.updateStatus(false);
|
|
39
|
-
this.scheduleReconnect();
|
|
40
|
-
};
|
|
39
|
+
es = new EventSource(SSE_URL);
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.refreshContent();
|
|
47
|
-
});
|
|
41
|
+
es.onopen = function () {
|
|
42
|
+
backoff = 1000; // Reset backoff on successful connection
|
|
43
|
+
updateStatus(true);
|
|
44
|
+
};
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
46
|
+
es.onerror = function () {
|
|
47
|
+
es.close();
|
|
48
|
+
es = null;
|
|
49
|
+
updateStatus(false);
|
|
50
|
+
scheduleReconnect();
|
|
51
|
+
};
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
53
|
+
es.addEventListener('file-change', function (event) {
|
|
54
|
+
var data = null;
|
|
55
|
+
try {
|
|
56
|
+
data = JSON.parse(event.data);
|
|
57
|
+
} catch (_e) {
|
|
58
|
+
data = { raw: event.data };
|
|
60
59
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}, jitter);
|
|
65
|
-
this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
updateStatus(connected) {
|
|
69
|
-
const dot = document.getElementById('sse-status');
|
|
70
|
-
if (dot) {
|
|
71
|
-
dot.setAttribute('data-connected', String(connected));
|
|
72
|
-
dot.setAttribute('title', connected ? 'Live updates: connected' : 'Live updates: disconnected');
|
|
60
|
+
// Notify HTMX listeners via custom event
|
|
61
|
+
if (typeof htmx !== 'undefined') {
|
|
62
|
+
htmx.trigger(document.body, 'sse:file-change', data);
|
|
73
63
|
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
refreshContent() {
|
|
77
|
-
const currentPath = window.location.pathname;
|
|
78
|
-
fetch(currentPath, {
|
|
79
|
-
headers: { 'HX-Request': 'true' }
|
|
80
|
-
})
|
|
81
|
-
.then((res) => {
|
|
82
|
-
if (res.ok) return res.text();
|
|
83
|
-
throw new Error('Fetch failed: ' + res.status);
|
|
84
|
-
})
|
|
85
|
-
.then((html) => {
|
|
86
|
-
const target = document.getElementById('main-content');
|
|
87
|
-
if (target) {
|
|
88
|
-
target.innerHTML = html;
|
|
89
|
-
}
|
|
90
|
-
})
|
|
91
|
-
.catch((err) => {
|
|
92
|
-
console.error('SSE content refresh failed:', err.message);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
64
|
+
});
|
|
95
65
|
}
|
|
96
66
|
|
|
97
|
-
document.addEventListener('DOMContentLoaded', ()
|
|
98
|
-
|
|
67
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
68
|
+
connect();
|
|
99
69
|
});
|
|
100
70
|
})();
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
/* theme-toggle.js — Toggle light/dark theme via data-bs-theme attribute + localStorage */
|
|
2
1
|
(function () {
|
|
3
2
|
'use strict';
|
|
4
3
|
|
|
5
4
|
var STORAGE_KEY = 'pbr-theme';
|
|
5
|
+
var root = document.documentElement;
|
|
6
|
+
|
|
7
|
+
// Apply saved or system preference immediately to prevent flash
|
|
8
|
+
var saved = localStorage.getItem(STORAGE_KEY);
|
|
9
|
+
if (saved) {
|
|
10
|
+
root.setAttribute('data-theme', saved);
|
|
11
|
+
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
12
|
+
root.setAttribute('data-theme', 'dark');
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
function getEffectiveTheme() {
|
|
8
|
-
|
|
9
|
-
if (explicit === 'light' || explicit === 'dark') return explicit;
|
|
10
|
-
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
16
|
+
return root.getAttribute('data-theme') || 'light';
|
|
11
17
|
}
|
|
12
18
|
|
|
13
19
|
function updateIcon(btn, theme) {
|
|
14
|
-
//
|
|
15
|
-
btn.textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
|
|
20
|
+
// Sun when dark (click to go light), moon when light (click to go dark)
|
|
21
|
+
btn.querySelector('.theme-btn__icon').textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
|
|
16
22
|
btn.setAttribute('aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
|
|
17
23
|
}
|
|
18
24
|
|
|
@@ -20,25 +26,24 @@
|
|
|
20
26
|
var btn = document.getElementById('theme-toggle');
|
|
21
27
|
if (!btn) return;
|
|
22
28
|
|
|
23
|
-
//
|
|
24
|
-
var stored = localStorage.getItem(STORAGE_KEY);
|
|
25
|
-
if (stored) {
|
|
26
|
-
document.documentElement.dataset.bsTheme = stored;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
+
// Sync icon with current theme
|
|
29
30
|
updateIcon(btn, getEffectiveTheme());
|
|
30
31
|
|
|
31
32
|
btn.addEventListener('click', function () {
|
|
32
33
|
var current = getEffectiveTheme();
|
|
33
34
|
var next = current === 'dark' ? 'light' : 'dark';
|
|
34
|
-
|
|
35
|
+
root.setAttribute('data-theme', next);
|
|
35
36
|
localStorage.setItem(STORAGE_KEY, next);
|
|
36
37
|
updateIcon(btn, next);
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
// Update icon if system preference changes and no explicit
|
|
40
|
+
// Update icon if system preference changes and no explicit choice is stored
|
|
40
41
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
|
|
41
42
|
if (!localStorage.getItem(STORAGE_KEY)) {
|
|
43
|
+
root.removeAttribute('data-theme');
|
|
44
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
45
|
+
root.setAttribute('data-theme', 'dark');
|
|
46
|
+
}
|
|
42
47
|
updateIcon(btn, getEffectiveTheme());
|
|
43
48
|
}
|
|
44
49
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { html } from 'hono/html';
|
|
2
|
+
|
|
3
|
+
interface LayoutProps {
|
|
4
|
+
title: string;
|
|
5
|
+
children: any;
|
|
6
|
+
currentView?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const navItems = [
|
|
10
|
+
{ href: '/', label: 'Command Center', view: 'home' },
|
|
11
|
+
{ href: '/explorer', label: 'Explorer', view: 'explorer' },
|
|
12
|
+
{ href: '/timeline', label: 'Timeline', view: 'timeline' },
|
|
13
|
+
{ href: '/settings', label: 'Settings', view: 'settings' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function Layout({ title, children, currentView }: LayoutProps) {
|
|
17
|
+
return (
|
|
18
|
+
<html lang="en" data-theme="light">
|
|
19
|
+
<head>
|
|
20
|
+
<meta charset="UTF-8" />
|
|
21
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
22
|
+
<title>{title} — PBR Dashboard</title>
|
|
23
|
+
|
|
24
|
+
{/* Open Props */}
|
|
25
|
+
<link rel="stylesheet" href="https://unpkg.com/open-props" />
|
|
26
|
+
<link rel="stylesheet" href="https://unpkg.com/open-props/normalize.min.css" />
|
|
27
|
+
|
|
28
|
+
{/* Local design system */}
|
|
29
|
+
<link rel="stylesheet" href="/css/tokens.css" />
|
|
30
|
+
<link rel="stylesheet" href="/css/layout.css" />
|
|
31
|
+
<link rel="stylesheet" href="/css/status-colors.css" />
|
|
32
|
+
|
|
33
|
+
{/* Prevent flash of wrong theme */}
|
|
34
|
+
{html`<script>
|
|
35
|
+
(function() {
|
|
36
|
+
var saved = localStorage.getItem('pbr-theme');
|
|
37
|
+
if (saved) { document.documentElement.setAttribute('data-theme', saved); }
|
|
38
|
+
else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
39
|
+
document.documentElement.setAttribute('data-theme', 'dark');
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
</script>`}
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<a href="#main-content" class="skip-link">Skip to content</a>
|
|
46
|
+
|
|
47
|
+
<nav class="sidebar" aria-label="Main navigation">
|
|
48
|
+
<div class="sidebar__brand">
|
|
49
|
+
<span class="sidebar__brand-name">PBR</span>
|
|
50
|
+
<span class="sidebar__brand-subtitle">Dashboard</span>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<ul class="sidebar__nav" role="list">
|
|
54
|
+
{navItems.map((item) => {
|
|
55
|
+
const isActive = currentView === item.view ||
|
|
56
|
+
(!currentView && item.view === 'home');
|
|
57
|
+
return (
|
|
58
|
+
<li key={item.href}>
|
|
59
|
+
<a
|
|
60
|
+
href={item.href}
|
|
61
|
+
class={`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`}
|
|
62
|
+
aria-current={isActive ? 'page' : undefined}
|
|
63
|
+
>
|
|
64
|
+
{item.label}
|
|
65
|
+
</a>
|
|
66
|
+
</li>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</ul>
|
|
70
|
+
|
|
71
|
+
<div class="sidebar__footer">
|
|
72
|
+
<button id="theme-toggle" class="theme-btn" type="button" aria-label="Toggle dark/light theme">
|
|
73
|
+
<span class="theme-btn__icon" aria-hidden="true">◐</span>
|
|
74
|
+
</button>
|
|
75
|
+
<span id="sse-status" data-connected="false" title="Live updates: disconnected" aria-label="Live update status"></span>
|
|
76
|
+
</div>
|
|
77
|
+
</nav>
|
|
78
|
+
|
|
79
|
+
<main id="main-content" class="main-content">
|
|
80
|
+
{children}
|
|
81
|
+
</main>
|
|
82
|
+
|
|
83
|
+
{/* HTMX */}
|
|
84
|
+
<script src="https://unpkg.com/htmx.org@2" defer></script>
|
|
85
|
+
{/* Alpine.js */}
|
|
86
|
+
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>
|
|
87
|
+
{/* Local scripts */}
|
|
88
|
+
<script src="/js/theme-toggle.js"></script>
|
|
89
|
+
<script src="/js/sse-client.js" defer></script>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { compress } from 'hono/compress';
|
|
5
|
+
import { logger } from 'hono/logger';
|
|
6
|
+
import { secureHeaders } from 'hono/secure-headers';
|
|
7
|
+
import { Layout } from './components/Layout';
|
|
8
|
+
import { indexRouter } from './routes/index.routes';
|
|
9
|
+
import { sseHandler } from './sse-handler';
|
|
10
|
+
import { startWatcher } from './watcher-setup';
|
|
11
|
+
import { currentPhaseMiddleware } from './middleware/current-phase';
|
|
12
|
+
|
|
13
|
+
interface ServerConfig {
|
|
14
|
+
projectDir: string;
|
|
15
|
+
port: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CurrentPhase {
|
|
19
|
+
number: number;
|
|
20
|
+
name: string;
|
|
21
|
+
status: string;
|
|
22
|
+
nextAction: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Env = {
|
|
26
|
+
Variables: {
|
|
27
|
+
projectDir: string;
|
|
28
|
+
currentPhase: CurrentPhase | null;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function createApp(config: ServerConfig) {
|
|
33
|
+
const app = new Hono<Env>();
|
|
34
|
+
|
|
35
|
+
// Inject projectDir into context for all routes
|
|
36
|
+
app.use('*', async (c, next) => {
|
|
37
|
+
c.set('projectDir', config.projectDir);
|
|
38
|
+
await next();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Security headers (replaces helmet)
|
|
42
|
+
app.use('*', secureHeaders());
|
|
43
|
+
|
|
44
|
+
// Compression — skip SSE endpoint to avoid buffering
|
|
45
|
+
app.use('*', async (c, next) => {
|
|
46
|
+
if (c.req.path.startsWith('/api/events')) {
|
|
47
|
+
return next();
|
|
48
|
+
}
|
|
49
|
+
return compress()(c, next);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Request logging
|
|
53
|
+
app.use('*', logger());
|
|
54
|
+
|
|
55
|
+
// Vary: Accept header on all responses
|
|
56
|
+
app.use('*', async (c, next) => {
|
|
57
|
+
await next();
|
|
58
|
+
c.header('Vary', 'Accept');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Static file serving from public/
|
|
62
|
+
app.use('*', serveStatic({ root: './public' }));
|
|
63
|
+
|
|
64
|
+
// Current phase middleware — populates c.var.currentPhase for all routes
|
|
65
|
+
app.use('*', currentPhaseMiddleware);
|
|
66
|
+
|
|
67
|
+
// Routes
|
|
68
|
+
app.route('/', indexRouter);
|
|
69
|
+
|
|
70
|
+
// SSE endpoint — real streamSSE handler with multi-client broadcast
|
|
71
|
+
app.get('/api/events/stream', sseHandler);
|
|
72
|
+
|
|
73
|
+
// 404 handler
|
|
74
|
+
app.notFound((c) => {
|
|
75
|
+
return c.html(
|
|
76
|
+
<Layout title="Not Found">
|
|
77
|
+
<h1>404 — Not Found</h1>
|
|
78
|
+
<p>The page you requested does not exist.</p>
|
|
79
|
+
<p><a href="/">Return to Command Center</a></p>
|
|
80
|
+
</Layout>,
|
|
81
|
+
404
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Error handler
|
|
86
|
+
app.onError((err, c) => {
|
|
87
|
+
console.error('Server error:', err);
|
|
88
|
+
return c.html(
|
|
89
|
+
<Layout title="Server Error">
|
|
90
|
+
<h1>500 — Server Error</h1>
|
|
91
|
+
<p>Something went wrong. Check the server logs for details.</p>
|
|
92
|
+
<p><a href="/">Return to Command Center</a></p>
|
|
93
|
+
</Layout>,
|
|
94
|
+
500
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return app;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function startServer(config: ServerConfig): void {
|
|
102
|
+
const app = createApp(config);
|
|
103
|
+
|
|
104
|
+
const server = serve({
|
|
105
|
+
fetch: app.fetch,
|
|
106
|
+
port: config.port,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Start file watcher — broadcasts SSE events on .planning/ changes
|
|
110
|
+
// Import caches lazily to avoid circular deps
|
|
111
|
+
const watcher = startWatcher(config.projectDir, () => {
|
|
112
|
+
// Cache invalidation callback — services with TTL caches expose them
|
|
113
|
+
// These will be wired as services are consumed by routes
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
console.log(`PBR Dashboard running at http://localhost:${config.port}`);
|
|
117
|
+
console.log(`Project directory: ${config.projectDir}`);
|
|
118
|
+
|
|
119
|
+
// Graceful shutdown
|
|
120
|
+
process.on('SIGINT', () => {
|
|
121
|
+
console.log('\nShutting down dashboard...');
|
|
122
|
+
watcher.close();
|
|
123
|
+
server.close(() => process.exit(0));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
process.on('SIGTERM', () => {
|
|
127
|
+
watcher.close();
|
|
128
|
+
server.close(() => process.exit(0));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { createApp };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono';
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
3
|
+
// @ts-ignore — dashboard.service.js is plain ESM with no type declarations
|
|
4
|
+
import { parseStateFile } from '../services/dashboard.service.js';
|
|
5
|
+
|
|
6
|
+
export const currentPhaseMiddleware: MiddlewareHandler = async (c, next) => {
|
|
7
|
+
try {
|
|
8
|
+
const projectDir = c.get('projectDir') as string;
|
|
9
|
+
const state = await parseStateFile(projectDir);
|
|
10
|
+
const cp = state.currentPhase;
|
|
11
|
+
if (cp && cp.id > 0) {
|
|
12
|
+
c.set('currentPhase', {
|
|
13
|
+
number: cp.id,
|
|
14
|
+
name: cp.name,
|
|
15
|
+
status: cp.status,
|
|
16
|
+
nextAction: state.nextAction || null,
|
|
17
|
+
});
|
|
18
|
+
} else {
|
|
19
|
+
c.set('currentPhase', null);
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
c.set('currentPhase', null);
|
|
23
|
+
}
|
|
24
|
+
await next();
|
|
25
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { Layout } from '../components/Layout';
|
|
3
|
+
|
|
4
|
+
type Env = {
|
|
5
|
+
Variables: {
|
|
6
|
+
projectDir: string;
|
|
7
|
+
};
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const router = new Hono<Env>();
|
|
11
|
+
|
|
12
|
+
router.get('/favicon.ico', (c) => {
|
|
13
|
+
return c.body(null, 204);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
router.get('/', (c) => {
|
|
17
|
+
const isHtmx = c.req.header('HX-Request');
|
|
18
|
+
|
|
19
|
+
if (isHtmx) {
|
|
20
|
+
return c.html(
|
|
21
|
+
<main id="main-content">
|
|
22
|
+
<h1>Command Center</h1>
|
|
23
|
+
<p>Project overview loads here.</p>
|
|
24
|
+
</main>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return c.html(
|
|
29
|
+
<Layout title="Command Center" currentView="home">
|
|
30
|
+
<h1>Command Center</h1>
|
|
31
|
+
<p>Project overview loads here.</p>
|
|
32
|
+
</Layout>
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export { router as indexRouter };
|