feat(frontend): tesanti brand re-skin — palette, wordmark, graph, toast, confirm

Applies the tesanti brand pack (docs/brand/tesanti.md) across every admin
surface, replacing the old slate + orange Tailwind defaults with black + white
+ signal red, three variable-weight fonts (Space Grotesk / Inter / JetBrains
Mono), mono eyebrows above every heading, and a hairline-first (not shadow-
first) elevation language.

New primitives:
- Wordmark  — "VolcanicCore" in Space Grotesk 500 with the red signal dot.
- Toast     — provider + useToast() hook + 360px surface-2 card with slide+
              fade. Replaces every alert() (edge/node mutation failures).
- ConfirmDialog — provider + promise-based useConfirm() covering optional
              type-to-confirm gating. Replaces every confirm()/prompt()
              (user delete, node remove, edge delete). Zero browser dialogs
              left in the app.
- RouteEdge — custom React Flow edge that keeps the canvas quiet by default
              and surfaces the mono "filter → transform" label as an opaque
              pill only when the edge is selected.

Graph marquee:
- vc-node states (source / router / sink) differentiated by top-border
  weight, inverse fill for sinks, and eyebrow — never by color. Red is
  reserved for selected + error.
- Node width 240px; edge label overlap eliminated.
- Zero-node empty state with a red primary "Add node" CTA.

Screen coverage:
- Login          — centered wordmark + "NATS ROUTING CONTROL" eyebrow.
- AppShell       — 240px sidebar, numbered mono eyebrows, 2px red left-border
                   on the active item, Lucide log-out on sign-out.
- Users          — mono-headed table, outline-destructive delete, chips.
- Server         — surface-2 card headers, hairline dividers, outline OK/UP
                   status chips.
- Audit          — full mono table.
- Every error banner picked up the alert-triangle prefix and full accent
  border.

Icons: added lucide-react and standardized on it (X, AlertTriangle, Check,
Share2, LogOut). Every unicode × / control glyph swapped out.

Fonts self-hosted via @fontsource-variable/{space-grotesk,inter,
jetbrains-mono}; no external requests, CSP-friendly.
This commit is contained in:
EugeneTes
2026-08-27 17:51:12 +00:00
parent 00605dfc82
commit 85d5d183f0
22 changed files with 1321 additions and 352 deletions

1
.gitignore vendored
View File

@@ -49,3 +49,4 @@ deploy.json
# Playwright MCP transient artifacts
.playwright-mcp/
.brand-shots/

218
docs/brand/tesanti.md Normal file
View File

