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.
The previous default of 8 was a regression risk: cm_transfer_credit.py
uses ThreadPoolExecutor with CM_TRANSFER_MAX_THREADS (default 20 in
prod compose), so up to 20 threads concurrently call self.db.query().
With pool_size=8, the 9th-20th threads would hit PoolError, which
gets caught by 'except Error' and silently returns []/False — making
transfers fail with no obvious cause.
Default bumped to 24 (covers the 20-thread default with 4 in reserve).
mysql.connector caps pool_size at 32; clamping with a clear log line
so a future operator who pushes CM_TRANSFER_MAX_THREADS too high gets
a readable message instead of a library traceback.
Operator note: if you raise CM_TRANSFER_MAX_THREADS, also raise
DB_POOL_SIZE to at least the same value (max 32). At 32 threads with
4 services × 32 = 128 conns total, still well under MySQL's default
max_connections=151.
Two wins, one root cause: every API request was opening TWO fresh MySQL
connections plus four wasted round-trips before the real query.
Old per-request shape (GET /acc/):
1. DB() constructor → open conn, SHOW TABLES LIKE 'acc',
SHOW TABLES LIKE 'user', close
2. db.query() → open conn, run SELECT, close
That's ~4 round-trips for ~10 ms of useful work. With the dashboard's
30 s auto-refresh and two open tabs (accounts + users), the api-server
churned through ~10 fresh MySQL connections every minute even when
nothing changed.
Changes:
- app/db.py: introduce a process-wide MySQLConnectionPool (size 8 by
default, override with DB_POOL_SIZE). DB() now just touches the cached
pool — no schema check, no fresh handshake. query()/execute() rent a
connection from the pool and return it via conn.close().
- app/db.py: extract the schema check into verify_tables_once() — runs
once at WSGI boot inside create_app() so a misconfigured DB still
fails fast at startup.
- app/cm_api.py: _close_database_connection() removed; the finally
blocks that wrapped every route are gone too. Pool reclamation lives
inside DB now.
- app/cm_api.py: create_app() and run() invoke verify_tables_once()
once at startup instead of CM_API.__init__ doing nothing useful.
Net: ~4× round-trip reduction per request, no MySQL handshake on the
hot path. With two gunicorn workers × pool_size 8 = 16 max in-flight
connections, well under MySQL's default max_connections=151.
(The user asked about 'batching the queries' — but the queries already
return the full row set in one shot. The bottleneck was connection
churn, not query shape. If row count grows past the comfortable single-
fetch range later, swap to LIMIT/OFFSET pagination at the API + table
component layer.)
${SUDO:+...}${SUDO:-...} is not the right ternary — ${SUDO:+x} expands
to 'x' when SUDO is non-empty AND ${SUDO:-y} expands to 'y' when SUDO
is empty, but they're not exclusive substitutions of the same variable
in this context, so 'sudo' (the value of $SUDO when set) leaked into
the output as 'sudosudo'. Replaced with an explicit if/else.
- The 'authenticate first' reminder was checking docker system info's
IndexServerAddress for 'gitea.04080616.xyz', but that field always
reports Docker Hub regardless of which registries you've logged into.
The reminder fired even right after a successful 'docker login' to
Gitea — pure noise. Reduced to a comment for the maintainer.
- The buildx error message now points at the actual root cause: buildx
is usually installed at the per-user ~/.docker/cli-plugins path, which
sudo doesn't see. Two fixes presented: docker group (no-sudo) or apt
install docker-buildx-plugin (sudo).
Mirrors the SUDO=/NO_SUDO=1 pattern from scripts/dev.sh so the script
works on hosts where the user isn't in the docker group (the default
on this dev box). Without this, 'docker info' fails immediately even
though 'docker login' (which needs no daemon socket) succeeds, and
publish.sh aborts before doing anything.
Reminder text updated to tell operators to 'sudo docker login' (or to
opt into rootless docker via NO_SUDO=1).
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.
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.
- 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.
End-state: a single web service (Next.js dashboard) per deployment, no
side-by-side Flask UI. The image name 'cm-web' now points at the Next.js
build; the legacy 'cm-web-next' tag is no longer published.
Changes:
- Delete app/cm_web_view.py and the Flask docker/web/Dockerfile.
- Rename docker/web-next/ → docker/web/ (Next.js Dockerfile takes the
cm-web slot).
- docker-compose.yml: drop the web-view service. Rename web-next → web,
container ${CM_DEPLOY_NAME}-web-next → ${CM_DEPLOY_NAME}-web, image
cm-web-next → cm-web, named volume web-next-auth-data → web-auth-data.
transfer-bot's depends_on no longer references web-view (vestigial
startup ordering, never a runtime dependency).
- docker-compose.override.yml: same rename, dockerfile path updated.
- envs: drop CM_WEB_NEXT_HOST_PORT. Repurpose CM_WEB_HOST_PORT for the
Next.js port (8010 dev, 8011 rex, 8012 siong) — same numeric values
formerly held by CM_WEB_NEXT_HOST_PORT, so aaPanel routes don't move.
- scripts/dev.sh: drops web-view + web-next from up/reset-db/logs;
--remove-orphans still cleans up legacy containers from before cutover.
- scripts/publish.sh: drop the cm-web-next build target.
- tests/test_debug_enabled.py: drop app.cm_web_view from the helper
matrix (cm_api is now the only Flask entrypoint with _debug_enabled).
- AGENTS.md / README.md / docs/aapanel-hardening.md: rewrite Flask-era
references; add migration steps for existing stacks; update aaPanel
port references (8000/8001/8005 → 8010/8011/8012).
- .gitignore: add .env, .venv/, .playwright-mcp/, node_modules/, .next/
so 'git add -A' can't accidentally stage secrets or build artifacts.
Operator action required to upgrade an existing deployment:
1. .env: drop CM_WEB_NEXT_HOST_PORT line. Set CM_WEB_HOST_PORT to
what CM_WEB_NEXT_HOST_PORT was. Make sure CM_AUTH_SECRET is set.
2. aaPanel: if proxy_pass pointed at the legacy Flask port
(8000/8001/8005), switch it to the new one (8010/8011/8012).
3. Pull the new cm-web image (Next.js) and redeploy the stack. The
old ${CM_DEPLOY_NAME}-web-view and ${CM_DEPLOY_NAME}-web-next
containers will be replaced by a single ${CM_DEPLOY_NAME}-web.
Verified locally: docker-compose YAML parses; transfer-bot runtime is
unchanged (only depends_on tidied); 38-test python suite passes.
Wraps openssl rand -hex 32 (with /dev/urandom fallback) so operators don't
have to remember the incantation. Defaults to printing the secret;
--write [path] sets/replaces CM_AUTH_SECRET in the target .env (./.env by
default) and prints the restart command.
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.
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.
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.
Avoids the well-known /login path that scanners hit by default.
The cm- prefix matches the rest of the project's namespacing
(cm-web-next, cm-api, etc.) and isn't on standard scanner wordlists.
Settings page moves to flat /cm-passkeys (was /settings/passkeys)
to drop the simple 'settings' word — same scanner-noise reasoning.
File paths follow: web/app/cm-auth/, web/app/cm-passkeys/.
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.
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 ''.
api-server is internal-only after C5 (no host port in prod compose),
so the permissive 'CORS(app)' default never fires in normal operation.
Removing it eliminates a stale '*' Access-Control-Allow-Origin that
would become attack surface if a host port were ever accidentally
re-exposed.
Server-side fetches from web-view (legacy Flask) and web-next
(Next.js RSC) don't trigger CORS — that's a browser-only mechanism.
flask_cors stays in requirements.txt because cm_web_view.py still
imports it; both get removed in B4 when the legacy web-view retires.
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.
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.
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.
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.
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.
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.