Skip to content

feat: build the four dashboard screens on mock data - #9

Merged
AbdikhaniAbdirashid merged 10 commits into
mainfrom
feat/dashboard-screens
Nov 1, 2026
Merged

AbdikhaniAbdirashid merged 10 commits into
mainfrom
feat/dashboard-screens

Conversation

@AbdikhaniAbdirashid

Copy link
Copy Markdown
Collaborator

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

Screen What works
Connect key check, error on a bad key, key kept in sessionStorage
Delivery log status filter, event type and endpoint name per row, link to detail
Delivery detail payload, full attempt history, replay
Endpoints list with status, add form with validation, signing secret shown once

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, whose event carries no payload. Spreading it over a DeliveryDetail silently 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.html as CSS variables with the same light and dark values. Status colours live in one StatusPill component.

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_modules in the final image — 82 MB.

Two details worth knowing:

  • Port 8080, not 80. The unprivileged image runs as uid 101, which cannot bind a port below 1024. The k8s Service will have to target 8080.
  • VITE_API_URL is 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.html is what makes reloading /deliveries/:id work. 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 build passes, tsc --noEmit clean, 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/abc as index.html, returns 404 for a missing asset, runs as uid 101, and sets no-cache on 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.ts for a real src/api.ts is phase 5.2.

@devinder-dev devinder-dev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. nginx security headers (nginx.conf): no CSP, X-Content-Type-Options, frame-ancestors or Referrer-Policy yet. The API key lives in sessionStorage, so an XSS would mean a stolen key, and a CSP is our main second line of defence. Also server_tokens off; and absolute_redirect off;.
  2. Exact versions: package.json uses ^, .nvmrc is 22, and the images are node:22-alpine and nginx-unprivileged:1.29-alpine. Everything else in the project is pinned exactly (Bun 1.4.4, postgres:18.6, k3s v1.36.5).
  3. The fonts never load (index.css:7): the Google Fonts @import is dropped from the production CSS.
  4. Replay should reload (DeliveryDetail.tsx:61): ADR 0002 says "Replay triggers one reload". With the mocks, the screen shows pending / 0 after Replay, and the 2 s poll flips it back to dead / 8.
  5. Key prefix + Disconnect (App.tsx): both are in the wireframe, and pages should send you to Connect when there's no key.
  6. Remove axios until src/api.ts uses 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_secret included, ends up in rows, 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 like bg-ok that still swap in dark mode, instead of bg-[var(--ok)] everywhere. public/favicon.svg is still the Vite logo.
  • The PR body says swapping the mocks for src/api.ts is 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:

  1. Merge #10 first.
  2. Update #7 to the new types and mocks, then merge it.
  3. Rebase this PR onto main (git rebase --onto origin/main feat/dashboard-mock-data). TypeScript will then flag disabled in StatusPill for 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

  1. Keep the Dockerfile and nginx.conf here, or move them into the 5.2 PR with the k8s manifest? Either works for me. Which CSP did you have in mind?
  2. Why npm + Node 22 rather than Bun or Node 24, and why oxlint?
  3. Once the detail screen calls the real API, what happens if a GET takes longer than 2 s with setInterval?
  4. What should the dashboard do on a 401 in the middle of a session?

Happy to pair on any of this.

Comment thread dashboard/nginx.conf
# Serves the built dashboard. Port 8080 because the unprivileged nginx image runs
# as uid 101 and cannot bind a port below 1024.

server {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dashboard/package.json Outdated
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dashboard/package.json Outdated
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.20.0",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axios isn't imported anywhere yet. Just in time: could it come in with src/api.ts in 5.3?

Comment thread dashboard/src/index.css Outdated
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');

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!.

Comment thread dashboard/src/pages/Endpoints.tsx Outdated
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 }

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small things here:

  • created still has signing_secret, so the secret stays in rows after "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>

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dashboard/src/components/StatusPill.tsx Outdated
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)]',

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disabled goes away in #10. Once #7 has the new types, TypeScript will flag this line.

Comment thread dashboard/.dockerignore
.git
Dockerfile
.dockerignore
*.local

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add .env* too? Docker doesn't read .gitignore, so a local .env would be copied into the build stage.

Comment thread dashboard/src/pages/DeliveryLog.tsx Outdated

