46 Commits

Author SHA1 Message Date
fa93a8c866 fix(web): drop 'keep scrolling to load more' tail from subtitle 2026-05-03 11:43:21 +08:00
ee74ebda64 feat(web): show real DB total in table header (replaces '200+')
The header used to show '200+' once the user had loaded a partial set
of pages — opaque, useless for an operator who actually needs to know
'how many accounts are in the system right now'.

Server (app/cm_api.py):
- /acc/ and /user/ list responses now wrap the rows alongside a
  COUNT(*) of the table: { rows: [...], total: N }. The single-row
  /acc/<username> path is unchanged (still returns Acc[] with one row).
- Each list request issues both queries (the page SELECT and the COUNT)
  on the same pooled connection. COUNT(*) on a 3k-row table is sub-ms;
  even when the cache misses, total request latency stays well under
  20ms on warm-cache MySQL.

Web client:
- web/lib/api.ts: Page<T> gains a  field; getAccountsPage and
  getUsersPage parse the new wrapped response.
- web/app/page.tsx + users/page.tsx: pass page.total down as
  initialTotal.
- web/components/{accounts,users}-table.tsx: hold total in state, sync
  it from every page fetch (initial, loadMore, sort change, force
  refresh) so cm99 monitor inserts during the session bump it correctly.
  Delete decrements it by 1 immediately so the header doesn't lie
  between the optimistic delete and the next refresh.
- PageHead now shows '<total>' as the big number. When loaded < total,
  a small zinc-400 line below reads 'Showing X of N — keep scrolling
  to load more'. Once the user reaches the end, the line goes away.

No new round trips for the count: it piggybacks on the same /acc/?...
or /user/?... request that already fetches the page. The 30s cache
covers the count too — so tab switches still don't hit MySQL.
2026-05-03 11:38:38 +08:00
6bb85222d1 perf(web): server-side pagination + infinite-scroll for accounts/users
For 3k+ row deployments, returning the full table in one shot is the
bottleneck — the JSON payload alone is hundreds of KB and the client
mounts thousands of EditableCell instances on every visit. Pagination
with auto-fetch on scroll shrinks both the wire payload and the initial
render to a single page (200 rows).

Server (app/cm_api.py):
- /acc/ and /user/ accept ?limit, ?offset, ?prefix, ?dir, plus ?sort on
  /user/ (f_username | last_update_time). Defaults: limit=200 (capped at
  1000), offset=0, dir=desc.
- ORDER BY done in SQL with prefix-priority: rows whose username starts
  with the configured CM_PREFIX_PATTERN come first, then asc/desc by the
  sort column. The 'dir' value is whitelisted to ASC|DESC before string
  interpolation; everything else goes through parameterised binding.
- Schema verification (verify_tables_once) deferred to first request via
  a Flask before_request hook — keeps create_app() free of MySQL touches
  so unit tests + gunicorn preload still work without a live DB.

Web client:
- web/lib/api.ts: getAccountsPage / getUsersPage return { rows, hasMore }.
  hasMore = (rows.length === PAGE_SIZE), so the client knows when to
  stop fetching. Each page is its own Next.js cache entry (the URL is
  the cache key) — caching from the previous commit still applies.
- web/app/actions.ts: loadMoreAccounts / loadMoreUsers Server Actions
  for next-page requests; refreshAccounts / refreshUsers force-evict the
  cache via revalidateTag before refetching page 1.
- web/app/page.tsx + users/page.tsx: only fetch the first page now.
- web/components/{accounts,users}-table.tsx: rewrote state model. Rows
  accumulate as the user scrolls. An IntersectionObserver on a sentinel
  div near the bottom triggers loadMore when it enters the viewport
  (300px rootMargin so the next page starts loading before the user
  reaches the end). useOptimistic wraps the accumulated rows for in-
  flight edits; on success the row is committed locally so the change
  survives even though we no longer router.refresh.
- Sort toggle now refetches from page 1 with the new dir/sort param.
  Local sort over a partial set would be inconsistent.
- Mutations: delete filters from local state; create + refresh both
  reset to page 1 so the row appears in its sorted position.
- Header count shows '<loaded>+' when more pages exist so the operator
  knows what they're seeing isn't the full table.

Removed AutoRefresh:
- web/app/layout.tsx no longer mounts AutoRefresh.
- web/components/auto-refresh.tsx deleted.
- Reason: router.refresh every 30s would yank the user back to page 1
  every time, losing scroll position and accumulated rows. Manual
  Refresh button replaces it (now wired to refreshAccounts/refreshUsers
  which evict cache + refetch).

