gwag admin UI
React + MUI + TanStack Router admin console for an instance of
gwag. Talks to the gateway over GraphQL only — every
view (services, peers, schema, future pages) is a graphql-codegen-typed
query against /graphql.
This is a dogfood of the project's stance: the gateway exposes its
own admin operations as GraphQL (via self-registration of the control
plane proto + planned huma route ingestion), and the UI consumes them
the same way any external client would.
Build flow
1. start the gateway cd ../examples/multi && ./run.sh
2. fetch + codegen pnpm run gen
3. dev server pnpm run dev → http://localhost:5173
4. production build pnpm run build → dist/
pnpm run gen is pnpm run schema && pnpm run codegen:
schema — curl ${GATEWAY_URL:-http://localhost:18080}/schema/graphql > schema.graphql
codegen — graphql-codegen reads schema.graphql + src/**/*.graphql and
emits typed functions into src/api/gateway.ts.
After codegen, every page imports sdk.<OperationName> from
@/api/client and gets typed args + responses for free. Add a new
operation: drop a query Foo { ... } into src/queries.graphql (or
inline in a .tsx via a tagged template — codegen reads both),
re-run pnpm run gen, use sdk.Foo() in any component.
Layout
package.json pnpm dependency manifest
codegen.ts graphql-codegen config
vite.config.ts Vite + TanStack-Router-Vite plugin
schema.graphql SDL cache (overwritten by `pnpm run schema`)
src/
main.tsx Provider wiring (MUI theme, TanStack Query, Router)
routeTree.gen.ts Generated by @tanstack/router-plugin
api/
client.ts GraphQLClient + sdk; lazy Authorization header
auth.ts sessionStorage-backed admin token store
events.ts graphql-ws client (subscriptions over /api/graphql)
gateway.ts Generated by codegen (placeholder otherwise)
providers/
EventsProvider.tsx graphql-ws subscriptions + ring buffer; useSubscribe() / useEvents()
components/
Layout.tsx AppBar (settings + notifications) + Drawer shell
SettingsDrawer.tsx Admin token entry; useAdminToken() hook
EventsTray.tsx Slide-out drawer rendering recent events
routes/
__root.tsx Wraps every page in <Layout>
index.tsx Dashboard (env, peers, services counters)
services.tsx Services table (ListServices)
peers.tsx Peers table + Forget mutation
schema.tsx SDL viewer (raw text from /schema/graphql)
queries.graphql Operations consumed by codegen
Hosting
All gateway endpoints live under /api/* so the SPA owns the
root. In dev, Vite proxies /api to the gateway (default
http://localhost:18080; override with GATEWAY_URL env). In
production, pnpm run build populates dist/, and the
//go:embed all:dist directive in ../ui/embed.go bakes it into
the gateway binary — gateway.UIHandler(ui.FS()) mounts at /
with SPA-style fallback to index.html.
Single-binary boot:
cd ui && pnpm install && pnpm run build
cd ..
go run ./examples/multi/cmd/gateway --nats-data /tmp/gw1
# UI at http://localhost:8080/, GraphQL at /api/graphql
Auth
The gateway gates write operations on a bearer token (logged at boot
as admin token = …). To unlock the UI's Forget button and any
future admin_* mutation:
- Click the gear icon in the AppBar.
- Paste the token into the Settings drawer.
- Save — graphql-request reads it from
sessionStorage per request
and sends Authorization: Bearer <token> to /graphql.
The badge dot on the gear is the "no token set" indicator. Storage
is sessionStorage only — closing the tab discards it; refresh keeps
it. There's intentionally no "remember me" option; the gateway boot
log is the source of truth, and persistent UI storage of a long-
lived bearer is the kind of thing that ends up in a screenshot.
If the deployment fronts the gateway with another auth layer
(session cookie, OAuth proxy), that's same-origin transparent to the
UI; the bearer is a separate concern for the gateway's own admin
surface.
Events provider
The Layout is wrapped in <EventsProvider> so any descendant page
can opt into a graphql-ws subscription:
import { useSubscribe } from '@/providers/EventsProvider';
useSubscribe<{ greeter_greetings: { greeting: string; forName: string } }>({
id: 'greeter-alice',
query: `subscription ($name:String!,$hmac:String!,$timestamp:Int!) {
greeter_greetings(name:$name, hmac:$hmac, timestamp:$timestamp) {
greeting forName
}
}`,
variables: { name: 'alice', hmac: 'x', timestamp: 0 },
onData: (p) => console.log(p),
});
Every event also lands in a global ring buffer (last 50). The bell
icon in the AppBar shows the unread count and opens an EventsTray
drawer that renders the buffer as a reverse-chronological feed.
graphql-ws is lazy: the WebSocket opens on the first subscribe and
closes after the last unsubscribe.
In production, subscription args (hmac, timestamp) come from
admin_signSubscriptionToken. Dev gateways started with
--insecure-subscribe accept any value.
Useful pnpm scripts
pnpm run dev # vite dev server with HMR
pnpm run build # tsc -b && vite build → dist/
pnpm run preview # vite preview against dist/
pnpm run schema # refresh schema.graphql from the running gateway
pnpm run codegen # graphql-codegen against schema.graphql
pnpm run gen # schema + codegen