19 Commits

Author SHA1 Message Date
EugeneTes
e1bd1709d7 fix(graph): always show edge filter/transform label
Drop the selected-only gate so the filter subject → transform destination
hint is visible on every edge, not just the focused one.
2026-08-27 19:52:29 +00:00
EugeneTes
c5d596a2a7 fix(graph): always render both handles on node cards
Round-2 spec dropped the input handle from source nodes and the output
handle from sink nodes so the topology's endpoints read as terminal at a
glance. But source/sink is *computed from graph topology* — a node with no
inbound edges is a "source". That meant every freshly-created (still-
unconnected) node classified as a source, lost its input handle, and could
never receive an edge — the graph became unusable the moment you tried to
build one from scratch.

Type is already carried by the SOURCE/ROUTER/SINK eyebrow + the top-border
weight on sources + the inverse fill on sinks. The handle presence was a
redundant fourth signal that broke basic functionality. Always render both;
keep the rest of the type language intact.
2026-08-27 19:26:18 +00:00
EugeneTes
85d5d183f0 feat(frontend): tesanti brand re-skin — palette, wordmark, graph, toast, confirm
Applies the tesanti brand pack (docs/brand/tesanti.md) across every admin
surface, replacing the old slate + orange Tailwind defaults with black + white
+ signal red, three variable-weight fonts (Space Grotesk / Inter / JetBrains
Mono), mono eyebrows above every heading, and a hairline-first (not shadow-
first) elevation language.

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

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

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

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

Fonts self-hosted via @fontsource-variable/{space-grotesk,inter,
jetbrains-mono}; no external requests, CSP-friendly.
2026-08-27 17:51:12 +00:00
EugeneTes
00605dfc82 docs: CLAUDE.md — invariants surfaced by the NodeExample session
Three real bugs and one data-model quirk showed up while wiring the
Examples/NodeExample smoke test to production. Adding them as design
invariants so future sessions don't re-hit them:

- JS API subjects belong in publish.allow (request/reply direction)
- SIGHUP must go through libc.kill; procps isn't in the base image
- Node display name is not the JetStream stream name

