Skip to content

Commit bc94ead

Browse files
mudlerclaude
andauthored
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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
api/react-ui/node_modules
2+
api/react-ui/dist
3+
.git

.github/workflows/build.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ jobs:
1515
uses: actions/setup-go@v6
1616
with:
1717
go-version: 1.26
18+
- name: Set up Node
19+
uses: actions/setup-node@v4
20+
with:
21+
node-version: 22
1822
- name: Run GoReleaser
1923
uses: goreleaser/goreleaser-action@v7
2024
with:

.github/workflows/release.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ jobs:
1717
uses: actions/setup-go@v6
1818
with:
1919
go-version: 1.26
20+
- name: Set up Node
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: 22
2024
- name: Run GoReleaser
2125
uses: goreleaser/goreleaser-action@v7
2226
with:

.github/workflows/test.yml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@ jobs:
1616
uses: actions/setup-go@v6
1717
with:
1818
go-version: 1.26
19-
19+
- name: Set up Node
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: 22
23+
- name: Build React UI
24+
run: make react-ui-force
25+
2026
- name: Build
21-
run:
22-
go build
23-
27+
run: go build
28+
2429
- name: Generate edgevpnconfig
2530
run: |
2631
./edgevpn -g > config.yaml
@@ -51,6 +56,12 @@ jobs:
5156
uses: actions/setup-go@v6
5257
with:
5358
go-version: 1.26
59+
- name: Set up Node
60+
uses: actions/setup-node@v4
61+
with:
62+
node-version: 22
63+
- name: Build React UI
64+
run: make react-ui-force
5465
- name: Download result for build
5566
uses: actions/download-artifact@v8
5667
with:

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@
88
/docs/node_modules/
99
/docs/.hugo_build.lock
1010
/docs/package-lock.json
11-
/docs/package.json
11+
/docs/package.json
12+
/api/react-ui/dist
13+
/api/react-ui/node_modules

.goreleaser.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Make sure to check the documentation at http://goreleaser.com
22
version: 2
3+
before:
4+
hooks:
5+
- make react-ui-force
36
builds:
47
- ldflags:
58
- -w -s

Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# Define argument for linker flags
22
ARG LDFLAGS=-s -w
33

4+
# Build the React UI in a Node stage so the Go builder can embed it.
5+
# Pinned to $BUILDPLATFORM: the bundle is arch-independent, so building it
6+
# once natively beats rebuilding it under QEMU for every target platform.
7+
FROM --platform=$BUILDPLATFORM node:22-alpine AS react-ui-builder
8+
WORKDIR /ui
9+
COPY api/react-ui/package.json api/react-ui/package-lock.json ./
10+
RUN npm ci
11+
COPY api/react-ui/ ./
12+
RUN npm run build
13+
414
# Use a temporary build image based on Golang 1.20-alpine
515
FROM golang:1.26-alpine as builder
616

@@ -13,6 +23,9 @@ ADD . /work
1323
# Set the current work directory inside the container
1424
WORKDIR /work
1525

26+
# Bring in the built UI so //go:embed finds it
27+
COPY --from=react-ui-builder /ui/dist /work/api/react-ui/dist
28+
1629
# Install git and build the edgevpn binary with the provided linker flags
1730
# --no-cache flag ensures the package cache isn't stored in the layer, reducing image size
1831
RUN apk add --no-cache git && \