// 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) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clock() is also in DeliveryDetail.tsx (with seconds). It could live once in src/format.ts.

@AbdikhaniAbdirashid

Copy link
Copy Markdown
Collaborator Author

Thanks for the review — it was detailed and it caught real things. Rebased and
pushed, so this is ready for another look.

Merge order first: I did what you suggested. #7 is rebased onto main, so it
now carries the #10 contract (EndpointStatus without disabled, last_error as
DeliveryError). This branch is rebased on top of that. As you predicted,
TypeScript pointed straight at StatusPill.tsx:11 and nothing else. #7 still needs
to merge before this one — I haven't retargeted the base yet.

Your six fixes

  1. nginx security headers — done: CSP, X-Content-Type-Options,
    frame-ancestors 'none', Referrer-Policy, server_tokens off,
    absolute_redirect off. The CSP allows fonts.googleapis.com for the
    stylesheet and fonts.gstatic.com for the files, and connect-src is limited
    to api.myrelay.tech.
  2. Exact versions — done, everything pinned without ^.
  3. The fonts never load — done. Moved to a <link> in index.html, which also
    starts the download without waiting for the CSS. Confirmed the faces actually
    register now; before, document.fonts was empty and the built CSS had no
    reference to Google Fonts at all.
  4. Replay should reload — done. A Map keyed by id holds what replay changed,
    and load() checks it first, so the 2 s poll stops undoing the result. Measured
    it: dead/8 → pending/0, still pending/0 three seconds later.
  5. Key prefix + Disconnect — done, both in the header, and the pages send you
    to Connect when there's no key. * routes there too, so a typo'd URL no longer
    gives a blank page.
  6. Remove axios — done, gone from package.json.

Your smaller things

  • Delivered detail fixtures: added two, so all four log rows open now.
  • Endpoints: each new one gets its own id (crypto.randomUUID()), and
    signing_secret is kept out of rows — the row is built without it and only the
    one-time box carries it.
  • clock() now lives once, in src/format.ts.
  • .env* added to .dockerignore.
  • public/favicon.svg replaced.
  • Still open, happy to do in a follow-up: the event types field, a Copy button for
    the secret, the "Not responding" label, and your @theme inline suggestion.