Tests: deferred verify_tables_once() means tests.test_bot_cli's
CreateAppFactoryTests pass without DB env vars again. All 38 existing
tests pass.
2026-05-03 11:29:34 +08:00
549e9b5939 perf(web): cache /acc/ and /user/ for 30s with tag invalidation
Eliminates the per-request DB hit when:
- A user opens a tab they've recently visited (within 30s)
- The 30s AutoRefresh fires while no mutations have happened
- Multiple browser tabs are open and switch between Accounts/Users
- A passing-by request races another request for the same data

How it works:
- web/lib/api.ts: fetchApi() now forwards the next.revalidate/tags
  options to Next.js's data cache. getAccounts/getUsers tag their
  responses ('accounts'/'users') with a 30-second freshness window.
- web/app/actions.ts: every mutation (update/create/delete X 2 tables)
  calls revalidateTag() so the next GET for that table bypasses the
  cache and re-reads from MySQL. Stale data never lingers after a write.

The cache lives in the cm-web Node process (per worker). For our
2-worker setup that's at most 2 cached copies; the next AutoRefresh
tick after the 30s window expires triggers exactly one DB read per
worker. If the operator manually clicks Refresh, that's a router.refresh
which also re-fetches.

Tradeoffs:
- External DB writes (e.g., the cm99.net monitor inserting a row) won't
  appear in the dashboard until the 30s window elapses or a mutation
  happens. The previous behavior had a 30s ceiling too (auto-refresh
  interval), so the perceived freshness is unchanged.
- Memory: each cached payload is a few KB to a few hundred KB. Trivial.

If you want stricter freshness later, drop CACHE_REVALIDATE_SECONDS in
web/lib/api.ts. If you want pagination on top of this, the cache key
becomes per-URL automatically, so /acc/?offset=200 caches separately
from /acc/?offset=0 — no further work needed.
2026-05-03 11:21:58 +08:00
9a4072129a perf(web): add route-level loading skeletons for instant tab switching
The 'switching is laggy with many accounts' report — root cause is that
both /page.tsx and /users/page.tsx are Server Components that block on
the API fetch before sending any HTML. During the wait, the previous
route stays frozen (no spinner, no feedback) — the user perceives a 'lag'
that grows with row count.

App Router's loading.tsx convention solves this: Next.js renders it
INSTANTLY on navigation, then streams in the real RSC tree once the data
fetch resolves. The skeleton matches the shape of the real shell + a few
placeholder rows so the swap is layout-stable.

Files:
- web/components/table-skeleton.tsx — shared skeleton (PageHead + N rows)
- web/app/loading.tsx — used for /
- web/app/users/loading.tsx — used for /users

