Frontend (SPA)
How the React dashboard is structured — routing, pages, components, the `me` data bus, polling, theming, and how it ships embedded in the Go binary.
The dashboard is a single-page React app at internal/web/spa/. It's compiled with Vite into internal/web/assets/, then embedded directly into the Go control-plane binary with //go:embed assets (internal/web/handler.go:30). There is no separate Node process at runtime — the Go binary serves the bundle.
Quick orientation
- One process, one binary — no Node.js on the production host.
- One global state object (
me, fetched from/api/me) — every page reads from it, polling at 4 s when something is deploying, otherwise 15 s. - No React Router — a 90-line custom path parser handles the 17 routes.
- No client-side WebSocket — the SPA polls; the WSS hub is for control-plane ↔ worker only.
Tech stack
| Concern | Choice | Notes |
|---|---|---|
| UI framework | React 19.2 | Function components only; no class components. |
| Build | Vite 8 + @vitejs/plugin-react | Brotli precompression for .js/.css/.html/.svg/.woff2. |
| Language | TypeScript 6 strict | Path alias @ → src. |
| Styling | Tailwind 3.4 + data-theme attribute | Dark/light/system, no Tailwind dark variant — themed via CSS variables on :root[data-theme]. |
| UI primitives | Radix UI (avatar, dialog, select, switch, scroll-area, tabs, tooltip, collapsible, …) | Wrapped in components/ui/ shadcn-style. |
| Animation | Framer Motion 12 for page transitions; GSAP 3 in marketing routes only (lazy-loaded). | |
| Editor | CodeMirror 6 via @uiw/react-codemirror (env-vars editor, lazy-loaded as vendor-codemirror chunk). | |
| Toasts | Sonner 2 (<Toaster /> in App.tsx). | |
| QR codes | qrcode (TOTP enrollment). | |
| Static-site rendering | puppeteer-core prerenders /, /login, /plans, /privacy, /terms to HTML (scripts/prerender.mjs). |
How it ships into the Go binary
npm run buildrunstsc -b && vite build.- Vite emits to
outDir = ../assets(=internal/web/assets/), withassetsDir: "static"so JS/CSS land underinternal/web/assets/static/. vite-plugin-compressionwrites.brsiblings for compressible files.internal/web/handler.godeclares//go:embed assets. The Go compile step bakes everything underassets/into the binary.- At request time the handler serves files out of the embedded
embed.FS. There is no filesystem read at runtime.
To re-embed after editing the SPA:
cd internal/web/spa && npm run build
go build -o /tmp/wisehosting-cp .
sudo systemctl restart wisehostingThe vite dev server (port 5173 by default — but npm run dev here proxies /api → http://127.0.0.1:8081 so it talks to a locally-running CP) is for development only. Never run vite on the production host.
Entry point: main.tsx → App.tsx
main.tsx mounts <App /> into #root. All routing, auth, and global state lives in App.tsx.
The component holds four pieces of state:
const [route, setRoute] = useState<Route>(parsePath());
const [me, setMe] = useState<Me | null>(null);
const [authState, setAuthState] = useState<"loading" | "ok" | "anon" | "error">("loading");
const [authError, setAuthError] = useState<string | null>(null);Plus one boolean (pendingOAuth) used to accelerate polling while a popup OAuth dance is in flight.
me — the global data bus
Me (src/lib/api.ts) is the shape returned by GET /api/me:
type Me = {
user: { id, email?, name?, avatar_url?, plan?, theme_pref?, totp_enabled?, linked_providers? };
apps: AppSummary[];
plan?: { id, max_apps, used_apps, cpu_limit, memory_mib, disk_mib, bandwidth };
platform: string;
domain: string;
support_url?: string;
};Every page reads from me rather than re-fetching on its own. There is no Redux, Zustand, Jotai, React Query, etc. — props pass me and onRefresh down to each page, and each page calls onRefresh() after a mutation.
Auth lifecycle
mount → refreshMe() → GET /api/me
├── 200 → setMe; setAuthState("ok")
├── 401 → setMe(null); setAuthState("anon") ← cleared on logout
└── any other error → setAuthState("error"); setAuthError(msg)The setMe(null) on 401 is important: without it, a stale me would keep showing the dashboard after a session expired. (See commit c7a0e02 App: clear me on 401.)
Polling cadence
Three polling layers cooperate:
| Trigger | Interval | When it runs |
|---|---|---|
| Visibility change | once | Tab becomes visible — calls refreshMe() immediately. |
| OAuth pending | every 3 s | After clicking Connect GitHub/GitLab/... in Settings/AddApp, until linked_providers updates. |
| Background refresh | 4 s if a deploy is in flight, else 15 s | Only fires while document.visibilityState === "visible" to save power on hidden tabs. |
The 4 s vs 15 s switch is decided by:
const hasBusy = !!me?.apps?.some(a => a.status === "deploying" || a.status === "pending");So a single deploying app speeds up all dashboard polls — fine because /api/me is cheap (one DB hit + a usage cache lookup).
Routing
The SPA has no react-router-dom. Routing is a 90-line state machine in App.tsx:
parsePath()splitslocation.pathnameinto a tagged-unionRoutevalue.- A
popstatelistener re-parses on back/forward. - A document-level
clicklistener intercepts internal<a href="/foo">clicks and callsnavigate(href)— which doeshistory.pushState+ dispatches a syntheticpopstate. - External links (
http*),target="_blank", modified clicks, and certain prefixes (/oauth/,/api/,/static/) are ignored — they fall through to the browser.
The full router is src/lib/router.ts:
export function navigate(to: string) {
if (location.pathname + location.search === to) return;
history.pushState(null, "", to);
window.dispatchEvent(new PopStateEvent("popstate"));
}That's it. No matchers, no params object — params are pulled out by parsePath() for /app/:id/:tab.
Route map
| Path | Route name | Component | Auth | Shell |
|---|---|---|---|---|
/ | home | <Landing> (anon) or <Home> (signed in) | optional | Centered (anon) or sidebar |
/apps | apps | <Apps> | required | Sidebar |
/add | add | <AddApp> | required | Sidebar |
/app/:id (and /app/:id/:tab) | app | <AppDetail> with tabs overview, deployments, logs, env, settings | required | Sidebar |
/logs | logs | <Logs> (cross-app log search) | required | Sidebar |
/webhooks | webhooks | <Webhooks> | implied | Sidebar |
/domains | domains | <Domains> (custom domain CRUD + DNS verify) | implied | Sidebar |
/alerts | alerts | <Alerts> | implied | Sidebar |
/env | env | <EnvVars> | implied | Sidebar |
/git | git | <GitIntegrations> (link GitHub/GitLab/Bitbucket/Codeberg) | implied | Sidebar |
/usage | usage | <Usage> (90-day samples, charts) | required | Sidebar |
/settings | settings | <Settings> (profile, theme, security, OAuth provider links, sessions, 2FA enroll) | required | Sidebar |
/plans | plans | <Plans> (lazy) | optional | Sidebar (auth) / centered (anon) |
/privacy | privacy | <Privacy> (lazy) | optional | Centered |
/terms | terms | <Terms> (lazy) | optional | Centered |
/login | login | <Login> (lazy) — Google sign-in | always shown | Centered |
/2fa | 2fa | <TwoFAChallenge> (lazy) — TOTP/backup code at sign-in | special | Centered (no top bar) |
PROTECTED = ["apps", "add", "app", "logs", "usage", "settings"] — visiting any of these without me redirects to <Login>.
The 2fa route is a special case: rendered in a clean centered shell with no top bar, because it sits between Google's redirect and the session cookie being set. The user has been authenticated by Google but no wh_session cookie has been issued yet.
Pages — what each route does
Home.tsx (/ when signed in)
The dashboard landing card. Plan summary, app count, recent activity, deploy-in-progress callouts. Receives me and onRefresh.
Landing.tsx (/ when anonymous, lazy)
Marketing page. Uses GSAP (vendor-marketing chunk) for hero animations. Pre-rendered to static HTML by scripts/prerender.mjs.
Apps.tsx (/apps)
Grid of all your apps, each with a <StatusBadge> and quick actions (deploy, restart, stop, delete via <ConfirmProvider>).
AddApp.tsx (/add)
Two-step wizard:
<RepoPicker>— list repos from a linked git provider; or paste a public URL.<ProjectPicker>— pick framework, name, env vars; submitsPOST /api/apps.
If no providers are linked, this calls onOAuthStart() to bump the OAuth-pending poller — the dashboard will refresh aggressively until the popup completes.
AppDetail.tsx (/app/:id/:tab)
Per-app page with five tabs:
| Tab | Source |
|---|---|
overview | repo URL, framework, status, container ID/port, uptime, <AppResources> for live CPU/memory. |
deployments | recent jobs from /api/apps/:id/deployments, with <DeploymentProgress> for active builds. |
logs | tails /api/apps/:id/logs from internal/logbus. |
env | CodeMirror editor (lazy vendor-codemirror chunk) for env vars; saves via /api/apps/:id/env. |
settings | rename, delete, auto-deploy toggle, <AlertsTab> for per-app alert rules. |
Logs.tsx (/logs)
Cross-app log search. Same logbus backend, but filters across every app you own.
Webhooks.tsx, Domains.tsx, Alerts.tsx, EnvVars.tsx, GitIntegrations.tsx
Account-wide CRUD pages. Each follows the same pattern: list view with inline edit, dialogs for create/edit, optimistic UI with onRefresh() after every mutation.
Usage.tsx (/usage)
Renders 90-day usage samples in stacked area charts. Pulls /api/usage — the control plane's internal/usage recorder writes 5-minute buckets.
Settings.tsx (/settings)
Profile, theme picker (light/dark/system, persisted via theme_pref), session list with revoke, linked OAuth providers, <TwoFactorCard> for TOTP enrollment + backup codes, account deletion.
Plans.tsx (/plans, lazy)
Pricing table. Public when anonymous (pre-rendered) — when signed in, highlights the active tier and lets the user request an upgrade.
Login.tsx (/login, lazy)
"Continue with Google" button. Pre-rendered to static HTML so the marketing-to-login transition is instant.
TwoFAChallenge.tsx (/2fa, lazy)
Standalone challenge: 6-digit TOTP or 10-character backup code. Submits to POST /api/2fa/challenge, which sets the session cookie on success.
2FA enrollment vs challenge
These are intentionally two different flows:
- Enrollment lives inside
Settings → Security(<TwoFactorCard>). The user is already signed in. Generates a TOTP secret, shows a QR code, the user scans it, the user types the code, the server verifies and setstotp_enabled = true, then issues backup codes. - Challenge is the
/2faroute. The user has just completed Google OAuth buttotp_enabled = true, so we issue a short-livedwh_2fa_pendingcookie and ask for the code. On success, that cookie is exchanged for the realwh_session.
Privacy.tsx, Terms.tsx
Legal text. Lazy-loaded, pre-rendered, static.
Components
| File | Role |
|---|---|
TopBar.tsx | Anonymous header (logo + sign in). |
SidebarShell.tsx | Authenticated layout. Holds the sidebar nav (apps list, account links), top header with user menu, scrollable main pane. |
Footer.tsx | Anonymous footer with platform name + support URL. |
Particles.tsx | Background canvas particles for centered shells. |
DeployCat.tsx | Floating widget that pops up while a deployment is active (uses me.apps.some(a => a.status === "deploying")). |
DeploymentProgress.tsx | Streamed progress bar inside AppDetail → deployments. |
AppResources.tsx | CPU/memory/network sparklines on the app overview tab. |
RepoPicker.tsx | Provider repo list, used inside AddApp. |
ProjectPicker.tsx | Framework + naming step of AddApp. |
TwoFactorCard.tsx | TOTP enroll/disable, backup codes — used inside Settings. |
StatusBadge.tsx | Coloured dot + label per app status. |
FrameworkIcon.tsx | Logo glyphs per detected framework (Node, Python, Go, …). |
GreetingMascot.tsx | Time-of-day mascot on Home. |
Counter.tsx, ScrambleText.tsx, TiltCard.tsx | Micro-interactions on the marketing/dashboard pages. |
CodeEditor.tsx | CodeMirror 6 wrapper used by env editor + framework Dockerfile preview. |
ExternalLink.tsx, PageBack.tsx, icons.tsx | Small utilities. |
components/ui/ — primitives
Lowercase shadcn-style wrappers around Radix:
avatar, badge, button, card, collapsible, confirm, dialog, input, label, scroll-area, select, skeleton, switch, toaster.
confirm.tsx exports <ConfirmProvider> + useConfirm() so any component can pop a confirmation modal without prop drilling.
API client (src/lib/api.ts)
A single api(method, path, body?, signal?) helper wraps fetch:
- Always sends
credentials: "include"so thewh_sessioncookie travels. - JSON-encodes the body when present and sets
Content-Type: application/json. - On non-2xx, parses
{ "error": "code" }and throws anAuthErrorwith the code (orhttp_<status>if no JSON body). - Pages catch
AuthErrorand act one.code(unauthenticated,quota_exceeded,validation_failed, etc.).
There are no per-route helpers — every call is just api<T>("POST", "/api/apps", payload). Keeps the surface tiny and lets the React component own the call site.
Theming (src/lib/theme.ts)
Three values: "light" | "dark" | "system". The function hydrateThemeFromServer(theme_pref) runs on every refreshMe() to pick up changes from another tab (e.g. you flip the toggle on your phone, this tab sees it on next poll).
Theme is applied by setting document.documentElement.dataset.theme. The Tailwind config has no darkMode: "class" — instead index.css defines :root[data-theme="dark"] { --bg: ...; --fg: ...; } and Tailwind utilities reference the CSS variables (bg-[hsl(var(--bg))], etc.). One single CSS pass for both themes.
Static-site rendering (scripts/prerender.mjs)
After vite build, an optional second step walks the public marketing routes through headless Chromium and writes static HTML.
npm run build:ssgWhat it does:
- Spawns a tiny Node static server on
127.0.0.1:5273overinternal/web/assets/. - Launches
puppeteer-coreagainst/usr/bin/chromium-browser(override withCHROMIUM_PATH). - For each route in
["/", "/privacy", "/terms", "/plans", "/login"], navigates and serializes the rendered DOM. - Writes the result back into
assets/— the root becomesindex.prerendered.html, others become<route>/index.html.
The Go handler picks up index.prerendered.html as the document for / so search engines and link previewers see real content. Authenticated routes are not prerendered (they would need a session cookie and would leak data).
Build outputs
After npm run build:
internal/web/assets/
├── index.html # SPA shell
├── index.prerendered.html # SSG output for /, served at /
├── plans/index.html # SSG /plans
├── privacy/index.html # SSG /privacy
├── terms/index.html # SSG /terms
├── login/index.html # SSG /login
├── og.png # social share image (tracked, see commit 957d46b)
└── static/
├── index-<hash>.js # main bundle
├── vendor-marketing-<hash>.js # gsap (lazy, only on Landing)
├── vendor-codemirror-<hash>.js# CodeMirror (lazy, env editor)
├── index-<hash>.css # Tailwind-built CSS
└── *.br # brotli precompressed siblingsThe Go handler serves .br siblings when the client sends Accept-Encoding: br.
Tips for newcomers
- To trace a click: search for the API call (e.g.
api("POST", "/api/apps")). The page that owns it is the one you want. - To add a route: extend the
Routeunion, add acaseinparsePath(), render the component insideApp.tsx's big switch. Add SEO metadata insrc/lib/seo.ts → ROUTE_SEO. - To add a sidebar entry: edit
SidebarShell.tsx's nav array — sidebar entries are just typed paths, the router does the rest. - To run dev with a real CP: start the Go control plane (
go run .from the repo root, listening on:8081), thencd internal/web/spa && npm run dev. Vite's/api → 127.0.0.1:8081proxy handles the rest.