Two things I found while testing

  • Endpoint URLs: https:// on its own was accepted. Replaced the
    startsWith check with a real one that parses the URL and rejects loopback, the
    private ranges, link-local and 169.254.169.254. It's the SSRF rule from
    CONTEXT.md. To be clear about what it is not: it only checks the host name,
    so a public name with a DNS record pointing at 127.0.0.1 still gets through.
    The API has to do the real check after resolving. This saves a round trip and
    gives a clearer message than a bare 422.
  • Timestamps: both tables showed clock time only, so an attempt log spanning
    days read out of order — try 7 at 03:36 appeared to come before try 6 at
    22:36. Now the date is shown whenever the timestamp isn't today.
  • Contrast: --ink-faint was 3.04:1 in light and 3.77:1 in dark, under the
    4.5:1 that AA wants for small text, and it carries real information (the "next
    16:34" hint, the endpoint URL). Now 5.25:1 and 4.64:1, both from the same Primer
    palette as the rest.

Your questions

  1. Dockerfile and nginx.conf: I'd keep them here, since the image is what this PR
    produces. The CSP is the one in nginx.conf now — tell me if you want
    connect-src opened up for anything else.
  2. npm + Node 22: Playwright runs on Node in this folder anyway, so a second
    runtime for the dashboard would mean two lockfiles and two caches in CI for no
    gain. Node 22 over 24 because it's the current LTS and the base image is
    node:22-alpine. oxlint because it's the create-vite default and it's fast
    enough to run on every save. Agreed these should be written down — I'll open a
    docs PR with a short ADR 0004 unless you'd rather add rows to 0002.
  3. A GET slower than 2 s with setInterval: the calls pile up and can land out of
    order, so an older answer can overwrite a newer one. The fix is to wait for the
    answer before scheduling the next one (setTimeout after the response, not
    setInterval), and to drop an answer whose request is no longer the current one.
    I'll do that in 5.3 when src/api.ts lands, since there's no request to be slow
    until then.
  4. A 401 mid-session: clear the key from sessionStorage and send the user to
    Connect with a message saying the key stopped working. One place, in the axios
    response interceptor, so every screen behaves the same. Also 5.3.

Your merge-order warning: understood, I won't merge this before #7.

@AbdikhaniAbdirashid

Copy link
Copy Markdown
Collaborator Author

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 (dashboard/.nvmrc and node:24.22.1-alpine in the Dockerfile), changed in 90b6163 — two days before I wrote that comment, so I described a state that no longer existed. Sorry for the noise.

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 nodejs/Release:

Node Status today (31 Oct) 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 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 ^20.19.0 || >=22.12.0, so on that version the build simply refuses to run. nvm had 24.22.1 installed all along, I just wasn't on it. Fixed, with nvm alias default 24.22.1 so a new terminal picks it up, and tsc -b && vite build now passes here: 99 modules, ~330 ms.

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 tsc -b, oxlint and the Playwright tests on Node 24.22.1, so neither of our laptops is the source of truth.

Base automatically changed from feat/dashboard-mock-data to main October 31, 2026 16:16
Abdikhani Abdirashid added 7 commits October 31, 2026 17:19
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.
@AbdikhaniAbdirashid

Copy link
Copy Markdown
Collaborator Author

Rebased onto main as you asked, now at 75d595c. #7 is merged, so this PR's diff is only the screens.

Used git rebase --onto origin/main feat/dashboard-mock-data rather than a plain rebase, since #7 was squashed — its four commits no longer exist on main as such, and replaying them would have conflicted on every file.

One conflict, in mocks.ts, and worth knowing how it resolved. It wasn't two versions of the same thing — it was two different additions landing in the same place:

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. mocks.ts now exports 16 fixtures, and all four log rows in the delivery list open to a real detail screen.

Checked after the rebase: tsc -b and vite build pass (99 modules, ~310 ms), oxlint exits clean.

Something the checks on this PR show. Three ran: api-test, api-image, deploy-script-test. None of them touch the dashboard, even though this PR changes 30 frontend files. So "the checks are green" currently says nothing about whether the dashboard builds — which is how it went unnoticed that my own machine was on Node 18 and couldn't build it at all.

Once this merges I'd like to add a dashboard-test job to pipeline.yml (tsc -b, oxlint, Playwright on Node 24.22.1), as its own PR, and make it a required check alongside api-test. No path filter, for the same reason you gave for api-test: a required check that never runs would block every API-only PR forever.

Also found while rebasing, not fixed here: playwright.config.ts runs against the dev server on localhost:5173, but docs/api.md allows CORS from http://localhost:4173, described as "the preview server Playwright uses in CI". Harmless now, since the screens run on mocks and nothing crosses origins — but it breaks every e2e test the moment 5.3 swaps in src/api.ts. I'd rather move the tests to vite preview on 4173 to match the contract: it also means the tests exercise the built files, which is what nginx actually serves. Separate PR unless you want it in this one.

@devinder-dev devinder-dev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), including endpointDeletedDelivery
    ("Old CRM"). DeliveryDetail.tsx registers only 4 detail fixtures, though, so opening that row
    says "Delivery not found", while endpointDeletedDetail is exported at mocks.ts:346.
  • e2e/deliveries.spec.ts:8 still 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 409 endpoint_deleted (docs/api.md, replay). Hide or disable
    Replay there, or show the 409 with its explanation.

Should fix in this PR

  1. 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.
  2. 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.
  3. 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 paste npm ci, npm run lint, npm run build and npm run test:e2e results.
    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 to npm 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.

@AbdikhaniAbdirashid

Copy link
Copy Markdown
Collaborator Author

All four are in, at d245a42. Three commits, one per concern.

Blocker: the deleted-endpoint row

endpointDeletedDetail was exported but never registered, so the row opened "Delivery not found". Registered it, and Replay is now replaced by a line of text when last_error === 'endpoint_deleted' — the contract refuses that replay with 409, and a button that can only fail is worse than saying why it is gone. A pending delivery keeps its button, since replaying one means "try now".

You said don't just change the numbers, and you were right to: the dead filter now holds two rows, and because the log is newest first, "Old CRM" sorts ahead of the row the replay test meant (25 Oct 13:20 vs 24 Oct 18:00). Updating ROWS alone would have left that test clicking the row with no Replay button. It now opens by name. The filter assertion also became an array, so it checks every dead row rather than the first.

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 toBeHidden() on "Delivery not found" proves nothing.

