WiseHosting
Reference

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

ConcernChoiceNotes
UI frameworkReact 19.2Function components only; no class components.
BuildVite 8 + @vitejs/plugin-reactBrotli precompression for .js/.css/.html/.svg/.woff2.
LanguageTypeScript 6 strictPath alias @ → src.
StylingTailwind 3.4 + data-theme attributeDark/light/system, no Tailwind dark variant — themed via CSS variables on :root[data-theme].
UI primitivesRadix UI (avatar, dialog, select, switch, scroll-area, tabs, tooltip, collapsible, …)Wrapped in components/ui/ shadcn-style.
AnimationFramer Motion 12 for page transitions; GSAP 3 in marketing routes only (lazy-loaded).
EditorCodeMirror 6 via @uiw/react-codemirror (env-vars editor, lazy-loaded as vendor-codemirror chunk).
ToastsSonner 2 (<Toaster /> in App.tsx).
QR codesqrcode (TOTP enrollment).
Static-site renderingpuppeteer-core prerenders /, /login, /plans, /privacy, /terms to HTML (scripts/prerender.mjs).

How it ships into the Go binary

  1. npm run build runs tsc -b && vite build.
  2. Vite emits to outDir = ../assets (= internal/web/assets/), with assetsDir: "static" so JS/CSS land under internal/web/assets/static/.
  3. vite-plugin-compression writes .br siblings for compressible files.
  4. internal/web/handler.go declares //go:embed assets. The Go compile step bakes everything under assets/ into the binary.
  5. 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 wisehosting

The 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.tsxApp.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:

TriggerIntervalWhen it runs
Visibility changeonceTab becomes visible — calls refreshMe() immediately.
OAuth pendingevery 3 sAfter clicking Connect GitHub/GitLab/... in Settings/AddApp, until linked_providers updates.
Background refresh4 s if a deploy is in flight, else 15 sOnly 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:

  1. parsePath() splits location.pathname into a tagged-union Route value.
  2. A popstate listener re-parses on back/forward.
  3. A document-level click listener intercepts internal <a href="/foo"> clicks and calls navigate(href) — which does history.pushState + dispatches a synthetic popstate.
  4. 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

PathRoute nameComponentAuthShell
/home<Landing> (anon) or <Home> (signed in)optionalCentered (anon) or sidebar
/appsapps<Apps>requiredSidebar
/addadd<AddApp>requiredSidebar
/app/:id (and /app/:id/:tab)app<AppDetail> with tabs overview, deployments, logs, env, settingsrequiredSidebar
/logslogs<Logs> (cross-app log search)requiredSidebar
/webhookswebhooks<Webhooks>impliedSidebar
/domainsdomains<Domains> (custom domain CRUD + DNS verify)impliedSidebar
/alertsalerts<Alerts>impliedSidebar
/envenv<EnvVars>impliedSidebar
/gitgit<GitIntegrations> (link GitHub/GitLab/Bitbucket/Codeberg)impliedSidebar
/usageusage<Usage> (90-day samples, charts)requiredSidebar
/settingssettings<Settings> (profile, theme, security, OAuth provider links, sessions, 2FA enroll)requiredSidebar
/plansplans<Plans> (lazy)optionalSidebar (auth) / centered (anon)
/privacyprivacy<Privacy> (lazy)optionalCentered
/termsterms<Terms> (lazy)optionalCentered
/loginlogin<Login> (lazy) — Google sign-inalways shownCentered
/2fa2fa<TwoFAChallenge> (lazy) — TOTP/backup code at sign-inspecialCentered (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:

  1. <RepoPicker> — list repos from a linked git provider; or paste a public URL.
  2. <ProjectPicker> — pick framework, name, env vars; submits POST /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:

TabSource
overviewrepo URL, framework, status, container ID/port, uptime, <AppResources> for live CPU/memory.
deploymentsrecent jobs from /api/apps/:id/deployments, with <DeploymentProgress> for active builds.
logstails /api/apps/:id/logs from internal/logbus.
envCodeMirror editor (lazy vendor-codemirror chunk) for env vars; saves via /api/apps/:id/env.
settingsrename, 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 sets totp_enabled = true, then issues backup codes.
  • Challenge is the /2fa route. The user has just completed Google OAuth but totp_enabled = true, so we issue a short-lived wh_2fa_pending cookie and ask for the code. On success, that cookie is exchanged for the real wh_session.

Privacy.tsx, Terms.tsx

Legal text. Lazy-loaded, pre-rendered, static.

Components

FileRole
TopBar.tsxAnonymous header (logo + sign in).
SidebarShell.tsxAuthenticated layout. Holds the sidebar nav (apps list, account links), top header with user menu, scrollable main pane.
Footer.tsxAnonymous footer with platform name + support URL.
Particles.tsxBackground canvas particles for centered shells.
DeployCat.tsxFloating widget that pops up while a deployment is active (uses me.apps.some(a => a.status === "deploying")).
DeploymentProgress.tsxStreamed progress bar inside AppDetail → deployments.
AppResources.tsxCPU/memory/network sparklines on the app overview tab.
RepoPicker.tsxProvider repo list, used inside AddApp.
ProjectPicker.tsxFramework + naming step of AddApp.
TwoFactorCard.tsxTOTP enroll/disable, backup codes — used inside Settings.
StatusBadge.tsxColoured dot + label per app status.
FrameworkIcon.tsxLogo glyphs per detected framework (Node, Python, Go, …).
GreetingMascot.tsxTime-of-day mascot on Home.
Counter.tsx, ScrambleText.tsx, TiltCard.tsxMicro-interactions on the marketing/dashboard pages.
CodeEditor.tsxCodeMirror 6 wrapper used by env editor + framework Dockerfile preview.
ExternalLink.tsx, PageBack.tsx, icons.tsxSmall 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 the wh_session cookie travels.
  • JSON-encodes the body when present and sets Content-Type: application/json.
  • On non-2xx, parses { "error": "code" } and throws an AuthError with the code (or http_<status> if no JSON body).
  • Pages catch AuthError and act on e.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:ssg

What it does:

  1. Spawns a tiny Node static server on 127.0.0.1:5273 over internal/web/assets/.
  2. Launches puppeteer-core against /usr/bin/chromium-browser (override with CHROMIUM_PATH).
  3. For each route in ["/", "/privacy", "/terms", "/plans", "/login"], navigates and serializes the rendered DOM.
  4. Writes the result back into assets/ — the root becomes index.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 siblings

The 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 Route union, add a case in parsePath(), render the component inside App.tsx's big switch. Add SEO metadata in src/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), then cd internal/web/spa && npm run dev. Vite's /api → 127.0.0.1:8081 proxy handles the rest.

On this page