@neat.is/web 0.2.10 → 0.3.1
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/app/components/AppShell.tsx +19 -1
- package/app/components/DebugPanel.tsx +112 -0
- package/app/components/GraphCanvas.tsx +4 -2
- package/app/components/Inspector.tsx +41 -5
- package/app/components/Rail.tsx +23 -10
- package/app/components/StatusBar.tsx +109 -10
- package/app/components/Toaster.tsx +67 -0
- package/app/components/TopBar.tsx +27 -0
- package/app/incidents/IncidentsClient.tsx +150 -0
- package/app/incidents/page.tsx +15 -144
- package/app/page.tsx +9 -1
- package/lib/proxy-client.ts +118 -0
- package/package.json +7 -2
|
@@ -6,6 +6,8 @@ import { Rail } from './Rail'
|
|
|
6
6
|
import { GraphCanvas } from './GraphCanvas'
|
|
7
7
|
import { Inspector } from './Inspector'
|
|
8
8
|
import { StatusBar } from './StatusBar'
|
|
9
|
+
import { DebugPanel } from './DebugPanel'
|
|
10
|
+
import { Toaster } from './Toaster'
|
|
9
11
|
import type { GraphNode, GraphEdge } from '@neat.is/types'
|
|
10
12
|
|
|
11
13
|
export interface GraphData {
|
|
@@ -36,10 +38,12 @@ export function AppShell() {
|
|
|
36
38
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
|
37
39
|
const [graphData, setGraphData] = useState<GraphData | null>(null)
|
|
38
40
|
// ADR-057 #2 — start with URL or localStorage (synchronous), then resolve
|
|
39
|
-
// against /projects on mount if neither was set.
|
|
41
|
+
// against /projects on mount if neither was set. Safe because AppShell
|
|
42
|
+
// mounts client-only via dynamic({ ssr: false }) in app/page.tsx (ADR-062).
|
|
40
43
|
const [project, setProjectState] = useState<string>(() => {
|
|
41
44
|
return readUrlProject() ?? readStoredProject() ?? 'default'
|
|
42
45
|
})
|
|
46
|
+
const [debugOpen, setDebugOpen] = useState(false)
|
|
43
47
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
48
|
const cyRef = useRef<any>(null)
|
|
45
49
|
const resolvedRef = useRef(readUrlProject() !== null || readStoredProject() !== null)
|
|
@@ -85,6 +89,18 @@ export function AppShell() {
|
|
|
85
89
|
if (nodeId) setSelectedNodeId(nodeId)
|
|
86
90
|
}, [])
|
|
87
91
|
|
|
92
|
+
// ADR-058 #4 — Ctrl+Shift+D / Cmd+Shift+D toggles the debug panel.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const handler = (e: KeyboardEvent) => {
|
|
95
|
+
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'D' || e.key === 'd')) {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
setDebugOpen((v) => !v)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
document.addEventListener('keydown', handler)
|
|
101
|
+
return () => document.removeEventListener('keydown', handler)
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
88
104
|
return (
|
|
89
105
|
<div className="app">
|
|
90
106
|
<TopBar
|
|
@@ -104,6 +120,8 @@ export function AppShell() {
|
|
|
104
120
|
/>
|
|
105
121
|
<Inspector project={project} selectedNodeId={selectedNodeId} graphData={graphData} />
|
|
106
122
|
<StatusBar project={project} graphData={graphData} />
|
|
123
|
+
<Toaster />
|
|
124
|
+
{debugOpen && <DebugPanel project={project} onClose={() => setDebugOpen(false)} />}
|
|
107
125
|
</div>
|
|
108
126
|
)
|
|
109
127
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
CORE_URL_PUBLIC,
|
|
6
|
+
apiCallBus,
|
|
7
|
+
connectionBus,
|
|
8
|
+
sseEventBus,
|
|
9
|
+
type ApiCallEvent,
|
|
10
|
+
type ConnectionEvent,
|
|
11
|
+
type SseEvent,
|
|
12
|
+
} from '../../lib/proxy-client'
|
|
13
|
+
|
|
14
|
+
interface DebugPanelProps {
|
|
15
|
+
project: string
|
|
16
|
+
onClose: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ADR-058 #4 — read-only diagnostic overlay toggled via Ctrl+Shift+D.
|
|
20
|
+
// Subscribes to the in-memory event buses populated by trackedFetch and the
|
|
21
|
+
// SSE/health hooks. No POST/PUT/DELETE buttons — observation only.
|
|
22
|
+
export function DebugPanel({ project, onClose }: DebugPanelProps) {
|
|
23
|
+
const [calls, setCalls] = useState<ApiCallEvent[]>([])
|
|
24
|
+
const [sseEvents, setSseEvents] = useState<SseEvent[]>([])
|
|
25
|
+
const [heartbeats, setHeartbeats] = useState<ConnectionEvent[]>([])
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const unsubCalls = apiCallBus.subscribe((e) => {
|
|
29
|
+
setCalls((prev) => [e, ...prev].slice(0, 10))
|
|
30
|
+
})
|
|
31
|
+
const unsubSse = sseEventBus.subscribe((e) => {
|
|
32
|
+
setSseEvents((prev) => [e, ...prev].slice(0, 10))
|
|
33
|
+
})
|
|
34
|
+
const unsubConn = connectionBus.subscribe((e) => {
|
|
35
|
+
setHeartbeats((prev) => [e, ...prev].slice(0, 20))
|
|
36
|
+
})
|
|
37
|
+
return () => {
|
|
38
|
+
unsubCalls()
|
|
39
|
+
unsubSse()
|
|
40
|
+
unsubConn()
|
|
41
|
+
}
|
|
42
|
+
}, [])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div
|
|
46
|
+
role="dialog"
|
|
47
|
+
aria-label="Debug panel"
|
|
48
|
+
style={{
|
|
49
|
+
position: 'fixed',
|
|
50
|
+
top: 60,
|
|
51
|
+
right: 16,
|
|
52
|
+
width: 420,
|
|
53
|
+
maxHeight: '70vh',
|
|
54
|
+
overflow: 'auto',
|
|
55
|
+
background: 'var(--ink-2, #14141a)',
|
|
56
|
+
border: '1px solid var(--rule, #2a2a30)',
|
|
57
|
+
color: 'var(--paper-1, #d8d3c9)',
|
|
58
|
+
fontFamily: 'JetBrains Mono, monospace',
|
|
59
|
+
fontSize: 11,
|
|
60
|
+
padding: 12,
|
|
61
|
+
zIndex: 1000,
|
|
62
|
+
boxShadow: '0 16px 40px rgba(0,0,0,0.45)',
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 8 }}>
|
|
66
|
+
<strong style={{ fontFamily: 'Spectral, serif', fontStyle: 'italic' }}>NEAT debug</strong>
|
|
67
|
+
<button onClick={onClose} aria-label="Close debug panel" title="Close (Ctrl+Shift+D)" style={{ background: 'transparent', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: 14 }}>×</button>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<section style={{ marginBottom: 10 }}>
|
|
71
|
+
<div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>environment</div>
|
|
72
|
+
<div>project: <code>{project}</code></div>
|
|
73
|
+
<div>NEAT_API_URL: <code>{CORE_URL_PUBLIC}</code></div>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<section style={{ marginBottom: 10 }}>
|
|
77
|
+
<div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>last {calls.length} api calls</div>
|
|
78
|
+
{calls.length === 0 && <div style={{ opacity: 0.5 }}>none yet</div>}
|
|
79
|
+
{calls.map((c, i) => (
|
|
80
|
+
<div key={i} style={{ display: 'flex', gap: 8, lineHeight: 1.5 }}>
|
|
81
|
+
<span style={{ width: 36, color: c.status >= 400 ? '#e87a7a' : c.status === 0 ? '#d3a847' : 'var(--paper-2)' }}>{c.status || '—'}</span>
|
|
82
|
+
<span style={{ width: 50, opacity: 0.6 }}>{c.durationMs}ms</span>
|
|
83
|
+
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.path}</span>
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</section>
|
|
87
|
+
|
|
88
|
+
<section style={{ marginBottom: 10 }}>
|
|
89
|
+
<div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>last {sseEvents.length} sse events</div>
|
|
90
|
+
{sseEvents.length === 0 && <div style={{ opacity: 0.5 }}>none yet</div>}
|
|
91
|
+
{sseEvents.map((e, i) => (
|
|
92
|
+
<div key={i} style={{ display: 'flex', gap: 8 }}>
|
|
93
|
+
<span style={{ width: 90 }}>{new Date(e.timestamp).toLocaleTimeString()}</span>
|
|
94
|
+
<span>{e.type}</span>
|
|
95
|
+
</div>
|
|
96
|
+
))}
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<section>
|
|
100
|
+
<div style={{ color: 'var(--paper-3)', marginBottom: 4 }}>heartbeats</div>
|
|
101
|
+
{heartbeats.length === 0 && <div style={{ opacity: 0.5 }}>none yet</div>}
|
|
102
|
+
{heartbeats.map((h, i) => (
|
|
103
|
+
<div key={i} style={{ display: 'flex', gap: 8 }}>
|
|
104
|
+
<span style={{ width: 90 }}>{new Date(h.timestamp).toLocaleTimeString()}</span>
|
|
105
|
+
<span style={{ width: 60 }}>{h.state}</span>
|
|
106
|
+
<span style={{ opacity: 0.6 }}>{h.rttMs ? `${h.rttMs}ms` : ''}</span>
|
|
107
|
+
</div>
|
|
108
|
+
))}
|
|
109
|
+
</section>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -486,12 +486,14 @@ export function GraphCanvas({ project, selectedNodeId, onNodeSelect, onGraphLoad
|
|
|
486
486
|
})
|
|
487
487
|
sse.addEventListener('node-removed', (e) => {
|
|
488
488
|
const { id } = JSON.parse(e.data) as { id: string }
|
|
489
|
-
cy.getElementById(id)
|
|
489
|
+
const el = cy.getElementById(id)
|
|
490
|
+
if (el && el.length) el.remove()
|
|
490
491
|
pushGraphUpdate()
|
|
491
492
|
})
|
|
492
493
|
sse.addEventListener('edge-removed', (e) => {
|
|
493
494
|
const { id } = JSON.parse(e.data) as { id: string }
|
|
494
|
-
cy.getElementById(id)
|
|
495
|
+
const el = cy.getElementById(id)
|
|
496
|
+
if (el && el.length) el.remove()
|
|
495
497
|
pushGraphUpdate()
|
|
496
498
|
})
|
|
497
499
|
sse.addEventListener('error', () => {
|
|
@@ -62,7 +62,7 @@ interface InspectorProps {
|
|
|
62
62
|
export function Inspector({ project, selectedNodeId, graphData }: InspectorProps) {
|
|
63
63
|
const [node, setNode] = useState<GraphNode | null>(null)
|
|
64
64
|
const [rootCause, setRootCause] = useState<RootCauseResult | null>(null)
|
|
65
|
-
const [activeTab, setActiveTab] = useState<'inspect' | 'edges'>('inspect')
|
|
65
|
+
const [activeTab, setActiveTab] = useState<'inspect' | 'edges' | 'owners' | 'history'>('inspect')
|
|
66
66
|
|
|
67
67
|
// Stable synthetic metrics — re-randomised per node, not per render
|
|
68
68
|
const metrics = useMemo(() => ({
|
|
@@ -99,8 +99,10 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
|
|
|
99
99
|
<div className="inspect-tabs" role="tablist">
|
|
100
100
|
<div className="inspect-tab on" role="tab" aria-selected={true}>Inspect</div>
|
|
101
101
|
<div className="inspect-tab" role="tab" aria-selected={false}>Edges</div>
|
|
102
|
-
|
|
103
|
-
<div className="inspect-tab" role="tab" aria-selected={false}>
|
|
102
|
+
{/* ADR-056 — Owners deferred: explicit disabled affordance. */}
|
|
103
|
+
<div className="inspect-tab disabled" role="tab" aria-selected={false} aria-disabled={true} title="Owners — coming in v0.3.x" style={{ opacity: 0.4, cursor: 'not-allowed' }}>Owners</div>
|
|
104
|
+
{/* ADR-056 — History deferred: explicit disabled affordance. */}
|
|
105
|
+
<div className="inspect-tab disabled" role="tab" aria-selected={false} aria-disabled={true} title="History — coming in v0.3.x" style={{ opacity: 0.4, cursor: 'not-allowed' }}>History</div>
|
|
104
106
|
</div>
|
|
105
107
|
<div className="insp-section" style={{ paddingTop: 32 }}>
|
|
106
108
|
<div style={{ fontFamily: 'Spectral, serif', fontStyle: 'italic', color: 'var(--paper-3)', textAlign: 'center' }}>
|
|
@@ -156,6 +158,7 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
|
|
|
156
158
|
const showMetrics = !['ConfigNode', 'FrontierNode'].includes(node.type)
|
|
157
159
|
const p99Num = parseFloat(metrics.p99)
|
|
158
160
|
const errNum = parseFloat(metrics.err)
|
|
161
|
+
const owner = (node as unknown as { owner?: string }).owner
|
|
159
162
|
|
|
160
163
|
return (
|
|
161
164
|
<aside className="inspect" id="inspect">
|
|
@@ -176,8 +179,26 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
|
|
|
176
179
|
>
|
|
177
180
|
Edges<span className="ct">{edgeCount}</span>
|
|
178
181
|
</div>
|
|
179
|
-
|
|
180
|
-
<div
|
|
182
|
+
{/* ADR-056 — Owners wired: shows ServiceNode.owner when available (ADR-054). */}
|
|
183
|
+
<div
|
|
184
|
+
className={`inspect-tab${activeTab === 'owners' ? ' on' : ''}`}
|
|
185
|
+
role="tab"
|
|
186
|
+
aria-selected={activeTab === 'owners'}
|
|
187
|
+
onClick={() => setActiveTab('owners')}
|
|
188
|
+
>
|
|
189
|
+
Owners
|
|
190
|
+
</div>
|
|
191
|
+
{/* ADR-056 — History deferred: explicit disabled affordance. */}
|
|
192
|
+
<div
|
|
193
|
+
className="inspect-tab disabled"
|
|
194
|
+
role="tab"
|
|
195
|
+
aria-selected={false}
|
|
196
|
+
aria-disabled={true}
|
|
197
|
+
title="History — coming in v0.3.x"
|
|
198
|
+
style={{ opacity: 0.4, cursor: 'not-allowed' }}
|
|
199
|
+
>
|
|
200
|
+
History
|
|
201
|
+
</div>
|
|
181
202
|
</div>
|
|
182
203
|
|
|
183
204
|
<div id="inspect-body">
|
|
@@ -323,6 +344,21 @@ export function Inspector({ project, selectedNodeId, graphData }: InspectorProps
|
|
|
323
344
|
</section>
|
|
324
345
|
)}
|
|
325
346
|
|
|
347
|
+
{activeTab === 'owners' && (
|
|
348
|
+
<section className="insp-section">
|
|
349
|
+
<div className="insp-h">Owners</div>
|
|
350
|
+
{owner ? (
|
|
351
|
+
<dl className="kv">
|
|
352
|
+
<dt>owner</dt>
|
|
353
|
+
<dd>{escapeHtml(owner)}</dd>
|
|
354
|
+
</dl>
|
|
355
|
+
) : (
|
|
356
|
+
<div style={{ color: 'var(--paper-3)', fontStyle: 'italic', fontFamily: 'Spectral, serif', fontSize: 12 }}>
|
|
357
|
+
no owner declared in package.json or pyproject.toml (ADR-054)
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</section>
|
|
361
|
+
)}
|
|
326
362
|
</div>
|
|
327
363
|
</aside>
|
|
328
364
|
)
|
package/app/components/Rail.tsx
CHANGED
|
@@ -31,10 +31,23 @@ export function Rail({ project }: RailProps) {
|
|
|
31
31
|
.catch(() => {})
|
|
32
32
|
}, [project])
|
|
33
33
|
|
|
34
|
+
// ADR-056 — Find is wired: dispatches a custom event TopBar's search input listens for.
|
|
35
|
+
// ADR-056 — Layers / NeatScript / Time travel / Diff / Comments / Agents / Settings
|
|
36
|
+
// are deferred features; rendered with `disabled` + tooltip affordance so the user
|
|
37
|
+
// perceives them as unavailable, not broken.
|
|
38
|
+
function focusFind(): void {
|
|
39
|
+
const input = document.querySelector<HTMLInputElement>('.top-search input')
|
|
40
|
+
input?.focus()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function disabledTip(label: string): string {
|
|
44
|
+
return `${label} — coming in v0.3.x`
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
return (
|
|
35
48
|
<nav className="rail">
|
|
36
49
|
<div className="rail-group">
|
|
37
|
-
<button className="rail-btn active" aria-label="Graph view">
|
|
50
|
+
<button className="rail-btn active" aria-label="Graph view" title="Graph view">
|
|
38
51
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
39
52
|
<circle cx="6" cy="6" r="2.5" /><circle cx="18" cy="6" r="2.5" />
|
|
40
53
|
<circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="18" r="2.5" />
|
|
@@ -42,13 +55,13 @@ export function Rail({ project }: RailProps) {
|
|
|
42
55
|
</svg>
|
|
43
56
|
<span className="rail-tip">Graph<span className="k">G</span></span>
|
|
44
57
|
</button>
|
|
45
|
-
<button className="rail-btn" aria-label="Layers
|
|
58
|
+
<button className="rail-btn" aria-label="Layers (coming soon)" disabled title={disabledTip('Layers')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
46
59
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
47
60
|
<path d="M4 7h16M4 12h10M4 17h16" />
|
|
48
61
|
</svg>
|
|
49
62
|
<span className="rail-tip">Layers<span className="k">L</span></span>
|
|
50
63
|
</button>
|
|
51
|
-
<button className="rail-btn" aria-label="Find node">
|
|
64
|
+
<button className="rail-btn" aria-label="Find node" onClick={focusFind} title="Find — focus search">
|
|
52
65
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
53
66
|
<circle cx="11" cy="11" r="6" /><path d="m20 20-4-4" />
|
|
54
67
|
</svg>
|
|
@@ -57,19 +70,19 @@ export function Rail({ project }: RailProps) {
|
|
|
57
70
|
</div>
|
|
58
71
|
|
|
59
72
|
<div className="rail-group">
|
|
60
|
-
<button className="rail-btn" aria-label="NeatScript editor">
|
|
73
|
+
<button className="rail-btn" aria-label="NeatScript editor (coming soon)" disabled title={disabledTip('NeatScript')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
61
74
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
62
75
|
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />
|
|
63
76
|
</svg>
|
|
64
77
|
<span className="rail-tip">NeatScript<span className="k">N</span></span>
|
|
65
78
|
</button>
|
|
66
|
-
<button className="rail-btn" aria-label="Time travel">
|
|
79
|
+
<button className="rail-btn" aria-label="Time travel (coming soon)" disabled title={disabledTip('Time travel')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
67
80
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
68
81
|
<circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
|
|
69
82
|
</svg>
|
|
70
83
|
<span className="rail-tip">Time travel<span className="k">T</span></span>
|
|
71
84
|
</button>
|
|
72
|
-
<button className="rail-btn" aria-label="Blast radius
|
|
85
|
+
<button className="rail-btn" aria-label="Blast radius (coming soon)" disabled title={disabledTip('Blast radius')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
73
86
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
74
87
|
<circle cx="12" cy="12" r="3" /><circle cx="12" cy="12" r="7" />
|
|
75
88
|
<path d="M12 3v2M12 19v2M3 12h2M19 12h2" />
|
|
@@ -77,7 +90,7 @@ export function Rail({ project }: RailProps) {
|
|
|
77
90
|
<span className="rail-tip">Blast radius<span className="k">B</span></span>
|
|
78
91
|
{blastBadge > 0 && <span className="badge">{blastBadge}</span>}
|
|
79
92
|
</button>
|
|
80
|
-
<button className="rail-btn" aria-label="Graph diff">
|
|
93
|
+
<button className="rail-btn" aria-label="Graph diff (coming soon)" disabled title={disabledTip('Diff')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
81
94
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
82
95
|
<path d="M8 4 4 8l4 4M16 12l4 4-4 4M14 4l-4 16" />
|
|
83
96
|
</svg>
|
|
@@ -86,7 +99,7 @@ export function Rail({ project }: RailProps) {
|
|
|
86
99
|
</div>
|
|
87
100
|
|
|
88
101
|
<div className="rail-group">
|
|
89
|
-
<button className="rail-btn" aria-label="Comments">
|
|
102
|
+
<button className="rail-btn" aria-label="Comments (coming soon)" disabled title={disabledTip('Comments')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
90
103
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
91
104
|
<path d="M4 6c0-1 1-2 2-2h12c1 0 2 1 2 2v9c0 1-1 2-2 2h-7l-4 4v-4H6c-1 0-2-1-2-2z" />
|
|
92
105
|
</svg>
|
|
@@ -99,7 +112,7 @@ export function Rail({ project }: RailProps) {
|
|
|
99
112
|
<span className="rail-tip">Incidents</span>
|
|
100
113
|
{incidentBadge > 0 && <span className="badge">{incidentBadge}</span>}
|
|
101
114
|
</Link>
|
|
102
|
-
<button className="rail-btn" aria-label="
|
|
115
|
+
<button className="rail-btn" aria-label="Agents (coming soon)" disabled title={disabledTip('Agents')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
103
116
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
104
117
|
<path d="M12 3v3M12 18v3M5 12H2M22 12h-3M6 6l2 2M16 16l2 2M6 18l2-2M16 8l2-2" />
|
|
105
118
|
<circle cx="12" cy="12" r="3" />
|
|
@@ -111,7 +124,7 @@ export function Rail({ project }: RailProps) {
|
|
|
111
124
|
<div className="rail-spacer" />
|
|
112
125
|
|
|
113
126
|
<div className="rail-group" style={{ borderTop: '1px solid var(--rule)' }}>
|
|
114
|
-
<button className="rail-btn" aria-label="Settings">
|
|
127
|
+
<button className="rail-btn" aria-label="Settings (coming soon)" disabled title={disabledTip('Settings')} style={{ opacity: 0.35, cursor: 'not-allowed' }}>
|
|
115
128
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
116
129
|
<circle cx="12" cy="12" r="3" />
|
|
117
130
|
<path d="M19 12a7 7 0 0 1-.4 2.3l2 1.5-2 3.4-2.3-1a7 7 0 0 1-4 2.3l-.4 2.5h-4l-.4-2.5a7 7 0 0 1-4-2.3l-2.3 1-2-3.4 2-1.5A7 7 0 0 1 5 12a7 7 0 0 1 .4-2.3l-2-1.5 2-3.4 2.3 1a7 7 0 0 1 4-2.3L12 1h4l.4 2.5a7 7 0 0 1 4 2.3l2.3-1 2 3.4-2 1.5" />
|
|
@@ -2,18 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
4
|
import type { GraphData } from './AppShell'
|
|
5
|
+
import {
|
|
6
|
+
CORE_URL_PUBLIC,
|
|
7
|
+
connectionBus,
|
|
8
|
+
sseEventBus,
|
|
9
|
+
type ConnectionEvent,
|
|
10
|
+
type SseEvent,
|
|
11
|
+
} from '../../lib/proxy-client'
|
|
5
12
|
|
|
6
13
|
interface StatusBarProps {
|
|
7
14
|
project: string
|
|
8
15
|
graphData: GraphData | null
|
|
9
16
|
}
|
|
10
17
|
|
|
18
|
+
type ConnState = 'ok' | 'slow' | 'down'
|
|
19
|
+
type SseState = 'connected' | 'reconnecting' | 'disconnected'
|
|
20
|
+
|
|
11
21
|
function formatTime(d: Date): string {
|
|
12
22
|
return d.toTimeString().slice(0, 8) + ' ' + d.toTimeString().slice(9, 12)
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
export function StatusBar({ project, graphData }: StatusBarProps) {
|
|
16
26
|
const [now, setNow] = useState(() => formatTime(new Date()))
|
|
27
|
+
// ADR-058 #1 — daemon connection state visible. Tracks /health latency
|
|
28
|
+
// and consecutive failures.
|
|
29
|
+
const [connState, setConnState] = useState<ConnState>('down')
|
|
30
|
+
// ADR-058 #2 — SSE state visible.
|
|
31
|
+
const [sseState, setSseState] = useState<SseState>('disconnected')
|
|
17
32
|
const [healthy, setHealthy] = useState<boolean | null>(null)
|
|
18
33
|
|
|
19
34
|
useEffect(() => {
|
|
@@ -21,28 +36,112 @@ export function StatusBar({ project, graphData }: StatusBarProps) {
|
|
|
21
36
|
return () => clearInterval(id)
|
|
22
37
|
}, [])
|
|
23
38
|
|
|
24
|
-
// ADR-
|
|
25
|
-
// reflects the active project's daemon state.
|
|
39
|
+
// ADR-058 #1 — heartbeat every 5s; classify latency.
|
|
26
40
|
useEffect(() => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
let consecutiveFailures = 0
|
|
42
|
+
const SLOW_MS = 800
|
|
43
|
+
const DOWN_FAILS = 2
|
|
44
|
+
|
|
45
|
+
async function check(): Promise<void> {
|
|
46
|
+
const start = performance.now()
|
|
47
|
+
try {
|
|
48
|
+
const r = await fetch('/api/health', { cache: 'no-store' })
|
|
49
|
+
const rtt = Math.round(performance.now() - start)
|
|
50
|
+
const ok = r.ok
|
|
51
|
+
if (!ok) {
|
|
52
|
+
consecutiveFailures += 1
|
|
53
|
+
const next: ConnState = consecutiveFailures >= DOWN_FAILS ? 'down' : 'slow'
|
|
54
|
+
setConnState(next)
|
|
55
|
+
setHealthy(false)
|
|
56
|
+
connectionBus.emit({ state: next, rttMs: rtt, timestamp: Date.now() })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
consecutiveFailures = 0
|
|
60
|
+
const next: ConnState = rtt > SLOW_MS ? 'slow' : 'ok'
|
|
61
|
+
setConnState(next)
|
|
62
|
+
setHealthy(true)
|
|
63
|
+
connectionBus.emit({ state: next, rttMs: rtt, timestamp: Date.now() })
|
|
64
|
+
} catch {
|
|
65
|
+
consecutiveFailures += 1
|
|
66
|
+
const next: ConnState = consecutiveFailures >= DOWN_FAILS ? 'down' : 'slow'
|
|
67
|
+
setConnState(next)
|
|
68
|
+
setHealthy(false)
|
|
69
|
+
connectionBus.emit({ state: next, timestamp: Date.now() })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
void check()
|
|
74
|
+
const id = setInterval(() => void check(), 5_000)
|
|
34
75
|
return () => clearInterval(id)
|
|
35
76
|
}, [project])
|
|
36
77
|
|
|
78
|
+
// ADR-058 #2 — track SSE connection state. EventSource auto-reconnects
|
|
79
|
+
// per spec; we surface the state transitions.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const sse = new EventSource('/api/events')
|
|
82
|
+
setSseState('reconnecting')
|
|
83
|
+
|
|
84
|
+
sse.onopen = () => {
|
|
85
|
+
setSseState('connected')
|
|
86
|
+
}
|
|
87
|
+
sse.onerror = () => {
|
|
88
|
+
// EventSource toggles readyState; readyState 0 = CONNECTING (reconnect),
|
|
89
|
+
// 2 = CLOSED.
|
|
90
|
+
setSseState(sse.readyState === EventSource.CLOSED ? 'disconnected' : 'reconnecting')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function record(type: string): (e: MessageEvent) => void {
|
|
94
|
+
return () => {
|
|
95
|
+
sseEventBus.emit({ type, timestamp: Date.now() })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
sse.addEventListener('node-added', record('node-added'))
|
|
99
|
+
sse.addEventListener('edge-added', record('edge-added'))
|
|
100
|
+
sse.addEventListener('node-removed', record('node-removed'))
|
|
101
|
+
sse.addEventListener('edge-removed', record('edge-removed'))
|
|
102
|
+
|
|
103
|
+
return () => sse.close()
|
|
104
|
+
}, [])
|
|
105
|
+
|
|
37
106
|
const nodeCount = graphData?.nodes.length ?? '—'
|
|
38
107
|
const edgeCount = graphData?.edges.length ?? '—'
|
|
39
108
|
|
|
109
|
+
// ADR-058 #1 — `data-connection-state` is a stable attribute the contract
|
|
110
|
+
// test asserts. The colour classes follow.
|
|
111
|
+
const connColor: Record<ConnState, string> = {
|
|
112
|
+
ok: 'var(--prov-observed)',
|
|
113
|
+
slow: '#d3a847',
|
|
114
|
+
down: '#e87a7a',
|
|
115
|
+
}
|
|
116
|
+
const sseColor: Record<SseState, string> = {
|
|
117
|
+
connected: 'var(--prov-observed)',
|
|
118
|
+
reconnecting: '#d3a847',
|
|
119
|
+
disconnected: '#e87a7a',
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Suppress unused-var warnings for buses imported above so we keep the
|
|
123
|
+
// module-side effect (event subscriptions in DebugPanel are wired).
|
|
124
|
+
void connectionBus
|
|
125
|
+
void sseEventBus
|
|
126
|
+
type _typecheck = ConnectionEvent | SseEvent
|
|
127
|
+
void (null as unknown as _typecheck)
|
|
128
|
+
|
|
40
129
|
return (
|
|
41
130
|
<footer className="status">
|
|
42
|
-
<div
|
|
131
|
+
<div
|
|
132
|
+
className="st-item"
|
|
133
|
+
data-connection-state={connState}
|
|
134
|
+
title={`daemon @ ${CORE_URL_PUBLIC} — ${connState}`}
|
|
135
|
+
>
|
|
136
|
+
<span className="dot" style={{ background: connColor[connState] }} />
|
|
43
137
|
<span className="k">neat</span>
|
|
44
138
|
<span className="v">{project}</span>
|
|
45
139
|
</div>
|
|
140
|
+
<div className="st-item" data-sse-state={sseState} title={`live updates: ${sseState}`}>
|
|
141
|
+
<span className="dot" style={{ background: sseColor[sseState] }} />
|
|
142
|
+
<span className="k">sse</span>
|
|
143
|
+
<span className="v">{sseState}</span>
|
|
144
|
+
</div>
|
|
46
145
|
<div className="st-item">
|
|
47
146
|
<span className="k">nodes</span>
|
|
48
147
|
<span className="v" id="st-nodes">{nodeCount}</span>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { toastBus, type ToastEvent } from '../../lib/proxy-client'
|
|
5
|
+
|
|
6
|
+
// ADR-058 #3 — surfaces non-2xx fetch responses as transient toasts.
|
|
7
|
+
// Subscribes to `toastBus` populated by trackedFetch. Auto-dismisses after
|
|
8
|
+
// six seconds; stacks up to four at a time.
|
|
9
|
+
export function Toaster() {
|
|
10
|
+
const [toasts, setToasts] = useState<ToastEvent[]>([])
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const unsub = toastBus.subscribe((t) => {
|
|
14
|
+
setToasts((prev) => [...prev, t].slice(-4))
|
|
15
|
+
window.setTimeout(() => {
|
|
16
|
+
setToasts((prev) => prev.filter((p) => p.id !== t.id))
|
|
17
|
+
}, 6_000)
|
|
18
|
+
})
|
|
19
|
+
return unsub
|
|
20
|
+
}, [])
|
|
21
|
+
|
|
22
|
+
if (toasts.length === 0) return null
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
role="status"
|
|
27
|
+
aria-live="polite"
|
|
28
|
+
style={{
|
|
29
|
+
position: 'fixed',
|
|
30
|
+
bottom: 56,
|
|
31
|
+
right: 16,
|
|
32
|
+
display: 'flex',
|
|
33
|
+
flexDirection: 'column',
|
|
34
|
+
gap: 8,
|
|
35
|
+
zIndex: 999,
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{toasts.map((t) => {
|
|
39
|
+
const color = t.level === 'error' ? '#e87a7a' : t.level === 'warn' ? '#d3a847' : 'var(--prov-observed)'
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
key={t.id}
|
|
43
|
+
className="toast"
|
|
44
|
+
onClick={() => setToasts((prev) => prev.filter((p) => p.id !== t.id))}
|
|
45
|
+
style={{
|
|
46
|
+
background: 'var(--ink-2, #14141a)',
|
|
47
|
+
border: `1px solid ${color}`,
|
|
48
|
+
borderLeft: `3px solid ${color}`,
|
|
49
|
+
padding: '8px 12px',
|
|
50
|
+
fontFamily: 'JetBrains Mono, monospace',
|
|
51
|
+
fontSize: 11,
|
|
52
|
+
color: 'var(--paper-1, #d8d3c9)',
|
|
53
|
+
maxWidth: 360,
|
|
54
|
+
cursor: 'pointer',
|
|
55
|
+
boxShadow: '0 8px 16px rgba(0,0,0,0.35)',
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline' }}>
|
|
59
|
+
<span style={{ color, fontWeight: 600 }}>{t.status ?? t.level}</span>
|
|
60
|
+
<span style={{ flex: 1 }}>{t.message}</span>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { CORE_URL_PUBLIC } from '../../lib/proxy-client'
|
|
4
5
|
|
|
5
6
|
interface Project {
|
|
6
7
|
name: string
|
|
@@ -189,10 +190,36 @@ export function TopBar({ project, onProjectChange, onNodeSelect, onRelayout, onT
|
|
|
189
190
|
</div>
|
|
190
191
|
|
|
191
192
|
<div className="top-actions">
|
|
193
|
+
{/* ADR-058 #5 — daemon URL visible. */}
|
|
194
|
+
<span className="daemon-url" title="NEAT daemon URL">{CORE_URL_PUBLIC}</span>
|
|
192
195
|
<button className="top-btn" aria-label={isLive ? 'Core connected' : 'Core offline'}>
|
|
193
196
|
<span className={`dot${isLive ? ' live' : ''}`} />
|
|
194
197
|
{isLive ? 'Live' : 'Offline'}
|
|
195
198
|
</button>
|
|
199
|
+
{/* ADR-056 — History deferred; explicitly disabled with affordance. */}
|
|
200
|
+
<button className="top-btn" disabled title="History — coming in v0.3.x" aria-label="History (coming soon)" style={{ opacity: 0.4, cursor: 'not-allowed' }}>
|
|
201
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
202
|
+
<circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
|
|
203
|
+
</svg>
|
|
204
|
+
History
|
|
205
|
+
</button>
|
|
206
|
+
{/* ADR-056 — Share wired: copies the deep-link URL to clipboard. */}
|
|
207
|
+
<button
|
|
208
|
+
className="top-btn"
|
|
209
|
+
title="Copy current view URL"
|
|
210
|
+
aria-label="Share — copy URL"
|
|
211
|
+
onClick={() => {
|
|
212
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
213
|
+
void navigator.clipboard.writeText(window.location.href)
|
|
214
|
+
}
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
218
|
+
<circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="6" r="2.5" /><circle cx="18" cy="18" r="2.5" />
|
|
219
|
+
<path d="m8 11 8-4M8 13l8 4" />
|
|
220
|
+
</svg>
|
|
221
|
+
Share
|
|
222
|
+
</button>
|
|
196
223
|
<button className="top-btn" title="Re-run cose layout" onClick={onRelayout}>
|
|
197
224
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
198
225
|
<path d="M12 8v4l3 2" /><circle cx="12" cy="12" r="9" />
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
|
|
6
|
+
interface Incident {
|
|
7
|
+
nodeId: string
|
|
8
|
+
timestamp: string
|
|
9
|
+
type: string
|
|
10
|
+
message: string
|
|
11
|
+
stacktrace?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface IncidentsResponse {
|
|
15
|
+
count: number
|
|
16
|
+
total: number
|
|
17
|
+
events: Incident[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatTs(iso: string): string {
|
|
21
|
+
try {
|
|
22
|
+
const d = new Date(iso)
|
|
23
|
+
return d.toLocaleDateString() + ' ' + d.toTimeString().slice(0, 8)
|
|
24
|
+
} catch {
|
|
25
|
+
return iso
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function IncidentsClient() {
|
|
30
|
+
const [data, setData] = useState<IncidentsResponse | null>(null)
|
|
31
|
+
const [loading, setLoading] = useState(true)
|
|
32
|
+
const [error, setError] = useState<string | null>(null)
|
|
33
|
+
const [openRow, setOpenRow] = useState<string | null>(null)
|
|
34
|
+
|
|
35
|
+
// ADR-057 #2 — read project from URL (deep-linkable) or localStorage,
|
|
36
|
+
// matching AppShell's resolution chain. The lazy initializer reads
|
|
37
|
+
// window.* synchronously; safe because the page mounts client-only via
|
|
38
|
+
// dynamic({ ssr: false }) per ADR-062 §4 (2026-05-11 amendment). The
|
|
39
|
+
// typeof window guard stays as belt-and-suspenders — if someone later
|
|
40
|
+
// removes the dynamic wrapper, this degrades to a flicker, not a crash.
|
|
41
|
+
const [project] = useState<string>(() => {
|
|
42
|
+
if (typeof window === 'undefined') return 'default'
|
|
43
|
+
const fromUrl = new URLSearchParams(window.location.search).get('project')
|
|
44
|
+
if (fromUrl) return fromUrl
|
|
45
|
+
try {
|
|
46
|
+
const stored = window.localStorage.getItem('neat:lastProject')
|
|
47
|
+
if (stored) return stored
|
|
48
|
+
} catch { /* noop */ }
|
|
49
|
+
return 'default'
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ADR-057 #3 — re-fetch when project changes.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setLoading(true)
|
|
55
|
+
fetch(`/api/incidents?limit=100&project=${encodeURIComponent(project)}`)
|
|
56
|
+
.then((r) => r.json())
|
|
57
|
+
.then((d: IncidentsResponse) => {
|
|
58
|
+
setData(d)
|
|
59
|
+
setLoading(false)
|
|
60
|
+
})
|
|
61
|
+
.catch((e: Error) => {
|
|
62
|
+
setError(e.message)
|
|
63
|
+
setLoading(false)
|
|
64
|
+
})
|
|
65
|
+
}, [project])
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div style={{ background: 'var(--ink-0)', minHeight: '100vh' }}>
|
|
69
|
+
<header className="topbar">
|
|
70
|
+
<div className="brand" title="NEAT">N</div>
|
|
71
|
+
<div className="crumbs">
|
|
72
|
+
<Link href="/" className="incidents-nav-link">graph view</Link>
|
|
73
|
+
<span className="sep">/</span>
|
|
74
|
+
<span className="here">incidents</span>
|
|
75
|
+
</div>
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
<div className="incidents-page" style={{ marginTop: 44 }}>
|
|
79
|
+
<h1>Incidents</h1>
|
|
80
|
+
<div className="subtitle">
|
|
81
|
+
{data ? `${data.total} total events — showing ${data.events.length}` : 'loading…'}
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{loading && (
|
|
85
|
+
<div className="incidents-empty">loading…</div>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
{error && (
|
|
89
|
+
<div className="incidents-empty" style={{ color: '#e87a7a' }}>
|
|
90
|
+
failed to load: {error}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{!loading && !error && data && data.events.length === 0 && (
|
|
95
|
+
<div className="incidents-empty">no incidents recorded</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{!loading && !error && data && data.events.length > 0 && (
|
|
99
|
+
<table className="incidents-table">
|
|
100
|
+
<thead>
|
|
101
|
+
<tr>
|
|
102
|
+
<th>Node</th>
|
|
103
|
+
<th>Time</th>
|
|
104
|
+
<th>Type</th>
|
|
105
|
+
<th>Message</th>
|
|
106
|
+
</tr>
|
|
107
|
+
</thead>
|
|
108
|
+
<tbody>
|
|
109
|
+
{data.events.map((evt, i) => {
|
|
110
|
+
const rowKey = `${i}-${evt.nodeId}`
|
|
111
|
+
const isOpen = openRow === rowKey
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<tr
|
|
115
|
+
key={rowKey}
|
|
116
|
+
style={{ cursor: evt.stacktrace ? 'pointer' : undefined }}
|
|
117
|
+
onClick={() => evt.stacktrace && setOpenRow(isOpen ? null : rowKey)}
|
|
118
|
+
title={evt.stacktrace ? (isOpen ? 'Collapse stacktrace' : 'Expand stacktrace') : undefined}
|
|
119
|
+
>
|
|
120
|
+
<td className="td-node">
|
|
121
|
+
<Link href={`/?node=${encodeURIComponent(evt.nodeId)}&project=${encodeURIComponent(project)}`} className="incidents-node-link">
|
|
122
|
+
{evt.nodeId}
|
|
123
|
+
</Link>
|
|
124
|
+
</td>
|
|
125
|
+
<td className="td-time">{formatTs(evt.timestamp)}</td>
|
|
126
|
+
<td className="td-type">{evt.type}</td>
|
|
127
|
+
<td className="td-msg">
|
|
128
|
+
{evt.message}
|
|
129
|
+
{evt.stacktrace && (
|
|
130
|
+
<span className="stack-toggle">{isOpen ? ' ▲' : ' ▼'}</span>
|
|
131
|
+
)}
|
|
132
|
+
</td>
|
|
133
|
+
</tr>
|
|
134
|
+
{isOpen && evt.stacktrace && (
|
|
135
|
+
<tr key={`${rowKey}-stack`}>
|
|
136
|
+
<td colSpan={4} className="td-stacktrace">
|
|
137
|
+
<pre className="stacktrace-pre">{evt.stacktrace}</pre>
|
|
138
|
+
</td>
|
|
139
|
+
</tr>
|
|
140
|
+
)}
|
|
141
|
+
</>
|
|
142
|
+
)
|
|
143
|
+
})}
|
|
144
|
+
</tbody>
|
|
145
|
+
</table>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
package/app/incidents/page.tsx
CHANGED
|
@@ -1,145 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
total: number
|
|
17
|
-
events: Incident[]
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function formatTs(iso: string): string {
|
|
21
|
-
try {
|
|
22
|
-
const d = new Date(iso)
|
|
23
|
-
return d.toLocaleDateString() + ' ' + d.toTimeString().slice(0, 8)
|
|
24
|
-
} catch {
|
|
25
|
-
return iso
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export default function IncidentsPage() {
|
|
30
|
-
const [data, setData] = useState<IncidentsResponse | null>(null)
|
|
31
|
-
const [loading, setLoading] = useState(true)
|
|
32
|
-
const [error, setError] = useState<string | null>(null)
|
|
33
|
-
const [openRow, setOpenRow] = useState<string | null>(null)
|
|
34
|
-
|
|
35
|
-
// ADR-057 #2 — page reads project from URL (deep-linkable) or localStorage,
|
|
36
|
-
// matching AppShell's resolution chain so the incidents view stays in sync
|
|
37
|
-
// with whichever project the operator last selected on the graph view.
|
|
38
|
-
const [project, setProject] = useState<string>('default')
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
if (typeof window === 'undefined') return
|
|
41
|
-
const fromUrl = new URLSearchParams(window.location.search).get('project')
|
|
42
|
-
let stored: string | null = null
|
|
43
|
-
try { stored = window.localStorage.getItem('neat:lastProject') } catch { /* noop */ }
|
|
44
|
-
setProject(fromUrl || stored || 'default')
|
|
45
|
-
}, [])
|
|
46
|
-
|
|
47
|
-
// ADR-057 #3 — re-fetch when project changes.
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
setLoading(true)
|
|
50
|
-
fetch(`/api/incidents?limit=100&project=${encodeURIComponent(project)}`)
|
|
51
|
-
.then((r) => r.json())
|
|
52
|
-
.then((d: IncidentsResponse) => {
|
|
53
|
-
setData(d)
|
|
54
|
-
setLoading(false)
|
|
55
|
-
})
|
|
56
|
-
.catch((e: Error) => {
|
|
57
|
-
setError(e.message)
|
|
58
|
-
setLoading(false)
|
|
59
|
-
})
|
|
60
|
-
}, [project])
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
<div style={{ background: 'var(--ink-0)', minHeight: '100vh' }}>
|
|
64
|
-
<header className="topbar">
|
|
65
|
-
<div className="brand" title="NEAT">N</div>
|
|
66
|
-
<div className="crumbs">
|
|
67
|
-
<Link href="/" className="incidents-nav-link">graph view</Link>
|
|
68
|
-
<span className="sep">/</span>
|
|
69
|
-
<span className="here">incidents</span>
|
|
70
|
-
</div>
|
|
71
|
-
</header>
|
|
72
|
-
|
|
73
|
-
<div className="incidents-page" style={{ marginTop: 44 }}>
|
|
74
|
-
<h1>Incidents</h1>
|
|
75
|
-
<div className="subtitle">
|
|
76
|
-
{data ? `${data.total} total events — showing ${data.events.length}` : 'loading…'}
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
|
-
{loading && (
|
|
80
|
-
<div className="incidents-empty">loading…</div>
|
|
81
|
-
)}
|
|
82
|
-
|
|
83
|
-
{error && (
|
|
84
|
-
<div className="incidents-empty" style={{ color: '#e87a7a' }}>
|
|
85
|
-
failed to load: {error}
|
|
86
|
-
</div>
|
|
87
|
-
)}
|
|
88
|
-
|
|
89
|
-
{!loading && !error && data && data.events.length === 0 && (
|
|
90
|
-
<div className="incidents-empty">no incidents recorded</div>
|
|
91
|
-
)}
|
|
92
|
-
|
|
93
|
-
{!loading && !error && data && data.events.length > 0 && (
|
|
94
|
-
<table className="incidents-table">
|
|
95
|
-
<thead>
|
|
96
|
-
<tr>
|
|
97
|
-
<th>Node</th>
|
|
98
|
-
<th>Time</th>
|
|
99
|
-
<th>Type</th>
|
|
100
|
-
<th>Message</th>
|
|
101
|
-
</tr>
|
|
102
|
-
</thead>
|
|
103
|
-
<tbody>
|
|
104
|
-
{data.events.map((evt, i) => {
|
|
105
|
-
const rowKey = `${i}-${evt.nodeId}`
|
|
106
|
-
const isOpen = openRow === rowKey
|
|
107
|
-
return (
|
|
108
|
-
<>
|
|
109
|
-
<tr
|
|
110
|
-
key={rowKey}
|
|
111
|
-
style={{ cursor: evt.stacktrace ? 'pointer' : undefined }}
|
|
112
|
-
onClick={() => evt.stacktrace && setOpenRow(isOpen ? null : rowKey)}
|
|
113
|
-
title={evt.stacktrace ? (isOpen ? 'Collapse stacktrace' : 'Expand stacktrace') : undefined}
|
|
114
|
-
>
|
|
115
|
-
<td className="td-node">
|
|
116
|
-
<Link href={`/?node=${encodeURIComponent(evt.nodeId)}&project=${encodeURIComponent(project)}`} className="incidents-node-link">
|
|
117
|
-
{evt.nodeId}
|
|
118
|
-
</Link>
|
|
119
|
-
</td>
|
|
120
|
-
<td className="td-time">{formatTs(evt.timestamp)}</td>
|
|
121
|
-
<td className="td-type">{evt.type}</td>
|
|
122
|
-
<td className="td-msg">
|
|
123
|
-
{evt.message}
|
|
124
|
-
{evt.stacktrace && (
|
|
125
|
-
<span className="stack-toggle">{isOpen ? ' ▲' : ' ▼'}</span>
|
|
126
|
-
)}
|
|
127
|
-
</td>
|
|
128
|
-
</tr>
|
|
129
|
-
{isOpen && evt.stacktrace && (
|
|
130
|
-
<tr key={`${rowKey}-stack`}>
|
|
131
|
-
<td colSpan={4} className="td-stacktrace">
|
|
132
|
-
<pre className="stacktrace-pre">{evt.stacktrace}</pre>
|
|
133
|
-
</td>
|
|
134
|
-
</tr>
|
|
135
|
-
)}
|
|
136
|
-
</>
|
|
137
|
-
)
|
|
138
|
-
})}
|
|
139
|
-
</tbody>
|
|
140
|
-
</table>
|
|
141
|
-
)}
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
)
|
|
1
|
+
import dynamic from 'next/dynamic'
|
|
2
|
+
|
|
3
|
+
// ADR-062 §4 (2026-05-11 amendment) — /incidents renders client-only.
|
|
4
|
+
// Same shape as app/page.tsx: the IncidentsClient subtree reads the URL
|
|
5
|
+
// and localStorage synchronously inside its useState lazy initializer,
|
|
6
|
+
// which would diverge from the SSR pass; removing the SSR pass keeps the
|
|
7
|
+
// resolution chain honest and avoids the double-fetch the deferred shape
|
|
8
|
+
// produced on every page load.
|
|
9
|
+
const IncidentsClient = dynamic(
|
|
10
|
+
() => import('./IncidentsClient').then((m) => m.IncidentsClient),
|
|
11
|
+
{ ssr: false },
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
export default function IncidentsPage(): JSX.Element {
|
|
15
|
+
return <IncidentsClient />
|
|
145
16
|
}
|
package/app/page.tsx
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import
|
|
1
|
+
import dynamic from 'next/dynamic'
|
|
2
|
+
|
|
3
|
+
// ADR-062 — AppShell renders client-only. The Next.js server emits the
|
|
4
|
+
// static HTML shell (head, fonts, CSS); the React tree builds on mount.
|
|
5
|
+
// Removing { ssr: false } reintroduces the hydration bug ADR-062 closed.
|
|
6
|
+
const AppShell = dynamic(
|
|
7
|
+
() => import('./components/AppShell').then((m) => m.AppShell),
|
|
8
|
+
{ ssr: false },
|
|
9
|
+
)
|
|
2
10
|
|
|
3
11
|
export default function Home(): JSX.Element {
|
|
4
12
|
return <AppShell />
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Public-facing daemon URL string. Browsers don't get NEAT_API_URL directly —
|
|
4
|
+
// the Next.js server routes proxy at /api/* — but the operator wants to see
|
|
5
|
+
// which daemon is on the other end (ADR-058 #5). This is the human-readable
|
|
6
|
+
// label, not a fetched URL.
|
|
7
|
+
export const CORE_URL_PUBLIC: string =
|
|
8
|
+
(typeof process !== 'undefined' && process.env?.NEXT_PUBLIC_NEAT_API_URL) ||
|
|
9
|
+
'localhost:8080'
|
|
10
|
+
|
|
11
|
+
// Tiny pub-sub for transient surfaces (toasts, debug-panel call log).
|
|
12
|
+
type Subscriber<T> = (value: T) => void
|
|
13
|
+
|
|
14
|
+
class Channel<T> {
|
|
15
|
+
private subs = new Set<Subscriber<T>>()
|
|
16
|
+
subscribe(fn: Subscriber<T>): () => void {
|
|
17
|
+
this.subs.add(fn)
|
|
18
|
+
return () => {
|
|
19
|
+
this.subs.delete(fn)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
emit(value: T): void {
|
|
23
|
+
this.subs.forEach((fn) => {
|
|
24
|
+
try {
|
|
25
|
+
fn(value)
|
|
26
|
+
} catch {
|
|
27
|
+
/* never let a subscriber kill the bus */
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ToastEvent {
|
|
34
|
+
id: number
|
|
35
|
+
level: 'error' | 'warn' | 'info'
|
|
36
|
+
message: string
|
|
37
|
+
status?: number
|
|
38
|
+
timestamp: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ApiCallEvent {
|
|
42
|
+
path: string
|
|
43
|
+
status: number
|
|
44
|
+
durationMs: number
|
|
45
|
+
timestamp: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SseEvent {
|
|
49
|
+
type: string
|
|
50
|
+
timestamp: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConnectionEvent {
|
|
54
|
+
state: 'ok' | 'slow' | 'down'
|
|
55
|
+
rttMs?: number
|
|
56
|
+
timestamp: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const toastBus = new Channel<ToastEvent>()
|
|
60
|
+
export const apiCallBus = new Channel<ApiCallEvent>()
|
|
61
|
+
export const sseEventBus = new Channel<SseEvent>()
|
|
62
|
+
export const connectionBus = new Channel<ConnectionEvent>()
|
|
63
|
+
|
|
64
|
+
let toastIdCounter = 0
|
|
65
|
+
function nextId(): number {
|
|
66
|
+
toastIdCounter += 1
|
|
67
|
+
return toastIdCounter
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ADR-058 #3 — every non-2xx fetch surfaces a toast carrying the error
|
|
71
|
+
// envelope from ADR-040 (`{ error, status, details? }`). Wrap raw `fetch`
|
|
72
|
+
// instead of editing every call-site so the rule is centralized.
|
|
73
|
+
export async function trackedFetch(input: string, init?: RequestInit): Promise<Response> {
|
|
74
|
+
const start = performance.now()
|
|
75
|
+
let status = 0
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(input, init)
|
|
78
|
+
status = res.status
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
let message = `${input} → ${res.status}`
|
|
81
|
+
try {
|
|
82
|
+
const cloned = res.clone()
|
|
83
|
+
const ct = cloned.headers.get('content-type') ?? ''
|
|
84
|
+
if (ct.includes('application/json')) {
|
|
85
|
+
const body = (await cloned.json()) as { error?: string; details?: string }
|
|
86
|
+
if (body?.error) {
|
|
87
|
+
message = body.error + (body.details ? ` — ${body.details}` : '')
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
/* fall back to the default message */
|
|
92
|
+
}
|
|
93
|
+
toastBus.emit({
|
|
94
|
+
id: nextId(),
|
|
95
|
+
level: res.status >= 500 ? 'error' : 'warn',
|
|
96
|
+
message,
|
|
97
|
+
status: res.status,
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
return res
|
|
102
|
+
} catch (err) {
|
|
103
|
+
toastBus.emit({
|
|
104
|
+
id: nextId(),
|
|
105
|
+
level: 'error',
|
|
106
|
+
message: `${input} — ${(err as Error).message}`,
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
})
|
|
109
|
+
throw err
|
|
110
|
+
} finally {
|
|
111
|
+
apiCallBus.emit({
|
|
112
|
+
path: input,
|
|
113
|
+
status,
|
|
114
|
+
durationMs: Math.round(performance.now() - start),
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neat.is/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "NEAT web shell — minimal Next.js app fronting the core API",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"homepage": "https://neat.is",
|
|
@@ -34,17 +34,22 @@
|
|
|
34
34
|
"clean": "rm -rf .next .turbo"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@neat.is/types": "^0.
|
|
37
|
+
"@neat.is/types": "^0.3.1",
|
|
38
38
|
"cytoscape": "^3.33.3",
|
|
39
39
|
"next": "14.2.35",
|
|
40
40
|
"react": "^18",
|
|
41
41
|
"react-dom": "^18"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
+
"@testing-library/jest-dom": "^6.6.0",
|
|
45
|
+
"@testing-library/react": "^16.1.0",
|
|
46
|
+
"@testing-library/user-event": "^14.5.2",
|
|
44
47
|
"@types/cytoscape": "^3.21.9",
|
|
45
48
|
"@types/node": "^20",
|
|
46
49
|
"@types/react": "^18",
|
|
47
50
|
"@types/react-dom": "^18",
|
|
51
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
52
|
+
"jsdom": "^25.0.1",
|
|
48
53
|
"postcss": "^8",
|
|
49
54
|
"tailwindcss": "^3.4.1",
|
|
50
55
|
"typescript": "^5",
|