Skip to content

refactor(admin): split the 1280-line dashboard into typed modules - #10

Open
gtko wants to merge 12 commits into
masterfrom
refacto/ameliorer-le-code
Open

refactor(admin): split the 1280-line dashboard into typed modules#10
gtko wants to merge 12 commits into
masterfrom
refacto/ameliorer-le-code

Conversation

@gtko

@gtko gtko commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

packages/templates/files/app/admin/dashboard-client.tsx was a single 1280-line file mixing 9 React components, 9 inline utilities, 4 inline modals, raw fetch() calls with unsafe as casts, and the whole useState-soup of the orchestrator. A lead flagged the quality. This PR breaks it apart in 10 reviewable steps — one commit per step — and adds the infra to keep examples/greenfield byte-equivalent to the templates payload.

Headline numbers:

before after
dashboard-client.tsx LOC 1280 167 (-87%)
largest file in app/admin/ 1280 194 (canary-modals.tsx)
useState in AdminDashboard 9 2
unsafe as / silent .catch(()=>({})) 4 0
tests touching the admin 0 61 (utils + reducers + API client)
admin routes shipped via npx copy . 30 (dashboard broken at users) 34 (fix included)

How to review

Each commit is a self-contained step that builds + tests green on its own. Read them in order:

  1. 154fb92 infrabin/sync-from-source.mjs (SHA-256 walk + --check), templates-sync.yml CI guard, Vitest 2 + tsconfig.json, pnpm sync:templates[:check] root scripts. Bug fix: 3 admin routes (bucket-info, shadow-history, rollback-shadow) were used by the dashboard but missing from manifest.json, so users' dashboards were broken after npx ... copy ..
  2. df9b396 types/constants/utils — pulls 9 inline types and 9 inline utilities into types/, constants/, utils/. Adds 40 unit tests (deriveStatus, prettyTimeAgo, nextCronFireMs, computeTrafficShares, computeTiming, …).
  3. a8abdc9 typed API clientapi/admin-client.ts exposes 13 endpoints with response shapes. api/errors.ts exports AdminApiError(status) + parseJsonError(res). Zero as casts, zero swallowed errors. 13 new tests with fetch mocked.
  4. 7647cfb hooks — 9 useState → 2. New useDashboardState (useReducer), useActionRunner (useReducer), useWallClock (SSR-safe), usePollRefresh. Reducers are pure and tested.
  5. 7f0b618 atomic componentsStateBanners, StatusLine, TimingLine, TrafficBar, ActionButton (ex-ActionBtn).
  6. 4b11338 isolated cardsSloLogCard, ShadowPercentCard.
  7. fc96d45 deploy rows mergedDeployRow with variant: 'prod' | 'shadow' replaces the two near-duplicate row components. Wrapped in DeploymentsCard / ShadowHistoryCard.
  8. cba8f9c canary state card — the largest inline block (~180 lines) becomes CanaryStateCard + CanaryActions + CanaryManualControls. Segment array building extracted to utils/segments.ts.
  9. a5275a9 modalsModalState gains an explicit { kind: 'closed' } variant (replaces the null arm). 4 inline <ConfirmModal> blocks → <CanaryModals/> with type-guard refinement.
  10. e226326 finalize — manifest 0.2.1 → 0.3.0 (ships the 26 new admin modules), drop the empty trailing banner.

New layout

packages/templates/files/app/admin/
  dashboard-client.tsx                 167 l   (was 1280)
  types/         dashboard.ts, modal.ts
  constants/     config.ts, labels.ts
  utils/         status, format, time, step, traffic, timing, segments
  hooks/         use-dashboard-state, use-action-runner, use-wall-clock, use-poll-refresh
  api/           admin-client.ts, errors.ts
  components/    14 components, none > 200 lines

examples/greenfield/app/admin/ mirrors packages/templates/files/app/admin/ byte-for-byte and is kept in sync by pnpm sync:templates. A new CI workflow (templates-sync.yml) fails the PR if the two diverge.

Test plan

  • pnpm sync:templates:check — passes (greenfield in sync)
  • pnpm test — 130 tests across core (69) and templates (61) all green
  • pnpm --filter=greenfield build — Next.js compiles all 22 routes
  • grep "as BucketInfoMap\\| as { entries\\| as any\\|catch(() => ({}))" packages/templates/files/app/admin — zero matches
  • LOC check: no file > 200 lines in app/admin/
  • Manual navigator pass on greenfield (needs human eyes): refresh, modals (cancel / promote / rollback / rollback-shadow), step controls, shadow percent save, SLO log expand, no React #418 in console after mount

Out of scope (follow-up PR)

The audit also flagged in packages/core/:

  • vercelFetch() + checkEnv() duplicated in deployments.ts, promote.ts, patch.ts — extract a shared vercel/client.ts
  • compose.ts is a 283-line function with deep nesting around the bucket resolution
  • zero tests on the Vercel API trio (deployments.ts, promote.ts, patch.ts)
  • the as ShadowConfig cast in edge-config/patch.ts could use a runtime guard

Left intentionally untouched to keep this PR scoped to the admin dashboard.

🤖 Generated with Claude Code

gtko and others added 10 commits May 21, 2026 19:05
Bootstraps the infrastructure required to refactor app/admin without
drifting examples/greenfield away from packages/templates/files.

- bin/sync-from-source.mjs: SHA-256 walk of files/, copies divergent
  entries to examples/greenfield. Supports --check for CI.
