@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,211 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
interface Project {
|
|
6
|
+
name: string
|
|
7
|
+
path?: string
|
|
8
|
+
status?: 'active' | 'paused' | 'broken'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SearchResult {
|
|
12
|
+
node: { id: string; type: string; name?: string }
|
|
13
|
+
score: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TopBarProps {
|
|
17
|
+
project: string
|
|
18
|
+
onProjectChange: (name: string) => void
|
|
19
|
+
onNodeSelect: (id: string) => void
|
|
20
|
+
onRelayout: () => void
|
|
21
|
+
onToggleLock: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function TopBar({ project, onProjectChange, onNodeSelect, onRelayout, onToggleLock }: TopBarProps) {
|
|
25
|
+
const [projects, setProjects] = useState<Project[]>([])
|
|
26
|
+
const [isLive, setIsLive] = useState(false)
|
|
27
|
+
const [query, setQuery] = useState('')
|
|
28
|
+
const [results, setResults] = useState<SearchResult[]>([])
|
|
29
|
+
const [showResults, setShowResults] = useState(false)
|
|
30
|
+
const [showSwitcher, setShowSwitcher] = useState(false)
|
|
31
|
+
const searchRef = useRef<HTMLDivElement>(null)
|
|
32
|
+
const switcherRef = useRef<HTMLDivElement>(null)
|
|
33
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
34
|
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
35
|
+
|
|
36
|
+
// ADR-051 — list projects via GET /projects, used by the switcher (ADR-057 #7).
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
fetch('/api/projects')
|
|
39
|
+
.then((r) => (r.ok ? r.json() : []))
|
|
40
|
+
.then((data: Project[] | { projects?: Project[] }) => {
|
|
41
|
+
const list = Array.isArray(data) ? data : Array.isArray(data?.projects) ? data.projects : []
|
|
42
|
+
setProjects(list)
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {})
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const check = () =>
|
|
49
|
+
fetch('/api/health')
|
|
50
|
+
.then((r) => r.json())
|
|
51
|
+
.then((d: { ok: boolean }) => setIsLive(d.ok === true))
|
|
52
|
+
.catch(() => setIsLive(false))
|
|
53
|
+
check()
|
|
54
|
+
const id = setInterval(check, 15_000)
|
|
55
|
+
return () => clearInterval(id)
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
// ADR-057 #5 — search is project-scoped.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
61
|
+
if (!query.trim()) {
|
|
62
|
+
setResults([])
|
|
63
|
+
setShowResults(false)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
debounceRef.current = setTimeout(() => {
|
|
67
|
+
fetch(`/api/search?q=${encodeURIComponent(query)}&project=${encodeURIComponent(project)}`)
|
|
68
|
+
.then((r) => r.json())
|
|
69
|
+
.then((d: { results: SearchResult[] }) => {
|
|
70
|
+
if (Array.isArray(d.results)) {
|
|
71
|
+
setResults(d.results.slice(0, 8))
|
|
72
|
+
setShowResults(true)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
.catch(() => {})
|
|
76
|
+
}, 280)
|
|
77
|
+
}, [query, project])
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const handler = (e: MouseEvent) => {
|
|
81
|
+
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
|
82
|
+
setShowResults(false)
|
|
83
|
+
}
|
|
84
|
+
if (switcherRef.current && !switcherRef.current.contains(e.target as Node)) {
|
|
85
|
+
setShowSwitcher(false)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
document.addEventListener('mousedown', handler)
|
|
89
|
+
return () => document.removeEventListener('mousedown', handler)
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
// ⌘K / Ctrl+K focuses the search input
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const handler = (e: KeyboardEvent) => {
|
|
95
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
inputRef.current?.focus()
|
|
98
|
+
}
|
|
99
|
+
// F key focuses search when not in an input
|
|
100
|
+
if (e.key === 'f' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
|
|
101
|
+
e.preventDefault()
|
|
102
|
+
inputRef.current?.focus()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
document.addEventListener('keydown', handler)
|
|
106
|
+
return () => document.removeEventListener('keydown', handler)
|
|
107
|
+
}, [])
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<header className="topbar">
|
|
111
|
+
<div className="brand" title="NEAT">N</div>
|
|
112
|
+
|
|
113
|
+
{/* ADR-057 #6 — active project always visible. ADR-057 #7 — switcher always reachable. */}
|
|
114
|
+
<div className="crumbs" ref={switcherRef}>
|
|
115
|
+
<button
|
|
116
|
+
className="repo project-switcher"
|
|
117
|
+
aria-label={`Active project: ${project}. Click to switch.`}
|
|
118
|
+
aria-expanded={showSwitcher}
|
|
119
|
+
onClick={() => setShowSwitcher((v) => !v)}
|
|
120
|
+
title="Switch project"
|
|
121
|
+
>
|
|
122
|
+
<span className="project-name">{project}</span>
|
|
123
|
+
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 4, opacity: 0.6 }}>
|
|
124
|
+
<path d="m6 9 6 6 6-6" />
|
|
125
|
+
</svg>
|
|
126
|
+
</button>
|
|
127
|
+
{showSwitcher && (
|
|
128
|
+
<div className="project-menu" role="menu">
|
|
129
|
+
{projects.length === 0 ? (
|
|
130
|
+
<div className="project-menu-empty">no registered projects</div>
|
|
131
|
+
) : (
|
|
132
|
+
projects.map((p) => (
|
|
133
|
+
<button
|
|
134
|
+
key={p.name}
|
|
135
|
+
className={`project-menu-item${p.name === project ? ' active' : ''}`}
|
|
136
|
+
role="menuitem"
|
|
137
|
+
onClick={() => {
|
|
138
|
+
onProjectChange(p.name)
|
|
139
|
+
setShowSwitcher(false)
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
{p.name}
|
|
143
|
+
</button>
|
|
144
|
+
))
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
<span className="sep">/</span>
|
|
149
|
+
<span className="here">graph view</span>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className="topbar-spacer" />
|
|
153
|
+
|
|
154
|
+
<div className="top-search" ref={searchRef}>
|
|
155
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
|
|
156
|
+
<circle cx="11" cy="11" r="7" /><path d="m20 20-3-3" />
|
|
157
|
+
</svg>
|
|
158
|
+
<input
|
|
159
|
+
ref={inputRef}
|
|
160
|
+
aria-label="Search nodes"
|
|
161
|
+
aria-expanded={showResults}
|
|
162
|
+
placeholder="find · query · @author · #service"
|
|
163
|
+
value={query}
|
|
164
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
165
|
+
onFocus={() => results.length > 0 && setShowResults(true)}
|
|
166
|
+
/>
|
|
167
|
+
{!query && <span className="kbd">⌘K</span>}
|
|
168
|
+
{showResults && results.length > 0 && (
|
|
169
|
+
<div className="search-results" role="listbox">
|
|
170
|
+
{results.map((r) => (
|
|
171
|
+
<div
|
|
172
|
+
key={r.node.id}
|
|
173
|
+
className="search-result-item"
|
|
174
|
+
role="option"
|
|
175
|
+
aria-selected={false}
|
|
176
|
+
onMouseDown={() => {
|
|
177
|
+
setQuery('')
|
|
178
|
+
setShowResults(false)
|
|
179
|
+
onNodeSelect(r.node.id)
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<span className="sr-name">{r.node.name ?? r.node.id}</span>
|
|
183
|
+
<span className="sr-type">{r.node.type.replace('Node', '').toLowerCase()}</span>
|
|
184
|
+
<span className="sr-score">{r.score.toFixed(2)}</span>
|
|
185
|
+
</div>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div className="top-actions">
|
|
192
|
+
<button className="top-btn" aria-label={isLive ? 'Core connected' : 'Core offline'}>
|
|
193
|
+
<span className={`dot${isLive ? ' live' : ''}`} />
|
|
194
|
+
{isLive ? 'Live' : 'Offline'}
|
|
195
|
+
</button>
|
|
196
|
+
<button className="top-btn" title="Re-run cose layout" onClick={onRelayout}>
|
|
197
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
198
|
+
<path d="M12 8v4l3 2" /><circle cx="12" cy="12" r="9" />
|
|
199
|
+
</svg>
|
|
200
|
+
Layout
|
|
201
|
+
</button>
|
|
202
|
+
<button className="top-btn" title="Toggle node dragging" onClick={onToggleLock}>
|
|
203
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
204
|
+
<rect x="5" y="11" width="14" height="9" rx="1.5" /><path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
|
205
|
+
</svg>
|
|
206
|
+
Lock
|
|
207
|
+
</button>
|
|
208
|
+
</div>
|
|
209
|
+
</header>
|
|
210
|
+
)
|
|
211
|
+
}
|
package/app/favicon.ico
ADDED
|
Binary file
|