@@ -0,0 +1,218 @@
# tesanti brand — VolcanicCore admin UI
The house style for VolcanicCore's admin UI. Three colors do all the work: **black, white, one signal red**. Borders carry structure, not shadows. Mono for every value. Restraint is the brand.
## 0. Fastest path to the tokens
**a) Fonts — self-hosted via Fontsource (no external request, CSP-friendly):**
```
npm i @fontsource-variable/space-grotesk @fontsource-variable/inter @fontsource-variable/jetbrains-mono
```
```ts
// main.tsx — import once
import '@fontsource-variable/space-grotesk';
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
```
30-sec alternative (CDN @import): `@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap");`
**b) tailwind.config.js → theme.extend** (yours is empty — drop this in):
```js
theme: { extend: {
colors: {
bg: '#0a0a0a',
surface: { DEFAULT:'#161616', 2:'#1f1f1f', 3:'#262626' },
line: { subtle:'#262626', DEFAULT:'#3d3d3d', strong:'#5e5e5e' },
fg: { DEFAULT:'#f7f7f7', 2:'#b8b8b8', muted:'#8a8a8a', disabled:'#5e5e5e' },
accent: { DEFAULT:'#e1261c', hover:'#f5382d', press:'#c31c14' },
ok:'#2fa85f', warn:'#d99a0a', danger:'#e1261c',
},
fontFamily: {
display: ['"Space Grotesk Variable"','ui-sans-serif','system-ui','sans-serif'],
sans: ['"Inter Variable"','ui-sans-serif','system-ui','sans-serif'],
mono: ['"JetBrains Mono Variable"','ui-monospace','Menlo','monospace'],
},
borderRadius: { none:'0', sm:'2px', DEFAULT:'4px', md:'6px', pill:'999px' },
boxShadow: { pop:'0 8px 24px rgba(0,0,0,0.6)' }, // ONLY floating menus
letterSpacing:{ tight:'-0.02em', snug:'-0.01em', caps:'0.14em' },
ringColor:{ DEFAULT:'#e1261c' }, ringWidth:{ DEFAULT:'2px' },
}}
```
(`line` is separate from Tailwind's `border` utility to dodge `border-border` — use `border border-line`, `border-line-subtle` for dividers.)
**c) globals.css base:**
```css
:root { color-scheme: dark; }
body { background:#0a0a0a; color:#f7f7f7; font-family:'"Inter Variable"',ui-sans-serif,system-ui,sans-serif; font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }
*:focus-visible { outline:2px solid #e1261c; outline-offset:2px; }
::selection { background:#f7f7f7; color:#0a0a0a; }
```
## 1. Palette (role → hex)
| Role | Dark (ship) | Light |
|---|---|---|
| bg — app canvas | #0a0a0a | #ffffff |
| surface — cards/sidebar/nodes | #161616 | #ffffff |
| surface-2 — elevated/menus/hover | #1f1f1f | #f7f7f7 |
| surface-3 — pressed/active fill | #262626 | #ececec |
| line-subtle — dividers | #262626 | #ececec |
| line — standard hairline | #3d3d3d | #d9d9d9 |
| line-strong — emphasis | #5e5e5e | #000000 |
| fg — primary text | #f7f7f7 | #0a0a0a |
| fg-2 — secondary | #b8b8b8 | #262626 |
| fg-muted — meta | #8a8a8a | #5e5e5e |
| fg-disabled | #5e5e5e | #8a8a8a |
| accent — primary/active/focus | #e1261c | #e1261c |
| accent-hover | #f5382d | #c31c14 |
| accent-press | #c31c14 | #c31c14 |
| accent-tint — fail wash | rgba(225,38,28,.10) | #fdecea |
| ok (status only) | #2fa85f | #0a8a3f |
| warn (status only) | #d99a0a | #c68a00 |
| danger | #e1261c | #e1261c |
RED RULES: accent only — primary button, active nav link, focus ring, down/error/reject status. Never decorative, never a gradient, never a large fill, never body text. One red per surface; if in doubt, grey. No gradients/textures/grain/glow/colored-shadows/neumorphism anywhere.
## 2. Typography
- Space Grotesk — display/titles/wordmark — weight **500** (600 only at very large sizes)
- Inter — body/UI/tables/labels — 400/500/600
- JetBrains Mono — eyebrows, ALL data values (subjects, IDs, seq nums, counts), code — 400/500
We use MEDIUM (500), not bold, for display — biggest shift from your bold wordmark.
Scale (admin-tuned): display 32–40 (SpaceGrotesk 500, -0.02em) · title 20 (SpaceGrotesk 500, -0.01em) · h-section 16 (Inter 600) · body 14 (Inter 400/500) · dense 13 · eyebrow 11 (Mono 500, 0.14em, UPPERCASE) · micro 10 (Mono caps) · data 13–14 (Mono, tabular).
Signature move: all-caps mono eyebrow above a heading, often with a red leading dot (● OVERVIEW) or red index (01 —). Sentence case everywhere else — buttons, nav, headings. ALL CAPS only for short mono labels. Every number/subject/ID/seq that changes → JetBrains Mono, tabular.
## 3. Logo / wordmark / favicon
Name stays "VolcanicCore", set in Space Grotesk 500, -0.02em, red signal dot after the final letter. Not bold, not orange.
Wordmark — React component (preferred):
```tsx
export function Wordmark({ size = 20, onDark = true }) {
return (
<span style={{ fontFamily:'"Space Grotesk Variable",sans-serif', fontWeight:500, fontSize:size,
letterSpacing:'-0.02em', lineHeight:1, display:'inline-flex', alignItems:'baseline',
color:onDark?'#f7f7f7':'#0a0a0a' }}>
VolcanicCore
<span style={{ width:size*0.25, height:size*0.25, background:'#e1261c', borderRadius:999,
marginLeft:size*0.2, alignSelf:'flex-end', marginBottom:size*0.14 }} />
</span>
);
}
```
Nav top-left: size 20. Login (centered — the one allowed centering exception): size 36 + mono eyebrow "NATS ROUTING CONTROL".
Wordmark — standalone SVG (for img/embeds):
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 48" width="360" height="48" aria-label="VolcanicCore">
<text x="0" y="34" font-family="'Space Grotesk','Space Grotesk Variable',ui-sans-serif,system-ui,sans-serif" font-size="32" font-weight="500" letter-spacing="-0.64" fill="#f7f7f7">VolcanicCore</text>
<circle cx="234" cy="30" r="4.5" fill="#e1261c"/>
</svg>
```
(fill #f7f7f7 → #0a0a0a for light surfaces.)
Favicon — path-based routing-core mark (renders without a font; black tile, neutral routing star, one red endpoint):
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-label="VolcanicCore">
<rect width="64" height="64" rx="12" fill="#0a0a0a"/>
<g stroke="#3d3d3d" stroke-width="2" stroke-linecap="round">
<line x1="32" y1="32" x2="47" y2="17"/><line x1="32" y1="32" x2="49" y2="45"/><line x1="32" y1="32" x2="15" y2="41"/>
</g>
<circle cx="49" cy="45" r="3.2" fill="#8a8a8a"/><circle cx="15" cy="41" r="3.2" fill="#8a8a8a"/>
<circle cx="47" cy="17" r="4" fill="#e1261c"/>
<circle cx="32" cy="32" r="5.5" fill="#f7f7f7"/>
</svg>
```
(light tab: tile fill #ffffff, core #0a0a0a.)
## 4. Component style
- Radius: buttons/inputs 6px max, cards/nodes 4px, tags 2px, pills 999px (status chips only). Square always OK. Nothing rounder than 6px.
- Elevation = surface step + hairline, NOT shadow. On black, shadows are invisible — go by lightness: canvas #0a0a0a → card #161616 → menu #1f1f1f, each +1px lighter border. Real shadow (`shadow-pop`) only for floating menus/popovers/command palette, paired with a 1px #262626 border.
- Focus ring always visible: 2px solid #e1261c, 2px offset. Never removed.
Buttons (sentence case, verbs):
- primary: bg #e1261c, white text — hover #f5382d, press #c31c14 + scale(.98). The ONE main action per view.
- secondary: transparent, 1px #3d3d3d border, #f7f7f7 — hover inverts to white bg / #0a0a0a text.
- ghost: transparent, #b8b8b8 text — hover #f7f7f7 (or red for a link).
- destructive: transparent, 1px #e1261c border, #e1261c text — hover fills solid red / white text. Confirm irreversible ones. (Outline, so it's distinct from the solid-red primary — never two solid reds in one group.)
Inputs: bg #0f0f0f, 1px #3d3d3d, text #f7f7f7, placeholder #5e5e5e, 4px radius, mono for value fields (subjects/endpoints/tokens). Focus = #e1261c border + ring 0 0 0 2px rgba(225,38,28,.35). Error = #e1261c border + a mono line stating the reason. Disabled = #1a1a1a bg / #5e5e5e text.
Status chips (pill, mono 10px caps): Connected = transparent/#2fa85f border+dot · Degraded = transparent/#d99a0a · Down = solid #e1261c bg, white text.
Nav sidebar (~240px, 1px #262626 right hairline, surface bg): active = surface-2 fill + 2px red left border + fg text; inactive = fg-muted, no fill. 16px Lucide icons.
## 5. Iconography
Lucide set, standardized: line never solid — stroke-width 1.5, fill none, stroke currentColor, round caps/joins. Sizes 16 inline / 20 button / 24 nav. Monochrome (inherit text color). A red icon means ONLY destructive/fail. No emoji, no unicode pictographs (★ ✓ ✗ → as icons) — use Lucide check/x/arrow-right.
## 6. Graph canvas — React Flow (the marquee)
RF defaults fight this brand: blue selection outlines, drop-shadowed nodes, rounded everything, marching-ants. Kill all of it. Differentiate node TYPES by weight and black/white fill; reserve red strictly for selected + error + live.
Global CSS (overrides .react-flow__* defaults):
```css
.react-flow { background:#0a0a0a; }
.react-flow__attribution { display:none; }
.react-flow__node.selected { box-shadow:none; } /* remove RF blue outline */
.vc-node { width:190px; padding:11px 13px; border-radius:4px; background:#161616; border:1px solid #3d3d3d; color:#f7f7f7; box-shadow:none; }
.vc-node.is-selected { border-color:#e1261c; box-shadow:0 0 0 2px rgba(225,38,28,.35); }
.vc-node.is-source { border-top:2px solid #5e5e5e; } /* type by weight, not color */
.vc-node.is-sink { background:#f7f7f7; color:#0a0a0a; border-color:#2a2a2a; } /* inverse = terminal */
.vc-node.is-error { border-color:#e1261c; background:linear-gradient(0deg,rgba(225,38,28,.10),rgba(225,38,28,.10)),#161616; }
.vc-node.is-muted { opacity:.5; }
.react-flow__handle { width:8px; height:8px; background:#262626; border:1px solid #5e5e5e; }
.react-flow__handle-connecting,.react-flow__handle-valid { background:#e1261c; border-color:#e1261c; }
.react-flow__edge-path { stroke:#3d3d3d; stroke-width:1.5; }
.react-flow__edge.selected .react-flow__edge-path { stroke:#e1261c; stroke-width:2; }
.react-flow__arrowhead { fill:#5e5e5e; }
.react-flow__controls { box-shadow:none; border:1px solid #262626; border-radius:4px; overflow:hidden; }
.react-flow__controls-button { background:#161616; border-bottom:1px solid #262626; color:#b8b8b8; }
.react-flow__controls-button:hover { background:#1f1f1f; color:#f7f7f7; }
.react-flow__minimap { background:#161616; border:1px solid #262626; border-radius:4px; }
```
Wiring:
```tsx
import { MarkerType } from '@xyflow/react';
const defaultEdgeOptions = {
type: 'smoothstep',
markerEnd: { type: MarkerType.ArrowClosed, color:'#5e5e5e', width:14, height:14 },
style: { stroke:'#3d3d3d', strokeWidth:1.5 },
};
<ReactFlow nodes={nodes} edges={edges} defaultEdgeOptions={defaultEdgeOptions}>
<Background variant="dots" gap={22} size={1} color="#191919" />
<Controls showInteractive={false} />
<MiniMap maskColor="rgba(0,0,0,0.6)"
nodeColor={n => n.data.kind==='sink' ? '#f7f7f7' : n.data.state==='error' ? '#e1261c' : '#3d3d3d'} />
</ReactFlow>
```
Node states:
- default (router/stream): #161616 surface, 1px #3d3d3d, 4px, no shadow.
- source (publisher/root): + 2px #5e5e5e TOP border, no input handle — type by weight, not color.
- sink (consumer/leaf): INVERSE fill — white node on black, no output handle — reads terminal instantly.
- selected (any): red 1px border + 2px red ring rgba(225,38,28,.35) — the brand focus ring on nodes.
- error/dead: solid red header bar + faint red tint fill — mirrors the Down/Reject chip.
- muted (offline): opacity .5, fg-muted text.
Edge states:
- default: #3d3d3d, 1.5px, smoothstep, STATIC (no marching-ants).
- selected: #e1261c, 2px, red arrowhead.
- dead route: #e1261c, 1.5px, DASHED (strokeDasharray '5 4') — broken/DLQ path.
- live traffic (optional): #e1261c + animated:true, used sparingly — motion only where data genuinely flows.
Per-edge override: `{ style:{ stroke:'#e1261c', strokeWidth:1.5, strokeDasharray:'5 4' }, markerEnd:{ type:MarkerType.ArrowClosed, color:'#e1261c' } }`.
## 7. Voice (admin copy)
Technical, precise, blunt. Short declarative sentences. No hype.
- Banned: seamless, powerful, revolutionary, leverage, unlock, empower, delightful. No "Oops", no emoji. Preferred verbs: routes, forwards, drops, rejects, connects, fails.
- Buttons: sentence case, name the action — "Add route", "Delete user", "Restart server". Not "Submit".
- Empty states: fact + next step — "No routes configured. Add a route to start forwarding messages."
- Errors: fact + reason — "Connection refused. Server nats-01 is unreachable — check the port and TLS cert." Never "Something went wrong."
- Destructive confirms: name the consequence — "Delete user meel? This revokes their tokens immediately."
- Status labels: mono caps — CONNECTED · DEGRADED · DOWN. Numbers always numerals, always mono.

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>VolcanicCore</title>
</head>
<body>
<div id="root"></div>

View File

@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/space-grotesk": "^5.3.0",
"@tanstack/react-query": "^5.102.7",
"@tanstack/react-router": "^1.170.32",
"@xyflow/react": "^12.11.5",

View File

@@ -8,6 +8,15 @@ importers:
.:
dependencies:
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
'@fontsource-variable/jetbrains-mono':
specifier: ^5.3.0
version: 5.3.0
'@fontsource-variable/space-grotesk':
specifier: ^5.3.0
version: 5.3.0
'@tanstack/react-query':
specifier: ^5.102.7
version: 5.102.7(react@19.2.8)
@@ -79,6 +88,15 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@fontsource-variable/inter@5.3.0':
resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==}
'@fontsource-variable/jetbrains-mono@5.3.0':
resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==}
'@fontsource-variable/space-grotesk@5.3.0':
resolution: {integrity: sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -995,6 +1013,12 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@fontsource-variable/inter@5.3.0': {}
'@fontsource-variable/jetbrains-mono@5.3.0': {}
'@fontsource-variable/space-grotesk@5.3.0': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 577 B

View File

@@ -1,16 +1,68 @@
import { Link, Outlet } from '@tanstack/react-router';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { LogOut } from 'lucide-react';
import { Wordmark } from './Wordmark';
import { useLogout } from '../hooks/useSession';
type NavItem = { to: string; label: string; eyebrow: string };
const NAV: NavItem[] = [
{ to: '/', label: 'Graph', eyebrow: '01' },
{ to: '/users', label: 'Users', eyebrow: '02' },
{ to: '/server', label: 'Server', eyebrow: '03' },
{ to: '/audit', label: 'Audit', eyebrow: '04' },
];
export default function AppShell() {
const state = useRouterState();
const path = state.location.pathname;
const navigate = useNavigate();
const logout = useLogout();
async function signOut() {
await logout.mutateAsync();
navigate({ to: '/login' });
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<nav className="border-b border-slate-800 px-6 py-3 flex gap-6">
<span className="font-bold">VolcanicCore</span>
<Link to="/" className="hover:text-white text-slate-300">Graph</Link>
<Link to="/users" className="hover:text-white text-slate-300">Users</Link>
<Link to="/server" className="hover:text-white text-slate-300">Server</Link>
<Link to="/audit" className="hover:text-white text-slate-300">Audit</Link>
</nav>
<main><Outlet /></main>
<div className="min-h-screen bg-bg text-fg flex">
<aside className="w-[240px] shrink-0 border-r border-line-subtle bg-surface flex flex-col">
<div className="px-5 py-5 border-b border-line-subtle">
<Wordmark size={20} />
</div>
<nav className="flex-1 py-3">
{NAV.map(item => {
const active = item.to === '/' ? path === '/' : path.startsWith(item.to);
return (
<Link
key={item.to}
to={item.to}
className={
'group relative flex items-center gap-3 px-5 py-2.5 transition-colors ' +
(active
? 'bg-surface-2 text-fg border-l-2 border-accent -ml-px pl-[19px]'
: 'text-fg-muted hover:text-fg hover:bg-surface-2')
}
>
<span className="font-mono text-[10px] tracking-caps text-fg-muted group-hover:text-fg-2">
{item.eyebrow}
</span>
<span className="text-[14px] font-medium">{item.label}</span>
</Link>
);
})}
</nav>
<div className="px-5 py-4 border-t border-line-subtle">
<button
onClick={signOut}
disabled={logout.isPending}
className="w-full inline-flex items-center gap-2 text-left text-[13px] text-fg-muted hover:text-fg transition-colors disabled:text-fg-disabled"
>
<LogOut size={16} strokeWidth={1.5} />
{logout.isPending ? 'Signing out…' : 'Sign out'}
</button>
</div>
</aside>
<main className="flex-1 min-w-0"><Outlet /></main>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { createContext, useCallback, useContext, useRef, useState } from 'react';
type ConfirmOptions = {
eyebrow: string;
title: string;
body: string;
confirmLabel: string;
confirmMatch?: string;
};
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
const ConfirmContext = createContext<ConfirmFn | null>(null);
export function useConfirm(): ConfirmFn {
const ctx = useContext(ConfirmContext);
if (!ctx) throw new Error('useConfirm must be used inside <ConfirmProvider>');
return ctx;
}
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<ConfirmOptions | null>(null);
const resolveRef = useRef<((v: boolean) => void) | null>(null);
const confirm = useCallback<ConfirmFn>((opts) => {
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
setState(opts);
});
}, []);
const close = useCallback((result: boolean) => {
setState(null);
const r = resolveRef.current;
resolveRef.current = null;
r?.(result);
}, []);
return (
<ConfirmContext.Provider value={confirm}>
{children}
{state && <ConfirmDialog opts={state} onCancel={() => close(false)} onConfirm={() => close(true)} />}
</ConfirmContext.Provider>
);
}
function ConfirmDialog({ opts, onCancel, onConfirm }: { opts: ConfirmOptions; onCancel: () => void; onConfirm: () => void }) {
const [typed, setTyped] = useState('');
const requiresType = Boolean(opts.confirmMatch);
const enabled = !requiresType || typed === opts.confirmMatch;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-6" onClick={onCancel}>
<div
className="bg-surface-2 border border-line rounded p-6 w-[420px] max-w-full space-y-5 shadow-pop"
onClick={e => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
>
<div>
<div className="font-mono text-[10px] tracking-caps text-fg-muted">{opts.eyebrow}</div>
<div className="text-[16px] font-medium mt-1">{opts.title}</div>
<div className="text-[13px] text-fg-2 mt-2 leading-snug">{opts.body}</div>
</div>
{requiresType && (
<label className="block space-y-1">
<span className="font-mono text-[10px] tracking-caps text-fg-muted">
Type <span className="text-fg-2">{opts.confirmMatch}</span> to confirm
</span>
<input
autoFocus
value={typed}
onChange={e => setTyped(e.target.value)}
className="w-full bg-[#0f0f0f] border border-line rounded px-3 py-2 font-mono text-[13px] text-fg placeholder:text-fg-disabled focus:border-accent focus:outline-none focus:ring focus:ring-accent/35"
/>
</label>
)}
<div className="flex gap-2 justify-end pt-1">
<button
onClick={onCancel}
className="bg-transparent text-fg-2 hover:text-fg rounded px-3 py-2 text-[13px] font-medium transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={!enabled}
className="bg-transparent border border-accent text-accent hover:bg-accent hover:text-white disabled:opacity-40 disabled:cursor-not-allowed rounded px-3 py-2 text-[13px] font-medium transition-colors"
>
{opts.confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { X, AlertTriangle, Check } from 'lucide-react';
export type ToastKind = 'success' | 'error';
export type Toast = {
id: number;
kind: ToastKind;
eyebrow: string;
body: string;
};
type ToastInput = { kind?: ToastKind; eyebrow?: string; body: string };
type ToastContextValue = {
push: (t: ToastInput) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return ctx;
}
const AUTO_DISMISS_MS = 5000;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const push = useCallback((t: ToastInput) => {
const id = nextId.current++;
const kind = t.kind ?? 'success';
const eyebrow = t.eyebrow ?? (kind === 'error' ? '● ERROR' : 'SAVED');
setToasts(prev => [...prev, { id, kind, eyebrow, body: t.body }]);
}, []);
return (
<ToastContext.Provider value={{ push }}>
{children}
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map(t => (
<ToastCard key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
))}
</div>
</ToastContext.Provider>
);
}
function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const [shown, setShown] = useState(false);
useEffect(() => {
const t = setTimeout(() => setShown(true), 10);
return () => clearTimeout(t);
}, []);
useEffect(() => {
if (toast.kind === 'error') return;
const t = setTimeout(onDismiss, AUTO_DISMISS_MS);
return () => clearTimeout(t);
}, [toast.kind, onDismiss]);
const isError = toast.kind === 'error';
return (
<div
role={isError ? 'alert' : 'status'}
style={{
width: 360,
transform: shown ? 'translateX(0)' : 'translateX(24px)',
opacity: shown ? 1 : 0,
transition: 'transform 200ms ease-out, opacity 200ms ease-out',
}}
className={
'pointer-events-auto bg-surface-2 border border-line-subtle rounded shadow-pop px-4 py-3 flex gap-3 items-start ' +
(isError ? 'border-l-2 border-l-accent' : '')
}
>
<span className="mt-1 shrink-0 inline-flex items-center justify-center">
{isError ? (
<AlertTriangle size={14} strokeWidth={1.5} className="text-accent" />
) : (
<Check size={14} strokeWidth={1.5} className="text-ok" />
)}
</span>
<div className="min-w-0 flex-1">
<span className="font-mono text-[10px] tracking-caps text-fg-muted">
{toast.eyebrow}
</span>
<div className="mt-1 text-[13px] text-fg leading-snug break-words">{toast.body}</div>
</div>
<button
onClick={onDismiss}
aria-label="Dismiss"
className="text-fg-muted hover:text-fg shrink-0 inline-flex"
>
<X size={14} strokeWidth={1.5} />
</button>
</div>
);
}

View File

@@ -0,0 +1,29 @@
export function Wordmark({ size = 20, onDark = true }: { size?: number; onDark?: boolean }) {
return (
<span
style={{
fontFamily: '"Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif',
fontWeight: 500,
fontSize: size,
letterSpacing: '-0.02em',
lineHeight: 1,
display: 'inline-flex',
alignItems: 'baseline',
color: onDark ? '#f7f7f7' : '#0a0a0a',
}}
>
VolcanicCore
<span
style={{
width: size * 0.25,
height: size * 0.25,
background: '#e1261c',
borderRadius: 999,
marginLeft: size * 0.2,
alignSelf: 'flex-end',
marginBottom: size * 0.14,
}}
/>
</span>
);
}

View File

@@ -1,17 +1,26 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
ReactFlow, Background, Controls,
ReactFlow, Background, BackgroundVariant, Controls, MiniMap, MarkerType,
useNodesState, useEdgesState, addEdge,
type Node as RfNode, type Edge as RfEdge,
type Connection, type NodeChange,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import NodeCard from './NodeCard';
import NodeCard, { type NodeKind } from './NodeCard';
import RouteEdge from './RouteEdge';
import type { Graph } from '../../api/types';
import { useCreateEdge, useDeleteEdge } from '../../hooks/useGraph';
import { api, ApiError } from '../../api/client';
import { useToast } from '../Toast';
const nodeTypes = { vcNode: NodeCard };
const edgeTypes = { route: RouteEdge };
const defaultEdgeOptions = {
type: 'route' as const,
markerEnd: { type: MarkerType.ArrowClosed, color: '#5e5e5e', width: 14, height: 14 },
style: { stroke: '#3d3d3d', strokeWidth: 1.5 },
};
export type GraphCanvasProps = {
graph: Graph;
@@ -25,9 +34,27 @@ export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: Gr
const [edges, setEdges, onEdgesChange] = useEdgesState<RfEdge>([]);
const createEdge = useCreateEdge();
const deleteEdge = useDeleteEdge();
const toast = useToast();
const pendingSaveRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// kind = source (no inbound), sink (no outbound), router (both)
const kindById = useMemo(() => {
const inbound = new Set<string>();
const outbound = new Set<string>();
for (const e of graph.edges) {
outbound.add(e.fromNodeId);
inbound.add(e.toNodeId);
}
const map = new Map<string, NodeKind>();
for (const n of graph.nodes) {
const hasIn = inbound.has(n.id);
const hasOut = outbound.has(n.id);
map.set(n.id, !hasIn ? 'source' : !hasOut ? 'sink' : 'router');
}
return map;
}, [graph.nodes, graph.edges]);
const flushPositionSave = useCallback(() => {
const batch = Array.from(pendingSaveRef.current.entries())
.map(([id, p]) => ({ id, x: p.x, y: p.y }));
@@ -35,8 +62,6 @@ export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: Gr
if (batch.length) api.saveNodePositions(batch).catch(() => { /* best-effort */ });
}, []);
// Wrap the raw onNodesChange: apply changes to local state (as before)
// AND collect any position updates from finished drags to persist server-side.
const onNodesChange = useCallback((changes: NodeChange[]) => {
onNodesChangeRaw(changes);
for (const c of changes) {
@@ -50,13 +75,8 @@ export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: Gr
}
}, [onNodesChangeRaw, flushPositionSave]);
// Flush any queued save on unmount.
useEffect(() => () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); flushPositionSave(); } }, [flushPositionSave]);
// Sync server graph → React Flow state. Position priority:
// 1. Local (in-flight drag) if any
// 2. Server-persisted position from graph.positions
// 3. Fallback grid layout
useEffect(() => {
setNodes(prev => {
const prevById = new Map(prev.map(n => [n.id, n]));
@@ -68,6 +88,7 @@ export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: Gr
type: 'vcNode',
data: {
node: n,
kind: kindById.get(n.id) ?? 'router',
stats: stats[n.id],
onOpen: onOpenNode,
} as unknown as Record<string, unknown>,
@@ -79,24 +100,29 @@ export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: Gr
};
});
});
}, [graph.nodes, graph.positions, stats, onOpenNode, setNodes]);
}, [graph.nodes, graph.positions, stats, onOpenNode, kindById, setNodes]);
useEffect(() => {
setEdges(graph.edges.map(e => ({
id: e.id,
source: e.fromNodeId,
target: e.toNodeId,
label: `${e.filterSubject} → ${e.transformDest}`,
labelBgStyle: { fill: '#1e293b' },
labelStyle: { fill: '#cbd5e1', fontSize: 10 },
style: { stroke: '#f97316', strokeWidth: 1.5 },
type: 'route',
data: { filterSubject: e.filterSubject, transformDest: e.transformDest },
markerEnd: { type: MarkerType.ArrowClosed, color: '#5e5e5e', width: 14, height: 14 },
style: { stroke: '#3d3d3d', strokeWidth: 1.5 },
})));
}, [graph.edges, setEdges]);
const onConnect = useCallback(async (c: Connection) => {
if (!c.source || !c.target) return;
// Optimistic: draw the edge locally so the user sees it immediately.
setEdges(eds => addEdge({ ...c, id: `pending-${Date.now()}`, style: { stroke: '#f97316', strokeDasharray: '4 4' } }, eds));
setEdges(eds => addEdge({
...c,
id: `pending-${Date.now()}`,
type: 'route',
style: { stroke: '#e1261c', strokeWidth: 1.5, strokeDasharray: '5 4' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#e1261c', width: 14, height: 14 },
}, eds));
try {
await createEdge.mutateAsync({
fromNodeId: c.source,
@@ -104,42 +130,55 @@ export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: Gr
filterSubject: '>',
transformDest: '>',
});
// The useGraph invalidation will re-render with the real edge.
} catch (err) {
// Remove the optimistic edge on failure.
setEdges(eds => eds.filter(e => !e.id.startsWith('pending-')));
alert(err instanceof ApiError ? err.message : 'Failed to create edge');
toast.push({
kind: 'error',
body: err instanceof ApiError ? err.message : 'Failed to create edge.',
});
}
}, [createEdge, setEdges]);
}, [createEdge, setEdges, toast]);
const onEdgesDelete = useCallback(async (deleted: RfEdge[]) => {
// React Flow already removed them from local state (via useEdgesState).
// Persist the deletions to the server; if any fail, the next graph refresh
// will restore them from the server's truth.
for (const e of deleted) {
if (e.id.startsWith('pending-')) continue;
try { await deleteEdge.mutateAsync(e.id); }
catch (err) { alert(err instanceof ApiError ? err.message : `Failed to delete edge ${e.id}`); }
catch (err) {
toast.push({
kind: 'error',
body: err instanceof ApiError ? err.message : `Failed to delete edge ${e.id}.`,
});
}
}
}, [deleteEdge]);
}, [deleteEdge, toast]);
return (
<div className="w-full h-[calc(100vh-60px)]">
<div className="w-full h-screen">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgesDelete={onEdgesDelete}
onEdgeClick={(_, edge) => onOpenEdge(edge.id)}
defaultEdgeOptions={defaultEdgeOptions}
nodesConnectable
deleteKeyCode={['Backspace', 'Delete']}
fitView
>
<Background />
<Controls />
<Background variant={BackgroundVariant.Dots} gap={22} size={1} color="#191919" />
<Controls showInteractive={false} />
<MiniMap
maskColor="rgba(0,0,0,0.6)"
nodeColor={(n) => {
const k = (n.data as { kind?: NodeKind })?.kind;
return k === 'sink' ? '#f7f7f7' : '#3d3d3d';
}}
nodeStrokeColor="#5e5e5e"
/>
</ReactFlow>
</div>
);

