@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.
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
+ }