web console (Next.js 15 static export, 16 routes, CSP-safe externalized scripts)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 changed files +12 −4
modified
apps/web/README.md
+8 −0
@@ -21,6 +21,14 @@ pnpm build # → apps/web/out/ (index.html + one directory per route, | |||||
| 21 | 21 | ||||
| 22 | `services/api/src/findata_api/main.py` mounts `apps/web/out` (or `FINDATA_WEB_DIST`) with `StaticFiles(html=True)`, so `/datasets/` serves `out/datasets/index.html`, and the console is same-origin with `/api/v1`. The CSP is `default-src 'self'` (inline styles allowed, inline scripts not); the bundle contains no inline scripts, external fonts or CDNs. | 22 | `services/api/src/findata_api/main.py` mounts `apps/web/out` (or `FINDATA_WEB_DIST`) with `StaticFiles(html=True)`, so `/datasets/` serves `out/datasets/index.html`, and the console is same-origin with `/api/v1`. The CSP is `default-src 'self'` (inline styles allowed, inline scripts not); the bundle contains no inline scripts, external fonts or CDNs. | ||
| 23 | 23 | ||||
| 24 | +### CSP and the post-build step | ||||
| 25 | + | ||||
| 26 | +Next's App Router emits its React Server Components payload as inline `<script>self.__next_f.push(…)</script>` tags, which `script-src 'self'` blocks. `pnpm build` therefore runs `scripts/externalize-inline-scripts.mjs` after `next build`: it moves those pushes, per page, into `out/_next/static/flight/<hash>.js` and replaces them with one blocking `<script src>`. The exported HTML contains **no inline scripts** (the script fails the build if it ever meets one that is not a `__next_f` push). | ||||
| 27 | + | ||||
| 28 | +### Smoke test without the backend | ||||
| 29 | + | ||||
| 30 | +`node scripts/mock-server.mjs [port] [--signed-out] [--mfa]` serves `out/` with the API's exact CSP header plus fixture answers for every `/api/v1` endpoint the console uses — handy for layout checks (375 px / desktop, light / dark) and for verifying hydration under CSP. Not used in production. | ||||
| 31 | + | ||||
| 24 | Detail pages use query strings instead of dynamic segments (static export cannot pre-render unknown ids): `/datasets/detail/?id=…`, `/symbols/detail/?symbol=…`. | 32 | Detail pages use query strings instead of dynamic segments (static export cannot pre-render unknown ids): `/datasets/detail/?id=…`, `/symbols/detail/?symbol=…`. | ||
| 25 | 33 | ||||
| 26 | ## Layout | 34 | ## Layout | ||
modified
apps/web/src/app/globals.css
+2 −2
@@ -108,7 +108,7 @@ code, pre, kbd, .mono { font-family: var(--font-mono); } | |||||
| 108 | 108 | ||||
| 109 | /* ------------------------------------------------------------------ components */ | 109 | /* ------------------------------------------------------------------ components */ | ||
| 110 | @layer components { | 110 | @layer components { | ||
| 111 | − .card { @apply rounded-lg border border-border bg-surface-1 p-4; } | 111 | + .card { @apply min-w-0 max-w-full rounded-lg border border-border bg-surface-1 p-4; } | ||
| 112 | .card-title { @apply mb-3 flex items-center justify-between gap-2 text-sm font-semibold text-ink-1; } | 112 | .card-title { @apply mb-3 flex items-center justify-between gap-2 text-sm font-semibold text-ink-1; } | ||
| 113 | .muted { @apply text-ink-3; } | 113 | .muted { @apply text-ink-3; } | ||
| 114 | .label { @apply mb-1 block text-xs font-medium text-ink-2; } | 114 | .label { @apply mb-1 block text-xs font-medium text-ink-2; } | ||
@@ -126,7 +126,7 @@ code, pre, kbd, .mono { font-family: var(--font-mono); } | |||||
| 126 | .btn-sm { @apply px-2 py-1 text-xs; } | 126 | .btn-sm { @apply px-2 py-1 text-xs; } | ||
| 127 | .btn-ghost { @apply border-transparent bg-transparent; } | 127 | .btn-ghost { @apply border-transparent bg-transparent; } | ||
| 128 | .pill { @apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap; } | 128 | .pill { @apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap; } | ||
| 129 | − .table-wrap { @apply -mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0; } | 129 | + .table-wrap { @apply -mx-4 max-w-[calc(100%+2rem)] overflow-x-auto px-4 sm:mx-0 sm:max-w-full sm:px-0; } | ||
| 130 | .table { @apply w-full border-collapse text-sm; } | 130 | .table { @apply w-full border-collapse text-sm; } | ||
| 131 | .table th { @apply sticky top-0 z-[1] border-b border-hairline bg-surface-2 px-2.5 py-2 text-left text-xs font-semibold text-ink-2 whitespace-nowrap; } | 131 | .table th { @apply sticky top-0 z-[1] border-b border-hairline bg-surface-2 px-2.5 py-2 text-left text-xs font-semibold text-ink-2 whitespace-nowrap; } | ||
| 132 | .table td { @apply border-b border-hairline px-2.5 py-1.5 align-top text-ink-1; } | 132 | .table td { @apply border-b border-hairline px-2.5 py-1.5 align-top text-ink-1; } | ||
modified
apps/web/src/app/keys/page.tsx
+1 −1
@@ -85,7 +85,7 @@ export default function KeysPage() { | |||||
| 85 | key: "usage", | 85 | key: "usage", | ||
| 86 | header: "Usage (14 d)", | 86 | header: "Usage (14 d)", | ||
| 87 | render: (k) => { | 87 | render: (k) => { | ||
| 88 | − const u = usageByPrincipal.get(`key:${k.prefix}`) ?? usageByPrincipal.get(k.prefix) ?? usageByPrincipal.get(k.name); | 88 | + const u = usageByPrincipal.get(`api_key:${k.id}`) ?? usageByPrincipal.get(`key:${k.prefix}`); | ||
| 89 | return u ? <span className="tnum text-xs">{fmtInt(u.requests)} req · {fmtCompact(u.rows)} rows · {fmtBytes(u.bytes)}</span> : <span className="text-ink-3">—</span>; | 89 | return u ? <span className="tnum text-xs">{fmtInt(u.requests)} req · {fmtCompact(u.rows)} rows · {fmtBytes(u.bytes)}</span> : <span className="text-ink-3">—</span>; | ||
| 90 | }, | 90 | }, | ||
| 91 | }, | 91 | }, | ||
modified
apps/web/src/components/charts.tsx
+1 −1
@@ -240,7 +240,7 @@ export function Meter({ value, max, marks = [], tone = "accent", label, classNam | |||||
| 240 | {marks.map((m) => { | 240 | {marks.map((m) => { | ||
| 241 | const x = max > 0 ? Math.max(0, Math.min(1, m.at / max)) : 0; | 241 | const x = max > 0 ? Math.max(0, Math.min(1, m.at / max)) : 0; | ||
| 242 | return ( | 242 | return ( | ||
| 243 | − <div key={m.label} className="absolute top-[-4px] flex flex-col items-center" style={{ left: `${x * 100}%`, transform: "translateX(-50%)" }}> | 243 | + <div key={m.label} className="absolute top-[-4px] flex flex-col" style={{ left: `${x * 100}%`, transform: x > 0.8 ? "translateX(-100%)" : x < 0.2 ? "none" : "translateX(-50%)", alignItems: x > 0.8 ? "flex-end" : x < 0.2 ? "flex-start" : "center" }}> | ||
| 244 | <div className="h-5 w-px bg-ink-2" /> | 244 | <div className="h-5 w-px bg-ink-2" /> | ||
| 245 | <div className="mt-0.5 whitespace-nowrap text-[10px] text-ink-3">{m.label}</div> | 245 | <div className="mt-0.5 whitespace-nowrap text-[10px] text-ink-3">{m.label}</div> | ||
| 246 | </div> | 246 | </div> | ||
| 247 | 247 | ||||