Repository navigation
feat: build the four dashboard screens on mock data - #9
Conversation
devinder-dev
left a comment
There was a problem hiding this comment.
Really nice work. The screens are clean, the files are small and easy to follow, and the comments explain why, which will help both of us at the defense. The types match docs/api.md exactly.
Checked locally: tsc -b, oxlint and npm run build pass, npm audit finds 0 vulnerabilities. The image builds (82 MB), runs as uid 101, serves /deliveries/abc as index.html, returns 404 for a missing asset, and sets no-cache on index.html and the one-year cache on /assets/.
A few things I'd like fixed before I approve. Details are in the inline comments.
Fixes
- nginx security headers (
nginx.conf): no CSP,X-Content-Type-Options,frame-ancestorsorReferrer-Policyyet. The API key lives in sessionStorage, so an XSS would mean a stolen key, and a CSP is our main second line of defence. Alsoserver_tokens off;andabsolute_redirect off;. - Exact versions:
package.jsonuses^,.nvmrcis22, and the images arenode:22-alpineandnginx-unprivileged:1.29-alpine. Everything else in the project is pinned exactly (Bun 1.4.4, postgres:18.6, k3s v1.36.5). - The fonts never load (
index.css:7): the Google Fonts@importis dropped from the production CSS. - Replay should reload (
DeliveryDetail.tsx:61): ADR 0002 says "Replay triggers one reload". With the mocks, the screen showspending / 0after Replay, and the 2 s poll flips it back todead / 8. - Key prefix + Disconnect (
App.tsx): both are in the wireframe, and pages should send you to Connect when there's no key. - Remove
axiosuntilsrc/api.tsuses it (just in time).
Smaller things (fine here or in a follow-up)
- A delivered detail mock: two of the four log rows open "Delivery not found".
- Endpoints: the created endpoint,
signing_secretincluded, ends up inrows, and every add reuses the same id (duplicate React keys). - From the wireframe: event types field, Copy button for the secret, "Not responding" label.
clock()is defined twice..env*is missing from.dockerignore.- Optional:
@theme inline { --color-ok: var(--ok); }would give classes likebg-okthat still swap in dark mode, instead ofbg-[var(--ok)]everywhere.public/favicon.svgis still the Vite logo. - The PR body says swapping the mocks for
src/api.tsis phase 5.2. In the plan it's 5.3 (5.2 is the image, cdn Ingress and certificate).
Decisions to write down
npm + Node 22 for the dashboard, and oxlint, aren't in an ADR yet. I think both are fine: Playwright runs on Node in this folder anyway, and oxlint is the create-vite default. Let's record them (a short ADR 0004, or a few rows in 0002) so we can both explain them. My laptop runs Node 24, so let's agree on one version.
Merge order
This is stacked on #7, and #10 changes the contract: EndpointStatus loses disabled, last_error becomes DeliveryError (adds endpoint_deleted), and there are new 409s (endpoint_limit_reached, endpoint_deleted). Suggest:
- Merge #10 first.
- Update #7 to the new types and mocks, then merge it.
- Rebase this PR onto main (
git rebase --onto origin/main feat/dashboard-mock-data). TypeScript will then flagdisabledinStatusPillfor you.
Please don't merge this one before #7: its base is #7's branch, which isn't protected, so it would skip review on its way to main.
Questions
- Keep the Dockerfile and
nginx.confhere, or move them into the 5.2 PR with the k8s manifest? Either works for me. Which CSP did you have in mind? - Why npm + Node 22 rather than Bun or Node 24, and why oxlint?
- Once the detail screen calls the real API, what happens if a GET takes longer than 2 s with
setInterval? - What should the dashboard do on a 401 in the middle of a session?
Happy to pair on any of this.
| # Serves the built dashboard. Port 8080 because the unprivileged nginx image runs | ||
| # as uid 101 and cannot bind a port below 1024. | ||
|
|
||
| server { |
There was a problem hiding this comment.
No security headers yet. The API key is in sessionStorage, so any XSS could read it, which makes a CSP worth having. Something like:
server_tokens off; # hides "Server: nginx/1.29.8"
absolute_redirect off; # /assets currently redirects to http://localhost:8080/assets/
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.myrelay.tech; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;Gotcha: a location only inherits add_header from the server block if it has no add_header of its own. /assets/ and = /index.html both have one, so the headers have to be repeated there. An include snippet keeps that to one line each.
| "preview": "vite preview" | ||
| }, | ||
| "dependencies": { | ||
| "@tailwindcss/vite": "^4.3.3", |
There was a problem hiding this comment.
Could these be exact versions? Everything else in the project is pinned exactly (Bun 1.4.4, postgres:18.6, k3s v1.36.5). npm config set save-exact true and a reinstall does it. Same for .nvmrc and the two FROM lines (a full tag like nginx-unprivileged:1.29.8-alpine, or a digest).
The lockfile has already moved past what's written here: oxlint resolves to 1.89.0 and vite to 8.3.5. A few are also under two weeks old (react-router 8.5.0, vite 8.3.5, oxlint 1.89.0). That's not a problem in itself, but with exact pins we decide when to take new releases.
| }, | ||
| "dependencies": { | ||
| "@tailwindcss/vite": "^4.3.3", | ||
| "axios": "^1.20.0", |
There was a problem hiding this comment.
axios isn't imported anywhere yet. Just in time: could it come in with src/api.ts in 5.3?
| so the built app and the sketch stay the same thing. Plain CSS variables rather | ||
| than Tailwind theme tokens, because they have to swap on the dark-mode query. */ | ||
|
|
||
| @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap'); |
There was a problem hiding this comment.
The build warns @import rules must precede all rules, and this import ends up dropped from the production CSS, so IBM Plex never loads and the app falls back to system fonts. Moving it above @import 'tailwindcss' fixes that. Self-hosting the two fonts (e.g. in public/fonts/) would be even better: no request to Google and a simpler CSP.
| // Replay answers with a Delivery, not a DeliveryDetail, so take only the four | ||
| // fields it changes. Spreading the whole answer would replace `event` with the | ||
| // short version and lose the payload. | ||
| function replay() { |
There was a problem hiding this comment.
ADR 0002 and docs/api.md say replay triggers one reload of the detail screen. Right now Replay copies four fields over, and with the mocks the 2 s poll flips it straight back: pending / 0, then dead / 8 two seconds later. Calling load(id) after replay (with the real API: POST, then GET) would match the plan and remove both the merge and the delivery!.
| if (!name.trim()) return setError('Give the endpoint a name.') | ||
| if (!url.startsWith('https://')) return setError('The URL must start with https://') | ||
|
|
||
| const created = { ...endpointCreated, name, url } |
There was a problem hiding this comment.
Two small things here:
createdstill hassigning_secret, so the secret stays inrowsafter "I have copied it", even though the comment above says it disappears.- Every add reuses
endpointCreated.id, so adding two endpoints gives duplicate React keys.
With the real API, strip the secret before adding the endpoint to the list, or simply reload the list.
| onSubmit={add} | ||
| className="flex max-w-md flex-col gap-3 rounded-md border border-[var(--line-soft)] bg-[var(--card)] p-4" | ||
| > | ||
| <h2 className="text-sm font-medium">Add endpoint</h2> |
There was a problem hiding this comment.
From the wireframe: an event types field (comma separated, default *), a Copy button for the secret, and "Not responding" instead of the raw failing. Fine as a follow-up.
| pending: 'bg-[var(--warn-bg)] text-[var(--warn)]', | ||
| failing: 'bg-[var(--warn-bg)] text-[var(--warn)]', | ||
| dead: 'bg-[var(--bad-bg)] text-[var(--bad)]', | ||
| disabled: 'bg-[var(--paper)] text-[var(--ink-faint)]', |
| .git | ||
| Dockerfile | ||
| .dockerignore | ||
| *.local |
There was a problem hiding this comment.
Could you add .env* too? Docker doesn't read .gitignore, so a local .env would be copied into the build stage.
|
|
||
| // Times are shown as clock time, not the full ISO string: the log is scanned for | ||
| // "which row is mine", and the date is almost always today. | ||
| function clock(iso: string) { |
There was a problem hiding this comment.
clock() is also in DeliveryDetail.tsx (with seconds). It could live once in src/format.ts.
0665956 to
2657274
Compare
72d8b02 to
62563c3
Compare
|
Thanks for the review — it was detailed and it caught real things. Rebased and Merge order first: I did what you suggested. #7 is rebased onto Your six fixes
Your smaller things
Two things I found while testing
Your questions
Your merge-order warning: understood, I won't merge this before #7. |
|
Correction on question 2 (Node version). My answer said Node 22, and that it was the current LTS. Both were wrong. The code is pinned to 24.22.1 ( The real reasons for 24.22.1: it matches your laptop, which is what you asked for, and the line is supported until April 2028. On the LTS claim specifically, from
So 22 had already left active LTS a year before I called it current, and 24 moved to maintenance eleven days ago. Maintenance still means security and bug fixes, just no new features, so 24.22.1 is safe for the defense and well past it. I'd rather not jump to 26 yet: it became LTS three days ago, and our own rule is to prefer releases at least two weeks old. Worth revisiting in a few weeks, and it's a one-line change in two files if you disagree. One thing I should flag. My shell was falling back to the system Node 18.19.1, because nvm wasn't being loaded and had no default set. Vite 8 requires Which points at a real gap: the dashboard isn't in CI. Whether it builds currently depends on whose machine ran it, and we've just seen how easily that drifts. Once this merges I'd like 5.2 to add a workflow running |
Vite + React + TypeScript + Tailwind v4 + React Router, as agreed in ADR 0002. All four screens from the wireframe run on the mock data in src/mocks.ts: connect with a key, browse and filter deliveries, open one and replay it, and manage endpoints. The detail screen reloads itself every 2 s while a delivery is pending, and only then. That is what makes retries visible during the demo. Palette and fonts come from docs/design/relay-wireframe.html as CSS variables, with the same light and dark values, so the built app and the sketch stay the same thing. The Dockerfile builds the app and serves it with unprivileged nginx on port 8080, with unknown paths handed to index.html so reloading a detail screen works. The k8s manifest comes separately, once there is an image in GHCR to point it at.
Security headers (nginx.conf). The owner's API key lives in sessionStorage, where any script on the page can read it, so a CSP is the second line of defence behind React's escaping: script-src means a script that got onto the page is never run, and connect-src means it could not send the key anywhere but our own API. Also nosniff, no-referrer and server_tokens off. nginx's add_header does not merge - a location with its own add_header drops everything inherited from the server block - so the per-path Cache-Control moved into a map and all headers now live in one place. Exact versions. Node 24.22.1 (the version Devinder already runs), no caret ranges in package.json, and exact image tags. The image dropped from 82 MB to 53 MB on the newer alpine base. Fonts. The Google Fonts @import never reached the production stylesheet: Tailwind's own @import expands in place, which pushes anything after it past the start of the file, where CSS ignores it. The fonts load from index.html instead, with preconnect. Replay. The 2 s reload handed back the dead fixture two seconds after a replay, because the fixtures are frozen and do not remember it. A module-level row now stands in for the server's memory; it goes away in phase 5.3. axios removed until src/api.ts actually uses it.
The header now carries the key prefix and a Disconnect button, and every screen except Connect redirects there when sessionStorage holds no key. On mock data the log rendered fine without one; against the real API it would have been an empty page full of 401s with nothing telling you why. The prefix comes from GET /v1/me rather than from what was typed, because the API is what knows which key this is. Only the prefix is ever shown. Three fixes to the previous commit, found by going back over it: Asset 404s were cached for a year. The Cache-Control header carried "always", which puts it on error responses too. During a rollout both the old and the new pod serve, so a client can read the new index.html from one and ask the other for a hashed asset it does not have - and then cache that 404 until next year. The three security headers keep "always"; this one no longer does. Replay only remembered one delivery. The stand-in for the server's memory was a single variable, so replaying a second delivery made the screen forget the first. It is keyed by id now. absolute_redirect off was asked for and missed. Without it nginx builds redirects from its own name and port 8080, which is neither the scheme nor the port the client reached us on through Traefik. Also: gzip for text responses, 'self' added to font-src so a self-hosted font would not fail silently later, a catch-all route so an unknown URL goes to Connect instead of rendering a header above nothing, and a correction to the CSP comment - connect-src does not stop a top-level navigation, so script-src is what actually stops the attack and the rest is defence in depth.
043eeb8 to
75d595c
Compare
|
Rebased onto Used One conflict, in
Both sides' first attempt is a timeout with the same four fields, so git interleaved them and the conflict looked worse than it was. Kept all three. Checked after the rebase: Something the checks on this PR show. Three ran: Once this merges I'd like to add a Also found while rebasing, not fixed here: |
devinder-dev
left a comment
There was a problem hiding this comment.
Thanks for the careful rebase, and for keeping all three detail fixtures in the mocks.ts
conflict. I had the PR reviewed against the code and docs/api.md, and checked the important
findings myself. The security headers, the exact versions, the key gate and the endpoint ids are
all in. One real bug came in with the rebase, though, so: request changes, with the fixes below.
Blocker: the deleted-endpoint delivery has no detail screen, and the e2e counts are stale
- The log now shows 5 rows (
mocks.ts,deliveries.data), includingendpointDeletedDelivery
("Old CRM").DeliveryDetail.tsxregisters only 4 detail fixtures, though, so opening that row
says "Delivery not found", whileendpointDeletedDetailis exported atmocks.ts:346. e2e/deliveries.spec.ts:8still expects{ all: 4, pending: 1, delivered: 2, dead: 1 }; it's
now 5 rows with 2 dead.- Fix: register
endpointDeletedDetail, update the counts, and test both this row and a truly
unknown id. Don't just change the numbers. - Once that row opens: Replay shows for any
status !== 'delivered', but the contract refuses a
deleted endpoint's delivery with 409endpoint_deleted(docs/api.md, replay). Hide or disable
Replay there, or show the 409 with its explanation.
Should fix in this PR
- One mock store for list and detail. Replay only updates the detail screen's own
Map, so
after a replay the log still says dead/8 while the detail says pending/0. New endpoints have
the same problem when you navigate away. A small shared mock helper is enough. Test
replay → log → detail, and add endpoint → leave → come back. - The documented length limits. The form accepts a name of any length and a URL over 2048
characters. The contract says name 1–100, URL ≤ 2048. Enforce both and test the boundaries;
the server stays the authority. - The URL check.
!host.includes('.')(Endpoints.tsx:42) refuses public IPv6 addresses
([2001:db8::1]has no dot). Use the URL parser to tell IP literals from host names. Also,
call it a helpful form check, not SSRF protection: the real SSRF check is server-side (step
3.2), and it also blocks our own VPS address, which the form doesn't.
Not blocking this PR, but before the step that needs it
- The dashboard isn't in CI yet (the green checks are the API's). Before I approve the next
head, please pastenpm ci,npm run lint,npm run buildandnpm run test:e2eresults.
The CI job comes with 5.5, or with #24, and then becomes a required check. - Playwright runs on the dev server, port 5173. The contract's CORS allows the preview server
on 4173 (docs/api.md). Fine for fixtures; switch tonpm run build+ preview on 4173 when the
real API client arrives (5.3). - Assets during a rollout (#24). With two pods on different versions, new HTML can ask an old
pod for a new asset and get a 404. Not caching the 404 helps, but doesn't prevent it. Let's
agree how to handle it before we claim zero downtime for the dashboard.
Nice to have: reset the detail screen's state when the route's id changes (opening another
delivery on the same screen), and a test for Disconnect, with the "how long the key is kept"
wording tightened.
|
All four are in, at d245a42. Three commits, one per concern. Blocker: the deleted-endpoint row
You said don't just change the numbers, and you were right to: the Two tests added, as asked: the row that was broken, and a genuinely unknown id — the second is the positive control for the first, or 1. One mock storeNew Cost of that choice: the order has to be computed instead of written down. It sorts on It is deliberately not a state library — mutable data behind plain functions, read when a screen mounts. React Router mounts a screen again on every navigation, which is all four screens need. No context, no subscriptions. It goes away in 5.3. Both cases you named are tested: replay → log → detail, and add endpoint → leave → come back. The second only uses in-app navigation, because 2. The documented length limits
3. The URL check
While fixing that I added the IPv6 private ranges, because recognising IPv6 and then letting And the comment is rewritten the way you asked — a helpful form check, not SSRF protection. It names all three reasons it is not: it runs in the browser and can be skipped by calling the API directly, it only sees the host name so a public name resolving to 127.0.0.1 passes, and it does not block our own VPS address. The real check is server-side in 3.2, after resolution and again before every attempt. The checks you asked for35 tests, up from 24 when you reviewed. Every new test was also run against the broken version, since three checks in this project have now passed for the wrong reason:
Not in this PR, on purpose
One thing I noticed and did not touch: |
devinder-dev
left a comment
There was a problem hiding this comment.
Approving d245a42. Everything from my review is fixed, and fixed well. Deriving the log from
the detail fixtures, so a row without a detail screen can't exist at all, is a better answer
than the one I suggested.
What I checked myself, on a clean copy of d245a42
npm ci,npm run lint(exit 0),npm run build: all fine.npm run test:e2e: 35 passed.- Your "reverted → fails" table, spot-checked: with
endpointDeletedDetailtaken out of
mockStore.ts, 3 delivery tests fail (the row count, the filters, the deleted-endpoint row).
So the tests guard it. - The URL check, against the parser's real output:
[2001:db8::1]is accepted;[fc00::1],
[fe80::1],[::1],2130706433and0x7f.1are refused. The parser turns the last two
into127.0.0.1before the check sees them, nice.
Small things, none blocking
- Two private addresses still get through the form: the IPv4-mapped form
[::ffff:127.0.0.1]
(the parser writes it as[::ffff:7f00:1]) and[::]. That's fine for a form check, which
the comment now says honestly. The server-side check in 3.2 must refuse both, and I've noted
that for my step. - The nice-to-haves (reset on an id change, the Disconnect test) and the
React.FormEvent
deprecation are fine as follow-ups.
Assets during a rollout (#24): one correction before we decide
maxSurge: 0 doesn't give "one version at a time" with 2 replicas. It replaces one pod while
the other old one keeps serving, so for a moment an old pod and a new pod still serve together.
The options that do work:
- the new image also carries the previous build's
assets/; - Traefik sticky sessions (a cookie keeps a browser on one pod, so its HTML and its assets
come from the same version); - saying honestly that a dashboard rollout can break one page load during the overlap.
Let's settle it in #24 before Wednesday. Until then, we don't claim zero downtime for the
dashboard.
The "nice to have" from your review of #9: reset the detail screen when the route's id changes. One file, 16 lines, on top of `main` now that #9 is in. ## The bug ```tsx const [delivery, setDelivery] = useState(() => getDelivery(id)) ``` `useState`'s initializer runs once per mount. Going from `/deliveries/A` to `/deliveries/B` is the same route pattern, so React Router keeps the same component instance — the initializer never runs again and the screen keeps showing A. ## The fix A thin wrapper that reads the id and hands it down as the React key, so the id *is* the screen's identity: ```tsx export default function DeliveryDetail() { const { id } = useParams() return <Screen key={id} id={id} /> } ``` Picked `key` over a reset effect because it is one concept rather than two, and it fixes the 2 s poll for free: a different key is a different component, so the old effect is cleaned up and the new one starts from scratch. A `useEffect` that compared ids would have had to reset the state and leave the timer to the existing `[delivery?.status, id]` dependency, which works but is two things to keep in step instead of one. ## What I did not do, and why **No test for it.** It is latent: nothing links one detail screen to another (`DeliveryDetail.tsx` has only the two "back to the log" links, and there are no `navigate()` calls outside Connect and App), and typing a URL reloads the page, which remounts anyway. So there is no user path to it today. A test would have to drive `history.pushState` directly, which is testing a route the UI cannot take — the same objection as writing the key into `sessionStorage` instead of going through the Connect form. I would rather have the reason recorded than a test that proves the mechanism exists rather than that the app behaves. So, honestly: the 35 passing tests show this broke nothing, not that it works. What it rests on is React's documented `key` semantics, and the reason it is worth having anyway is that it makes "add a next-delivery link" a safe change instead of one that silently brings the bug with it. The comment in the file says that. ## Checked `npm run build`, `npm run lint`, `npm run test:e2e` (35 passed) on this branch, against `main`. ## One thing worth knowing about this branch I nearly lost this commit. `feat/dashboard-screens` was deleted when you squash-merged #9, and I pushed the stale local branch before noticing — which recreated it on the remote, carrying the pre-squash commits that are already on `main` under one squash. I have deleted it again and moved the fix onto this branch, cut from current `main`. Nothing was lost and `main` was never touched, but it is worth saying out loud rather than quietly cleaning up: if you saw a `feat/dashboard-screens` reappear for a few minutes, that was me, and it was junk. Co-authored-by: Abdikhani Abdirashid <Abdikhani_abdirashd@hotmail.com>
Records the dashboard's toolchain, which you asked for in the #9 review: npm + Node + oxlint were chosen in the code without ever being written down, so neither of us could point at a reason for them. New file: `docs/decisions/0005-dashboard-toolchain.md`. 0004 is taken (public images, #18), hence 0005. Docs only, no code. ## The three decisions | Part | Choice | Short reason | |---|---|---| | Package manager | npm | Vite's `engines` names Node, Playwright is released against Node; Bun would mean trusting its compatibility layer for the two tools that decide whether the dashboard builds and whether the e2e tests can be believed | | Node | 24.22.1, pinned | matches your laptop, fixes until Apr 2028 | | Linter | oxlint 1.89.0 | `create-vite` default, no config, fast enough to actually stay on; ESLint's extra rules need a typed-lint setup first | ## On the Node version — a correction My answer to your question 2 on #9 said **Node 22, current LTS**. Both halves were wrong, and I've corrected it there too. The real picture, from `nodejs/Release`: | Node | Status on 31 Oct 2026 | Supported until | |---|---|---| | 22 | maintenance since 21 Oct 2025 | 30 Apr 2027 | | 24 | maintenance since 20 Oct 2026 | 30 Apr 2028 | | 26 | active LTS since 28 Oct 2026 | 30 Apr 2029 | So 22 had left active LTS a year before I called it current, and 24 moved to maintenance eleven days ago. I'm staying on 24 rather than moving to 26 because 26 is three days old as an LTS and our rule is to prefer releases at least two weeks old — the week of the defense is the wrong time to be early. Maintenance still means security and bug fixes, and Apr 2028 is well past us. Happy to move to 26 after the defense if you'd rather. ## Two things the ADR writes down as consequences - **Two runtimes on purpose.** Bun for `api/` and the worker, Node for `dashboard/`. Each folder declares its own: `api/.bun-version`, `dashboard/.nvmrc`. - **`package.json` has no `engines` field.** Today npm installs happily on a Node that Vite then refuses. I hit exactly that this morning: my shell was falling back to the system Node 18, so the dashboard build could not run on my machine at all. Adding `engines` turns that into one clear error at install time instead. Worth doing when the dashboard enters CI. ## Still open, noted in the ADR The dashboard isn't in CI. Whether it builds currently depends on whose laptop ran it, which is how the Node 18 problem went unnoticed. Step 5.2 should run `tsc -b`, `oxlint` and the Playwright tests on Node 24.22.1. Co-authored-by: Abdikhani Abdirashid <Abdikhani_abdirashd@hotmail.com>
The four screens from the wireframe, running on the mock data from #7.
Vite + React + TypeScript + Tailwind v4 + React Router, per ADR 0002.
Based on #7 so the diff stays clean — GitHub retargets this to main once that merges.
Screens
Three things worth a look
The detail screen reloads every 2 s while a delivery is pending, and only then — nothing starts for one that is delivered or dead, and the timer is cleared when the screen is left. ADR 0002 asks for this, and it is what makes retries visible during the demo.
Replay takes four fields, not the whole answer. The replay endpoint returns a
Delivery, whoseeventcarries no payload. Spreading it over aDeliveryDetailsilently emptied the payload section. TypeScript caught it, which is a decent argument for copying the contract types exactly.The key goes in sessionStorage, not localStorage, so it is gone when the tab closes rather than waiting for the next person on a shared machine.
Styling
Palette and fonts lifted from
docs/design/relay-wireframe.htmlas CSS variables with the same light and dark values. Status colours live in oneStatusPillcomponent.Two small departures from the sketch: times show as clock time rather than the ISO string, since the log is scanned for "which row is mine"; and a pending row carries its next attempt time, because "pending" on its own says nothing.
Dockerfile
Two stages: Node builds the app, then unprivileged nginx serves the result. No Node and no
node_modulesin the final image — 82 MB.Two details worth knowing:
VITE_API_URLis baked in at build time, not read at startup. Changing the API address means rebuilding the image; a Kubernetes env var will not do it.try_files $uri $uri/ /index.htmlis what makes reloading/deliveries/:idwork. Assets under/assets/are excluded from the fallback, so a missing script returns a clean 404 instead of HTML that fails to parse.Checked
npm run buildpasses,tsc --noEmitclean, no console errors. Every screen and both themes verified in a browser — filters, the add form, and replay (dead → pending,attempts → 0, payload and attempt log untouched).The image was built and run locally: serves
/, serves/deliveries/abcas index.html, returns 404 for a missing asset, runs as uid 101, and setsno-cacheon index.html and a one-year immutable cache on hashed assets.Next
The k8s manifest is written and validated against the cluster with a server-side dry run, but it points at an image that is not in GHCR yet, so it comes as a separate infra PR. Swapping
src/mocks.tsfor a realsrc/api.tsis phase 5.2.