1. One mock store

New src/mockStore.ts. The design point is that it keeps only the five detail fixtures and derives the log's rows from them, rather than holding two lists and updating both on replay. Two lists is the exact shape of the bug above, moved one file over; deriving makes "a log row with no detail screen" impossible rather than guarded by a test.

Cost of that choice: the order has to be computed instead of written down. It sorts on created_at descending with a plain string compare, which is correct because every timestamp is ISO 8601 in UTC and those sort lexicographically in time order.

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 page.goto() reloads the page and restarts the store, which would hide the bug.

2. The documented length limits

NAME_MAX = 100 and URL_MAX = 2048 from docs/api.md, with the server still the authority. The URL length is checked before parsing: a 4000-character string can be a perfectly valid URL, and the limit is on characters. Both boundaries are tested in one test each — 100 accepted / 101 refused, 2048 accepted / 2049 refused — so a rule that refused everything could not pass as a rule that refuses what it should.

3. The URL check

isLiteral now tells IP literals from host names using the parser's output, so [2001:db8::1] is accepted instead of being caught by the dot rule. Public IPv4 literals too.

While fixing that I added the IPv6 private ranges, because recognising IPv6 and then letting [fd00::1] through would have been worse than not recognising it at all: fc00::/7 (unique-local) and fe80::/10 (link-local), alongside ::1.

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 for

$ npm ci
found 0 vulnerabilities

$ npm run build
> tsc -b && vite build
✓ 100 modules transformed.
dist/index.html                   1.02 kB │ gzip:  0.55 kB
dist/assets/index-BvPBfJjX.css   12.28 kB │ gzip:  3.49 kB
dist/assets/index-DRh3JNxK.js   278.84 kB │ gzip: 87.23 kB
✓ built in 486ms

$ npm run lint
(oxlint, exit 0, no output)

$ npm run test:e2e
Running 35 tests using 1 worker
35 passed (27.7s)

35 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:

Reverted Result
endpointDeletedDetail out of the fixtures the new row test fails, the unknown-id test still passes
isLiteral removed only accepts https://[2001:db8::1]/hook fails — the IPv4 case still passes, so the control is precise
the NAME_MAX check removed the 100/101 test fails
details.set() dropped from replayDelivery both replay tests fail, the two non-replay ones still pass

Not in this PR, on purpose

  • The dashboard-test CI job. Agreed it belongs with infra: add the dashboard manifests for cdn.myrelay.tech #24 or 5.5, and should become a required check alongside api-test, with no path filter for the reason you gave for api-test.
  • Playwright on 4173. Staying on the dev server while the screens run on fixtures; moving to build + preview when the real client arrives in 5.3, so the tests exercise what nginx serves.
  • Assets during a rollout (infra: add the dashboard manifests for cdn.myrelay.tech #24). You are right that not caching the 404 only narrows it. I don't think we can honestly call the dashboard zero-downtime without one of: keeping the previous build's assets in the image, or maxSurge: 0 so only one version serves at a time. Happy to take whichever you prefer — worth deciding before we claim it on Wednesday.
  • Nice-to-haves: resetting detail state on an id change, and a Disconnect test. Can do both here if you want them before merge; otherwise they are small follow-ups.

One thing I noticed and did not touch: React.FormEvent is flagged deprecated by TypeScript in React 19.3 (Endpoints.tsx:1, Connect.tsx). Not a build failure and not in your list, so I left it rather than widen the diff.

@devinder-dev devinder-dev left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 endpointDeletedDetail taken 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], 2130706433 and 0x7f.1 are refused. The parser turns the last two
    into 127.0.0.1 before 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:

  1. the new image also carries the previous build's assets/;
  2. Traefik sticky sessions (a cookie keeps a browser on one pod, so its HTML and its assets
    come from the same version);
  3. 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.

@AbdikhaniAbdirashid
AbdikhaniAbdirashid merged commit 7c321c6 into main Nov 1, 2026
4 checks passed
@AbdikhaniAbdirashid
AbdikhaniAbdirashid deleted the feat/dashboard-screens branch November 1, 2026 13:41
AbdikhaniAbdirashid added a commit that referenced this pull request Nov 2, 2026
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>
AbdikhaniAbdirashid added a commit that referenced this pull request Nov 3, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants