feat(server): serve dashboard SPA with deep-link fallback; remove v1 portal
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m26s
Build and Test / Build Agent (Windows) (push) Successful in 7m17s
Build and Test / Security Audit (push) Successful in 4m29s
Build and Test / Build Summary (push) Has been skipped

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:
2026-05-30 13:44:13 -07:00
parent 6ecb937eb6
commit 67f3722b3c
10 changed files with 159 additions and 3436 deletions

View File

@@ -24,20 +24,36 @@ use axum::{
extract::{ConnectInfo, Json, Path, Query, Request, State},
http::StatusCode,
middleware::{self as axum_middleware, Next},
response::{Html, IntoResponse},
routing::{delete, get, post, put},
response::IntoResponse,
routing::{any, delete, get, post, put},
Router,
};
use serde::Deserialize;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use tower_http::services::ServeDir;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing::{info, Level};
use tracing_subscriber::FmtSubscriber;
use auth::{generate_random_password, hash_password, AuthenticatedUser, JwtConfig, TokenBlacklist};
/// Root of the static asset tree, relative to the server's working directory.
/// Holds the agent `downloads/` tree AND the v2 SPA build under `app/`.
const STATIC_DIR: &str = "static";
/// Directory the React/Vite SPA is built into (`dashboard/` Vite `build.outDir`
/// points here). The Axum `fallback_service` serves this tree at the server
/// root, so `npm run build` lands the SPA exactly where it is served — no copy
/// step. A dedicated subdir so the Vite build's `emptyOutDir` clears only the
/// SPA, never the agent downloads tree in the static root.
const SPA_DIR: &str = "static/app";
/// The SPA entry document. Returned (with 200) for any unmatched, non-API,
/// non-WS, non-asset GET so `BrowserRouter` deep links (`/machines`,
/// `/sessions`, `/login`) survive a hard reload.
const SPA_INDEX: &str = "static/app/index.html";
use metrics::SharedMetrics;
use prometheus_client::registry::Registry;
use support_codes::{CodeValidation, CreateCodeRequest, SupportCode, SupportCodeManager};
@@ -413,15 +429,43 @@ async fn main() -> Result<()> {
get(api::downloads::download_support),
)
.route("/api/download/agent", get(api::downloads::download_agent))
// HTML page routes (clean URLs)
.route("/login", get(serve_login))
.route("/dashboard", get(serve_dashboard))
.route("/users", get(serve_users))
// Namespace 404 guards. These wildcard routes catch any /api/* or /ws/*
// path that no explicit route above matched, returning a JSON 404 so the
// SPA fallback_service never answers an API/WS path with index.html. They
// are intentionally the LEAST specific routes in each namespace: matchit
// (axum 0.7) prefers a static segment over a `*` capture, so every real
// route above still wins. `any(...)` covers every method (a bad WS path
// is a GET, but POST/PUT/etc. to a dead /api/* path must 404 too, not 405).
.route("/api/*rest", any(api_not_found))
.route("/ws/*rest", any(api_not_found))
// Public agent download tree (e.g. /downloads/guruconnect.exe). Mounted
// explicitly so it keeps working after the v2 SPA takes over the root
// fallback below — CLAUDE.md documents this as the public download URL.
// `nest_service` is matched BEFORE `fallback_service`, so these binaries
// are served from disk and never fall through to the SPA index.html.
.nest_service("/downloads", ServeDir::new(format!("{STATIC_DIR}/downloads")))
// NOTE: there are intentionally no /login, /dashboard, /users routes.
// The v2 SPA (BrowserRouter) owns those paths and resolves them via the
// fallback_service below; registering server-side handlers for them would
// shadow the SPA on a hard reload.
// State and middleware
.with_state(state.clone())
.layer(axum_middleware::from_fn_with_state(state, auth_layer))
// Serve static files for portal (fallback)
.fallback_service(ServeDir::new("static").append_index_html_on_directories(true))
// SPA fallback: serve the React/Vite build from SPA_DIR and, for any
// unmatched path, return the SPA index.html WITH 200 (via `.fallback`,
// not `.not_found_service` which would force a 404) so BrowserRouter
// deep links resolve. This is the Router's `fallback_service`, so it runs
// ONLY after every explicit /api/*, /ws/*, /health, /metrics route and
// the /downloads nest fail to match. An unknown /api/... path therefore
// never reaches here — it hits the per-router 404 and returns the normal
// (non-HTML) 404 the typed client expects. Real assets under /assets/*
// are served from disk by ServeDir with correct content-types; only
// genuinely missing files fall through to index.html.
.fallback_service(
ServeDir::new(SPA_DIR)
.append_index_html_on_directories(true)
.fallback(ServeFile::new(SPA_INDEX)),
)
// Middleware
.layer(axum_middleware::from_fn(middleware::add_security_headers)) // SEC-7 & SEC-12
.layer(TraceLayer::new_for_http())
@@ -474,6 +518,23 @@ async fn health() -> &'static str {
"OK"
}
/// Explicit 404 for unmatched paths under the `/api` and `/ws` namespaces.
///
/// CRITICAL: without these catch-all routes, an unknown `/api/...` or `/ws/...`
/// path would fall through to the SPA `fallback_service` and be answered with
/// `index.html` (HTTP 200, text/html). That would mask real 404s and break the
/// dashboard's typed client, which parses a JSON error envelope from API 404s.
/// These routes are LESS specific than every real `/api/...` / `/ws/...` route
/// (matchit matches a static segment before a `*` capture), so they only catch
/// genuinely-unrouted API/WS paths and return a proper JSON 404 — never HTML.
async fn api_not_found() -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
[(axum::http::header::CONTENT_TYPE, "application/json")],
r#"{"error":"Not Found","status_code":404}"#,
)
}
/// Prometheus metrics endpoint
async fn prometheus_metrics(State(state): State<AppState>) -> String {
use prometheus_client::encoding::text::encode;
@@ -849,24 +910,3 @@ async fn trigger_machine_update(
}
}
// Static page handlers
async fn serve_login() -> impl IntoResponse {
match tokio::fs::read_to_string("static/login.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}
async fn serve_dashboard() -> impl IntoResponse {
match tokio::fs::read_to_string("static/dashboard.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}
async fn serve_users() -> impl IntoResponse {
match tokio::fs::read_to_string("static/users.html").await {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Page not found").into_response(),
}
}