If row counts keep growing past a few hundred and the table itself
becomes the bottleneck (vs the network fetch this addresses), the next
step is pagination: accept ?limit=&offset= on /acc/ and /user/ in
cm_api.py and add a 'Load more' button (or a virtual list) at the
table-component layer.
2026-05-03 11:14:13 +08:00
626344cc16 fix(web): unblock PWA icon under trailingSlash routing
next.config.ts has trailingSlash: true, so Next.js 308-redirects /icon to
/icon/. The middleware matcher only excluded the no-slash form, so after
the redirect the auth gate kicked in and bounced /icon/ to /cm-auth — the
browser got an HTML page where it expected a PNG, and the manifest icon
failed to install ('Download error or resource isn't a valid image').

- middleware: matcher now allows the optional slash on icon and apple-icon.
- manifest: point icons at the canonical /icon/ and /apple-icon/ URLs so
  the browser fetches the PNG directly without a redirect round-trip.
2026-05-03 10:26:09 +08:00
eb297e977e fix(web): don't apply break-all when EditableCell has a custom renderView
The break-all on the value span was added so long URLs (link column)
wrap inside the narrow mobile cards. But it was also forcing
character-by-character breaks inside the StatusBadge (e.g., 'available'
splitting into 'ava\nila\nble' on narrow screens). Skip break-all when
renderView is provided — those callers render their own atomic widgets
(badges) that should never break.
2026-05-03 10:19:02 +08:00
d94dfc7f9a fix(web-auth): sign out works, drop passkey settings UI, add password reveal
- nav: the menu's onClick={setOpen(false)} on the Sign-out submit button
  was racing the form POST — React unmounted the form before the request
  flushed, so logout silently no-op'd. Drop the onClick; the Server
  Action's redirect to /cm-auth tears the menu down naturally.
- nav: drop the 'Passkey settings' link (passkey UI is gone).
- Delete web/app/cm-passkeys/. The WebAuthn Server Actions in
  auth-actions.ts are unreachable now (hasPasskeysForLogin always returns
  false in practice — no enrollment path), so the 'Sign in with passkey'
  button on /cm-auth never renders. The action handlers stay in case we
  reinstate enrollment later; they're dead code but harmless.
- auth-form: add an eye-toggle button on the password field that flips
  type=password ↔ text. tabIndex=-1 so Tab still goes input → submit
  without stopping at the toggle. Right-padded the input (pr-10) so the
  glyph doesn't overlap typed characters.
2026-05-03 10:17:54 +08:00
fc62834019 fix(web-auth): soften forgot-password hint to 'contact IT' 2026-05-03 09:48:05 +08:00
f4d5f97c42 fix(web-auth): import WebAuthn JSON types from @simplewebauthn/types
In @simplewebauthn/server v11 the JSON response and transport types are
no longer re-exported from the server package — they live in the sibling
@simplewebauthn/types package. Adds the dep and switches the imports.
2026-05-03 09:45:36 +08:00
312cc4dc21 fix(web-auth): gate Secure cookie on CM_DEBUG, pass CM_AGENT creds to web-next
Previously the session cookie used Secure=NODE_ENV==='production', and the
dev override still runs the standalone build with NODE_ENV=production, so
the cookie was unreachable from phone-on-LAN testing over HTTP. Switching
to CM_DEBUG lets dev (CM_DEBUG=true) drop the Secure flag while keeping
prod (CM_DEBUG=false) safe.

Also wires CM_AGENT_ID/CM_AGENT_PASSWORD/CM_DEBUG into the web-next
service env block so the login Server Action can compare against them.
2026-05-03 09:01:35 +08:00
b4c526bf9f feat(web): nav account menu with sign out + passkey settings link 2026-05-03 08:31:00 +08:00
6ee95bca08 feat(web): /cm-passkeys settings page for passkey enroll/remove 2026-05-03 08:30:25 +08:00
9e74d75c94 feat(web): /cm-auth login page with passkey + password options 2026-05-03 08:29:25 +08:00
0d0dfd593c feat(web): middleware redirects unauthenticated requests to /cm-auth 2026-05-03 08:28:16 +08:00
7dd8bfcefa feat(web): Server Actions for password login + WebAuthn passkey flows 2026-05-03 08:28:04 +08:00
380e86b885 feat(web): WebAuthn relying-party helper (host-derived rpID/origin) 2026-05-03 08:27:32 +08:00
7a6569800e feat(web): JSON-file passkey store with atomic writes + write lock 2026-05-03 08:27:21 +08:00
a8751b6731 feat(web): add iron-session wrapper (web/lib/auth.ts) 2026-05-03 08:27:05 +08:00
e5a2a36fb2 build(web): add iron-session and simplewebauthn deps 2026-05-03 08:25:32 +08:00
9771bb72c5 fix(web): move mobile status back into card header next to username
Earlier change made the badge the editable trigger and demoted it
into the body's Status row. That separated status from the row
identifier on mobile, which read as 'where is this status from?'.

Move the EditableCell-with-StatusBadge back into the card header,
right after the username, and drop the body Status row entirely.
Mobile now matches desktop's information density: identifier +
status badge inline, edit via badge click.
2026-05-03 08:17:23 +08:00
fe26878b38 fix(web): drop duplicate status — badge IS the editable trigger
The status column was rendering both a StatusBadge and a separate
EditableCell next to it, so 'done' showed twice in the same cell.
Adds a renderView prop to EditableCell so callers can override the
view-mode display; status now uses the badge as its visual, click-
to-edit behavior intact. Mobile card header drops its standalone
badge for the same reason — the body's Status row now shows the
badge inline.
2026-05-03 08:13:51 +08:00
6c984b6200 fix(web): lock body scroll while modal is open
Native <dialog> in iOS Safari (and a few other browsers) doesn't
prevent the page underneath from scrolling when the user scrolls
inside the dialog or near its edges. Save and restore body overflow
on open/close so the background stays put. Stays correct for stacked
dialogs because we save the previous value rather than blanket-reset
to ''.
2026-05-03 08:12:35 +08:00
3bfd35ef8d fix(web): PWA notch safe-area + skip autoFocus on touch devices
Adds viewportFit: 'cover' so the PWA can draw under the notch /
Dynamic Island when installed. Nav and Toast read env(safe-area-inset-*)
to keep their content out of the hardware cutouts (no-op on browsers
without a notch — env() resolves to 0).

Replaces autoFocus on the first field of CreateAccountDialog and
CreateUserDialog with a useEffect that only focuses on pointer devices
(matchMedia '(hover: hover) and (pointer: fine)'). Phones no longer
get the soft keyboard popping the instant a dialog opens.
2026-05-02 21:26:42 +08:00
eebbcb3db2 feat(web): success toast on confirmed create/delete
Adds a small top-centered <Toast> that fires only when the Server
Action returns { ok: true } (i.e., the DB write actually succeeded).
Auto-dismisses after 3s.

Wires both create dialogs (CreateAccountDialog, CreateUserDialog) with
an onSuccess callback that the table parent uses to push the toast,
and the delete confirm-flow does the same. Inline-edit success stays
quiet (no toast) — only add/delete trigger it, per the requested
scope.
2026-05-02 21:20:25 +08:00
e3ac94cada feat(web): manual create flow with input dialog for acc and user
api-server gets /create-acc-data and /create-user-data POST routes
that INSERT into the respective tables with required-field validation.
Frontend adds an 'Add' button next to Refresh in each table head;
opens a native <dialog> form with all fields. Inputs use 16px font on
phone (sm:text-[13px] desktop) so iOS doesn't auto-zoom.

A small form-dialog-shell helper centralizes the modal chrome,
field label, and input class so create-account-dialog and
create-user-dialog stay focused on their fields and validation.
2026-05-02 21:19:24 +08:00
e507714dc5 feat(web): delete with confirm dialog + fix iOS auto-zoom on edit
Adds × delete button per row in both tables (desktop column +
mobile card header). Click → native <dialog> confirm modal with
Esc/backdrop-cancel, destructive red button, error inline.
Wires deleteAccount/deleteUser Server Actions calling the new
api-server routes; revalidatePath refreshes the list on success.

EditableCell input switches to text-base (16px) on phone (sm:text-[13px]
above 640px), preventing iOS Safari auto-zoom-on-focus that was
shifting the layout when the soft keyboard appeared.
2026-05-02 21:17:19 +08:00
f13e3993e9 feat(web): reskin to refined SaaS aesthetic (per Dribbble reference)
Drop the brutalist hazard-tape vocabulary in favor of refined modern
SaaS: white cards on zinc-50, soft ring-1 zinc-200 borders (no hard
2px black), rounded-full pills, sans for chrome + mono for tabular
data, emerald replacing yellow as the saturated accent. Theme color
shifts to zinc-900 with an emerald dot on the icon.
2026-05-02 21:15:02 +08:00
3fe33772ce fix(web): editable cell wraps long values instead of overflowing
Long URLs in the link column would overflow on mobile because
truncate + inline-flex without min-w-0 expanded the cell beyond the
card width. Switch to flex+items-start, min-w-0 on the value span,
break-all so unbreakable strings wrap. Edit hint stays pinned right
with shrink-0.
2026-05-02 21:08:19 +08:00
b497e133bd feat(web): add PWA icons via Next.js ImageResponse (frontend-design) 2026-05-02 21:02:58 +08:00
afc94e613e feat(web): add PWA manifest config (theme_color matches layout viewport) 2026-05-02 21:01:18 +08:00
7a5c00d08a feat(web): add layout, nav, and error boundary (frontend-design) 2026-05-02 21:01:15 +08:00
c0749d1af0 feat(web): add Server Component entry pages for / and /users 2026-05-02 20:56:56 +08:00
7b97e593e5 feat(web): add data tables and editable-cell primitive (frontend-design) 2026-05-02 20:56:49 +08:00
0ebd35f964 feat(web): add 30s auto-refresh client component 2026-05-02 20:50:55 +08:00
b398faba0a feat(web): add updateAccount and updateUser Server Actions 2026-05-02 20:50:50 +08:00
3297c500a4 feat(web): add server-side api-server fetch helper 2026-05-02 20:50:45 +08:00
aa76131b23 feat(web): add TypeScript types for Acc and User 2026-05-02 20:50:38 +08:00
21bb1f0dde fix(web): bump tailwind to ^4.1 (4.0.0 had a postcss/oxide serde mismatch) 2026-05-02 20:39:04 +08:00
ccb6c27c3d fix(web): pin typescript to 5.7.2 (5.7.0 was never published) 2026-05-02 20:37:33 +08:00
ff99b1248a feat(web): hide /api entirely — RSC + Server Actions instead
The Route Handler proxy and hash mapping are gone. Browser never
hits a JSON endpoint: data reads happen in React Server Components
fetching api-server:3000 server-side; mutations (B2) will use
Next.js Server Actions. Zero public API surface to scrape or
enumerate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:34:31 +08:00
96fa650caf feat(docker): add multi-stage Dockerfile for cm-web-next 2026-05-02 20:31:53 +08:00
addc40e851 feat(web): hash-encoded API paths + catch-all Route Handler proxy 2026-05-02 20:31:38 +08:00
17e60db935 feat(web): add scaffold layout and page (frontend-design generated) 2026-05-02 20:31:16 +08:00
a556b4e3a0 feat(web): add .gitignore and .dockerignore 2026-05-02 20:25:43 +08:00
3b8973ba20 feat(web): bootstrap Next.js 15 project configs (no lockfile yet) 2026-05-02 20:25:36 +08:00