@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,145 @@
|
|
|
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 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
|
+
)
|
|
145
|
+
}
|
package/app/layout.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import './globals.css'
|
|
3
|
+
|
|
4
|
+
export const metadata: Metadata = {
|
|
5
|
+
title: 'NEAT',
|
|
6
|
+
description: 'Live semantic graph of your software systems.',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({
|
|
10
|
+
children,
|
|
11
|
+
}: Readonly<{
|
|
12
|
+
children: React.ReactNode
|
|
13
|
+
}>) {
|
|
14
|
+
return (
|
|
15
|
+
<html lang="en">
|
|
16
|
+
<head>
|
|
17
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
18
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
19
|
+
<link
|
|
20
|
+
href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap"
|
|
21
|
+
rel="stylesheet"
|
|
22
|
+
/>
|
|
23
|
+
</head>
|
|
24
|
+
<body>{children}</body>
|
|
25
|
+
</html>
|
|
26
|
+
)
|
|
27
|
+
}
|
package/app/page.tsx
ADDED
package/lib/fixtures.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Fixture data returned when NEAT_DEMO=1 and core is unreachable.
|
|
2
|
+
// Represents a realistic microservices graph for standalone frontend dev.
|
|
3
|
+
|
|
4
|
+
export const FIXTURE_GRAPH = {
|
|
5
|
+
nodes: [
|
|
6
|
+
{ id: 'service:checkout', type: 'ServiceNode', name: 'checkout', language: 'TypeScript', version: '2.4.1' },
|
|
7
|
+
{ id: 'service:payments', type: 'ServiceNode', name: 'payments', language: 'Go', version: '1.2.0' },
|
|
8
|
+
{ id: 'service:auth', type: 'ServiceNode', name: 'auth', language: 'TypeScript', version: '3.0.0' },
|
|
9
|
+
{ id: 'service:api-gateway', type: 'ServiceNode', name: 'api/gateway', language: 'TypeScript', version: '1.0.5' },
|
|
10
|
+
{ id: 'service:notifications', type: 'ServiceNode', name: 'notifications', language: 'Python', version: '1.1.0' },
|
|
11
|
+
{ id: 'database:payments-db.internal', type: 'DatabaseNode', name: 'payments-db', host: 'payments-db.internal', port: '5432', engine: 'postgresql', engineVersion: '15.2' },
|
|
12
|
+
{ id: 'database:auth-db.internal', type: 'DatabaseNode', name: 'auth-db', host: 'auth-db.internal', port: '5432', engine: 'postgresql', engineVersion: '14.8' },
|
|
13
|
+
{ id: 'infra:redis:cache.internal', type: 'InfraNode', name: 'cache', kind: 'cache', host: 'cache.internal', port: '6379' },
|
|
14
|
+
],
|
|
15
|
+
edges: [
|
|
16
|
+
{ id: 'CALLS:OBSERVED:service:api-gateway->service:checkout', source: 'service:api-gateway', target: 'service:checkout', type: 'CALLS', provenance: 'OBSERVED', confidence: 1.0, callCount: 42891 },
|
|
17
|
+
{ id: 'CALLS:OBSERVED:service:api-gateway->service:auth', source: 'service:api-gateway', target: 'service:auth', type: 'CALLS', provenance: 'OBSERVED', confidence: 1.0, callCount: 18204 },
|
|
18
|
+
{ id: 'CALLS:OBSERVED:service:checkout->service:payments', source: 'service:checkout', target: 'service:payments', type: 'CALLS', provenance: 'OBSERVED', confidence: 1.0, callCount: 9341 },
|
|
19
|
+
{ id: 'CALLS:EXTRACTED:service:checkout->service:notifications', source: 'service:checkout', target: 'service:notifications', type: 'CALLS', provenance: 'EXTRACTED', confidence: 0.9 },
|
|
20
|
+
{ id: 'CONNECTS_TO:OBSERVED:service:payments->database:payments-db.internal', source: 'service:payments', target: 'database:payments-db.internal', type: 'CONNECTS_TO', provenance: 'OBSERVED', confidence: 1.0 },
|
|
21
|
+
{ id: 'CONNECTS_TO:EXTRACTED:service:auth->database:auth-db.internal', source: 'service:auth', target: 'database:auth-db.internal', type: 'CONNECTS_TO', provenance: 'EXTRACTED', confidence: 0.95 },
|
|
22
|
+
{ id: 'CONNECTS_TO:INFERRED:service:checkout->infra:redis:cache.internal', source: 'service:checkout', target: 'infra:redis:cache.internal', type: 'CONNECTS_TO', provenance: 'INFERRED', confidence: 0.6 },
|
|
23
|
+
{ id: 'CONNECTS_TO:INFERRED:service:auth->infra:redis:cache.internal', source: 'service:auth', target: 'infra:redis:cache.internal', type: 'CONNECTS_TO', provenance: 'INFERRED', confidence: 0.6 },
|
|
24
|
+
],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const FIXTURE_INCIDENTS = {
|
|
28
|
+
count: 3,
|
|
29
|
+
total: 3,
|
|
30
|
+
events: [
|
|
31
|
+
{
|
|
32
|
+
nodeId: 'service:payments',
|
|
33
|
+
timestamp: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
|
34
|
+
type: 'ERR_VERSION_MISMATCH',
|
|
35
|
+
message: 'pg driver 7.4.0 incompatible with PostgreSQL 15 — connection failed',
|
|
36
|
+
stacktrace: 'Error: connect ECONNREFUSED\n at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1187:16)\n at pg.Client.connect (/app/node_modules/pg/lib/client.js:54:9)',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
nodeId: 'service:checkout',
|
|
40
|
+
timestamp: new Date(Date.now() - 1000 * 60 * 38).toISOString(),
|
|
41
|
+
type: 'ERR_TIMEOUT',
|
|
42
|
+
message: 'upstream payments service exceeded 5s timeout on /charge',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
nodeId: 'service:auth',
|
|
46
|
+
timestamp: new Date(Date.now() - 1000 * 60 * 91).toISOString(),
|
|
47
|
+
type: 'ERR_RATE_LIMIT',
|
|
48
|
+
message: 'Redis rate-limit key expired — 429 burst on /token',
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const FIXTURE_HEALTH = { ok: true, project: 'demo' }
|
|
54
|
+
|
|
55
|
+
export const FIXTURE_PROJECTS = [
|
|
56
|
+
{ name: 'demo', path: '/workspace/demo', status: 'active' as const },
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
export const FIXTURE_VIOLATIONS = { violations: [] }
|
|
60
|
+
|
|
61
|
+
export function fixtureSearch(q: string) {
|
|
62
|
+
const lower = q.toLowerCase()
|
|
63
|
+
const results = FIXTURE_GRAPH.nodes
|
|
64
|
+
.filter((n) => n.name.toLowerCase().includes(lower) || n.id.toLowerCase().includes(lower))
|
|
65
|
+
.map((n) => ({ node: { id: n.id, type: n.type, name: n.name }, score: 0.95 }))
|
|
66
|
+
return { results }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function fixtureNodeDetail(id: string) {
|
|
70
|
+
const node = FIXTURE_GRAPH.nodes.find((n) => n.id === id)
|
|
71
|
+
if (!node) return { error: 'not found' }
|
|
72
|
+
return { node }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function fixtureRootCause(id: string) {
|
|
76
|
+
if (id === 'service:payments') {
|
|
77
|
+
return {
|
|
78
|
+
origin: id,
|
|
79
|
+
rootCauseNode: 'database:payments-db.internal',
|
|
80
|
+
reason: 'pg driver 7.4.0 is incompatible with PostgreSQL 15 — protocol mismatch causes connection failure',
|
|
81
|
+
fixRecommendation: 'upgrade pg to ^8.x (supports PostgreSQL 15 protocol)',
|
|
82
|
+
confidence: 0.87,
|
|
83
|
+
traversalPath: [id, 'database:payments-db.internal'],
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { origin: id, rootCauseNode: null, reason: '', fixRecommendation: null, confidence: 0, traversalPath: [] }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function fixtureBlastRadius(id: string) {
|
|
90
|
+
const downstream = FIXTURE_GRAPH.edges
|
|
91
|
+
.filter((e) => e.source === id)
|
|
92
|
+
.map((e) => ({ nodeId: e.target, distance: 1, confidence: e.confidence, path: [id, e.target] }))
|
|
93
|
+
return { origin: id, affectedNodes: downstream, violationCount: 0 }
|
|
94
|
+
}
|
package/lib/proxy.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const CORE_URL = process.env.NEAT_CORE_URL ?? 'http://localhost:8080'
|
|
2
|
+
export const DEMO = process.env.NEAT_DEMO === '1'
|
|
3
|
+
|
|
4
|
+
export async function proxyGet(url: string, fallback: () => Response): Promise<Response> {
|
|
5
|
+
try {
|
|
6
|
+
const upstream = await fetch(url, { cache: 'no-store' })
|
|
7
|
+
const body = await upstream.text()
|
|
8
|
+
return new Response(body, {
|
|
9
|
+
status: upstream.status,
|
|
10
|
+
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
|
|
11
|
+
})
|
|
12
|
+
} catch {
|
|
13
|
+
if (DEMO) return fallback()
|
|
14
|
+
return Response.json({ error: 'failed to reach neat-core', coreUrl: CORE_URL }, { status: 502 })
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neat.is/web",
|
|
3
|
+
"version": "0.2.10",
|
|
4
|
+
"description": "NEAT web shell — minimal Next.js app fronting the core API",
|
|
5
|
+
"license": "BUSL-1.1",
|
|
6
|
+
"homepage": "https://neat.is",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/NEAT-Technologies/Neat.git",
|
|
10
|
+
"directory": "packages/web"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"app",
|
|
20
|
+
"lib",
|
|
21
|
+
"public",
|
|
22
|
+
"next.config.js",
|
|
23
|
+
"tsconfig.json",
|
|
24
|
+
"postcss.config.js",
|
|
25
|
+
"tailwind.config.js",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "next dev",
|
|
30
|
+
"build": "next build",
|
|
31
|
+
"start": "next start",
|
|
32
|
+
"lint": "eslint app",
|
|
33
|
+
"test": "vitest run --passWithNoTests",
|
|
34
|
+
"clean": "rm -rf .next .turbo"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@neat.is/types": "^0.2.10",
|
|
38
|
+
"cytoscape": "^3.33.3",
|
|
39
|
+
"next": "14.2.35",
|
|
40
|
+
"react": "^18",
|
|
41
|
+
"react-dom": "^18"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/cytoscape": "^3.21.9",
|
|
45
|
+
"@types/node": "^20",
|
|
46
|
+
"@types/react": "^18",
|
|
47
|
+
"@types/react-dom": "^18",
|
|
48
|
+
"postcss": "^8",
|
|
49
|
+
"tailwindcss": "^3.4.1",
|
|
50
|
+
"typescript": "^5",
|
|
51
|
+
"vitest": "^2.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
4
|
+
"allowJs": true,
|
|
5
|
+
"skipLibCheck": true,
|
|
6
|
+
"strict": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"module": "esnext",
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"jsx": "preserve",
|
|
14
|
+
"incremental": true,
|
|
15
|
+
"plugins": [
|
|
16
|
+
{
|
|
17
|
+
"name": "next"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"paths": {
|
|
21
|
+
"@/*": ["./*"]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
25
|
+
"exclude": ["node_modules"]
|
|
26
|
+
}
|