Also add the Examples path to the repo layout, mention the end-to-end
smoke procedure, and gitignore .playwright-mcp/.
2026-08-27 16:06:57 +00:00
EugeneTes
1a5528dac6 fix(examples): NodeExample points at real stream name Test2
The node's display name is "Test_2" but its JetStream stream name (chosen
by the operator, constrained to [A-Za-z0-9_-]+) is "Test2". NATS API
calls target streamName, not the node's display name.
2026-08-27 16:05:27 +00:00
EugeneTes
b17af56d14 fix(config-writer): SIGHUP via libc kill(2) instead of /bin/kill
The aspnet:9.0 runtime base image doesn't ship procps, so
Process.Start(\"kill\", \"-HUP <pid>\") failed with ENOENT — the first
reconcile after boot wrote the new nats.conf to disk, then threw when
signalling, and subsequent reconciles saw the on-disk text already
matched the target and skipped the retry. Net effect: nats-server kept
running with whatever config it parsed at startup, and configuration
changes never took effect at runtime.

Switch to a direct P/Invoke to libc kill(2) so we don't depend on any
external binary.
2026-08-27 15:46:41 +00:00
EugeneTes
ddaa43d924 fix(config): JS API subjects belong in publish.allow, not subscribe.allow
The JetStream request/reply model has the CLIENT publishing requests to
$JS.API.<...> (and $JS.ACK.<...>) and receiving replies on _INBOX.>.
ConfigGenerator was granting these on the subscribe side instead, so
end users could never create consumers, fetch, ack, or query streams —
their JS calls silently timed out on unauthorized publishes. Also add
STREAM.INFO / CONSUMER.INFO / CONSUMER.DURABLE.CREATE / CONSUMER.DELETE,
which end-user clients need but weren't granted at all.

Tests now assert placement (publish vs subscribe block) so this can't
regress silently. The example switches to Test_2 as the destination
stream name to match the real deployment.
2026-08-27 15:37:56 +00:00
EugeneTes
ad1a2462e5 feat(examples): NodeExample — publish to Test, consume from Test 2
Small .NET 9 console app that opens two NATS connections, one per node,
to exercise the edge-routing feature end-to-end. Uses NATS.Client.Core
for the publisher and NATS.Client.JetStream for the ephemeral consumer.
2026-08-27 15:16:14 +00:00
VolcanicCore
4336eae7ca docs: CLAUDE.md — project context, deployment, invariants for future sessions 2026-08-27 15:07:38 +00:00
VolcanicCore
b874b9fb06 feat(nodes): allow empty subjects for pure receiver nodes
Removes the 'at least one subject required' validator from both the backend
POST /api/nodes endpoint and the frontend node drawer. NATS accepts streams
with no subjects — messages flow in via 'sources' (incoming edges) only.
Publishers can still be assigned but they'll get an empty publish allow list.
2026-08-27 15:01:41 +00:00
VolcanicCore
3cbde17c27 feat(frontend): editable node drawer (name/subjects/quotas) + danger-zone remove 2026-08-27 14:53:19 +00:00
VolcanicCore
9b1b5009bf feat(frontend): editable filter+transform in edge drawer with test-match preview 2026-08-27 14:45:42 +00:00
VolcanicCore
076249bd15 feat(layout): persist node positions in node_positions table
Adds a lightweight table + repository + POST /api/nodes/positions endpoint.
/api/graph now returns positions keyed by node id. Frontend uses saved
positions on load and debounce-saves 300ms after a drag ends.
2026-08-27 14:17:09 +00:00
VolcanicCore
5927636f9f feat(frontend): delete edges via drawer button or Delete/Backspace key 2026-08-27 14:12:29 +00:00
VolcanicCore
5df1fe85be fix(frontend): use useNodesState/useEdgesState so React Flow properly tracks measurements + connections 2026-08-27 14:08:19 +00:00
VolcanicCore
821f433ee8 fix(frontend): pre-set node width/height so React Flow doesn't hide them 2026-08-27 13:59:51 +00:00
VolcanicCore
baa6aafa66 fix(frontend): un-wrap NodeCard data so custom nodes render in v12 2026-08-27 13:55:27 +00:00
VolcanicCore
cddfb5d3ac fix(deploy): pull nats-server binary from official nats:alpine image
The previous stage downloaded from GitHub via curl, which fails under Coolify's
builder network with an opaque exit 2. Using the official image avoids the
network dependency entirely.
2026-08-27 13:37:47 +00:00
VolcanicCore
a0085cecb9 feat(deploy): docker-compose.yml with two-domain Traefik routing 2026-08-27 13:28:43 +00:00
37 changed files with 1963 additions and 301 deletions

5
.gitignore vendored
View File

@@ -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
View 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.

View File

@@ -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 \

View 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>

View 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
View 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
View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 577 B

View File

@@ -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>,

View File

@@ -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>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,26 @@
import { useCallback, 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>
);

View File

@@ -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>
);
}

View 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>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,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">&gt;</code> matches the rest. In the transform,
reference captures as <code className="font-mono text-fg-2">{'{{wildcard(N)}}'}</code>{' '}(1-indexed).
</p>
{saveError && <ErrorLine>{saveError}</ErrorLine>}
<button onClick={save} disabled={!dirty || updateEdge.isPending} className={BTN_PRIMARY + ' w-full'}>
{updateEdge.isPending ? 'Saving…' : dirty ? 'Save changes' : 'Saved'}
</button>
</div>
<div className="space-y-2 border-t border-line-subtle pt-4">
<div className={EYEBROW}>Test match</div>
<div className="flex gap-2">
<input
className={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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -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,

View File

@@ -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>();

View File

@@ -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
);

View File

@@ -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();
}
}

View File

@@ -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}\"");

View File

@@ -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}");
}
}

View File

@@ -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",