- workflows/templates-sync.yml: PR guard that fails on any divergence
  touching packages/templates/files/**, examples/greenfield/** or the
  sync script itself.
- root scripts: pnpm sync:templates[:check].
- packages/templates: vitest 2 + tsconfig (extends base, JSX preserve,
  @/* alias to files/*), passWithNoTests so pnpm test stays green
  until the first util test lands.
- manifest.json: add the three admin routes that were used by the
  dashboard but never shipped to consumers (bucket-info, shadow-history,
  rollback-shadow). Without this fix npx copy . produces a broken UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dashboard-client.tsx had 9 inline types and 9 inline utilities mixed
with the orchestrator logic. Pulled them into per-concern modules so
the orchestrator stops being the only place that defines shared shapes.

types/dashboard.ts   - Status, BucketInfo(Map), ShadowHistoryEntry,
                       Segment, SloCheck, DashboardProps
types/modal.ts       - ModalState (kept null arm for now; replaced in
                       step 9 with a {kind:'closed'} discriminant)
constants/config.ts  - REFRESH_INTERVAL_MS, TICK_MS, STEP_*,
                       SLO_CHECK_PERIOD_MS, SHADOW_HISTORY_CAP,
                       SLO_CHECKS_CAP (replaces 10_000, 1000, '4',
                       15 * 60_000, "20", "10" magic literals)
constants/labels.ts  - STATUS_LABEL + phase labels (FR strings out of
                       the JSX)
utils/status.ts      - deriveStatus, statusToPhase, new isCanaryLive
utils/format.ts      - prettyTimeAgo, formatDuration, shortHost +
                       new firstLine (fixes split('\n')[0] under
                       noUncheckedIndexedAccess)
utils/time.ts        - nextCronFireMs, parisHour, phaseLabel
utils/step.ts        - stepSize (uses STEP_MIN/STEP_MAX)
utils/traffic.ts     - computeTrafficShares replaces 5 inline derived
                       consts
utils/timing.ts      - computeTiming replaces the SLO-anchored
                       expectedNextTs / msToNext / overdue block

Tests cover every util branch (40 cases). Greenfield mirror synced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dashboard-client.tsx had 4 raw GETs + 9 inline POST call sites with
unsafe casts (as BucketInfoMap, as {entries: ...}) and silent error
swallowing (.catch(() => ({}))). Move every endpoint behind a typed
client so the casts disappear and route mistakes surface at compile
time.

- api/admin-client.ts: 13 typed endpoints (fetchState, fetchDeployments,
  fetchBucketInfo, fetchShadowHistory + pause, resume, cancel, promote,
  stepBack, stepForward, setShadowPercent, rollback, rollbackShadow).
  getJson<T>/postJson wrappers default to cache: 'no-store' and parse
  the response into the declared shape.
- api/errors.ts: AdminApiError(status) + parseJsonError(res) replace
  the `{ error: ... } || 'HTTP <status>'` pattern.
- dashboard-client.tsx:
  * refresh() now Promise.allSettled — a single failing endpoint keeps
    partial data instead of nuking the whole snapshot.
  * run(id, fn) takes a thunk instead of (path, body), so the call
    sites just pass `adminApi.pause` / `() => adminApi.stepForward(n)`.
  * zero `as` casts, zero swallowed JSON errors.

13 new tests for the client (fetch mocked) + error parser.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulled the orchestrator state out of AdminDashboard into 4 dedicated
hooks. dashboard-client.tsx had 9 useStates and three useEffects mixing
data fetching, wall-clock ticking and per-action progress tracking —
they now live in modules with explicit signatures and pure reducers.

- hooks/use-dashboard-state.ts: useReducer over
  { config, deployments, bucketInfo, shadowHistory, error, refreshing }
  with refresh/start + refresh/end actions. refresh() is exposed
  alongside state; the reducer is exported separately for unit tests.
- hooks/use-action-runner.ts: useReducer over { pendingAction,
  actionError } with action/start | success | error actions. run(id,
  fn) bails out if another action is already pending. onSuccess is
  captured by ref so a non-memoized caller does not rebuild run.
- hooks/use-wall-clock.ts: SSR-safe wall-clock tick. Returns null
  during the first render to preserve the React #418 invariant.
- hooks/use-poll-refresh.ts: setInterval wrapper for the data refresh.

Net effect on the orchestrator: 9 useStates -> 2 (modal, stepInput),
3 useEffects -> 0. Both reducers come with tests covering every action
arm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the small JSX building blocks that AdminDashboard inlined into
their own files. No behavior change — every consumer site keeps the
same DOM and className soup.

- components/state-banners.tsx: error + actionError banners (replaces
  ~14 lines of inline JSX in the orchestrator)
- components/status-line.tsx: status dot + label + pct
- components/timing-line.tsx: phase / elapsed / next-check copy
- components/traffic-bar.tsx: bar + legend (Segment type now imported
  from types/dashboard.ts)
- components/action-button.tsx: ex-ActionBtn, renamed to make the
  abbreviation drop. Same className composition, same pending check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both cards were sitting inline at ~130 and ~70 lines respectively in
the orchestrator. They each have their own little state (expand set
for SLO, draft input for shadow percent) so they read more naturally
as standalone components.

- components/slo-log-card.tsx (was SloLog): also splits the per-row
  rendering into a SloRow sub-component to keep the parent under 100
  lines. SLO_CHECKS_CAP replaces the hardcoded "/ 10" badge.
- components/shadow-percent-card.tsx (was ShadowPercentCard): same
  behavior — draft mirrors the current prop, validates 0–100, only
  enables "Enregistrer" when changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ShadowHistoryRow and DeploymentRow were 60-line near-duplicates: same
state pill, same meta line, same rollback button — only the source
object shape differed. Unified them behind a discriminated-union
DeployRow that adapts via variant: 'prod' | 'shadow'.

- components/deploy-row.tsx: viewOfProd / viewOfShadow map each
  variant's source object to a common { ref, sha, msg, state, url,
  createdAt } view model, then a single JSX block renders it. Shadow
  variant inherits the "disable when state === 'ERROR'" rule via
  buttonDisabled; prod variant gets the same effect from its parent.
- components/deployments-card.tsx (new): wraps DeployRow with variant
  prod, owns the isCurrent check against prodHost.
- components/shadow-history-card.tsx (new): same with variant shadow,
  uses the SHADOW_HISTORY_CAP constant instead of the inline "20".

Net effect: ~180 lines of inline JSX in dashboard-client.tsx replaced
with two <Card /> calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "État du canary" section was the largest inline block of the
orchestrator: ~180 lines of header + StatusLine + TimingLine +
TrafficBar segment array + Pause/Resume/Cancel + step input +
step-back/step-forward + Promote. Split it into a card with two
focused action rows.

- components/canary-state-card.tsx: orchestrates the card layout and
  consumes the segment array from utils.
- components/canary-actions.tsx: Pause / Resume / Cancel canary
  (primary action row). Encodes the disable matrix per status.
- components/canary-manual-controls.tsx: step input + step-back /
  step-forward / promote (secondary row). Internalises stepSize +
  STEP_MIN/MAX/DEFAULT.
- utils/segments.ts: buildTrafficSegments() builds the [shadow,
  previous?, new] array from the shares + bucket info. Pulled the
  0.5-pt "effective hint" threshold out as EFFECTIVE_HINT_THRESHOLD
  so the magic number is named.

dashboard-client.tsx now renders <CanaryStateCard … /> with a wide
but flat prop bag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dashboard-client.tsx had ~150 lines of repeated <ConfirmModal> blocks
with `modal?.kind === '...'` narrowing duplicated in `open`, `body`,
`confirmPhrase`, `pending`, and `onConfirm`. Replaced the lot with a
single <CanaryModals/> and a tighter modal type.

- types/modal.ts: replace `null` arm with `{ kind: 'closed' }`. The
  union is now exhaustive: discriminated narrowing in canary-modals
  doesn't need optional chaining on modal, and exported type guards
  (isRollback, isRollbackShadow) give callers a way to refine.
  Exported CLOSED_MODAL singleton so the orchestrator doesn't
  re-allocate the closed state on every render.
- components/canary-modals.tsx: one component that owns the 4 dialogs.
  rollback and rollback-shadow are pulled into RollbackModal /
  RollbackShadowModal local components so each modal's body sits with
  its own narrowed data — no more inline `modal?.kind === 'rollback'`
  guards inside JSX.
- dashboard-client.tsx: uses CLOSED_MODAL for initial state +
  closeModal. Action callbacks receive the deploy / target object
  directly via props (no narrowing leak into the orchestrator).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wrap up the 10-step refactor of packages/templates/files/app/admin:

- dashboard-client.tsx is down to 167 lines (from 1280, -87%); drop
  the now-empty trailing "Sub-components" banner.
- manifest 0.2.1 -> 0.3.0: the admin payload now ships 26 new
  modules (types, constants, utils, hooks, api, components) on top
  of the original 8.

Verified on the final state:
- pnpm sync:templates:check        OK
- pnpm test                         core 69 + templates 61 = 130
- pnpm --filter=greenfield build    OK
- grep "as BucketInfoMap|as { entries|as any|catch(() => ({}))"
  in app/admin                     zero matches
- LOC: no file > 200 lines in app/admin; orchestrator < 100 lines
  of actual logic (the rest is the CanaryStateCard prop bag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread .github/workflows/templates-sync.yml Outdated
Comment thread .github/workflows/templates-sync.yml Outdated
Tambouil
Tambouil previously approved these changes May 22, 2026
Co-authored-by: Valentin Berceaux <60550907+Tambouil@users.noreply.github.com>
Co-authored-by: Valentin Berceaux <60550907+Tambouil@users.noreply.github.com>
@gtko
gtko requested a review from PBab13 June 5, 2026 09:52

gtko commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

@paul.babin tu pourrais me dire si on peut valider ça, c'est pour avoir un code plus propre, j'ai lancer l'IA dessus.

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