Commit bc94ead
react UI design system (#1063)
* docs: add design spec for React UI port and design system
Covers sub-projects 0 (design system) and 1 (React UI port) of the
EdgeVPN overhaul. Records the chosen visual direction, the build-infra
ordering constraint around go:embed, the server-side SPA changes, and
the six-route parity mapping.
Docs restructure and the custom Hugo theme are deferred to their own
specs; findings from the scoping audit are recorded in Appendix B so
they are not lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: add implementation plan for the React UI port
15 tasks, TDD throughout, sequenced so build infrastructure lands
before the go:embed directive that would otherwise break all four
Go-compiling workflows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: drop literal colour fallbacks from PeerGraph in the plan
They contradicted the global constraint forbidding literal colours in
components, and the plan's own verification grep.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): scaffold React + Vite + TypeScript frontend
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build: add Makefile and gitignore react-ui build output
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* build(react-ui): pin node >=20.19 and keep tsc -b output out of the source tree
Vite 8 requires Node >=20.19; record it via .nvmrc and package.json engines.
tsconfig.node.json had composite:true with no outDir, so 'tsc -b' emitted
vite.config.js next to vite.config.ts, which Vite would resolve first and
silently shadow the real config. Emit to node_modules/.tmp instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: build React UI before every Go compile step
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: pin Node version for goreleaser workflows
The goreleaser before-hook runs `make react-ui-force`, which invokes npm.
Without an explicit setup-node step both workflows depended on whatever
Node the ubuntu-latest runner image happened to ship. Pin to 22, matching
test.yml.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* ci: pin UI builder to build platform, add .dockerignore
images.yml builds three platforms, so the arch-independent React bundle was
being rebuilt under QEMU twice over. Pin the Node stage to $BUILDPLATFORM so
it builds once, natively. The Go stage stays per-arch.
Add a .dockerignore: COPY api/react-ui/ runs after npm ci, so without it the
host's glibc/x86 node_modules is merged over the container's musl one and can
break native rollup/esbuild binaries.
Also collapse a stray double blank line in test.yml.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add design tokens and the EdgeVPN mark
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add wire types and formatting helpers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add typed API client mirroring api/client
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add visibility-aware non-overlapping polling hook
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): bound and cancel polling requests so a hung node cannot wedge the loop
A request that never answered latched inFlight forever: the AbortController
was never aborted and nothing timed the request out, so the loop died with a
spinner stuck on and no error to show. Hold the controller in a ref and abort
it on cleanup, bound each request at 3x the interval (10s floor) since pages
poll from 1500ms to 5500ms, and race the abort rather than trusting the
fetcher to observe its signal. A timeout surfaces as an error; only an
unmount abort stays silent. Register cleanup unconditionally so the mounted
invariant holds while disabled.
Adds tests for visibility gating and catch-up, resume after a slow request
settles, timeout recovery, and unmount cancellation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): own the polling guard per request so a re-run keeps its fetch
StrictMode double-invokes effects in development: the cleanup aborted the
mount request but the guard was only released in the finally, a microtask
later, so the synchronous effect re-run found it still held and skipped its
fetch. Pages rendered empty until the first tick. Any mid-flight effect
re-run (enabled, intervalMs) has the same shape.
Release the guard synchronously in cleanup, and collapse inFlight and the
controller ref into one activeRun ref so the guard is owned by identity. An
abandoned request fails the superseded() check and so cannot release its
replacement's guard, which would have traded a stuck-true guard for a
stuck-false one and reopened request stacking. Clearing loading is owned by
whoever leaves nothing in flight, so a hook disabled mid-request settles.
Adds tests for the StrictMode double-invoke, effect-re-run stacking on both
sides, loading after a mid-request disable, and the intervalMs * 3 timeout
branch above the floor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add shell layout, shared components and routing
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add summary page
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add nodes and services pages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add DNS and ledger pages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add peers page merging nodes, peerstore and metrics
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): key per-peer bandwidth metrics by base58 peer ID
encoding/json resolves a map key by its String kind before consulting its
TextMarshaler. peer.ID is a string type holding raw multihash bytes, so
marshalling the bandwidth counter's map directly made /api/metrics/peer emit
those raw bytes, mangled to U+FFFD because they are not valid UTF-8, and no
consumer could correlate them with the peer IDs every other endpoint reports.
/api/metrics/protocol was checked and needs no change: protocol.ID values are
already the readable protocol path.
Also fixes bytesToSize, which indexed past the start of its unit table for
sub-byte values and rendered "424.4 undefined/s" once real rates arrived, and
corrects the peers page wording: /api/nodes peers are on the ledger too, in the
healthcheck bucket, so the column now says "VPN machine" and the warn pill
reads "address book" rather than colliding with the PeerRow.known field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(ui): add ego peer graph with live bandwidth-weighted edges
This node at the centre, its direct peers on a ring, edge width from real
per-peer bandwidth. Edges between other peers are deliberately absent: no
endpoint exposes that topology, and the note under the graph says so.
Under prefers-reduced-motion the single static frame is re-issued when the
peer list changes. It is drawn on mount, before the first poll returns, so
without this a reduced-motion user would be left with a bare centre dot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): plot only real peers on the graph, and stop resetting the bitmap
A peerstore entry is an address the DHT handed us, not a connection. Drawing
a spoke for each buried the graph under ~1100 overlapping edges and claimed a
connectivity this node does not have. The ring now plots online || known, and
the copy and aria-label state how many address-book entries were left out
rather than dropping them silently. The table below still lists everything.
Three smaller fixes in the same pass:
- IntersectionObserver reads the last entry, not the first, so a batched pair
of crossings cannot leave the loop stalled while visible or running while
off-screen.
- The bitmap is resized only when the size or devicePixelRatio actually
changed, via a ResizeObserver instead of a getBoundingClientRect per frame.
Assigning canvas.width clears the canvas, which made the clearRect dead, and
the layout read forced a reflow every frame against a 1000-row table.
- maxRate is reduced rather than spread as arguments, which throws RangeError
on a large peer set and would kill the loop for good.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(api): serve the React SPA and remove the Alpine UI
Replaces the embedded Alpine.js/Tailwind-CDN page with the React bundle.
Adds SPA fallback, immutable asset caching and a no-cache index.
Removes the committed 2930-line generated index.html, the vendored
minified JS, and the api/generate template pipeline. The CDN Font
Awesome dependency goes with it, which fixes icon rendering on
air-gapped nodes.
Also switches the Vite base from './' to '/'. A relative base broke
every deep link: at /app/nodes the browser resolved the module URL to
/app/assets/index-<hash>.js, which the /app/* SPA route answered with
index.html as text/html, so the module was rejected and the page
rendered blank. TestIndexReferencesAbsoluteAssets guards the setting,
since no handler-level test can observe that failure.
getFileSystem() panicked when the embedded FS was unusable; registerUI
returns an error instead and the caller logs and continues, so a binary
without the UI still serves the JSON API.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): normalise the request path before the SPA fallback guard
The guard compared the raw request path against "/api" and "/debug" with
a plain prefix test, so paths that only name the API after normalisation
slipped through and were answered with index.html as text/html:
//api/summary, /./api/nope and /app/../api/nope. curl and browsers
collapse these before sending and our client uses absolute paths, so
this was not reachable from the app, but a reverse proxy that does not
normalise slashes makes //api/... reachable — and returning HTML where
the caller expects JSON is what this guard exists to prevent.
The same plain prefix test failed in the other direction too, denying
the SPA fallback to /apiary because "/api" string-prefixes it.
isUnder() cleans the path and then compares whole segments, fixing both.
The check also had to move. /app/../api/nope matches the real /app/*
route, so serveIndex runs directly and the error handler never sees it;
cleaning the path in the error handler alone left that vector failing.
reserved() is now checked at the top of serveIndex, covering the routed
and the fallback path.
/API/summary is intentionally not handled: echo routes case-sensitively,
so it was never a real API path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: document the React UI build and Node requirement
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: require Node for source builds in the getting started guide
The docs site listed only Golang and make as build requirements and told
readers to run 'go build'. The web UI is now a React app embedded with
//go:embed, so that fails with 'pattern react-ui/dist/*: no matching
files found' on a fresh clone. Point at 'make build' and document the
stub for Go-only work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): surface fetch errors on every page, and stop claiming metrics are off
Four of the six pages never rendered their usePolling error. A node that was
down or answering 500 was drawn as "No machines on the ledger yet" — a claim
about the network, made when we had heard nothing from it at all. Nodes, DNS,
Services and Blockchain now render each poll's error in the panel that owns it,
with the element, class and phrasing SummaryPage and PeersPage already use.
DNS keeps its delete error too: the two failures are independent.
SummaryPage's bandwidth panel branched on `metrics.data ?` and said "Bandwidth
metrics are not enabled on this node." for everything else — a 500, a timeout,
an unreachable node, and the whole window before the first response, so it also
flashed on every mount. /api/metrics is only registered when the node has a
bandwidth counter, so exactly one status means "not enabled": 404. ApiError
already carries .status, so branch on it — 404 says not enabled, anything else
is an error, and nothing at all is said until something comes back.
Also move PeerRow to src/types/api.ts. PeerGraph imported it from
pages/PeersPage, making a component depend on a page. usePeerRows stays put.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(api): drop the error-handler SPA fallback, and log UI failure properly
/app and /app/* are real registered routes, so every path the router's /app
basename can produce was already served without the fallback. What it actually
caught were paths the router cannot match — including the old Alpine UI's own
URLs. Probed live before this change: /index.html and /js/alpine.min.js both
returned 200 text/html carrying the React index, so a stale bookmark or a
cached page booted the app at an unroutable path and showed react-router's raw
"Unexpected Application Error! 404 Not Found" instead of a clean 404.
The reserved() guard at the top of serveIndex stays. It is not redundant with
the deleted handler: /app/../api/nope matches the /app/* route and reaches
serveIndex directly, so without it an API path is answered with an HTML page.
TestBrowserDeepLinkOutsideAppFallsBackToIndex pinned only the removed
behaviour and is gone. TestAPILookalikePathGetsFallback rode on the fallback
too, but the segment-awareness of isUnder it guards is still live inside
serveIndex, so it is re-pointed through /app/* rather than deleted.
TestOldUIPathsReturn404 is new. Every API-404 test is untouched.
While here, api.go logged UI-registration failure with fmt.Printf. The node's
logger is not reachable from this package — node.Node.config is unexported and
exposes no accessor — so use echo's own logger, which is right there and is the
correct one for a server-registration failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: correct D4's Vite base to what actually shipped
D4 said "Vite still uses `base: './'`". It ships base: '/', changed during
implementation because a relative base makes the browser resolve ./assets/...
against the current deep link (/app/nodes/assets/...), which the SPA route
answers with index.html — every deep link loaded blank. Note the consequence
for the deferred subpath work: it needs an absolute base injected at serve
time, not just a router change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>1 parent 3a6954a commit bc94ead
61 files changed
Lines changed: 9151 additions & 4040 deletions
File tree
- .github/workflows
- api
- generate
- public
- images
- js
- react-ui
- public
- src
- components
- hooks
- lib
- pages
- styles
- types
- docs
- content/en/docs/Getting started
- superpowers
- plans
- specs
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
18 | 22 | | |
19 | 23 | | |
20 | 24 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
20 | 24 | | |
21 | 25 | | |
22 | 26 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
19 | | - | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
20 | 26 | | |
21 | | - | |
22 | | - | |
23 | | - | |
| 27 | + | |
| 28 | + | |
24 | 29 | | |
25 | 30 | | |
26 | 31 | | |
| |||
51 | 56 | | |
52 | 57 | | |
53 | 58 | | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
54 | 65 | | |
55 | 66 | | |
56 | 67 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
3 | 6 | | |
4 | 7 | | |
5 | 8 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
4 | 14 | | |
5 | 15 | | |
6 | 16 | | |
| |||
13 | 23 | | |
14 | 24 | | |
15 | 25 | | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
16 | 29 | | |
17 | 30 | | |
18 | 31 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
75 | 75 | | |
76 | 76 | | |
77 | 77 | | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
78 | 98 | | |
79 | 99 | | |
80 | 100 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
20 | | - | |
21 | 20 | | |
22 | 21 | | |
23 | | - | |
24 | 22 | | |
25 | 23 | | |
26 | 24 | | |
| |||
46 | 44 | | |
47 | 45 | | |
48 | 46 | | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | 47 | | |
62 | 48 | | |
63 | 49 | | |
| |||
235 | 221 | | |
236 | 222 | | |
237 | 223 | | |
238 | | - | |
239 | | - | |
| 224 | + | |
| 225 | + | |
240 | 226 | | |
241 | 227 | | |
242 | 228 | | |
| |||
262 | 248 | | |
263 | 249 | | |
264 | 250 | | |
265 | | - | |
266 | 251 | | |
267 | 252 | | |
268 | 253 | | |
| |||
275 | 260 | | |
276 | 261 | | |
277 | 262 | | |
278 | | - | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
279 | 276 | | |
280 | 277 | | |
281 | 278 | | |
| |||
430 | 427 | | |
431 | 428 | | |
432 | 429 | | |
433 | | - | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
434 | 434 | | |
435 | 435 | | |
436 | 436 | | |
| |||
0 commit comments