Compare commits
19 Commits
design/ini
...
version-on
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bd1709d7 | ||
|
|
c5d596a2a7 | ||
|
|
85d5d183f0 | ||
|
|
00605dfc82 | ||
|
|
1a5528dac6 | ||
|
|
b17af56d14 | ||
|
|
ddaa43d924 | ||
|
|
ad1a2462e5 | ||
|
|
4336eae7ca | ||
|
|
b874b9fb06 | ||
|
|
3cbde17c27 | ||
|
|
9b1b5009bf | ||
|
|
076249bd15 | ||
|
|
5927636f9f | ||
|
|
5df1fe85be | ||
|
|
821f433ee8 | ||
|
|
baa6aafa66 | ||
|
|
cddfb5d3ac | ||
|
|
a0085cecb9 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,3 +45,8 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
deploy.json
|
||||
|
||||
# Playwright MCP transient artifacts
|
||||
.playwright-mcp/
|
||||
.brand-shots/
|
||||
|
||||
136
CLAUDE.md
Normal file
136
CLAUDE.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# VolcanicCore — project notes for Claude
|
||||
|
||||
## What this is
|
||||
|
||||
Self-hosted NATS routing system. One .NET 9 backend supervises a bundled `nats-server` child process and exposes an admin UI (React SPA) where an operator draws a directed graph of JetStream streams (nodes) and routes (edges). Publishers only see their own node's subject namespace; VolcanicCore configures the edges as `sources` on destination streams with `filter_subject` + `subject_transforms`, so message routing runs entirely inside `nats-server` — the backend is not on the data path.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
src/VolcanicCore.Core/ pure C# library (data, domain, NATS admin, reconciler diff logic)
|
||||
Auth/ Argon2id admin password, HMAC session cookies
|
||||
Data/Migrations/ numbered SQL files, embedded resources, applied idempotently
|
||||
Data/Repositories/ Dapper repos (Nodes, Edges, Users, Assignments, Meta, AuditLog, NodePositions)
|
||||
Models/ Node, Edge, NatsUser, Assignment, Snapshot records
|
||||
Nats/ StreamConfig records + NATS.Net-backed INatsAdminClient
|
||||
NatsConfig/ ConfigGenerator (pure) + ConfigWriter (atomic + SIGHUP + revert)
|
||||
Reconciler/ SnapshotBuilder + DiffCalculator + Operation union + Trigger
|
||||
Validation/ DagValidator, SubjectTransformer, StreamNameValidator, UlidFactory
|
||||
src/VolcanicCore.Api/ ASP.NET Core host — Kestrel + endpoints + hosted services
|
||||
Endpoints/ minimal-API groups (Auth, Nodes, Edges, Users, Assignments, Graph, Health)
|
||||
Hosted/ NatsSupervisor + Reconciler + TrafficTap (all IHostedService)
|
||||
Middleware/AdminAuthMiddleware.cs cookie gate; /api/health + /api/login + non-/api paths are public
|
||||
WebSockets/ AdminBroadcaster (parallel send + IAsyncDisposable) + AdminHub (/api/ws)
|
||||
Program.cs DI wiring, first-run bootstrap, SPA fallback, static files
|
||||
frontend/ Vite + React 19 + TS + Tailwind + TanStack Router/Query + @xyflow/react
|
||||
src/api/ typed client + zod schemas
|
||||
src/components/graph/ GraphCanvas + NodeCard
|
||||
src/hooks/ useGraph (queries + mutations), useHealth, useTrafficStats
|
||||
src/screens/ Login, Graph, Users, Server, Audit
|
||||
tests/e2e/ Playwright (login + graph-flow)
|
||||
tests/VolcanicCore.Core.Tests/ xUnit unit tests, 80+ (all pure logic — no NATS)
|
||||
tests/VolcanicCore.Integration.Tests/ xUnit with RealNatsFixture spawning real nats-server per test
|
||||
scripts/get-nats-server.sh downloads pinned nats-server binary to tools/nats-server
|
||||
tools/nats-server gitignored, populated by the script
|
||||
docs/superpowers/specs/ design doc
|
||||
docs/superpowers/plans/ the three implementation plans
|
||||
Examples/NodeExample/ small .NET 9 client that publishes to the "Test" stream and consumes routed messages from "Test2" — end-to-end smoke against the deployed system
|
||||
Dockerfile 4-stage: pnpm build → dotnet publish → grab nats from official image → runtime
|
||||
docker-compose.yml one service, Coolify-managed FQDN for :8080 + hand-crafted Traefik labels for :8081
|
||||
deploy.json gitignored — Coolify project/app UUIDs + non-secret app env vars
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend:** .NET 9, Microsoft.Data.Sqlite, Dapper, Konscious.Security.Cryptography.Argon2, Serilog (JSON), NATS.Net 2.5.11 + NATS.Client.JetStream 2.5.11
|
||||
- **NATS server:** pinned v2.10.22, downloaded from Docker Hub `nats:2.10.22-alpine` at build time (originally used GitHub Releases — abandoned because Coolify's builder network can't reach github reliably)
|
||||
- **Frontend:** Vite 8, React 19, TypeScript 6, Tailwind 3, `@xyflow/react` 12, `@tanstack/react-router` 1.170, `@tanstack/react-query` 5, zod 4
|
||||
- **Tests:** xUnit v2 (unit + integration), Playwright (E2E)
|
||||
- **Storage:** SQLite (own state) + JetStream file store (message data), both under `$STATE_DIR/`
|
||||
- **Package manager for frontend:** pnpm 9
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
./scripts/get-nats-server.sh # downloads tools/nats-server binary
|
||||
(cd frontend && pnpm install)
|
||||
|
||||
# Terminal 1 — backend
|
||||
export ADMIN_PASSWORD=hunter2
|
||||
export SESSION_SECRET=$(openssl rand -base64 48 | head -c 64)
|
||||
export STATE_DIR=/tmp/vc-dev
|
||||
export PORT_ADMIN=8080
|
||||
export PORT_NATS_WS=8081
|
||||
export NATS_SERVER_PATH=$(pwd)/tools/nats-server
|
||||
dotnet run --project src/VolcanicCore.Api
|
||||
|
||||
# Terminal 2 — frontend dev server (proxies /api and /api/ws to :8080)
|
||||
cd frontend && pnpm dev
|
||||
# → http://localhost:5173/
|
||||
|
||||
# Tests
|
||||
dotnet test # unit + integration (needs NATS_SERVER_PATH)
|
||||
NATS_SERVER_PATH=$(pwd)/tools/nats-server dotnet test # from the repo root
|
||||
cd frontend && npx playwright test # needs backend running on 18080
|
||||
|
||||
# Production build (frontend → wwwroot, then backend serves)
|
||||
(cd frontend && pnpm build) # writes to src/VolcanicCore.Api/wwwroot/
|
||||
dotnet run --project src/VolcanicCore.Api # serves SPA at http://localhost:8080/
|
||||
```
|
||||
|
||||
## Deployment (Coolify)
|
||||
|
||||
Live at:
|
||||
- Admin UI: **https://core.tes.gd**
|
||||
- NATS WebSocket for user apps: **wss://nats-core.tes.gd**
|
||||
|
||||
Both routed by Coolify's Traefik on `192.168.50.73`. Deploy is API-driven per `deploying-to-coolify-via-api`:
|
||||
|
||||
- Project: `VolcanicCore` — uuid stored in `deploy.json`
|
||||
- App: `volcaniccore` — uuid stored in `deploy.json`
|
||||
- Build pack: `dockercompose` — uses the repo's `docker-compose.yml`
|
||||
- Trigger a redeploy: `curl -sS -X POST "$COOLIFY_URL/api/v1/deploy?uuid=$(jq -r .app.uuid deploy.json)" -H "Authorization: Bearer $COOLIFY_KEY"`
|
||||
|
||||
**Fire-and-forget** per user's global rule — don't poll deployment status unless explicitly asked to. The `deployment_uuid` in the POST response is the confirmation; user checks the UI.
|
||||
|
||||
## Design + plans
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-08-27-volcaniccore-design.md`
|
||||
- Plan 1 (backend foundation, 24 tasks): `docs/superpowers/plans/2026-08-27-backend-foundation.md`
|
||||
- Plan 2 (NATS integration, 22 tasks + 2 fixes): `docs/superpowers/plans/2026-08-27-nats-integration.md`
|
||||
- Plan 3 (frontend + container, 14 tasks): `docs/superpowers/plans/2026-08-27-frontend-and-container.md`
|
||||
|
||||
All three are complete and merged to `main`.
|
||||
|
||||
## Known limitations / deferred work
|
||||
|
||||
- **KICK-user-on-remove is a no-op.** NATS forbids JetStream on the system account, and our admin `INatsConnection` auths as an internal `_reconciler` user in the APP account (which has JS). `$SYS.REQ.SERVER.*.KICK` requires SYS auth. Fixing this needs a second `INatsConnection` authed as `sys` — see `NatsAdminClient.ListCidsForUserAsync`/`KickCidAsync` (both currently return empty/Task.CompletedTask). Removed users stay connected until they voluntarily reconnect (SIGHUP doesn't force-disconnect existing sessions).
|
||||
- **Cycle-refuse UX Playwright test skipped** (React Flow drag simulation is fragile). Backend DAG-validator has 8 unit tests covering the same behavior.
|
||||
- **HA / clustering** — out of scope. Single-node nats-server per the design.
|
||||
- **Kick-secret rotation** — SYS password generated once on first boot, never rotated; would need an admin action.
|
||||
|
||||
## Design invariants (don't break these)
|
||||
|
||||
1. **Never delete a JetStream stream from the reconciler.** Node removal only removes the SQLite row + edges; the stream on disk stays. Re-creating a node with the same `stream_name` re-adopts. Enforced by the absence of `DeleteStreamAsync` in the codebase.
|
||||
2. **`stream_name` is immutable after creation.** JetStream doesn't support in-place rename. UI + endpoints enforce it (no `stream_name` in `UpdateNodeRequest`).
|
||||
3. **DAG only — cycles rejected at edge create.** Both the API (`EdgesRepository.InsertIfWouldNotCycle` in a Serializable txn) and the frontend's canonical path validate.
|
||||
4. **SQLite is the source of truth; NATS is derived.** Every graph mutation goes SQLite-first, fires the reconciler trigger, then converges NATS. The 30s tick is only a safety net for drift.
|
||||
5. **`_reconciler` user in APP** is what the backend uses for all JS admin RPCs. The `sys` user in SYS is defined but currently unused (kept for the future two-connection design).
|
||||
6. **JetStream API subjects live in `publish.allow`, NOT `subscribe.allow`.** The request/reply model has the client publishing to `$JS.API.<...>` / `$JS.ACK.<...>` and subscribing to `_INBOX.>` for replies. Putting them on the subscribe side means end-user clients silently time out on every consumer/fetch/ack. See `ConfigGenerator.cs:78-102` and the placement assertions in `tests/VolcanicCore.Core.Tests/ConfigGeneratorTests.cs` (`ExtractPermissionBlocks` helper) — those pin the invariant.
|
||||
7. **Never SIGHUP via `Process.Start("kill", ...)`.** The `aspnet:9.0` runtime image ships without `procps`, so shelling to `kill` throws ENOENT at runtime and the config reload silently fails while the reconciler still reports "idle" (the next tick sees the on-disk text already matches the target and skips the retry). Use the `libc.kill(pid, SIGHUP)` P/Invoke in `ConfigWriter.SendSighup`.
|
||||
8. **Node display name ≠ stream name.** `Node.Name` is a UI-only label; `Node.StreamName` (constrained by `StreamNameValidator` to `[A-Za-z0-9_-]+`) is what NATS actually addresses. Any client — including the `Examples/NodeExample` — has to use `streamName`, not `name`.
|
||||
|
||||
## Release notes preference
|
||||
|
||||
Release notes declined for this project — don't prompt on push.
|
||||
|
||||
## When making changes
|
||||
|
||||
- Backend changes → `dotnet test` (80+ unit tests + integration tests need `NATS_SERVER_PATH`).
|
||||
- Frontend changes → `(cd frontend && pnpm build)` — writes into `wwwroot/`.
|
||||
- New API endpoints → also update the typed client in `frontend/src/api/client.ts` and the zod schemas in `types.ts`.
|
||||
- New graph mutations → make sure the endpoint fires the reconciler trigger (`trigger.Fire()`) after `audit.Append(...)`.
|
||||
- New SQLite tables → new migration file under `src/VolcanicCore.Core/Data/Migrations/` (numbered `NNN_name.sql`); it'll be picked up automatically because the csproj embeds `Data/Migrations/*.sql`.
|
||||
- Never commit `tools/nats-server`, `frontend/node_modules/`, `frontend/dist/`, `src/VolcanicCore.Api/wwwroot/`, `.playwright-mcp/`, or `deploy.json` — all gitignored.
|
||||
- To end-to-end verify a permissions or reconciler change against production: after the Coolify deploy finishes, `cd Examples/NodeExample && dotnet run`. If it times out on `$JS.API.STREAM.INFO.*`, check `Publish Violation` lines in the app logs (`GET $COOLIFY_URL/api/v1/applications/$app_uuid/logs`) — the offending subject is right there.
|
||||
16
Dockerfile
16
Dockerfile
@@ -22,22 +22,16 @@ COPY tests/ ./tests/
|
||||
COPY --from=frontend-build /work/wwwroot ./src/VolcanicCore.Api/wwwroot
|
||||
RUN dotnet publish src/VolcanicCore.Api/VolcanicCore.Api.csproj -c Release -o /out
|
||||
|
||||
# ── Stage 3: fetch nats-server binary ───────────────────────────────────
|
||||
FROM alpine:3.20 AS nats-fetch
|
||||
ARG NATS_VERSION=v2.10.22
|
||||
RUN apk add --no-cache curl tar
|
||||
RUN arch=$(uname -m) && \
|
||||
case "$arch" in x86_64|amd64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac && \
|
||||
curl -fsSL "https://github.com/nats-io/nats-server/releases/download/${NATS_VERSION}/nats-server-${NATS_VERSION}-linux-${arch}.tar.gz" \
|
||||
| tar -xz -C /tmp && \
|
||||
mv "/tmp/nats-server-${NATS_VERSION}-linux-${arch}/nats-server" /nats-server && \
|
||||
chmod +x /nats-server
|
||||
# ── Stage 3: nats-server binary from official image ─────────────────────
|
||||
# Grab the pinned binary out of the official nats image instead of downloading
|
||||
# from GitHub — the direct download failed under Coolify's builder network.
|
||||
FROM nats:2.10.22-alpine AS nats-fetch
|
||||
|
||||
# ── Stage 4: runtime ────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0
|
||||
RUN useradd -u 1000 -m vc
|
||||
COPY --from=backend-build /out /app
|
||||
COPY --from=nats-fetch /nats-server /usr/local/bin/nats-server
|
||||
COPY --from=nats-fetch /usr/local/bin/nats-server /usr/local/bin/nats-server
|
||||
RUN mkdir -p /var/lib/volcaniccore && chown -R vc:vc /var/lib/volcaniccore
|
||||
ENV STATE_DIR=/var/lib/volcaniccore \
|
||||
PORT_ADMIN=8080 \
|
||||
|
||||
16
Examples/NodeExample/NodeExample.csproj
Normal file
16
Examples/NodeExample/NodeExample.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>NodeExample</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NATS.Net" Version="2.5.11" />
|
||||
<PackageReference Include="NATS.Client.JetStream" Version="2.5.11" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
88
Examples/NodeExample/Program.cs
Normal file
88
Examples/NodeExample/Program.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using System.Text;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Client.JetStream;
|
||||
using NATS.Client.JetStream.Models;
|
||||
|
||||
// Two NATS connections, one per node:
|
||||
// - "Test" node: publisher (authed as "test")
|
||||
// - "Test 2" node: consumer (authed as "test")
|
||||
//
|
||||
// Assumes the graph in VolcanicCore has: a "Test" node capturing "test.>" (or
|
||||
// at least "test.income.one"), a "Test 2" node, and an edge Test -> Test 2 that
|
||||
// routes matching subjects. Also assumes the "test" user is assigned to both
|
||||
// nodes in the admin UI.
|
||||
|
||||
const string natsUrl = "wss://nats-core.tes.gd";
|
||||
const string username = "test";
|
||||
const string password = "test";
|
||||
const string testStream = "Test";
|
||||
// The node's display name is "Test_2" but its underlying JetStream stream is
|
||||
// "Test2" (the operator chose that in the admin UI). NATS APIs address the
|
||||
// stream by streamName, not by node name.
|
||||
const string test2Stream = "Test2";
|
||||
const string publishSubject = "test.income.one";
|
||||
const int messageCount = 5;
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
|
||||
await using var pubConn = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = natsUrl,
|
||||
Name = $"example-publisher-{testStream}",
|
||||
AuthOpts = new NatsAuthOpts { Username = username, Password = password },
|
||||
});
|
||||
await pubConn.ConnectAsync();
|
||||
Console.WriteLine($"[pub] connected to {natsUrl} as {username} (node: {testStream})");
|
||||
|
||||
await using var subConn = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = natsUrl,
|
||||
Name = $"example-consumer-{test2Stream}",
|
||||
AuthOpts = new NatsAuthOpts { Username = username, Password = password },
|
||||
});
|
||||
await subConn.ConnectAsync();
|
||||
Console.WriteLine($"[sub] connected to {natsUrl} as {username} (node: {test2Stream})");
|
||||
|
||||
var subJs = new NatsJSContext(subConn);
|
||||
var test2 = await subJs.GetStreamAsync(test2Stream, cancellationToken: cts.Token);
|
||||
var consumer = await test2.CreateOrUpdateConsumerAsync(new ConsumerConfig
|
||||
{
|
||||
Name = $"example-{Guid.NewGuid():N}",
|
||||
AckPolicy = ConsumerConfigAckPolicy.Explicit,
|
||||
DeliverPolicy = ConsumerConfigDeliverPolicy.New,
|
||||
InactiveThreshold = TimeSpan.FromMinutes(5),
|
||||
}, cts.Token);
|
||||
Console.WriteLine($"[sub] ephemeral consumer created on stream \"{test2Stream}\"");
|
||||
|
||||
var reader = Task.Run(async () =>
|
||||
{
|
||||
Console.WriteLine($"[sub] waiting for messages on \"{test2Stream}\"...");
|
||||
var opts = new NatsJSConsumeOpts { MaxMsgs = 1000, IdleHeartbeat = TimeSpan.FromSeconds(5) };
|
||||
await foreach (var msg in consumer.ConsumeAsync<byte[]>(opts: opts, cancellationToken: cts.Token))
|
||||
{
|
||||
var body = msg.Data is null ? "" : Encoding.UTF8.GetString(msg.Data);
|
||||
Console.WriteLine($"[sub] <- subject={msg.Subject} seq={msg.Metadata?.Sequence.Stream} body={body}");
|
||||
await msg.AckAsync(cancellationToken: cts.Token);
|
||||
}
|
||||
}, cts.Token);
|
||||
|
||||
// Give the consumer a beat to attach before we publish.
|
||||
await Task.Delay(500, cts.Token);
|
||||
|
||||
for (var i = 1; i <= messageCount; i++)
|
||||
{
|
||||
var payload = $"hello #{i} @ {DateTimeOffset.UtcNow:O}";
|
||||
await pubConn.PublishAsync(publishSubject, Encoding.UTF8.GetBytes(payload), cancellationToken: cts.Token);
|
||||
Console.WriteLine($"[pub] -> subject={publishSubject} body={payload}");
|
||||
await Task.Delay(250, cts.Token);
|
||||
}
|
||||
|
||||
// Let the reader drain what we just sent (Test -> edge -> Test 2 takes a moment).
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(5), cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
|
||||
cts.Cancel();
|
||||
try { await reader; } catch (OperationCanceledException) { }
|
||||
|
||||
Console.WriteLine("done.");
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
# VolcanicCore — one container, two ports:
|
||||
# - 8080 admin UI + REST + admin WebSocket (routed by Coolify's auto-emitted Traefik
|
||||
# labels from docker_compose_domains → core.tes.gd)
|
||||
# - 8081 NATS WebSocket listener for user apps (routed by the extra Traefik labels
|
||||
# below → nats-core.tes.gd)
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- vc-data:/var/lib/volcaniccore
|
||||
environment:
|
||||
SERVICE_FQDN_APP_8080: ${SERVICE_FQDN_APP_8080}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
expose:
|
||||
- "8080"
|
||||
- "8081"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.vc-nats.entrypoints=https
|
||||
- traefik.http.routers.vc-nats.rule=Host(`nats-core.tes.gd`)
|
||||
- traefik.http.routers.vc-nats.tls=true
|
||||
- traefik.http.routers.vc-nats.tls.certresolver=letsencrypt
|
||||
- traefik.http.routers.vc-nats.service=vc-nats-svc
|
||||
- traefik.http.services.vc-nats-svc.loadbalancer.server.port=8081
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
vc-data:
|
||||
driver: local
|
||||
218
docs/brand/tesanti.md
Normal file
218
docs/brand/tesanti.md
Normal 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
24
frontend/pnpm-lock.yaml
generated
24
frontend/pnpm-lock.yaml
generated
@@ -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 |
@@ -60,6 +60,8 @@ export const api = {
|
||||
updateNode: (id: string, input: Omit<Node, 'id' | 'streamName' | 'createdAt'>) =>
|
||||
req('PATCH', `/api/nodes/${id}`, input, x => NodeSchema.parse(x)) as Promise<Node>,
|
||||
deleteNode: (id: string) => req<void>('DELETE', `/api/nodes/${id}`),
|
||||
saveNodePositions: (updates: Array<{ id: string; x: number; y: number }>) =>
|
||||
req<void>('POST', '/api/nodes/positions', updates),
|
||||
|
||||
createEdge: (input: Omit<Edge, 'id'>) =>
|
||||
req('POST', '/api/edges', input, x => EdgeSchema.parse(x)) as Promise<Edge>,
|
||||
|
||||
@@ -35,11 +35,15 @@ export const AssignmentSchema = z.object({
|
||||
});
|
||||
export type Assignment = z.infer<typeof AssignmentSchema>;
|
||||
|
||||
export const PositionSchema = z.object({ x: z.number(), y: z.number() });
|
||||
export type Position = z.infer<typeof PositionSchema>;
|
||||
|
||||
export const GraphSchema = z.object({
|
||||
nodes: z.array(NodeSchema),
|
||||
edges: z.array(EdgeSchema),
|
||||
users: z.array(UserSchema),
|
||||
assignments: z.array(AssignmentSchema),
|
||||
positions: z.record(z.string(), PositionSchema).default({}),
|
||||
});
|
||||
export type Graph = z.infer<typeof GraphSchema>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
96
frontend/src/components/ConfirmDialog.tsx
Normal file
96
frontend/src/components/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/Toast.tsx
Normal file
108
frontend/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/Wordmark.tsx
Normal file
29
frontend/src/components/Wordmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useCallback, useMemo, useState } 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,
|
||||
applyNodeChanges,
|
||||
type Connection, type NodeChange, type EdgeChange,
|
||||
type Connection, type NodeChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import NodeCard, { type NodeCardData } from './NodeCard';
|
||||
import NodeCard, { type NodeKind } from './NodeCard';
|
||||
import RouteEdge from './RouteEdge';
|
||||
import type { Graph } from '../../api/types';
|
||||
import { useCreateEdge } from '../../hooks/useGraph';
|
||||
import { ApiError } from '../../api/client';
|
||||
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;
|
||||
@@ -21,39 +30,99 @@ export type GraphCanvasProps = {
|
||||
};
|
||||
|
||||
export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: GraphCanvasProps) {
|
||||
const [positions, setPositions] = useState<Record<string, { x: number; y: number }>>({});
|
||||
const [nodes, setNodes, onNodesChangeRaw] = useNodesState<RfNode>([]);
|
||||
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);
|
||||
|
||||
const rfNodes = useMemo<RfNode[]>(() =>
|
||||
graph.nodes.map((n, i) => ({
|
||||
id: n.id,
|
||||
type: 'vcNode',
|
||||
data: { data: { node: n, stats: stats[n.id], onOpen: onOpenNode } as NodeCardData },
|
||||
position: positions[n.id] ?? { x: 100 + (i % 4) * 260, y: 100 + Math.floor(i / 4) * 160 },
|
||||
})), [graph.nodes, stats, onOpenNode, positions]);
|
||||
// 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 rfEdges = useMemo<RfEdge[]>(() =>
|
||||
graph.edges.map(e => ({
|
||||
const flushPositionSave = useCallback(() => {
|
||||
const batch = Array.from(pendingSaveRef.current.entries())
|
||||
.map(([id, p]) => ({ id, x: p.x, y: p.y }));
|
||||
pendingSaveRef.current.clear();
|
||||
if (batch.length) api.saveNodePositions(batch).catch(() => { /* best-effort */ });
|
||||
}, []);
|
||||
|
||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
onNodesChangeRaw(changes);
|
||||
for (const c of changes) {
|
||||
if (c.type === 'position' && c.dragging === false && c.position) {
|
||||
pendingSaveRef.current.set(c.id, c.position);
|
||||
}
|
||||
}
|
||||
if (pendingSaveRef.current.size > 0) {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(flushPositionSave, 300);
|
||||
}
|
||||
}, [onNodesChangeRaw, flushPositionSave]);
|
||||
|
||||
useEffect(() => () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); flushPositionSave(); } }, [flushPositionSave]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(prev => {
|
||||
const prevById = new Map(prev.map(n => [n.id, n]));
|
||||
return graph.nodes.map((n, i) => {
|
||||
const existing = prevById.get(n.id);
|
||||
const saved = graph.positions?.[n.id];
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'vcNode',
|
||||
data: {
|
||||
node: n,
|
||||
kind: kindById.get(n.id) ?? 'router',
|
||||
stats: stats[n.id],
|
||||
onOpen: onOpenNode,
|
||||
} as unknown as Record<string, unknown>,
|
||||
position: existing?.position ?? saved ?? {
|
||||
x: 100 + (i % 4) * 260,
|
||||
y: 100 + Math.floor(i / 4) * 160,
|
||||
},
|
||||
...(existing?.measured ? { measured: existing.measured } : {}),
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [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 },
|
||||
})), [graph.edges]);
|
||||
|
||||
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
||||
const applied = applyNodeChanges(changes, rfNodes);
|
||||
const next: Record<string, { x: number; y: number }> = {};
|
||||
for (const n of applied) next[n.id] = n.position;
|
||||
setPositions(next);
|
||||
}, [rfNodes]);
|
||||
|
||||
const onEdgesChange = useCallback((_: EdgeChange[]) => { /* no-op — server owns edges */ }, []);
|
||||
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;
|
||||
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,
|
||||
@@ -62,24 +131,54 @@ export default function GraphCanvas({ graph, stats, onOpenNode, onOpenEdge }: Gr
|
||||
transformDest: '>',
|
||||
});
|
||||
} catch (err) {
|
||||
alert(err instanceof ApiError ? err.message : 'Failed to create edge');
|
||||
setEdges(eds => eds.filter(e => !e.id.startsWith('pending-')));
|
||||
toast.push({
|
||||
kind: 'error',
|
||||
body: err instanceof ApiError ? err.message : 'Failed to create edge.',
|
||||
});
|
||||
}
|
||||
}, [createEdge]);
|
||||
}, [createEdge, setEdges, toast]);
|
||||
|
||||
const onEdgesDelete = useCallback(async (deleted: RfEdge[]) => {
|
||||
for (const e of deleted) {
|
||||
if (e.id.startsWith('pending-')) continue;
|
||||
try { await deleteEdge.mutateAsync(e.id); }
|
||||
catch (err) {
|
||||
toast.push({
|
||||
kind: 'error',
|
||||
body: err instanceof ApiError ? err.message : `Failed to delete edge ${e.id}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [deleteEdge, toast]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-[calc(100vh-60px)]">
|
||||
<div className="w-full h-screen">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
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;
|
||||
};
|
||||
|
||||
export type VcRfNode = RfNode<{ data: NodeCardData } & Record<string, unknown>, 'vcNode'>;
|
||||
// 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.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-2 h-2" />
|
||||
<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}>
|
||||
<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-2 h-2" />
|
||||
<Handle type="source" position={Position.Right} className="react-flow__handle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
40
frontend/src/components/graph/RouteEdge.tsx
Normal file
40
frontend/src/components/graph/RouteEdge.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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,
|
||||
markerEnd, style, data,
|
||||
} = props;
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition,
|
||||
});
|
||||
const d = (data ?? {}) as RouteEdgeData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} markerEnd={markerEnd} style={style} />
|
||||
{(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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import { useGraph, useCreateNode } from '../hooks/useGraph';
|
||||
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);
|
||||
@@ -12,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} />
|
||||
</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} />
|
||||
</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('');
|
||||
@@ -75,54 +146,298 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeDrawerContent({ nodeId }: { nodeId: string }) {
|
||||
function NodeDrawerContent({ nodeId, onDeleted }: { nodeId: string; onDeleted: () => void }) {
|
||||
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);
|
||||
if (!node) return <div>Node not found</div>;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [subjectsText, setSubjectsText] = useState('');
|
||||
const [maxBytes, setMaxBytes] = useState('');
|
||||
const [maxAgeMs, setMaxAgeMs] = useState('');
|
||||
const [maxMsgs, setMaxMsgs] = useState('');
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
setName(node.name);
|
||||
setSubjectsText(node.subjects.join('\n'));
|
||||
setMaxBytes(String(node.maxBytes));
|
||||
setMaxAgeMs(node.maxAgeMs !== null ? String(node.maxAgeMs) : '');
|
||||
setMaxMsgs(node.maxMsgs !== null ? String(node.maxMsgs) : '');
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [nodeId, node?.name, node?.subjects, node?.maxBytes, node?.maxAgeMs, node?.maxMsgs]);
|
||||
|
||||
if (!node) return <div className="text-fg-muted">Node not found.</div>;
|
||||
|
||||
const parsedSubjects = subjectsText.split(/\s+/).filter(Boolean);
|
||||
const parsedMaxBytes = parseInt(maxBytes, 10);
|
||||
const parsedAge = maxAgeMs.trim() ? parseInt(maxAgeMs, 10) : null;
|
||||
const parsedMsgs = maxMsgs.trim() ? parseInt(maxMsgs, 10) : null;
|
||||
|
||||
const dirty =
|
||||
name !== node.name ||
|
||||
parsedSubjects.join('\n') !== node.subjects.join('\n') ||
|
||||
parsedMaxBytes !== node.maxBytes ||
|
||||
parsedAge !== node.maxAgeMs ||
|
||||
parsedMsgs !== node.maxMsgs;
|
||||
|
||||
async function save() {
|
||||
setSaveError(null);
|
||||
if (!Number.isFinite(parsedMaxBytes) || parsedMaxBytes <= 0) {
|
||||
setSaveError('max_bytes must be a positive number.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateNode.mutateAsync({
|
||||
id: node!.id,
|
||||
input: {
|
||||
name,
|
||||
subjects: parsedSubjects,
|
||||
maxBytes: parsedMaxBytes,
|
||||
maxAgeMs: parsedAge,
|
||||
maxMsgs: parsedMsgs,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof ApiError ? err.message : 'Save failed.');
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
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) {
|
||||
toast.push({
|
||||
kind: 'error',
|
||||
body: err instanceof ApiError ? err.message : 'Delete failed.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div><span className="text-slate-400">Name:</span> {node.name}</div>
|
||||
<div><span className="text-slate-400">Stream:</span> <span className="font-mono">{node.streamName}</span></div>
|
||||
<div><span className="text-slate-400">Subjects:</span>
|
||||
<ul className="mt-1 font-mono text-xs">{node.subjects.map(s => <li key={s}>{s}</li>)}</ul>
|
||||
<div className="space-y-5">
|
||||
<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 space-y-1">
|
||||
<span className={EYEBROW}>Name</span>
|
||||
<input className={INPUT_CLS} value={name} onChange={e => setName(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<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-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 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 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 && <ErrorLine>{saveError}</ErrorLine>}
|
||||
|
||||
<button onClick={save} disabled={!dirty || updateNode.isPending} className={BTN_PRIMARY + ' w-full'}>
|
||||
{updateNode.isPending ? 'Saving…' : dirty ? 'Save changes' : 'Saved'}
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<div><span className="text-slate-400">Max bytes:</span> {node.maxBytes.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EdgeDrawerContent({ edgeId }: { edgeId: string }) {
|
||||
function EdgeDrawerContent({ edgeId, onDeleted }: { edgeId: string; onDeleted: () => void }) {
|
||||
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);
|
||||
if (!edge) return <div>Edge not found</div>;
|
||||
const fromNode = graph?.nodes.find(n => n.id === edge?.fromNodeId);
|
||||
const toNode = graph?.nodes.find(n => n.id === edge?.toNodeId);
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
const [dest, setDest] = useState('');
|
||||
const [testSubject, setTestSubject] = useState('');
|
||||
const [preview, setPreview] = useState<{ matches: boolean; rewritten: string | null } | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (edge) { setFilter(edge.filterSubject); setDest(edge.transformDest); setPreview(null); setSaveError(null); }
|
||||
}, [edgeId, edge?.filterSubject, edge?.transformDest]);
|
||||
|
||||
if (!edge) return <div className="text-fg-muted">Edge not found.</div>;
|
||||
|
||||
const dirty = filter !== edge.filterSubject || dest !== edge.transformDest;
|
||||
|
||||
async function save() {
|
||||
setSaveError(null);
|
||||
try {
|
||||
await updateEdge.mutateAsync({ id: edge!.id, input: { filterSubject: filter, transformDest: dest } });
|
||||
} catch (err) { setSaveError(err instanceof ApiError ? err.message : 'Save failed.'); }
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
try {
|
||||
const p = await api.previewTransform(filter, dest, testSubject);
|
||||
setPreview(p);
|
||||
} catch (err) {
|
||||
setPreview({ matches: false, rewritten: err instanceof ApiError ? err.message : 'Preview failed.' });
|
||||
}
|
||||
}
|
||||
|
||||
async function del() {
|
||||
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) {
|
||||
toast.push({
|
||||
kind: 'error',
|
||||
body: err instanceof ApiError ? err.message : 'Delete failed.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div><span className="text-slate-400">From:</span> {edge.fromNodeId}</div>
|
||||
<div><span className="text-slate-400">To:</span> {edge.toNodeId}</div>
|
||||
<div><span className="text-slate-400">Filter:</span> <span className="font-mono">{edge.filterSubject}</span></div>
|
||||
<div><span className="text-slate-400">Transform:</span> <span className="font-mono">{edge.transformDest}</span></div>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<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-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">></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={INPUT_MONO_CLS + ' flex-1'}
|
||||
value={testSubject}
|
||||
onChange={e => setTestSubject(e.target.value)}
|
||||
placeholder="sample subject"
|
||||
onKeyDown={e => e.key === 'Enter' && runPreview()}
|
||||
/>
|
||||
<button onClick={runPreview} disabled={!testSubject} className={BTN_SECONDARY}>Test</button>
|
||||
</div>
|
||||
{preview && (
|
||||
preview.matches
|
||||
? <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="font-mono text-[12px] text-fg-muted">no match</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -5,9 +5,11 @@ namespace VolcanicCore.Api.Endpoints;
|
||||
|
||||
public static class GraphEndpoints
|
||||
{
|
||||
public sealed record PositionUpdate(string Id, double X, double Y);
|
||||
|
||||
public static void Map(WebApplication app)
|
||||
{
|
||||
app.MapGet("/api/graph", (ISnapshotBuilder snapshots) =>
|
||||
app.MapGet("/api/graph", (ISnapshotBuilder snapshots, INodePositionsRepository positions) =>
|
||||
{
|
||||
var snap = snapshots.Build();
|
||||
return Results.Ok(new
|
||||
@@ -16,9 +18,19 @@ public static class GraphEndpoints
|
||||
edges = snap.Edges,
|
||||
users = snap.Users,
|
||||
assignments = snap.Assignments,
|
||||
positions = positions.GetAll()
|
||||
.ToDictionary(p => p.NodeId, p => new { x = p.PosX, y = p.PosY }),
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/nodes/positions", (
|
||||
PositionUpdate[] updates,
|
||||
INodePositionsRepository repo) =>
|
||||
{
|
||||
repo.UpsertMany(updates.Select(u => new NodePosition(u.Id, u.X, u.Y)));
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapGet("/api/audit", (int? limit, long? beforeId, IAuditLogRepository repo) =>
|
||||
{
|
||||
var take = Math.Clamp(limit ?? 100, 1, 500);
|
||||
|
||||
@@ -31,16 +31,15 @@ public static class NodesEndpoints
|
||||
{
|
||||
if (!StreamNameValidator.IsValid(req.StreamName))
|
||||
return Bad("invalid_stream_name", "stream_name must match [A-Za-z0-9_-]+");
|
||||
if (req.Subjects is null || req.Subjects.Length == 0)
|
||||
return Bad("missing_subjects", "at least one subject is required");
|
||||
if (req.MaxBytes <= 0)
|
||||
return Bad("invalid_quota", "max_bytes must be positive");
|
||||
// Empty subjects are allowed — a "pure receiver" node is source-fed only.
|
||||
|
||||
var node = new Node(
|
||||
Id: ulids.NewUlid(),
|
||||
Name: req.Name,
|
||||
StreamName: req.StreamName,
|
||||
Subjects: req.Subjects,
|
||||
Subjects: req.Subjects ?? Array.Empty<string>(),
|
||||
MaxBytes: req.MaxBytes,
|
||||
MaxAgeMs: req.MaxAgeMs,
|
||||
MaxMsgs: req.MaxMsgs,
|
||||
@@ -72,7 +71,7 @@ public static class NodesEndpoints
|
||||
var updated = existing with
|
||||
{
|
||||
Name = req.Name,
|
||||
Subjects = req.Subjects,
|
||||
Subjects = req.Subjects ?? Array.Empty<string>(),
|
||||
MaxBytes = req.MaxBytes,
|
||||
MaxAgeMs = req.MaxAgeMs,
|
||||
MaxMsgs = req.MaxMsgs,
|
||||
|
||||
@@ -50,6 +50,7 @@ builder.Services.AddSingleton<INodesRepository, NodesRepository>();
|
||||
builder.Services.AddSingleton<IEdgesRepository, EdgesRepository>();
|
||||
builder.Services.AddSingleton<IUsersRepository, UsersRepository>();
|
||||
builder.Services.AddSingleton<IAssignmentsRepository, AssignmentsRepository>();
|
||||
builder.Services.AddSingleton<INodePositionsRepository, NodePositionsRepository>();
|
||||
builder.Services.AddSingleton<IMetaRepository, MetaRepository>();
|
||||
builder.Services.AddSingleton<IAuditLogRepository, AuditLogRepository>();
|
||||
builder.Services.AddSingleton<IUlidFactory, UlidFactory>();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS node_positions (
|
||||
node_id TEXT PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
|
||||
pos_x REAL NOT NULL,
|
||||
pos_y REAL NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
using Dapper;
|
||||
|
||||
namespace VolcanicCore.Core.Data.Repositories;
|
||||
|
||||
public sealed record NodePosition(string NodeId, double PosX, double PosY);
|
||||
|
||||
public interface INodePositionsRepository
|
||||
{
|
||||
IReadOnlyList<NodePosition> GetAll();
|
||||
void UpsertMany(IEnumerable<NodePosition> positions);
|
||||
}
|
||||
|
||||
public sealed class NodePositionsRepository : INodePositionsRepository
|
||||
{
|
||||
private readonly ISqliteConnectionFactory _factory;
|
||||
public NodePositionsRepository(ISqliteConnectionFactory factory) => _factory = factory;
|
||||
|
||||
public IReadOnlyList<NodePosition> GetAll()
|
||||
{
|
||||
using var conn = _factory.Open();
|
||||
return conn.Query<NodePosition>(
|
||||
"SELECT node_id AS NodeId, pos_x AS PosX, pos_y AS PosY FROM node_positions").ToList();
|
||||
}
|
||||
|
||||
public void UpsertMany(IEnumerable<NodePosition> positions)
|
||||
{
|
||||
using var conn = _factory.Open();
|
||||
using var tx = conn.BeginTransaction();
|
||||
conn.Execute(@"
|
||||
INSERT INTO node_positions (node_id, pos_x, pos_y)
|
||||
VALUES (@NodeId, @PosX, @PosY)
|
||||
ON CONFLICT(node_id) DO UPDATE SET pos_x = excluded.pos_x, pos_y = excluded.pos_y",
|
||||
positions, tx);
|
||||
tx.Commit();
|
||||
}
|
||||
}
|
||||
@@ -75,20 +75,30 @@ public static class ConfigGenerator
|
||||
{
|
||||
var user = users[i];
|
||||
var assignedNodeIds = assignmentsByUser.GetValueOrDefault(user.Id, new List<string>());
|
||||
var publishAllow = assignedNodeIds
|
||||
.Where(subjectsByNodeId.ContainsKey)
|
||||
.SelectMany(id => subjectsByNodeId[id])
|
||||
// JetStream request/reply model: the client PUBLISHES a request to
|
||||
// $JS.API.<...> (or $JS.ACK.<...>) and SUBSCRIBES to _INBOX.> to
|
||||
// receive the reply. So the API grants belong on the publish side.
|
||||
var publishAllow = new List<string>();
|
||||
publishAllow.AddRange(
|
||||
assignedNodeIds
|
||||
.Where(subjectsByNodeId.ContainsKey)
|
||||
.SelectMany(id => subjectsByNodeId[id]));
|
||||
foreach (var id in assignedNodeIds)
|
||||
{
|
||||
if (!streamByNodeId.TryGetValue(id, out var stream)) continue;
|
||||
publishAllow.Add($"$JS.API.STREAM.INFO.{stream}");
|
||||
publishAllow.Add($"$JS.API.CONSUMER.CREATE.{stream}.>");
|
||||
publishAllow.Add($"$JS.API.CONSUMER.DURABLE.CREATE.{stream}.>");
|
||||
publishAllow.Add($"$JS.API.CONSUMER.INFO.{stream}.>");
|
||||
publishAllow.Add($"$JS.API.CONSUMER.DELETE.{stream}.>");
|
||||
publishAllow.Add($"$JS.API.CONSUMER.MSG.NEXT.{stream}.>");
|
||||
publishAllow.Add($"$JS.ACK.{stream}.>");
|
||||
}
|
||||
publishAllow = publishAllow
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var subscribeAllow = new List<string> { "_INBOX.>" };
|
||||
foreach (var id in assignedNodeIds.OrderBy(id => streamByNodeId.GetValueOrDefault(id, ""), StringComparer.Ordinal))
|
||||
{
|
||||
if (!streamByNodeId.TryGetValue(id, out var stream)) continue;
|
||||
subscribeAllow.Add($"$JS.API.CONSUMER.CREATE.{stream}.>");
|
||||
subscribeAllow.Add($"$JS.API.CONSUMER.MSG.NEXT.{stream}.>");
|
||||
subscribeAllow.Add($"$JS.ACK.{stream}.>");
|
||||
}
|
||||
|
||||
sb.AppendLine(" {");
|
||||
sb.AppendLine($" user: \"{user.Username}\"");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace VolcanicCore.Core.NatsConfig;
|
||||
@@ -69,27 +69,26 @@ public sealed class ConfigWriter : IConfigWriter
|
||||
return true;
|
||||
}
|
||||
|
||||
// POSIX kill(2). Signalling via libc means we don't depend on /bin/kill
|
||||
// being present — the aspnet:9.0 base image ships without procps, so
|
||||
// Process.Start("kill", ...) fails at runtime with ENOENT and the config
|
||||
// reload silently never happens.
|
||||
[DllImport("libc", SetLastError = true, EntryPoint = "kill")]
|
||||
private static extern int LibcKill(int pid, int sig);
|
||||
|
||||
private const int SIGHUP = 1;
|
||||
|
||||
private static void SendSighup(int processId)
|
||||
{
|
||||
// Cross-platform: use kill(1) on Linux; on Windows nats-server doesn't support hot reload.
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
using var proc = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo("kill", $"-HUP {processId}")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
},
|
||||
};
|
||||
proc.Start();
|
||||
proc.WaitForExit(2000);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
|
||||
throw new PlatformNotSupportedException(
|
||||
"nats-server hot reload requires SIGHUP; Windows is not supported.");
|
||||
var rc = LibcKill(processId, SIGHUP);
|
||||
if (rc != 0)
|
||||
{
|
||||
var errno = Marshal.GetLastPInvokeError();
|
||||
throw new InvalidOperationException(
|
||||
$"kill(pid={processId}, SIGHUP) failed with errno={errno}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,13 +50,25 @@ public class ConfigGeneratorTests
|
||||
|
||||
Assert.Contains("user: \"alice\"", text);
|
||||
Assert.Contains("password: \"alice-pass\"", text);
|
||||
Assert.Contains("publish {", text);
|
||||
// publish subjects from the node's subjects list
|
||||
Assert.Contains("\"mail.inbox.test-sender.*\"", text);
|
||||
// subscribe grants for that stream
|
||||
Assert.Contains("\"$JS.API.CONSUMER.CREATE.SENDER_STREAM.>\"", text);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.MSG.NEXT.SENDER_STREAM.>\"", text);
|
||||
Assert.Contains("\"$JS.ACK.SENDER_STREAM.>\"", text);
|
||||
|
||||
// JetStream request/reply uses the client's PUBLISH side for API calls,
|
||||
// and only _INBOX.> on the subscribe side to receive replies.
|
||||
var (pub, sub) = ExtractPermissionBlocks(text, "alice");
|
||||
|
||||
// Ingress subjects from the assigned node.
|
||||
Assert.Contains("\"mail.inbox.test-sender.*\"", pub);
|
||||
// Every JS API call this user needs to make is an outbound publish.
|
||||
Assert.Contains("\"$JS.API.STREAM.INFO.SENDER_STREAM\"", pub);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.CREATE.SENDER_STREAM.>\"", pub);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.DURABLE.CREATE.SENDER_STREAM.>\"", pub);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.INFO.SENDER_STREAM.>\"", pub);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.DELETE.SENDER_STREAM.>\"", pub);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.MSG.NEXT.SENDER_STREAM.>\"", pub);
|
||||
Assert.Contains("\"$JS.ACK.SENDER_STREAM.>\"", pub);
|
||||
|
||||
// Subscribe side is _INBOX.> only — no $JS subjects.
|
||||
Assert.Contains("\"_INBOX.>\"", sub);
|
||||
Assert.DoesNotContain("$JS", sub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -72,10 +84,13 @@ public class ConfigGeneratorTests
|
||||
Assignments: new[] { new Assignment("n1", "u"), new Assignment("n2", "u") },
|
||||
SysPassword: "sys");
|
||||
var text = ConfigGenerator.Generate(SampleInput(snap));
|
||||
Assert.Contains("\"a.>\"", text);
|
||||
Assert.Contains("\"b.>\"", text);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.CREATE.S1.>\"", text);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.CREATE.S2.>\"", text);
|
||||
var (pub, _) = ExtractPermissionBlocks(text, "bob");
|
||||
Assert.Contains("\"a.>\"", pub);
|
||||
Assert.Contains("\"b.>\"", pub);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.CREATE.S1.>\"", pub);
|
||||
Assert.Contains("\"$JS.API.CONSUMER.CREATE.S2.>\"", pub);
|
||||
Assert.Contains("\"$JS.API.STREAM.INFO.S1\"", pub);
|
||||
Assert.Contains("\"$JS.API.STREAM.INFO.S2\"", pub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -96,6 +111,27 @@ public class ConfigGeneratorTests
|
||||
Assert.DoesNotContain("$JS.API", text.Substring(text.IndexOf("user: \"solo\"", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
// Returns the raw text between `publish { allow: [ ... ] }` and
|
||||
// `subscribe { allow: [ ... ] }` for the named user, so a test can assert
|
||||
// where a subject appears (publish side vs subscribe side).
|
||||
private static (string Publish, string Subscribe) ExtractPermissionBlocks(string text, string username)
|
||||
{
|
||||
var marker = $"user: \"{username}\"";
|
||||
var start = text.IndexOf(marker, StringComparison.Ordinal);
|
||||
Assert.NotEqual(-1, start);
|
||||
var section = text.Substring(start);
|
||||
// Cut at the next user boundary so a later user's grants don't leak in.
|
||||
var next = section.IndexOf(" }", StringComparison.Ordinal);
|
||||
if (next > 0) section = section.Substring(0, next);
|
||||
|
||||
var pubIdx = section.IndexOf("publish {", StringComparison.Ordinal);
|
||||
var subIdx = section.IndexOf("subscribe {", StringComparison.Ordinal);
|
||||
Assert.True(pubIdx >= 0 && subIdx > pubIdx, "expected publish + subscribe blocks in order");
|
||||
var pub = section.Substring(pubIdx, subIdx - pubIdx);
|
||||
var sub = section.Substring(subIdx);
|
||||
return (pub, sub);
|
||||
}
|
||||
|
||||
private static Snapshot Fixture()
|
||||
{
|
||||
var sender = new Node("n1", "sender", "SENDER_STREAM",
|
||||
|
||||
Reference in New Issue
Block a user