Makefile

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.PHONY: all build react-ui react-ui-force test clean
2+
3+
all: build
4+
5+
# Skip the npm build when dist already exists, so repeated `make build`
6+
# is fast. This intentionally reuses a stale dist — use react-ui-force
7+
# in CI and releases where correctness matters more than speed.
8+
react-ui:
9+
ifneq ($(wildcard api/react-ui/dist),)
10+
@echo "api/react-ui/dist already exists, skipping build"
11+
else
12+
cd api/react-ui && npm ci && npm run build
13+
endif
14+
15+
# Always rebuild from source. Used by CI, goreleaser and Docker.
16+
react-ui-force:
17+
rm -rf api/react-ui/dist
18+
cd api/react-ui && npm ci && npm run build
19+
20+
api/react-ui/dist: react-ui
21+
22+
build: api/react-ui/dist
23+
go build -o edgevpn
24+
25+
test: api/react-ui/dist
26+
go test ./...
27+
28+
clean:
29+
rm -rf api/react-ui/dist edgevpn

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,26 @@ Check out [Kairos](https://github.com/kairos-io/kairos) for seeing EdgeVPN in ac
7575

7676
Download the precompiled static release in the [releases page](https://github.com/mudler/edgevpn/releases). You can either install it in your system or just run it.
7777

78+
# :hammer: Building from source
79+
80+
The web UI is a React application compiled into the binary, so a Node
81+
toolchain (**Node 20.19+**) is required for a full build:
82+
83+
```bash
84+
make build # builds the UI if needed, then the Go binary
85+
make react-ui-force # force a clean UI rebuild
86+
```
87+
88+
`go build` alone works only when `api/react-ui/dist` already exists —
89+
the UI is embedded with `//go:embed`, and a missing directory is a
90+
compile error. For Go-only work you can stub it:
91+
92+
```bash
93+
mkdir -p api/react-ui/dist && touch api/react-ui/dist/index.html
94+
```
95+
96+
See [`api/react-ui/README.md`](api/react-ui/README.md) for frontend development.
97+
7898
# :computer: Usage
7999

80100
EdgeVPN works by generating tokens (or a configuration file) that can be shared between different machines, hosts or peers to access to a decentralized secured network between them.

api/api.go

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,8 @@ package api
1717

1818
import (
1919
"context"
20-
"embed"
2120
"errors"
2221
"fmt"
23-
"io/fs"
2422
"net"
2523
"net/http"
2624
_ "net/http/pprof"
@@ -46,18 +44,6 @@ import (
4644
"github.com/mudler/edgevpn/pkg/types"
4745
)
4846

49-
//go:embed public
50-
var embededFiles embed.FS
51-
52-
func getFileSystem() http.FileSystem {
53-
fsys, err := fs.Sub(embededFiles, "public")
54-
if err != nil {
55-
panic(err)
56-
}
57-
58-
return http.FS(fsys)
59-
}
60-
6147
const (
6248
MachineURL = "/api/machines"
6349
UsersURL = "/api/users"
@@ -235,8 +221,8 @@ func API(ctx context.Context, l string, defaultInterval, timeout time.Duration,
235221
ec := echo.New()
236222

237223
var (
238-
unixSocketPath string
239-
ownsSocketFile bool // true iff WE created the file; false for systemd-passed FDs
224+
unixSocketPath string
225+
ownsSocketFile bool // true iff WE created the file; false for systemd-passed FDs
240226
)
241227
if strings.HasPrefix(l, UnixSocketScheme) {
242228
unixSocketPath = strings.TrimPrefix(l, UnixSocketScheme)
@@ -262,7 +248,6 @@ func API(ctx context.Context, l string, defaultInterval, timeout time.Duration,
262248
ec.Listener = unixListener
263249
}
264250

265-
assetHandler := http.FileServer(getFileSystem())
266251
if debugMode {
267252
ec.GET("/debug/pprof/*", echo.WrapHandler(http.DefaultServeMux))
268253
}
@@ -275,7 +260,19 @@ func API(ctx context.Context, l string, defaultInterval, timeout time.Duration,
275260
return c.JSON(http.StatusOK, bwc.GetBandwidthByProtocol())
276261
})
277262
ec.GET(filepath.Join(MetricsURL, "peer"), func(c echo.Context) error {
278-
return c.JSON(http.StatusOK, bwc.GetBandwidthByPeer())
263+
// The counter keys by peer.ID, which is a *string* type holding raw
264+
// multihash bytes. encoding/json resolves a map key by its String
265+
// kind before consulting its TextMarshaler, so marshalling that map
266+
// directly never calls peer.ID.String() and emits the raw bytes —
267+
// mangled to U+FFFD on the way out, since they are not valid UTF-8.
268+
// The result cannot be matched against the peer IDs every other
269+
// endpoint reports, so key by the base58 form explicitly.
270+
byPeer := bwc.GetBandwidthByPeer()
271+
out := make(map[string]metrics.Stats, len(byPeer))
272+
for p, stats := range byPeer {
273+
out[p.String()] = stats
274+
}
275+
return c.JSON(http.StatusOK, out)
279276
})
280277
ec.GET(filepath.Join(MetricsURL, "peer", ":peer"), func(c echo.Context) error {
281278
return c.JSON(http.StatusOK, bwc.GetBandwidthForPeer(peer.ID(c.Param("peer"))))
@@ -430,7 +427,10 @@ func API(ctx context.Context, l string, defaultInterval, timeout time.Duration,
430427
return c.JSON(http.StatusOK, list)
431428
})
432429

433-
ec.GET("/*", echo.WrapHandler(http.StripPrefix("/", assetHandler)))
430+
if err := registerUI(ec); err != nil {
431+
// A binary built without the React UI must still serve the API.
432+
ec.Logger.Errorf("web UI not available: %v", err)
433+
}
434434

435435
ec.GET(BlockchainURL, func(c echo.Context) error {
436436
return c.JSON(http.StatusOK, ledger.LastBlock())

0 commit comments

Comments
 (0)