View File

@@ -1,8 +1,11 @@
import { Handle, Position, type Node as RfNode, type NodeProps } from '@xyflow/react';
import type { Node as VcNode } from '../../api/types';
export type NodeKind = 'source' | 'sink' | 'router';
export type NodeCardData = {
node: VcNode;
kind: NodeKind;
stats?: { msgsPerSec: number; bytesPerSec: number };
onOpen: (id: string) => void;
};
@@ -10,24 +13,39 @@ export type NodeCardData = {
// React Flow's Node.data must satisfy Record<string, unknown>; NodeCardData does.
export type VcRfNode = RfNode<NodeCardData & Record<string, unknown>, 'vcNode'>;
export default function NodeCard({ data }: NodeProps<VcRfNode>) {
const { node, stats, onOpen } = data;
export default function NodeCard({ data, selected }: NodeProps<VcRfNode>) {
const { node, kind, stats, onOpen } = data;
const throughput = stats?.msgsPerSec ?? 0;
const isSource = kind === 'source';
const isSink = kind === 'sink';
const classes = [
'vc-node cursor-pointer',
selected && 'is-selected',
isSource && 'is-source',
isSink && 'is-sink',
].filter(Boolean).join(' ');
const subLabelColor = isSink ? 'text-[#5e5e5e]' : 'text-fg-muted';
const eyebrow = isSource ? 'SOURCE' : isSink ? 'SINK' : 'ROUTER';
return (
<div
onClick={() => onOpen(node.id)}
className="bg-slate-800 border border-slate-700 rounded-md px-3 py-2 min-w-52 cursor-pointer hover:border-orange-500 relative"
>
<Handle type="target" position={Position.Left} className="!w-3 !h-3 !bg-orange-500 !border-slate-900" />
<div className="text-sm font-semibold text-slate-100">{node.name}</div>
<div className="text-xs text-slate-400 font-mono">{node.streamName}</div>
<div className="mt-2 flex items-center gap-2 text-xs">
<span className="text-slate-400">{node.subjects.length} subj</span>
<span className={`text-slate-400 ${throughput > 0 ? 'text-orange-400' : ''}`}>
<div onClick={() => onOpen(node.id)} className={classes}>
{!isSource && (
<Handle type="target" position={Position.Left} className="react-flow__handle" />
)}
<div className={`font-mono text-[10px] tracking-caps ${subLabelColor}`}>{eyebrow}</div>
<div className="mt-1 text-[14px] font-medium leading-tight">{node.name}</div>
<div className={`mt-0.5 font-mono text-[12px] ${subLabelColor} truncate`}>{node.streamName}</div>
<div className="mt-2 flex items-center gap-3 font-mono text-[11px]">
<span className={subLabelColor}>{node.subjects.length} subj</span>
<span className={throughput > 0 ? 'text-accent' : subLabelColor}>
{throughput.toFixed(0)}/s
</span>
</div>
<Handle type="source" position={Position.Right} className="!w-3 !h-3 !bg-orange-500 !border-slate-900" />
{!isSink && (
<Handle type="source" position={Position.Right} className="react-flow__handle" />
)}
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react';
type RouteEdgeData = { filterSubject?: string; transformDest?: string };
export default function RouteEdge(props: EdgeProps) {
const {
id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition,
selected, markerEnd, style, data,
} = props;
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition,
});
const d = (data ?? {}) as RouteEdgeData;
const showLabel = Boolean(selected);
return (
<>
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
{showLabel && (d.filterSubject || d.transformDest) && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
className="bg-surface border border-line rounded-sm px-2 py-0.5 font-mono text-[11px] text-fg-2 whitespace-nowrap"
>
{d.filterSubject}
{d.transformDest && (
<>
<span className="text-fg-muted mx-1.5">→</span>
{d.transformDest}
</>
)}
</div>
</EdgeLabelRenderer>
)}
</>
);
}

View File

@@ -1,3 +1,91 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root { color-scheme: dark; }
body {
background: #0a0a0a;
color: #f7f7f7;
font-family: '"Inter Variable"', ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
*:focus-visible {
outline: 2px solid #e1261c;
outline-offset: 2px;
}
::selection {
background: #f7f7f7;
color: #0a0a0a;
}
/* React Flow overrides — kill defaults (blue selection, drop shadows, marching-ants) */
.react-flow { background: #0a0a0a; }
.react-flow__attribution { display: none; }
.react-flow__node.selected { box-shadow: none; }
.vc-node {
width: 240px;
padding: 12px 14px;
border-radius: 4px;
background: #161616;
border: 1px solid #3d3d3d;
color: #f7f7f7;
box-shadow: none;
}
.vc-node.is-selected {
border-color: #e1261c;
box-shadow: 0 0 0 2px rgba(225, 38, 28, 0.35);
}
.vc-node.is-source { border-top: 2px solid #5e5e5e; }
.vc-node.is-sink {
background: #f7f7f7;
color: #0a0a0a;
border-color: #2a2a2a;
}
.vc-node.is-error {
border-color: #e1261c;
background: linear-gradient(0deg, rgba(225, 38, 28, 0.10), rgba(225, 38, 28, 0.10)), #161616;
}
.vc-node.is-muted { opacity: 0.5; }
.react-flow__handle {
width: 8px;
height: 8px;
background: #262626;
border: 1px solid #5e5e5e;
}
.react-flow__handle-connecting,
.react-flow__handle-valid {
background: #e1261c;
border-color: #e1261c;
}
.react-flow__edge-path { stroke: #3d3d3d; stroke-width: 1.5; }
.react-flow__edge.selected .react-flow__edge-path { stroke: #e1261c; stroke-width: 2; }
.react-flow__arrowhead { fill: #5e5e5e; }
.react-flow__controls {
box-shadow: none;
border: 1px solid #262626;
border-radius: 4px;
overflow: hidden;
}
.react-flow__controls-button {
background: #161616;
border-bottom: 1px solid #262626;
color: #b8b8b8;
}
.react-flow__controls-button:hover {
background: #1f1f1f;
color: #f7f7f7;
}
.react-flow__minimap {
background: #161616;
border: 1px solid #262626;
border-radius: 4px;
}

View File

@@ -3,6 +3,11 @@ import ReactDOM from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { router } from './router';
import { ToastProvider } from './components/Toast';
import { ConfirmProvider } from './components/ConfirmDialog';
import '@fontsource-variable/space-grotesk';
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
import './globals.css';
const queryClient = new QueryClient({
@@ -12,7 +17,11 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ToastProvider>
<ConfirmProvider>
<RouterProvider router={router} />
</ConfirmProvider>
</ToastProvider>
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -1,29 +1,47 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
const EYEBROW = 'font-mono text-[10px] tracking-caps text-fg-muted';
export default function Audit() {
const { data, isLoading } = useQuery({
queryKey: ['audit'],
queryFn: () => api.getAudit(200),
});
if (isLoading || !data) return <div className="p-6">Loading…</div>;
if (isLoading || !data) return <div className="p-6 font-mono text-[12px] text-fg-muted">Loading…</div>;
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-4">Audit log</h1>
<table className="w-full text-sm font-mono">
<thead className="text-left text-slate-400 border-b border-slate-800">
<tr><th className="py-2">Time</th><th>Action</th><th>Target</th></tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id} className="border-b border-slate-900">
<td className="py-1">{new Date(row.at).toISOString()}</td>
<td>{row.action}</td>
<td className="text-slate-400 truncate max-w-md">{row.target ?? ''}</td>
</tr>
))}
</tbody>
</table>
<div className="p-8 max-w-6xl">
<div className="mb-6">
<div className={EYEBROW}>● AUDIT</div>
<h1 className="text-[24px] font-medium mt-1">Change log</h1>
</div>
{data.length === 0 ? (
<div className="border border-line-subtle rounded p-8 text-center text-fg-muted">
No audit entries yet.
</div>
) : (
<div className="border border-line-subtle rounded overflow-hidden">
<table className="w-full text-[13px] font-mono">
<thead className="bg-surface">
<tr className={EYEBROW + ' text-left'}>
<th className="py-3 px-4 font-normal">Time</th>
<th className="py-3 px-4 font-normal">Action</th>
<th className="py-3 px-4 font-normal">Target</th>
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={row.id} className="border-t border-line-subtle hover:bg-surface transition-colors">
<td className="py-2.5 px-4 text-fg-2 whitespace-nowrap">{new Date(row.at).toISOString()}</td>
<td className="py-2.5 px-4 text-fg">{row.action}</td>
<td className="py-2.5 px-4 text-fg-muted truncate max-w-md">{row.target ?? ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,53 @@
import { useEffect, useState } from 'react';
import { X, AlertTriangle, Share2 } from 'lucide-react';
import { useToast } from '../components/Toast';
import { useConfirm } from '../components/ConfirmDialog';
import { useGraph, useCreateNode, useUpdateNode, useDeleteNode, useDeleteEdge, useUpdateEdge } from '../hooks/useGraph';
import { api } from '../api/client';
import { useTrafficStats } from '../hooks/useTrafficStats';
import GraphCanvas from '../components/graph/GraphCanvas';
import { ApiError } from '../api/client';
const INPUT_CLS =
'w-full bg-[#0f0f0f] border border-line rounded px-3 py-2 text-fg placeholder:text-fg-disabled focus:border-accent focus:outline-none focus:ring focus:ring-accent/35';
const INPUT_MONO_CLS = INPUT_CLS + ' font-mono text-[13px]';
const BTN_PRIMARY =
'bg-accent hover:bg-accent-hover active:bg-accent-press disabled:bg-surface-2 disabled:text-fg-disabled text-white rounded px-3 py-2 text-[13px] font-medium transition-colors';
const BTN_SECONDARY =
'bg-transparent border border-line text-fg hover:bg-fg hover:text-bg rounded px-3 py-2 text-[13px] font-medium transition-colors';
const BTN_DESTRUCTIVE =
'bg-transparent border border-accent text-accent hover:bg-accent hover:text-white rounded px-3 py-2 text-[13px] font-medium transition-colors disabled:opacity-40';
const EYEBROW = 'font-mono text-[10px] tracking-caps text-fg-muted';
const ERROR_CLS = 'font-mono text-[12px] text-accent border border-accent bg-accent/10 rounded px-2.5 py-1.5 flex items-start gap-2';
function ErrorLine({ children }: { children: React.ReactNode }) {
return (
<div className={ERROR_CLS}>
<AlertTriangle size={16} strokeWidth={1.5} className="mt-[1px] shrink-0" />
<span>{children}</span>
</div>
);
}
function EmptyGraph({ onAddNode }: { onAddNode: () => void }) {
return (
<div className="w-full h-[calc(100vh-68px)] flex items-center justify-center px-6">
<div className="max-w-sm text-center space-y-4">
<div className="inline-flex justify-center">
<Share2 size={32} strokeWidth={1.5} className="text-line" />
</div>
<div>
<div className={EYEBROW}>● NO ROUTES</div>
<div className="text-[14px] text-fg mt-2 leading-snug">
No routes configured. Add a node to start routing messages.
</div>
</div>
<button onClick={onAddNode} className={BTN_PRIMARY}>Add node</button>
</div>
</div>
);
}
export default function Graph() {
const { data: graph, isLoading, error } = useGraph();
const [trafficOn, setTrafficOn] = useState(false);
@@ -13,47 +56,74 @@ export default function Graph() {
const [selectedEdge, setSelectedEdge] = useState<string | null>(null);
const [showNewNode, setShowNewNode] = useState(false);
if (isLoading) return <div className="p-6">Loading…</div>;
if (error) return <div className="p-6 text-red-400">Error: {(error as Error).message}</div>;
if (isLoading) return <div className="p-6 font-mono text-[12px] text-fg-muted">Loading…</div>;
if (error) return <div className="p-6 font-mono text-[12px] text-accent">Error: {(error as Error).message}</div>;
if (!graph) return null;
return (
<div>
<div className="border-b border-slate-800 px-6 py-2 flex items-center gap-3">
<button
onClick={() => setShowNewNode(true)}
className="bg-orange-600 hover:bg-orange-500 rounded px-3 py-1 text-sm font-semibold"
>+ Add node</button>
<label className="text-sm flex items-center gap-2 ml-auto">
<input type="checkbox" checked={trafficOn} onChange={e => setTrafficOn(e.target.checked)} />
View traffic
</label>
<div className="relative">
<div className="fixed top-0 right-0 left-[240px] z-20 bg-bg/90 backdrop-blur border-b border-line-subtle px-6 py-3 flex items-center gap-4">
<div>
<div className={EYEBROW}>● GRAPH</div>
<div className="text-[16px] font-medium mt-0.5">Routing topology</div>
</div>
<div className="ml-auto flex items-center gap-3">
<label className="flex items-center gap-2 text-[13px] text-fg-2 cursor-pointer">
<input
type="checkbox"
checked={trafficOn}
onChange={e => setTrafficOn(e.target.checked)}
className="accent-accent"
/>
View traffic
</label>
<button onClick={() => setShowNewNode(true)} className={BTN_PRIMARY}>Add node</button>
</div>
</div>
<div className="pt-[68px]">
{graph.nodes.length === 0 ? (
<EmptyGraph onAddNode={() => setShowNewNode(true)} />
) : (
<GraphCanvas
graph={graph}
stats={stats}
onOpenNode={setSelectedNode}
onOpenEdge={setSelectedEdge}
/>
)}
</div>
<GraphCanvas
graph={graph}
stats={stats}
onOpenNode={setSelectedNode}
onOpenEdge={setSelectedEdge}
/>
{showNewNode && <NewNodeDialog onClose={() => setShowNewNode(false)} />}
{selectedNode && <div className="fixed right-0 top-14 bottom-0 w-96 bg-slate-900 border-l border-slate-800 p-4 z-10">
<div className="flex justify-between items-center mb-4">
<h2 className="font-bold">Node</h2>
<button onClick={() => setSelectedNode(null)}>×</button>
</div>
<NodeDrawerContent nodeId={selectedNode} onDeleted={() => setSelectedNode(null)} />
</div>}
{selectedEdge && <div className="fixed right-0 top-14 bottom-0 w-96 bg-slate-900 border-l border-slate-800 p-4 z-10">
<div className="flex justify-between items-center mb-4">
<h2 className="font-bold">Edge</h2>
<button onClick={() => setSelectedEdge(null)}>×</button>
</div>
<EdgeDrawerContent edgeId={selectedEdge} onDeleted={() => setSelectedEdge(null)} />
</div>}
{selectedNode && (
<Drawer title="Node" eyebrow="INSPECT" onClose={() => setSelectedNode(null)}>
<NodeDrawerContent nodeId={selectedNode} onDeleted={() => setSelectedNode(null)} />
</Drawer>
)}
{selectedEdge && (
<Drawer title="Edge" eyebrow="INSPECT" onClose={() => setSelectedEdge(null)}>
<EdgeDrawerContent edgeId={selectedEdge} onDeleted={() => setSelectedEdge(null)} />
</Drawer>
)}
</div>
);
}
function Drawer({ title, eyebrow, onClose, children }: { title: string; eyebrow: string; onClose: () => void; children: React.ReactNode }) {
return (
<aside className="fixed right-0 top-0 bottom-0 w-96 bg-surface border-l border-line-subtle p-5 z-30 overflow-y-auto">
<div className="flex justify-between items-start mb-5">
<div>
<div className={EYEBROW}>{eyebrow}</div>
<h2 className="text-[18px] font-medium mt-0.5">{title}</h2>
</div>
<button onClick={onClose} className="text-fg-muted hover:text-fg inline-flex" aria-label="Close drawer">
<X size={18} strokeWidth={1.5} />
</button>
</div>
{children}
</aside>
);
}
function NewNodeDialog({ onClose }: { onClose: () => void }) {
const [name, setName] = useState('');
const [streamName, setStreamName] = useState('');
@@ -76,22 +146,39 @@ function NewNodeDialog({ onClose }: { onClose: () => void }) {
});
onClose();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Failed');
setError(err instanceof ApiError ? err.message : 'Failed to create node.');
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-20">
<form onSubmit={submit} className="bg-slate-900 p-6 rounded-lg w-96 space-y-3">
<h2 className="text-lg font-bold">New node</h2>
<input className="w-full bg-slate-800 rounded px-2 py-1" placeholder="Name" value={name} onChange={e => setName(e.target.value)} />
<input className="w-full bg-slate-800 rounded px-2 py-1" placeholder="Stream name (immutable)" value={streamName} onChange={e => setStreamName(e.target.value)} />
<textarea className="w-full bg-slate-800 rounded px-2 py-1 font-mono text-sm" rows={3} placeholder="Subjects (one per line)" value={subjects} onChange={e => setSubjects(e.target.value)} />
<input className="w-full bg-slate-800 rounded px-2 py-1" placeholder="Max bytes" value={maxBytes} onChange={e => setMaxBytes(e.target.value)} />
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="flex gap-2 justify-end">
<button type="button" onClick={onClose} className="px-3 py-1 rounded bg-slate-700">Cancel</button>
<button type="submit" disabled={createNode.isPending} className="px-3 py-1 rounded bg-orange-600 hover:bg-orange-500">Create</button>
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-40 px-6">
<form onSubmit={submit} className="bg-surface border border-line-subtle p-6 rounded w-96 space-y-4 shadow-pop">
<div>
<div className={EYEBROW}>● NEW</div>
<h2 className="text-[18px] font-medium mt-0.5">Node</h2>
</div>
<label className="block space-y-1">
<span className={EYEBROW}>Name</span>
<input className={INPUT_CLS} placeholder="orders-hot" value={name} onChange={e => setName(e.target.value)} />
</label>
<label className="block space-y-1">
<span className={EYEBROW}>Stream name (immutable)</span>
<input className={INPUT_MONO_CLS} placeholder="orders_hot" value={streamName} onChange={e => setStreamName(e.target.value)} />
</label>
<label className="block space-y-1">
<span className={EYEBROW}>Subjects (one per line)</span>
<textarea className={INPUT_MONO_CLS} rows={3} placeholder="orders.>" value={subjects} onChange={e => setSubjects(e.target.value)} />
</label>
<label className="block space-y-1">
<span className={EYEBROW}>Max bytes</span>
<input className={INPUT_MONO_CLS} placeholder="10485760" value={maxBytes} onChange={e => setMaxBytes(e.target.value)} />
</label>
{error && <ErrorLine>{error}</ErrorLine>}
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button>
<button type="submit" disabled={createNode.isPending} className={BTN_PRIMARY}>
{createNode.isPending ? 'Creating…' : 'Create node'}
</button>
</div>
</form>
</div>
@@ -102,6 +189,8 @@ function NodeDrawerContent({ nodeId, onDeleted }: { nodeId: string; onDeleted: (
const { data: graph } = useGraph();
const updateNode = useUpdateNode();
const deleteNode = useDeleteNode();
const toast = useToast();
const confirm = useConfirm();
const node = graph?.nodes.find(n => n.id === nodeId);
const [name, setName] = useState('');
@@ -122,7 +211,7 @@ function NodeDrawerContent({ nodeId, onDeleted }: { nodeId: string; onDeleted: (
}
}, [nodeId, node?.name, node?.subjects, node?.maxBytes, node?.maxAgeMs, node?.maxMsgs]);
if (!node) return <div>Node not found</div>;
if (!node) return <div className="text-fg-muted">Node not found.</div>;
const parsedSubjects = subjectsText.split(/\s+/).filter(Boolean);
const parsedMaxBytes = parseInt(maxBytes, 10);
@@ -139,10 +228,9 @@ function NodeDrawerContent({ nodeId, onDeleted }: { nodeId: string; onDeleted: (
async function save() {
setSaveError(null);
if (!Number.isFinite(parsedMaxBytes) || parsedMaxBytes <= 0) {
setSaveError('max_bytes must be a positive number');
setSaveError('max_bytes must be a positive number.');
return;
}
// Empty subjects are allowed — pure receiver nodes rely on incoming edges only.
try {
await updateNode.mutateAsync({
id: node!.id,
@@ -155,99 +243,74 @@ function NodeDrawerContent({ nodeId, onDeleted }: { nodeId: string; onDeleted: (
},
});
} catch (err) {
setSaveError(err instanceof ApiError ? err.message : 'Save failed');
setSaveError(err instanceof ApiError ? err.message : 'Save failed.');
}
}
async function del() {
if (!confirm(
`Remove "${node!.name}" from the graph?\n\n` +
`The underlying JetStream stream "${node!.streamName}" is preserved on the NATS server — messages stay put. ` +
`Re-creating a node with the same stream_name later will re-adopt this data.\n\n` +
`All edges touching this node will also be removed.`
)) return;
const ok = await confirm({
eyebrow: '● REMOVE NODE',
title: `Remove ${node!.name} from the graph?`,
body: `The JetStream stream ${node!.streamName} stays on the NATS server — messages are preserved. All edges touching this node will be removed. Re-creating a node with the same stream name later will re-adopt the data.`,
confirmLabel: 'Remove node',
});
if (!ok) return;
try {
await deleteNode.mutateAsync(node!.id);
onDeleted();
} catch (err) {
alert(err instanceof ApiError ? err.message : 'Delete failed');
toast.push({
kind: 'error',
body: err instanceof ApiError ? err.message : 'Delete failed.',
});
}
}
return (
<div className="space-y-4 text-sm">
<div className="space-y-5">
<div>
<span className="text-slate-400 text-xs">Stream (immutable)</span>
<div className="font-mono mt-1">{node.streamName}</div>
<div className="text-slate-500 text-xs mt-1">Internal id: <span className="font-mono">{node.id}</span></div>
<span className={EYEBROW}>Stream (immutable)</span>
<div className="font-mono text-[13px] mt-1">{node.streamName}</div>
<div className="font-mono text-[11px] text-fg-muted mt-1">id {node.id}</div>
</div>
<label className="block">
<span className="text-slate-400 text-xs">Name</span>
<input
className="w-full mt-1 bg-slate-800 rounded px-2 py-1"
value={name}
onChange={e => setName(e.target.value)}
/>
<label className="block space-y-1">
<span className={EYEBROW}>Name</span>
<input className={INPUT_CLS} value={name} onChange={e => setName(e.target.value)} />
</label>
<label className="block">
<span className="text-slate-400 text-xs">Subjects (one per line)</span>
<textarea
className="w-full mt-1 bg-slate-800 rounded px-2 py-1 font-mono text-xs"
rows={4}
value={subjectsText}
onChange={e => setSubjectsText(e.target.value)}
/>
<div className="text-xs text-slate-500 mt-1">
<label className="block space-y-1">
<span className={EYEBROW}>Subjects (one per line)</span>
<textarea className={INPUT_MONO_CLS} rows={4} value={subjectsText} onChange={e => setSubjectsText(e.target.value)} />
<div className="text-[12px] text-fg-muted mt-1">
Publishers assigned to this node can publish to these patterns. Changes trigger a nats.conf reload.
</div>
</label>
<div className="grid grid-cols-1 gap-2">
<label className="block">
<span className="text-slate-400 text-xs">Max bytes</span>
<input
className="w-full mt-1 bg-slate-800 rounded px-2 py-1 font-mono text-xs"
value={maxBytes}
onChange={e => setMaxBytes(e.target.value)}
/>
<div className="grid grid-cols-1 gap-3">
<label className="block space-y-1">
<span className={EYEBROW}>Max bytes</span>
<input className={INPUT_MONO_CLS} value={maxBytes} onChange={e => setMaxBytes(e.target.value)} />
</label>
<label className="block">
<span className="text-slate-400 text-xs">Max age ms (blank = no limit)</span>
<input
className="w-full mt-1 bg-slate-800 rounded px-2 py-1 font-mono text-xs"
value={maxAgeMs}
onChange={e => setMaxAgeMs(e.target.value)}
/>
<label className="block space-y-1">
<span className={EYEBROW}>Max age ms (blank = no limit)</span>
<input className={INPUT_MONO_CLS} value={maxAgeMs} onChange={e => setMaxAgeMs(e.target.value)} />
</label>
<label className="block">
<span className="text-slate-400 text-xs">Max msgs (blank = no limit)</span>
<input
className="w-full mt-1 bg-slate-800 rounded px-2 py-1 font-mono text-xs"
value={maxMsgs}
onChange={e => setMaxMsgs(e.target.value)}
/>
<label className="block space-y-1">
<span className={EYEBROW}>Max msgs (blank = no limit)</span>
<input className={INPUT_MONO_CLS} value={maxMsgs} onChange={e => setMaxMsgs(e.target.value)} />
</label>
</div>
{saveError && <div className="text-red-400 text-xs">{saveError}</div>}
{saveError && <ErrorLine>{saveError}</ErrorLine>}
<button
onClick={save}
disabled={!dirty || updateNode.isPending}
className="w-full px-3 py-2 rounded bg-orange-600 hover:bg-orange-500 disabled:opacity-40 text-white text-sm font-semibold"
>
<button onClick={save} disabled={!dirty || updateNode.isPending} className={BTN_PRIMARY + ' w-full'}>
{updateNode.isPending ? 'Saving…' : dirty ? 'Save changes' : 'Saved'}
</button>
<div className="pt-4 border-t border-slate-800 space-y-2">
<div className="text-slate-400 text-xs uppercase tracking-wide">Danger zone</div>
<button
onClick={del}
disabled={deleteNode.isPending}
className="w-full px-3 py-2 rounded bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-semibold"
>
<div className="pt-5 border-t border-line-subtle space-y-2">
<div className={EYEBROW}>Danger zone</div>
<button onClick={del} disabled={deleteNode.isPending} className={BTN_DESTRUCTIVE + ' w-full'}>
{deleteNode.isPending ? 'Removing…' : 'Remove from graph (keeps stream data)'}
</button>
</div>
@@ -259,6 +322,8 @@ function EdgeDrawerContent({ edgeId, onDeleted }: { edgeId: string; onDeleted: (
const { data: graph } = useGraph();
const updateEdge = useUpdateEdge();
const deleteEdge = useDeleteEdge();
const toast = useToast();
const confirm = useConfirm();
const edge = graph?.edges.find(e => e.id === edgeId);
const fromNode = graph?.nodes.find(n => n.id === edge?.fromNodeId);
const toNode = graph?.nodes.find(n => n.id === edge?.toNodeId);
@@ -269,12 +334,11 @@ function EdgeDrawerContent({ edgeId, onDeleted }: { edgeId: string; onDeleted: (
const [preview, setPreview] = useState<{ matches: boolean; rewritten: string | null } | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
// Reset the editor when the drawer switches to a different edge.
useEffect(() => {
if (edge) { setFilter(edge.filterSubject); setDest(edge.transformDest); setPreview(null); setSaveError(null); }
}, [edgeId, edge?.filterSubject, edge?.transformDest]);
if (!edge) return <div>Edge not found</div>;
if (!edge) return <div className="text-fg-muted">Edge not found.</div>;
const dirty = filter !== edge.filterSubject || dest !== edge.transformDest;
@@ -282,7 +346,7 @@ function EdgeDrawerContent({ edgeId, onDeleted }: { edgeId: string; onDeleted: (
setSaveError(null);
try {
await updateEdge.mutateAsync({ id: edge!.id, input: { filterSubject: filter, transformDest: dest } });
} catch (err) { setSaveError(err instanceof ApiError ? err.message : 'Save failed'); }
} catch (err) { setSaveError(err instanceof ApiError ? err.message : 'Save failed.'); }
}
async function runPreview() {
@@ -290,99 +354,87 @@ function EdgeDrawerContent({ edgeId, onDeleted }: { edgeId: string; onDeleted: (
const p = await api.previewTransform(filter, dest, testSubject);
setPreview(p);
} catch (err) {
setPreview({ matches: false, rewritten: err instanceof ApiError ? err.message : 'Preview failed' });
setPreview({ matches: false, rewritten: err instanceof ApiError ? err.message : 'Preview failed.' });
}
}
async function del() {
if (!confirm('Delete this edge? Messages already routed through it stay in the destination stream.')) return;
const ok = await confirm({
eyebrow: '● DELETE EDGE',
title: 'Delete this edge?',
body: 'Messages already routed through it stay in the destination stream. New matching messages will no longer be forwarded.',
confirmLabel: 'Delete edge',
});
if (!ok) return;
try {
await deleteEdge.mutateAsync(edge!.id);
onDeleted();
} catch (err) {
alert(err instanceof ApiError ? err.message : 'Failed to delete edge');
toast.push({
kind: 'error',
body: err instanceof ApiError ? err.message : 'Delete failed.',
});
}
}
return (
<div className="space-y-4 text-sm">
<div className="space-y-1">
<div><span className="text-slate-400">From:</span> <span className="font-mono">{fromNode?.streamName ?? edge.fromNodeId}</span></div>
<div><span className="text-slate-400">To:</span> <span className="font-mono">{toNode?.streamName ?? edge.toNodeId}</span></div>
</div>
<div className="space-y-5">
<div className="space-y-2">
<label className="block">
<span className="text-slate-400 text-xs">Filter subject</span>
<input
className="w-full mt-1 bg-slate-800 rounded px-2 py-1 font-mono text-xs"
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="e.g. orders.*"
/>
</label>
<label className="block">
<span className="text-slate-400 text-xs">Transform dest</span>
<input
className="w-full mt-1 bg-slate-800 rounded px-2 py-1 font-mono text-xs"
value={dest}
onChange={e => setDest(e.target.value)}
placeholder="e.g. archive.{{wildcard(1)}}"
/>
</label>
<p className="text-xs text-slate-500 leading-snug">
Wildcards: <code className="text-slate-300">*</code> matches one token,{' '}
<code className="text-slate-300">&gt;</code> matches the rest. In the transform,
reference captures as <code className="text-slate-300">{'{{wildcard(N)}}'}</code>
{' '}(1-indexed).
</p>
{saveError && <div className="text-red-400 text-xs">{saveError}</div>}
<div className="flex gap-2">
<button
onClick={save}
disabled={!dirty || updateEdge.isPending}
className="px-3 py-1 rounded bg-orange-600 hover:bg-orange-500 disabled:opacity-40 text-white text-xs font-semibold"
>
{updateEdge.isPending ? 'Saving…' : dirty ? 'Save changes' : 'Saved'}
</button>
<div>
<span className={EYEBROW}>From</span>
<div className="font-mono text-[13px] mt-1">{fromNode?.streamName ?? edge.fromNodeId}</div>
</div>
<div>
<span className={EYEBROW}>To</span>
<div className="font-mono text-[13px] mt-1">{toNode?.streamName ?? edge.toNodeId}</div>
</div>
</div>
<div className="space-y-2 border-t border-slate-800 pt-3">
<div className="text-slate-400 text-xs">Test match</div>
<div className="space-y-3">
<label className="block space-y-1">
<span className={EYEBROW}>Filter subject</span>
<input className={INPUT_MONO_CLS} value={filter} onChange={e => setFilter(e.target.value)} placeholder="orders.*" />
</label>
<label className="block space-y-1">
<span className={EYEBROW}>Transform dest</span>
<input className={INPUT_MONO_CLS} value={dest} onChange={e => setDest(e.target.value)} placeholder="archive.{{wildcard(1)}}" />
</label>
<p className="text-[12px] text-fg-muted leading-snug">
Wildcards: <code className="font-mono text-fg-2">*</code> matches one token,{' '}
<code className="font-mono text-fg-2">&gt;</code> matches the rest. In the transform,
reference captures as <code className="font-mono text-fg-2">{'{{wildcard(N)}}'}</code>{' '}(1-indexed).
</p>
{saveError && <ErrorLine>{saveError}</ErrorLine>}
<button onClick={save} disabled={!dirty || updateEdge.isPending} className={BTN_PRIMARY + ' w-full'}>
{updateEdge.isPending ? 'Saving…' : dirty ? 'Save changes' : 'Saved'}
</button>
</div>
<div className="space-y-2 border-t border-line-subtle pt-4">
<div className={EYEBROW}>Test match</div>
<div className="flex gap-2">
<input
className="flex-1 bg-slate-800 rounded px-2 py-1 font-mono text-xs"
className={INPUT_MONO_CLS + ' flex-1'}
value={testSubject}
onChange={e => setTestSubject(e.target.value)}
placeholder="sample subject to test"
placeholder="sample subject"
onKeyDown={e => e.key === 'Enter' && runPreview()}
/>
<button
onClick={runPreview}
disabled={!testSubject}
className="px-3 py-1 rounded bg-slate-700 hover:bg-slate-600 text-xs disabled:opacity-40"
>
Test
</button>
<button onClick={runPreview} disabled={!testSubject} className={BTN_SECONDARY}>Test</button>
</div>
{preview && (
preview.matches
? <div className="text-xs">
<span className="text-green-400 mr-2">match</span>
<span className="text-slate-400">→</span>{' '}
<span className="font-mono">{preview.rewritten}</span>
? <div className="font-mono text-[12px] flex items-center gap-2">
<span className="text-ok">match</span>
<span className="text-fg-muted">→</span>
<span>{preview.rewritten}</span>
</div>
: <div className="text-xs text-slate-500">no match</div>
: <div className="font-mono text-[12px] text-fg-muted">no match</div>
)}
</div>
<div className="pt-4 border-t border-slate-800">
<button
onClick={del}
disabled={deleteEdge.isPending}
className="w-full px-3 py-2 rounded bg-red-700 hover:bg-red-600 disabled:opacity-50 text-white text-sm font-semibold"
>
<div className="pt-5 border-t border-line-subtle">
<button onClick={del} disabled={deleteEdge.isPending} className={BTN_DESTRUCTIVE + ' w-full'}>
{deleteEdge.isPending ? 'Deleting…' : 'Delete edge'}
</button>
</div>

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { AlertTriangle } from 'lucide-react';
import { useLogin } from '../hooks/useSession';
import { ApiError } from '../api/client';
import { Wordmark } from '../components/Wordmark';
export default function Login() {
const [password, setPassword] = useState('');
@@ -16,30 +18,47 @@ export default function Login() {
await login.mutateAsync(password);
navigate({ to: '/' });
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Login failed');
setError(err instanceof ApiError ? err.message : 'Login failed.');
}
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<form onSubmit={submit} className="bg-slate-900 p-8 rounded-lg shadow-lg w-96 space-y-4">
<h1 className="text-2xl font-bold text-center">VolcanicCore</h1>
<input
type="password"
className="w-full bg-slate-800 rounded px-3 py-2 text-slate-100"
placeholder="Admin password"
value={password}
onChange={e => setPassword(e.target.value)}
autoFocus
/>
{error && <div className="text-red-400 text-sm">{error}</div>}
<button
type="submit"
disabled={login.isPending || !password}
className="w-full bg-orange-600 hover:bg-orange-500 disabled:bg-slate-700 rounded px-4 py-2 font-semibold"
>
{login.isPending ? 'Signing in…' : 'Sign in'}
</button>
<div className="min-h-screen bg-bg text-fg flex items-center justify-center px-6">
<form onSubmit={submit} className="w-full max-w-sm space-y-8">
<div className="text-center space-y-3">
<div className="flex justify-center">
<Wordmark size={36} />
</div>
<div className="font-mono text-[11px] tracking-caps text-fg-muted">
NATS ROUTING CONTROL
</div>
</div>
<div className="space-y-3">
<label className="block">
<span className="font-mono text-[10px] tracking-caps text-fg-muted">Admin password</span>
<input
type="password"
className="mt-1.5 w-full bg-[#0f0f0f] border border-line rounded font-mono px-3 py-2 text-fg placeholder:text-fg-disabled focus:border-accent focus:outline-none focus:ring focus:ring-accent/35"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
autoFocus
/>
</label>
{error && (
<div className="font-mono text-[12px] text-accent border border-accent bg-accent/10 rounded px-3 py-2 flex items-start gap-2">
<AlertTriangle size={16} strokeWidth={1.5} className="mt-[1px] shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={login.isPending || !password}
className="w-full bg-accent hover:bg-accent-hover active:bg-accent-press disabled:bg-surface-2 disabled:text-fg-disabled text-white rounded px-4 py-2.5 text-[14px] font-medium transition-colors"
>
{login.isPending ? 'Signing in…' : 'Sign in'}
</button>
</div>
</form>
</div>
);

View File

@@ -1 +1,11 @@
export default function NotFound() { return <div className="p-6">Not found</div>; }
export default function NotFound() {
return (
<div className="min-h-screen bg-bg text-fg flex items-center justify-center px-6">
<div className="text-center space-y-2">
<div className="font-mono text-[10px] tracking-caps text-fg-muted">● 404</div>
<div className="text-[20px] font-medium">Route not found.</div>
<a href="/" className="inline-block text-accent hover:text-accent-hover text-[13px]">Go to graph →</a>
</div>
</div>
);
}

View File

@@ -1,24 +1,78 @@
import { useHealth } from '../hooks/useHealth';
const EYEBROW = 'font-mono text-[10px] tracking-caps text-fg-muted';
function StatusChip({ label, kind }: { label: string; kind: 'ok' | 'warn' | 'down' }) {
const cls =
kind === 'ok' ? 'border border-ok text-ok' :
kind === 'warn' ? 'border border-warn text-warn' :
'bg-accent text-white border border-accent';
return (
<span className={`inline-flex items-center gap-1.5 rounded-pill px-2.5 py-0.5 font-mono text-[10px] tracking-caps ${cls}`}>
{kind !== 'down' && <span className={`inline-block w-1.5 h-1.5 rounded-pill ${kind === 'ok' ? 'bg-ok' : 'bg-warn'}`} />}
{label.toUpperCase()}
</span>
);
}
function Row({ label, value, valueMono = false }: { label: string; value: React.ReactNode; valueMono?: boolean }) {
return (
<div className="flex justify-between items-center py-2 border-b border-line-subtle last:border-0">
<div className={EYEBROW}>{label}</div>
<div className={valueMono ? 'font-mono text-[13px]' : 'text-[14px]'}>{value}</div>
</div>
);
}
export default function Server() {
const { data: health, isLoading } = useHealth();
if (isLoading || !health) return <div className="p-6">Loading…</div>;
if (isLoading || !health) return <div className="p-6 font-mono text-[12px] text-fg-muted">Loading…</div>;
const barColor = health.status === 'degraded' ? 'bg-red-600'
: health.nats === 'up' ? 'bg-green-700' : 'bg-yellow-600';
const overall: 'ok' | 'warn' | 'down' =
health.status === 'degraded' ? 'down'
: health.nats === 'up' ? 'ok'
: 'warn';
const natsKind: 'ok' | 'warn' | 'down' =
health.nats === 'up' ? 'ok'
: health.nats === 'down' ? 'down'
: 'warn';
return (
<div className="p-6 space-y-4">
<h1 className="text-xl font-bold">Server status</h1>
<div className={`${barColor} px-4 py-2 rounded font-mono text-sm`}>
{health.status.toUpperCase()} · nats: {health.nats} (pid {health.natsPid ?? 'n/a'}, crashes {health.natsCrashes})
<div className="p-8 max-w-3xl space-y-6">
<div className="flex items-start">
<div>
<div className={EYEBROW}>● SERVER</div>
<h1 className="text-[24px] font-medium mt-1">Runtime status</h1>
</div>
<div className="ml-auto"><StatusChip label={health.status} kind={overall} /></div>
</div>
<div className="text-sm space-y-1">
<div><span className="text-slate-400">Reconciler:</span> {health.reconciler}</div>
<div><span className="text-slate-400">Last run:</span> {health.reconcilerLastRun ?? 'never'}</div>
{health.reconcilerLastError && (
<div className="text-red-400"><span className="text-slate-400">Last error:</span> {health.reconcilerLastError}</div>
)}
<div className="border border-line-subtle rounded">
<div className="px-4 py-3 border-b border-line-subtle bg-surface">
<div className={EYEBROW}>nats-server</div>
</div>
<div className="px-4">
<Row label="State" value={<StatusChip label={health.nats} kind={natsKind} />} />
<Row label="PID" value={health.natsPid ?? '—'} valueMono />
<Row label="Crashes" value={health.natsCrashes} valueMono />
</div>
</div>
<div className="border border-line-subtle rounded">
<div className="px-4 py-3 border-b border-line-subtle bg-surface">
<div className={EYEBROW}>Reconciler</div>
</div>
<div className="px-4">
<Row label="State" value={<span className="font-mono text-[12px] uppercase tracking-caps">{health.reconciler}</span>} />
<Row label="Last run" value={health.reconcilerLastRun ?? 'never'} valueMono />
{health.reconcilerLastError && (
<Row
label="Last error"
value={<span className="text-accent font-mono text-[12px]">{health.reconcilerLastError}</span>}
/>
)}
</div>
</div>
</div>
);

View File

@@ -1,6 +1,20 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { useGraph, useCreateUser, useDeleteUser, useCreateAssignment, useDeleteAssignment } from '../hooks/useGraph';
import { ApiError } from '../api/client';
import { useConfirm } from '../components/ConfirmDialog';
import { useToast } from '../components/Toast';
const EYEBROW = 'font-mono text-[10px] tracking-caps text-fg-muted';
const INPUT_CLS =
'w-full bg-[#0f0f0f] border border-line rounded px-3 py-2 text-fg placeholder:text-fg-disabled focus:border-accent focus:outline-none focus:ring focus:ring-accent/35';
const BTN_PRIMARY =
'bg-accent hover:bg-accent-hover active:bg-accent-press disabled:bg-surface-2 disabled:text-fg-disabled text-white rounded px-3 py-2 text-[13px] font-medium transition-colors';
const BTN_SECONDARY =
'bg-transparent border border-line text-fg hover:bg-fg hover:text-bg rounded px-3 py-2 text-[13px] font-medium transition-colors';
const BTN_DESTRUCTIVE =
'bg-transparent border border-accent text-accent hover:bg-accent hover:text-white rounded px-2.5 py-1 text-[12px] font-medium transition-colors';
const ERROR_CLS = 'font-mono text-[12px] text-accent border border-accent/40 bg-accent/10 rounded px-2.5 py-1.5';
export default function Users() {
const { data: graph } = useGraph();
@@ -8,75 +22,108 @@ export default function Users() {
const deleteUser = useDeleteUser();
const createAsn = useCreateAssignment();
const deleteAsn = useDeleteAssignment();
const confirm = useConfirm();
const toast = useToast();
const [showNew, setShowNew] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!graph) return <div className="p-6">Loading…</div>;
if (!graph) return <div className="p-6 font-mono text-[12px] text-fg-muted">Loading…</div>;
async function del(id: string, username: string) {
const typed = prompt(`Type "${username}" to confirm deletion:`);
if (typed !== username) return;
const ok = await confirm({
eyebrow: '● DELETE USER',
title: `Delete user ${username}?`,
body: 'This revokes their tokens immediately. NATS clients using them will disconnect at the next reconnect.',
confirmLabel: 'Delete user',
confirmMatch: username,
});
if (!ok) return;
try { await deleteUser.mutateAsync(id); }
catch (err) { setError(err instanceof ApiError ? err.message : 'Failed'); }
catch (err) {
const msg = err instanceof ApiError ? err.message : 'Delete failed.';
setError(msg);
toast.push({ kind: 'error', body: msg });
}
}
return (
<div className="p-6">
<div className="flex items-center mb-4">
<h1 className="text-xl font-bold">NATS users</h1>
<button onClick={() => setShowNew(true)} className="ml-auto bg-orange-600 hover:bg-orange-500 rounded px-3 py-1 text-sm font-semibold">+ New user</button>
<div className="p-8 max-w-6xl">
<div className="flex items-start mb-6">
<div>
<div className={EYEBROW}>● USERS</div>
<h1 className="text-[24px] font-medium mt-1">NATS users</h1>
</div>
<button onClick={() => setShowNew(true)} className={BTN_PRIMARY + ' ml-auto'}>Add user</button>
</div>
{error && <div className="text-red-400 mb-2">{error}</div>}
<table className="w-full text-sm">
<thead className="text-left text-slate-400 border-b border-slate-800">
<tr><th className="py-2">Username</th><th>Password</th><th>Nodes</th><th></th></tr>
</thead>
<tbody>
{graph.users.map(u => {
const asns = graph.assignments.filter(a => a.userId === u.id);
return (
<tr key={u.id} className="border-b border-slate-900">
<td className="py-2 font-mono">{u.username}</td>
<td>
<button
onClick={() => navigator.clipboard.writeText(u.password)}
className="text-xs px-2 py-1 bg-slate-800 rounded hover:bg-slate-700"
>Copy</button>
</td>
<td className="text-xs">
<div className="flex flex-wrap gap-1">
{asns.map(a => {
const node = graph.nodes.find(n => n.id === a.nodeId);
return (
<span key={a.nodeId} className="bg-slate-800 rounded px-1 flex items-center gap-1">
{node?.streamName ?? a.nodeId}
<button onClick={() => deleteAsn.mutate({ nodeId: a.nodeId, userId: u.id })}>×</button>
</span>
);
})}
<select
className="bg-slate-800 rounded px-1 text-xs"
value=""
onChange={e => {
if (e.target.value)
createAsn.mutate({ nodeId: e.target.value, userId: u.id });
}}
>
<option value="">+ Assign</option>
{graph.nodes
.filter(n => !asns.some(a => a.nodeId === n.id))
.map(n => <option key={n.id} value={n.id}>{n.streamName}</option>)}
</select>
</div>
</td>
<td className="text-right">
<button onClick={() => del(u.id, u.username)} className="text-red-400 text-xs">Delete</button>
</td>
{error && <div className={ERROR_CLS + ' mb-4'}>{error}</div>}
{graph.users.length === 0 ? (
<div className="border border-line-subtle rounded p-8 text-center">
<div className="text-fg-muted text-[14px]">No users configured.</div>
<div className="text-fg-muted text-[13px] mt-1">Add a user to grant NATS publish access.</div>
</div>
) : (
<div className="border border-line-subtle rounded overflow-hidden">
<table className="w-full text-[13px]">
<thead className="bg-surface">
<tr className={EYEBROW + ' text-left'}>
<th className="py-3 px-4 font-normal">Username</th>
<th className="py-3 px-4 font-normal">Password</th>
<th className="py-3 px-4 font-normal">Assigned nodes</th>
<th className="py-3 px-4"></th>
</tr>
);
})}
</tbody>
</table>
</thead>
<tbody>
{graph.users.map(u => {
const asns = graph.assignments.filter(a => a.userId === u.id);
return (
<tr key={u.id} className="border-t border-line-subtle">
<td className="py-3 px-4 font-mono">{u.username}</td>
<td className="py-3 px-4">
<button
onClick={() => navigator.clipboard.writeText(u.password)}
className={BTN_SECONDARY + ' !px-2.5 !py-1 !text-[12px]'}
>Copy</button>
</td>
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1.5">
{asns.map(a => {
const node = graph.nodes.find(n => n.id === a.nodeId);
return (
<span key={a.nodeId} className="bg-surface-2 border border-line-subtle rounded-sm px-2 py-0.5 font-mono text-[12px] flex items-center gap-1.5">
{node?.streamName ?? a.nodeId}
<button
onClick={() => deleteAsn.mutate({ nodeId: a.nodeId, userId: u.id })}
className="text-fg-muted hover:text-accent inline-flex"
aria-label={`Remove assignment ${node?.streamName ?? a.nodeId}`}
><X size={12} strokeWidth={1.5} /></button>
</span>
);
})}
<select
className="bg-surface-2 border border-line-subtle rounded-sm px-2 py-0.5 text-[12px] text-fg-2"
value=""
onChange={e => {
if (e.target.value)
createAsn.mutate({ nodeId: e.target.value, userId: u.id });
}}
>
<option value="">+ Assign</option>
{graph.nodes
.filter(n => !asns.some(a => a.nodeId === n.id))
.map(n => <option key={n.id} value={n.id}>{n.streamName}</option>)}
</select>
</div>
</td>
<td className="py-3 px-4 text-right">
<button onClick={() => del(u.id, u.username)} className={BTN_DESTRUCTIVE}>Delete</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showNew && <NewUserDialog onClose={() => setShowNew(false)} createUser={createUser} />}
</div>
);
@@ -94,20 +141,29 @@ function NewUserDialog({ onClose, createUser }: { onClose: () => void; createUse
await createUser.mutateAsync({ username, password });
onClose();
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Failed');
setError(err instanceof ApiError ? err.message : 'Failed to create user.');
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-20">
<form onSubmit={submit} className="bg-slate-900 p-6 rounded-lg w-96 space-y-3">
<h2 className="text-lg font-bold">New user</h2>
<input className="w-full bg-slate-800 rounded px-2 py-1" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} />
<input className="w-full bg-slate-800 rounded px-2 py-1 font-mono" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} />
{error && <div className="text-red-400 text-sm">{error}</div>}
<div className="flex gap-2 justify-end">
<button type="button" onClick={onClose} className="px-3 py-1 rounded bg-slate-700">Cancel</button>
<button type="submit" className="px-3 py-1 rounded bg-orange-600 hover:bg-orange-500">Create</button>
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-40 px-6">
<form onSubmit={submit} className="bg-surface border border-line-subtle p-6 rounded w-96 space-y-4 shadow-pop">
<div>
<div className={EYEBROW}>● NEW</div>
<h2 className="text-[18px] font-medium mt-0.5">User</h2>
</div>
<label className="block space-y-1">
<span className={EYEBROW}>Username</span>
<input className={INPUT_CLS} placeholder="publisher-01" value={username} onChange={e => setUsername(e.target.value)} />
</label>
<label className="block space-y-1">
<span className={EYEBROW}>Password</span>
<input className={INPUT_CLS + ' font-mono text-[13px]'} placeholder="••••••••" value={password} onChange={e => setPassword(e.target.value)} />
</label>
{error && <div className={ERROR_CLS}>{error}</div>}
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={onClose} className={BTN_SECONDARY}>Cancel</button>
<button type="submit" className={BTN_PRIMARY}>Create user</button>
</div>
</form>
</div>

View File

@@ -1,6 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { extend: {} },
theme: {
extend: {
colors: {
bg: '#0a0a0a',
surface: { DEFAULT: '#161616', 2: '#1f1f1f', 3: '#262626' },
line: { subtle: '#262626', DEFAULT: '#3d3d3d', strong: '#5e5e5e' },
fg: { DEFAULT: '#f7f7f7', 2: '#b8b8b8', muted: '#8a8a8a', disabled: '#5e5e5e' },
accent: { DEFAULT: '#e1261c', hover: '#f5382d', press: '#c31c14' },
ok: '#2fa85f',
warn: '#d99a0a',
danger: '#e1261c',
},
fontFamily: {
display: ['"Space Grotesk Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
sans: ['"Inter Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono Variable"', 'ui-monospace', 'Menlo', 'monospace'],
},
borderRadius: { none: '0', sm: '2px', DEFAULT: '4px', md: '6px', pill: '999px' },
boxShadow: { pop: '0 8px 24px rgba(0,0,0,0.6)' },
letterSpacing: { tight: '-0.02em', snug: '-0.01em', caps: '0.14em' },
ringColor: { DEFAULT: '#e1261c' },
ringWidth: { DEFAULT: '2px' },
},
},
plugins: [],
};