feat(server): serve dashboard SPA with deep-link fallback; remove v1 portal
Axum now serves the v2 React/Vite dashboard SPA at / with a client-side routing fallback, and the dead v1 HTML portal is removed (nothing was live on the server to preserve). - SPA served from server/static/app via ServeDir with a fallback to index.html, so deep links (/machines, /sessions) resolve to the SPA. - /api/*rest and /ws/*rest return JSON 404 so unrouted API/WS paths never leak index.html to clients; real /api, /ws, /health, /metrics, and the /downloads nest keep precedence (matchit static-over-wildcard). - Path-aware Cache-Control: hashed /assets immutable, index.html no-cache. - Vite builds to server/static/app (base /); the artifact is gitignored and rebuilt at deploy time (npm ci && npm run build). - Removed v1 portal files (login/dashboard/users/index/viewer .html) and their dead serve_* handlers; the SPA owns /, /login, /dashboard, /users. Verified locally: server boots, / and deep links serve the SPA, unknown /api path returns JSON 404 (not HTML), /health and /downloads intact. cargo build + clippy -D warnings green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,21 +89,50 @@ same-origin requests reach the backend with no CORS setup.
|
||||
To develop the UI against a *remote* backend instead, set `VITE_API_URL`
|
||||
(see `.env.example`).
|
||||
|
||||
## Production serving — follow-up (NOT wired in this pass)
|
||||
## Production serving — WIRED
|
||||
|
||||
The build uses `base: "./"` so emitted assets use relative paths. Production
|
||||
serving means copying `dist/` into the GC server's static directory and adding a
|
||||
catch-all route that returns `index.html` for non-API, non-asset paths (so deep
|
||||
links like `/machines` survive a hard reload under the `BrowserRouter`).
|
||||
The SPA is served by the GC Axum server from the server root. No manual copy
|
||||
step: `vite.config.ts` sets `build.outDir` to `../server/static/app/`, so the
|
||||
build lands exactly where the server serves it.
|
||||
|
||||
That Rust-side wiring is a **deploy concern** and is intentionally left for a
|
||||
later step:
|
||||
### Build & deploy flow
|
||||
|
||||
1. Copy `dist/` → `server/static/` (or serve `dist/` directly).
|
||||
2. Add an Axum fallback route serving `index.html` for unmatched GET paths,
|
||||
*after* the `/api/*`, `/ws/*`, and static-asset routes.
|
||||
3. If the dashboard is mounted under a sub-path rather than the server root,
|
||||
switch Vite `base` to that path and pass the same `basename` to
|
||||
`<BrowserRouter>`.
|
||||
```bash
|
||||
# from dashboard/
|
||||
npm run build # tsc -b && vite build -> ../server/static/app/
|
||||
```
|
||||
|
||||
No server/Rust changes were made in this pass.
|
||||
That single command refreshes the served SPA. `emptyOutDir` clears only
|
||||
`server/static/app/` (the dedicated SPA subdir), so the v1 portal files in the
|
||||
static root are never touched.
|
||||
|
||||
### How the server serves it (`server/src/main.rs`)
|
||||
|
||||
- `base` is **`/`** (absolute asset paths). The SPA uses `BrowserRouter`, so a
|
||||
hard reload of a deep link (`/machines`) must still load `/assets/*`; relative
|
||||
(`./`) paths would resolve against the deep-link path and 404. Absolute is
|
||||
required.
|
||||
- The Router's `fallback_service` is `ServeDir::new("static/app")` with
|
||||
`.fallback(ServeFile::new("static/app/index.html"))`. Real files under
|
||||
`/assets/*` are served from disk; any other unmatched path returns
|
||||
`index.html` (HTTP **200**) so React Router resolves the route.
|
||||
- **Precedence / safety:** the fallback runs only after every explicit
|
||||
`/api/*`, `/ws/*`, `/health`, `/metrics` route and the `/downloads` nest. Two
|
||||
catch-all routes — `/api/*rest` and `/ws/*rest` — return a JSON **404** for
|
||||
unrouted API/WS paths, so the SPA fallback never answers an API/WS path with
|
||||
HTML (which would break this client's error-envelope parsing).
|
||||
- **Caching:** `/assets/*` (content-hashed) → `immutable`, one year;
|
||||
`index.html` and everything else → `no-cache, must-revalidate`.
|
||||
|
||||
### Build output in git
|
||||
|
||||
`server/static/app/` is a build artifact. Whether to commit it or `.gitignore`
|
||||
it depends on the deploy model (server-side `npm run build` vs shipping the
|
||||
repo's static dir). Decide at commit time. The old `dashboard/dist/` path is no
|
||||
longer used.
|
||||
|
||||
### Sub-path mounting (not used)
|
||||
|
||||
The dashboard is mounted at the server root. If it is ever moved under a
|
||||
sub-path, switch Vite `base` to that path and pass the same `basename` to
|
||||
`<BrowserRouter>`.
|
||||
|
||||
@@ -5,14 +5,23 @@ import react from "@vitejs/plugin-react";
|
||||
// forwarded to the Rust server on :3002 so `npm run dev` works against a real
|
||||
// backend without CORS gymnastics.
|
||||
//
|
||||
// `base` is "./" so the built assets reference relative paths — production
|
||||
// serving copies `dist/` into the server's static dir and a catch-all route
|
||||
// serves index.html. Wiring that catch-all in the Rust server is a DEPLOY
|
||||
// concern (see README), not done in this pass.
|
||||
// PRODUCTION SERVING (wired in the GC server):
|
||||
// - `base` is "/" (absolute asset paths). The SPA is served from the server
|
||||
// root and uses `BrowserRouter`, so a hard reload of a deep link such as
|
||||
// `/machines` must still resolve `/assets/*` correctly. Relative ("./")
|
||||
// asset paths would break here: the browser would resolve them against the
|
||||
// deep-link path (`/machines/assets/...`) and 404. Absolute "/" is required.
|
||||
// - `build.outDir` points straight into the server's static tree at
|
||||
// `server/static/app/`, so `npm run build` lands the SPA exactly where the
|
||||
// Axum `fallback_service` serves it — no manual copy step at deploy time.
|
||||
// - `emptyOutDir` is true, which is SAFE because the target is the dedicated
|
||||
// `app/` SUBDIR. It wipes only `server/static/app/`, never the static root,
|
||||
// so the v1 portal files (login.html, dashboard.html, viewer.html,
|
||||
// downloads/) are untouched.
|
||||
const GC_SERVER = "http://localhost:3002";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
base: "/",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5273,
|
||||
@@ -29,7 +38,11 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
// Emit directly into the server's static tree. The Axum server serves this
|
||||
// directory as its SPA fallback (see server/src/main.rs). Dedicated subdir,
|
||||
// so emptyOutDir only clears the SPA build — not the v1 portal files.
|
||||
outDir: "../server/static/app",
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user