@neat.is/web 0.2.10
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/api/events/route.ts +54 -0
- package/app/api/graph/blast-radius/[id]/route.ts +19 -0
- package/app/api/graph/node/[id]/route.ts +17 -0
- package/app/api/graph/root-cause/[id]/route.ts +17 -0
- package/app/api/graph/route.ts +12 -0
- package/app/api/health/route.ts +13 -0
- package/app/api/incidents/route.ts +16 -0
- package/app/api/policies/violations/route.ts +11 -0
- package/app/api/projects/route.ts +11 -0
- package/app/api/search/route.ts +17 -0
- package/app/api/stale-events/route.ts +15 -0
- package/app/claude-design/Neat Graph View.html +925 -0
- package/app/claude-design/app.js +604 -0
- package/app/components/AppShell.tsx +109 -0
- package/app/components/GraphCanvas.tsx +607 -0
- package/app/components/Inspector.tsx +329 -0
- package/app/components/Rail.tsx +124 -0
- package/app/components/StatusBar.tsx +72 -0
- package/app/components/TopBar.tsx +211 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +891 -0
- package/app/incidents/page.tsx +145 -0
- package/app/layout.tsx +27 -0
- package/app/page.tsx +5 -0
- package/lib/fixtures.ts +94 -0
- package/lib/proxy.ts +16 -0
- package/package.json +53 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import type { GraphNode, GraphEdge } from '@neat.is/types'
|
|
5
|
+
import type { GraphData } from './AppShell'
|
|
6
|
+
|
|
7
|
+
interface RootCauseResult {
|
|
8
|
+
origin: string
|
|
9
|
+
rootCauseNode: string | null
|
|
10
|
+
reason: string
|
|
11
|
+
fixRecommendation: string | null
|
|
12
|
+
confidence: number
|
|
13
|
+
traversalPath: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function escapeHtml(s: string): string {
|
|
17
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] ?? c))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function visualProv(provenance: string): 'STATIC' | 'OBSERVED' | 'INFERRED' {
|
|
21
|
+
if (provenance === 'OBSERVED') return 'OBSERVED'
|
|
22
|
+
if (provenance === 'INFERRED') return 'INFERRED'
|
|
23
|
+
return 'STATIC'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nodeName(node: GraphNode): string {
|
|
27
|
+
return (node as unknown as { name?: string }).name ?? node.id
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nodeProps(node: GraphNode): [string, string][] {
|
|
31
|
+
const props: [string, string][] = []
|
|
32
|
+
const n = node as Record<string, unknown>
|
|
33
|
+
if (n.language) props.push(['language', String(n.language)])
|
|
34
|
+
if (n.version) props.push(['version', String(n.version)])
|
|
35
|
+
if (n.engine) props.push(['engine', String(n.engine)])
|
|
36
|
+
if (n.engineVersion) props.push(['engine version', String(n.engineVersion)])
|
|
37
|
+
if (n.provider) props.push(['provider', String(n.provider)])
|
|
38
|
+
if (n.region) props.push(['region', String(n.region)])
|
|
39
|
+
if (n.kind) props.push(['kind', String(n.kind)])
|
|
40
|
+
if (n.host) props.push(['host', String(n.host)])
|
|
41
|
+
if (n.port) props.push(['port', String(n.port)])
|
|
42
|
+
if (n.fileType) props.push(['file type', String(n.fileType)])
|
|
43
|
+
if (n.path) props.push(['path', String(n.path)])
|
|
44
|
+
if (n.firstObserved) props.push(['first seen', String(n.firstObserved)])
|
|
45
|
+
if (n.lastObserved) props.push(['last seen', String(n.lastObserved)])
|
|
46
|
+
return props
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface EdgeRow {
|
|
50
|
+
verb: string
|
|
51
|
+
target: string
|
|
52
|
+
prov: 'STATIC' | 'OBSERVED' | 'INFERRED'
|
|
53
|
+
conf?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface InspectorProps {
|
|
57
|
+
project: string
|
|
58
|
+
selectedNodeId: string | null
|
|
59
|
+
graphData: GraphData | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function Inspector({ project, selectedNodeId, graphData }: InspectorProps) {
|
|
63
|
+
const [node, setNode] = useState<GraphNode | null>(null)
|
|
64
|
+
const [rootCause, setRootCause] = useState<RootCauseResult | null>(null)
|
|
65
|
+
const [activeTab, setActiveTab] = useState<'inspect' | 'edges'>('inspect')
|
|
66
|
+
|
|
67
|
+
// Stable synthetic metrics — re-randomised per node, not per render
|
|
68
|
+
const metrics = useMemo(() => ({
|
|
69
|
+
rps: Math.round(40 + Math.random() * 200),
|
|
70
|
+
p99: (38 + Math.random() * 64).toFixed(1),
|
|
71
|
+
err: (Math.random() * 0.7).toFixed(2),
|
|
72
|
+
rpsDelta: (Math.random() * 4).toFixed(1),
|
|
73
|
+
p99Delta: (Math.random() * 8).toFixed(1),
|
|
74
|
+
errDelta: (Math.random() * 0.3).toFixed(2),
|
|
75
|
+
}), [selectedNodeId])
|
|
76
|
+
|
|
77
|
+
// ADR-057 #3 — re-fetch when project or selection changes.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!selectedNodeId) {
|
|
80
|
+
setNode(null)
|
|
81
|
+
setRootCause(null)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
const proj = `?project=${encodeURIComponent(project)}`
|
|
85
|
+
fetch(`/api/graph/node/${encodeURIComponent(selectedNodeId)}${proj}`)
|
|
86
|
+
.then((r) => r.json())
|
|
87
|
+
.then((d: { node: GraphNode }) => setNode(d.node))
|
|
88
|
+
.catch(() => {})
|
|
89
|
+
|
|
90
|
+
fetch(`/api/graph/root-cause/${encodeURIComponent(selectedNodeId)}${proj}`)
|
|
91
|
+
.then((r) => r.json())
|
|
92
|
+
.then((d: RootCauseResult) => setRootCause(d.rootCauseNode ? d : null))
|
|
93
|
+
.catch(() => {})
|
|
94
|
+
}, [selectedNodeId, project])
|
|
95
|
+
|
|
96
|
+
if (!selectedNodeId || !node) {
|
|
97
|
+
return (
|
|
98
|
+
<aside className="inspect" id="inspect">
|
|
99
|
+
<div className="inspect-tabs" role="tablist">
|
|
100
|
+
<div className="inspect-tab on" role="tab" aria-selected={true}>Inspect</div>
|
|
101
|
+
<div className="inspect-tab" role="tab" aria-selected={false}>Edges</div>
|
|
102
|
+
<div className="inspect-tab" role="tab" aria-selected={false}>Owners</div>
|
|
103
|
+
<div className="inspect-tab" role="tab" aria-selected={false}>History</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="insp-section" style={{ paddingTop: 32 }}>
|
|
106
|
+
<div style={{ fontFamily: 'Spectral, serif', fontStyle: 'italic', color: 'var(--paper-3)', textAlign: 'center' }}>
|
|
107
|
+
select a node to inspect
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</aside>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Derive edges from graphData (avoids extra fetch)
|
|
115
|
+
const outEdges: EdgeRow[] = graphData
|
|
116
|
+
? graphData.edges
|
|
117
|
+
.filter((e: GraphEdge) => e.source === node.id)
|
|
118
|
+
.map((e: GraphEdge) => {
|
|
119
|
+
const targetNode = graphData.nodes.find((n: GraphNode) => n.id === e.target)
|
|
120
|
+
return {
|
|
121
|
+
verb: e.type.toLowerCase().replace(/_/g, ' '),
|
|
122
|
+
target: targetNode ? nodeName(targetNode) : e.target,
|
|
123
|
+
prov: visualProv(e.provenance),
|
|
124
|
+
conf: e.confidence,
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
: []
|
|
128
|
+
|
|
129
|
+
const inEdges: EdgeRow[] = graphData
|
|
130
|
+
? graphData.edges
|
|
131
|
+
.filter((e: GraphEdge) => e.target === node.id)
|
|
132
|
+
.map((e: GraphEdge) => {
|
|
133
|
+
const srcNode = graphData.nodes.find((n: GraphNode) => n.id === e.source)
|
|
134
|
+
return {
|
|
135
|
+
verb: e.type.toLowerCase().replace(/_/g, ' '),
|
|
136
|
+
target: srcNode ? nodeName(srcNode) : e.source,
|
|
137
|
+
prov: visualProv(e.provenance),
|
|
138
|
+
conf: e.confidence,
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
: []
|
|
142
|
+
|
|
143
|
+
const allEdges = [...outEdges, ...inEdges]
|
|
144
|
+
const edgeCount = allEdges.length
|
|
145
|
+
const props = nodeProps(node)
|
|
146
|
+
const name = nodeName(node)
|
|
147
|
+
const labelParts = name.split('/')
|
|
148
|
+
const stem = labelParts.length > 1 ? labelParts[0] + '/' : ''
|
|
149
|
+
const rest = labelParts.length > 1 ? labelParts.slice(1).join('/') : name
|
|
150
|
+
|
|
151
|
+
const provCounts: Record<string, number> = { STATIC: 0, OBSERVED: 0, INFERRED: 0 }
|
|
152
|
+
allEdges.forEach((e) => { provCounts[e.prov] = (provCounts[e.prov] ?? 0) + 1 })
|
|
153
|
+
const total = allEdges.length || 1
|
|
154
|
+
|
|
155
|
+
const typeLabel = node.type.replace('Node', '').toUpperCase()
|
|
156
|
+
const showMetrics = !['ConfigNode', 'FrontierNode'].includes(node.type)
|
|
157
|
+
const p99Num = parseFloat(metrics.p99)
|
|
158
|
+
const errNum = parseFloat(metrics.err)
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<aside className="inspect" id="inspect">
|
|
162
|
+
<div className="inspect-tabs" role="tablist">
|
|
163
|
+
<div
|
|
164
|
+
className={`inspect-tab${activeTab === 'inspect' ? ' on' : ''}`}
|
|
165
|
+
role="tab"
|
|
166
|
+
aria-selected={activeTab === 'inspect'}
|
|
167
|
+
onClick={() => setActiveTab('inspect')}
|
|
168
|
+
>
|
|
169
|
+
Inspect
|
|
170
|
+
</div>
|
|
171
|
+
<div
|
|
172
|
+
className={`inspect-tab${activeTab === 'edges' ? ' on' : ''}`}
|
|
173
|
+
role="tab"
|
|
174
|
+
aria-selected={activeTab === 'edges'}
|
|
175
|
+
onClick={() => setActiveTab('edges')}
|
|
176
|
+
>
|
|
177
|
+
Edges<span className="ct">{edgeCount}</span>
|
|
178
|
+
</div>
|
|
179
|
+
<div className="inspect-tab" role="tab" aria-selected={false}>Owners</div>
|
|
180
|
+
<div className="inspect-tab" role="tab" aria-selected={false}>History</div>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div id="inspect-body">
|
|
184
|
+
{activeTab === 'inspect' && (
|
|
185
|
+
<>
|
|
186
|
+
<section className="insp-section">
|
|
187
|
+
<div className="insp-eyebrow">{escapeHtml(typeLabel)}</div>
|
|
188
|
+
<div className="insp-title">
|
|
189
|
+
{stem && <span className="stem">{escapeHtml(stem)}</span>}
|
|
190
|
+
{escapeHtml(rest)}
|
|
191
|
+
</div>
|
|
192
|
+
<div className="insp-sub">{escapeHtml(node.id)}</div>
|
|
193
|
+
<div className="insp-tags">
|
|
194
|
+
{(node as { language?: string }).language && (
|
|
195
|
+
<span className="tag">{escapeHtml((node as { language: string }).language)}</span>
|
|
196
|
+
)}
|
|
197
|
+
{(node as { engine?: string }).engine && (
|
|
198
|
+
<span className="tag">{escapeHtml((node as { engine: string }).engine)}</span>
|
|
199
|
+
)}
|
|
200
|
+
{(node as { kind?: string }).kind && (
|
|
201
|
+
<span className="tag">{escapeHtml((node as { kind: string }).kind)}</span>
|
|
202
|
+
)}
|
|
203
|
+
{props.length === 0 && <span className="tag">{escapeHtml(typeLabel.toLowerCase())}</span>}
|
|
204
|
+
</div>
|
|
205
|
+
</section>
|
|
206
|
+
|
|
207
|
+
{showMetrics && (
|
|
208
|
+
<section className="insp-section">
|
|
209
|
+
<div className="metrics">
|
|
210
|
+
<div className="metric">
|
|
211
|
+
<div className="lbl">req/s</div>
|
|
212
|
+
<div className="val">{metrics.rps.toLocaleString()}</div>
|
|
213
|
+
<div className="delta">+{metrics.rpsDelta}%</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="metric">
|
|
216
|
+
<div className="lbl">p99 ms</div>
|
|
217
|
+
<div className="val">{metrics.p99}</div>
|
|
218
|
+
<div className={`delta${p99Num > 80 ? ' bad' : ''}`}>{p99Num > 80 ? '+' : '−'}{metrics.p99Delta}%</div>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="metric">
|
|
221
|
+
<div className="lbl">err %</div>
|
|
222
|
+
<div className="val">{metrics.err}</div>
|
|
223
|
+
<div className={`delta${errNum > 0.4 ? ' bad' : ''}`}>{errNum > 0.4 ? '+' : '−'}{metrics.errDelta}</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</section>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{rootCause && (
|
|
230
|
+
<section className="insp-section">
|
|
231
|
+
<div className="insp-h">Root cause</div>
|
|
232
|
+
<div className="root-cause-block">
|
|
233
|
+
<div className="rc-label">divergence detected</div>
|
|
234
|
+
<div className="rc-node">{escapeHtml(rootCause.rootCauseNode ?? '')}</div>
|
|
235
|
+
<div className="rc-reason">{escapeHtml(rootCause.reason)}</div>
|
|
236
|
+
{rootCause.fixRecommendation && (
|
|
237
|
+
<div className="rc-fix">{escapeHtml(rootCause.fixRecommendation)}</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</section>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{props.length > 0 && (
|
|
244
|
+
<section className="insp-section">
|
|
245
|
+
<div className="insp-h">Properties <span className="ct">{props.length}</span></div>
|
|
246
|
+
<dl className="kv">
|
|
247
|
+
{props.map(([k, v]) => (
|
|
248
|
+
<>
|
|
249
|
+
<dt key={`k-${k}`}>{escapeHtml(k)}</dt>
|
|
250
|
+
<dd key={`v-${k}`}>{escapeHtml(v)}</dd>
|
|
251
|
+
</>
|
|
252
|
+
))}
|
|
253
|
+
</dl>
|
|
254
|
+
</section>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
<section className="insp-section">
|
|
258
|
+
<div className="insp-h">Outgoing <span className="ct">{outEdges.length}</span></div>
|
|
259
|
+
<ul className="edge-list">
|
|
260
|
+
{outEdges.length ? outEdges.map((e, i) => (
|
|
261
|
+
<li key={i}>
|
|
262
|
+
<span className={`pdot ${e.prov}`} />
|
|
263
|
+
<span className="verb">{escapeHtml(e.verb)}</span>
|
|
264
|
+
<span className="target">{escapeHtml(e.target)}</span>
|
|
265
|
+
<span className="conf">{typeof e.conf === 'number' ? e.conf.toFixed(2) : '—'}</span>
|
|
266
|
+
</li>
|
|
267
|
+
)) : (
|
|
268
|
+
<li><span className="verb">—</span><span className="target" style={{ color: 'var(--paper-3)' }}>no outgoing edges</span></li>
|
|
269
|
+
)}
|
|
270
|
+
</ul>
|
|
271
|
+
</section>
|
|
272
|
+
|
|
273
|
+
<section className="insp-section">
|
|
274
|
+
<div className="insp-h">Incoming <span className="ct">{inEdges.length}</span></div>
|
|
275
|
+
<ul className="edge-list">
|
|
276
|
+
{inEdges.length ? inEdges.map((e, i) => (
|
|
277
|
+
<li key={i}>
|
|
278
|
+
<span className={`pdot ${e.prov}`} />
|
|
279
|
+
<span className="verb">{escapeHtml(e.verb)}</span>
|
|
280
|
+
<span className="target">{escapeHtml(e.target)}</span>
|
|
281
|
+
<span className="conf">{typeof e.conf === 'number' ? e.conf.toFixed(2) : '—'}</span>
|
|
282
|
+
</li>
|
|
283
|
+
)) : (
|
|
284
|
+
<li><span className="verb">—</span><span className="target" style={{ color: 'var(--paper-3)' }}>no incoming edges</span></li>
|
|
285
|
+
)}
|
|
286
|
+
</ul>
|
|
287
|
+
</section>
|
|
288
|
+
|
|
289
|
+
<section className="insp-section">
|
|
290
|
+
<div className="insp-h">Provenance <span className="ct">{edgeCount}</span></div>
|
|
291
|
+
{(['STATIC', 'OBSERVED', 'INFERRED'] as const).map((k) => {
|
|
292
|
+
const pct = (provCounts[k] / total) * 100
|
|
293
|
+
return (
|
|
294
|
+
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: '11.5px', margin: '5px 0' }}>
|
|
295
|
+
<span className={`pdot ${k}`} style={{ width: 7, height: 7, borderRadius: '50%', background: `var(--prov-${k.toLowerCase()})`, flexShrink: 0 }} />
|
|
296
|
+
<span style={{ fontStyle: 'italic', width: 70, color: 'var(--paper-2)', fontFamily: 'Spectral, serif' }}>{k.toLowerCase()}</span>
|
|
297
|
+
<div style={{ flex: 1, height: 4, background: 'var(--ink-3)', borderRadius: 2, overflow: 'hidden' }}>
|
|
298
|
+
<div style={{ width: `${pct}%`, height: '100%', background: `var(--prov-${k.toLowerCase()})` }} />
|
|
299
|
+
</div>
|
|
300
|
+
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '10.5px', color: 'var(--paper-3)', width: 34, textAlign: 'right' }}>{provCounts[k]}</span>
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
})}
|
|
304
|
+
</section>
|
|
305
|
+
</>
|
|
306
|
+
)}
|
|
307
|
+
|
|
308
|
+
{activeTab === 'edges' && (
|
|
309
|
+
<section className="insp-section">
|
|
310
|
+
<div className="insp-h">All edges <span className="ct">{edgeCount}</span></div>
|
|
311
|
+
<ul className="edge-list">
|
|
312
|
+
{allEdges.length ? allEdges.map((e, i) => (
|
|
313
|
+
<li key={i}>
|
|
314
|
+
<span className={`pdot ${e.prov}`} />
|
|
315
|
+
<span className="verb">{escapeHtml(e.verb)}</span>
|
|
316
|
+
<span className="target">{escapeHtml(e.target)}</span>
|
|
317
|
+
<span className="conf">{typeof e.conf === 'number' ? e.conf.toFixed(2) : '—'}</span>
|
|
318
|
+
</li>
|
|
319
|
+
)) : (
|
|
320
|
+
<li><span style={{ color: 'var(--paper-3)', fontStyle: 'italic', fontFamily: 'Spectral, serif' }}>no edges</span></li>
|
|
321
|
+
)}
|
|
322
|
+
</ul>
|
|
323
|
+
</section>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
</div>
|
|
327
|
+
</aside>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
|
|
6
|
+
interface RailProps {
|
|
7
|
+
project: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Rail({ project }: RailProps) {
|
|
11
|
+
const [blastBadge, setBlastBadge] = useState(0)
|
|
12
|
+
const [incidentBadge, setIncidentBadge] = useState(0)
|
|
13
|
+
|
|
14
|
+
// ADR-057 #3 — re-fetch on project change.
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const proj = `?project=${encodeURIComponent(project)}`
|
|
17
|
+
fetch(`/api/policies/violations${proj}`)
|
|
18
|
+
.then((r) => r.json())
|
|
19
|
+
.then((d: { violations: unknown[] }) => {
|
|
20
|
+
if (Array.isArray(d.violations)) {
|
|
21
|
+
setBlastBadge(Math.min(d.violations.length, 9))
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
.catch(() => {})
|
|
25
|
+
|
|
26
|
+
fetch(`/api/incidents?limit=1&project=${encodeURIComponent(project)}`)
|
|
27
|
+
.then((r) => r.json())
|
|
28
|
+
.then((d: { total: number }) => {
|
|
29
|
+
if (typeof d.total === 'number') setIncidentBadge(Math.min(d.total, 9))
|
|
30
|
+
})
|
|
31
|
+
.catch(() => {})
|
|
32
|
+
}, [project])
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<nav className="rail">
|
|
36
|
+
<div className="rail-group">
|
|
37
|
+
<button className="rail-btn active" aria-label="Graph view">
|
|
38
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
39
|
+
<circle cx="6" cy="6" r="2.5" /><circle cx="18" cy="6" r="2.5" />
|
|
40
|
+
<circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="18" r="2.5" />
|
|
41
|
+
<path d="M8 6h8M6 8v8M18 8v8M8 18h8" />
|
|
42
|
+
</svg>
|
|
43
|
+
<span className="rail-tip">Graph<span className="k">G</span></span>
|
|
44
|
+
</button>
|
|
45
|
+
<button className="rail-btn" aria-label="Layers view">
|
|
46
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
47
|
+
<path d="M4 7h16M4 12h10M4 17h16" />
|
|
48
|
+
</svg>
|
|
49
|
+
<span className="rail-tip">Layers<span className="k">L</span></span>
|
|
50
|
+
</button>
|
|
51
|
+
<button className="rail-btn" aria-label="Find node">
|
|
52
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
53
|
+
<circle cx="11" cy="11" r="6" /><path d="m20 20-4-4" />
|
|
54
|
+
</svg>
|
|
55
|
+
<span className="rail-tip">Find<span className="k">F</span></span>
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div className="rail-group">
|
|
60
|
+
<button className="rail-btn" aria-label="NeatScript editor">
|
|
61
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
62
|
+
<path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />
|
|
63
|
+
</svg>
|
|
64
|
+
<span className="rail-tip">NeatScript<span className="k">N</span></span>
|
|
65
|
+
</button>
|
|
66
|
+
<button className="rail-btn" aria-label="Time travel">
|
|
67
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
68
|
+
<circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" />
|
|
69
|
+
</svg>
|
|
70
|
+
<span className="rail-tip">Time travel<span className="k">T</span></span>
|
|
71
|
+
</button>
|
|
72
|
+
<button className="rail-btn" aria-label="Blast radius analysis">
|
|
73
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
74
|
+
<circle cx="12" cy="12" r="3" /><circle cx="12" cy="12" r="7" />
|
|
75
|
+
<path d="M12 3v2M12 19v2M3 12h2M19 12h2" />
|
|
76
|
+
</svg>
|
|
77
|
+
<span className="rail-tip">Blast radius<span className="k">B</span></span>
|
|
78
|
+
{blastBadge > 0 && <span className="badge">{blastBadge}</span>}
|
|
79
|
+
</button>
|
|
80
|
+
<button className="rail-btn" aria-label="Graph diff">
|
|
81
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
82
|
+
<path d="M8 4 4 8l4 4M16 12l4 4-4 4M14 4l-4 16" />
|
|
83
|
+
</svg>
|
|
84
|
+
<span className="rail-tip">Diff<span className="k">D</span></span>
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div className="rail-group">
|
|
89
|
+
<button className="rail-btn" aria-label="Comments">
|
|
90
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
91
|
+
<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
|
+
</svg>
|
|
93
|
+
<span className="rail-tip">Comments<span className="k">C</span></span>
|
|
94
|
+
</button>
|
|
95
|
+
<Link href="/incidents" className="rail-btn" aria-label="Incidents log" style={{ textDecoration: 'none' }}>
|
|
96
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
97
|
+
<path d="M12 9v4M12 17h.01" /><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
98
|
+
</svg>
|
|
99
|
+
<span className="rail-tip">Incidents</span>
|
|
100
|
+
{incidentBadge > 0 && <span className="badge">{incidentBadge}</span>}
|
|
101
|
+
</Link>
|
|
102
|
+
<button className="rail-btn" aria-label="Agent control panel">
|
|
103
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
104
|
+
<path d="M12 3v3M12 18v3M5 12H2M22 12h-3M6 6l2 2M16 16l2 2M6 18l2-2M16 8l2-2" />
|
|
105
|
+
<circle cx="12" cy="12" r="3" />
|
|
106
|
+
</svg>
|
|
107
|
+
<span className="rail-tip">Agents<span className="k">A</span></span>
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<div className="rail-spacer" />
|
|
112
|
+
|
|
113
|
+
<div className="rail-group" style={{ borderTop: '1px solid var(--rule)' }}>
|
|
114
|
+
<button className="rail-btn" aria-label="Settings">
|
|
115
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
|
116
|
+
<circle cx="12" cy="12" r="3" />
|
|
117
|
+
<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" />
|
|
118
|
+
</svg>
|
|
119
|
+
<span className="rail-tip">Settings</span>
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
</nav>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import type { GraphData } from './AppShell'
|
|
5
|
+
|
|
6
|
+
interface StatusBarProps {
|
|
7
|
+
project: string
|
|
8
|
+
graphData: GraphData | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatTime(d: Date): string {
|
|
12
|
+
return d.toTimeString().slice(0, 8) + ' ' + d.toTimeString().slice(9, 12)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function StatusBar({ project, graphData }: StatusBarProps) {
|
|
16
|
+
const [now, setNow] = useState(() => formatTime(new Date()))
|
|
17
|
+
const [healthy, setHealthy] = useState<boolean | null>(null)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const id = setInterval(() => setNow(formatTime(new Date())), 1000)
|
|
21
|
+
return () => clearInterval(id)
|
|
22
|
+
}, [])
|
|
23
|
+
|
|
24
|
+
// ADR-057 #3 — re-check health when project changes so the indicator
|
|
25
|
+
// reflects the active project's daemon state.
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const check = () =>
|
|
28
|
+
fetch(`/api/health?project=${encodeURIComponent(project)}`)
|
|
29
|
+
.then((r) => r.json())
|
|
30
|
+
.then((d: { ok: boolean }) => setHealthy(d.ok === true))
|
|
31
|
+
.catch(() => setHealthy(false))
|
|
32
|
+
check()
|
|
33
|
+
const id = setInterval(check, 15_000)
|
|
34
|
+
return () => clearInterval(id)
|
|
35
|
+
}, [project])
|
|
36
|
+
|
|
37
|
+
const nodeCount = graphData?.nodes.length ?? '—'
|
|
38
|
+
const edgeCount = graphData?.edges.length ?? '—'
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<footer className="status">
|
|
42
|
+
<div className={`st-item${healthy ? ' live' : ' live-dead'}`}>
|
|
43
|
+
<span className="k">neat</span>
|
|
44
|
+
<span className="v">{project}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="st-item">
|
|
47
|
+
<span className="k">nodes</span>
|
|
48
|
+
<span className="v" id="st-nodes">{nodeCount}</span>
|
|
49
|
+
</div>
|
|
50
|
+
<div className="st-item">
|
|
51
|
+
<span className="k">edges</span>
|
|
52
|
+
<span className="v" id="st-edges">{edgeCount}</span>
|
|
53
|
+
</div>
|
|
54
|
+
{healthy === false && (
|
|
55
|
+
<div className="st-item">
|
|
56
|
+
<span className="k" style={{ color: '#e87a7a' }}>core offline</span>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
<div className="st-spacer" />
|
|
61
|
+
|
|
62
|
+
<div className="scrub">
|
|
63
|
+
<span className="k">t</span>
|
|
64
|
+
<div className="bar">
|
|
65
|
+
<div className="fill" />
|
|
66
|
+
<div className="head" />
|
|
67
|
+
</div>
|
|
68
|
+
<span className="now">now ⌐ {now}</span>
|
|
69
|
+
</div>
|
|
70
|
+
</footer>
|
|
71
|
+
)
|
|
72
|
+
}
|