Each QuickCreate destination opens its create surface via useFocusParam:
?focus=create:server|monitor|domain|workspace|project (cron lands with the
jobs commit).
The CRM '+' lives in the sidebar footer and mobile top bar next to the
notification bell: one menu for New Service/Server/Monitor/Domain/Cron/
Workspace/Project. Route-based flows navigate directly; modal-based flows
use ?focus=create:<kind> deep links. FavoriteStar pins entities from detail
pages.
utils/recents.js tracks detail-page visits (10 max) and pinned favorites in
localStorage; useRecordVisit is the one-line detail-page hook. The palette
gains a Favorites group (weighted high, searchable) and folds visited
entities into Recently used.
Services groups by project or status, Workspaces and Servers by status; the
hand-rolled __select columns are gone in favor of DataTable's native
selection; Servers captures groupBy in its saved views.
ResourceListPage owns persisted groupBy state (captured in saved views),
renders GroupMenu in the toolbar, accepts selectable/selectedIds/
onToggleSelect/onSelectAll for the native checkbox column, and turns the row
cursor on by default for list pages.
useTableViews deep-compares the live table state against the active view
(hiddenKeys set-normalized, sorts order-sensitive); a dirty view shows an
amber dot on the trigger and Update / Reset-to-saved actions in the popover.
Group-by: groupable columns + groupBy prop render collapsible group header
rows (chevron + label + count); GroupMenu popover picks the grouping. Keyboard
nav: j/k (or arrows) move a cursor row, Enter opens it, x toggles selection —
ignored while typing or in dialogs. Selection: selectable + controlled
selectedKeys/onToggleRow/onToggleAll render a real checkbox column with an
indeterminate select-all, ending the hand-rolled __select column pattern.
One anatomy diagram, the toolbar placement rule, the drawer width scale, and
the EmptyState/Modal/useConfirm/row-click conventions; primitives list gains
ListToolbar, SortChipBar, ViewMenu and the views hook.
ResourceListPage renders the shared ListToolbar and gains a searchInTopbar
flag; Workspaces moves its search into the shared topbar to match Services
(search state still flows through for views). Superseded .wp-list__toolbar /
__tabletools SCSS removed; the .empty-state specificity comment is updated
now that the page-partial copies are gone.
Drawer widths snap to the 380/520/640/720 scale; thirteen raw ui/dialog uses
(services, projects, vaults, serverdetail tabs, webhooks, doctor panel)
convert to the shared Modal wrapper. ConnectProviderModal keeps its custom
chrome (branded header + scroll region Modal can't express).
Security tabs' legacy empty-state markup becomes the component, their bespoke
confirmDialog flows (firewall, fail2ban, ip lists, ssh keys) move to
useConfirm, and the remaining window.confirm calls (telemetry cleanup,
service settings, connections hub) are replaced. The duplicate .empty-state
block in _security.scss is gone.
DataTable emptyState overrides and legacy empty-state markup become the
shared component (wording and CTAs preserved). Also fixes DataTable passing
emptyMessage to a nonexistent EmptyState prop — it now lands on
'description', so custom empty messages actually render.
servers/deployments/server-services/packages/survey toolbars and the email
accounts/queue rows move to ListToolbar (selects get .sk-listhead__select);
email table emptyState overrides become emptyTitle/emptyMessage; the legacy
unscoped .empty-state in _servers.scss is scoped under .servers-page.
cron/bk/incidents listheads move to ListToolbar (counts and menus preserved);
bk-empty and mon-empty become EmptyState; run-history drawer width snapped to
the 640 step of the standard scale.
The .dom-listhead rows (incl. Git's six borrowed section headers) move to the
shared ListToolbar; .dom-empty filtered-empty blocks become EmptyState;
retired SCSS deleted.
.sk-listhead [title][filters][extras] [count][tools] replaces the five
hand-rolled listhead patterns; includes a standard __select style for
secondary pickers. Codifies the placement rule in its docstring.
Both tables switch to controlled sort/cols (same storageKeys); views capture
the FilterDrawer value plus sorts and columns, never server-side page state.
ViewMenu publishes into the topbar via useTopbarActions with a primitive
active-key dep to avoid re-publish loops.
ViewMenu published into the shared topbar before the filter button; the table
switches to controlled sort/cols (same storageKeys, choices survive). A view
captures search, the FilterDrawer status/type value, sorts and columns.
useTableViews merges builtin views with the user's saved views from the API
(auto-applying the default on load); ViewMenu is the picker popover — apply
on click, star for default, delete, save-current-as form, update-active. The
ResourceListPage toolbar grows the menu (state = filter/search/sorts/hidden
columns/page size), so Services and Workspaces get views by declaring their
builtin defs.
SavedView model (user/page/name/state JSON/is_default, unique per
user+page+name), idempotent 082 migration, saved_view_service with
one-default-per-page enforcement, and a JWT-scoped /api/v1/views CRUD
blueprint. Covered by 9 endpoint tests.
Queue messages/operations, delivery log, process table, env-history and
docker containers migrated. The containers tab's select+button sort UI is
deleted in favor of native header sorting (running-first default kept) with
controlled menus; FilterDrawer pairings untouched.
Docker containers/images (size parses to bytes for numeric sort), cloudflared
tunnels, cron, systemd services and survey sites now use the shared primitive;
tabs with toolbars (cloudflared, cron, services) get controlled menus.
Users, invitations, API keys, webhook deliveries, top endpoints and migration
history gain sortable columns (compact variants preserved) and standard
footers. ActivityTab keeps its server-paged fetch; its ad-hoc pager swaps to
DataTableFooter's paged mode.
Workspace sites/services/servers/members, the domain DNS record grid and the
service packages table now use the shared primitive with sortable columns and
footers; conditional DNS columns and fixed-layout classes preserved.
FTP users/connections and the nine fleet tables (versions, rollouts, command
queue, approvals, alerts, anomalies, thresholds, capacity) gain sortable
columns, persisted choices and standard footers; tables with an in-page
toolbar get controlled SortMenu/ColumnsMenu.
Repos and webhooks gain controlled SortMenu/ColumnsMenu in the listhead and
filtered footers; the webhook drawer's recent-deployments table sorts
uncontrolled. The commits feed is not table-shaped and stays as-is.
Snapshots table gains controlled sort/column menus in the listhead plus a
sort chip bar; schedules, latest-snapshots digest and run history sort
uncontrolled with persisted choices; standard footers throughout.
Firewall, Fail2ban, IP allow/blocklists, integrity changes, quarantine,
scanner findings/history and SSH keys now use the shared primitive: sortable
columns (epoch/numeric sortValues), SortMenu+ColumnsMenu in card headers
(.sec-tableactions slot), standard footers, persisted choices.
SortChipBar renders one chip per sort level above the table — click to flip
direction, x to remove, Reset to clear — and collapses to nothing when no
sort is active. Wired into ResourceListPage (list view) and the Servers page.
The servers list now runs on the shared primitive: identical cell markup and
style hooks, sortable name/status/CPU/mem/disk/last-seen (nulls last),
SortMenu + ColumnsMenu in the listhead, a shared footer, and the page's
sticky header preserved. Meter cells generated by a small helper.
Both pages declare sortable columns with meaningful sortValue accessors
(status severity order, timestamps, numerics, nulls last) and pass a
storageKey so ResourceListPage persists sort/columns/page-size.
Jobs' custom Prev/Next 'Page N' pager and Telemetry's bare 'Load more' swap
to the shared DataTableFooter (same state setters, fetch logic untouched);
both tables gain sortable columns and persisted choices. Orphaned pager SCSS
removed.
Email components/accounts/queue tables gain sortable columns; accounts and
queue toolbars get SortMenu + ColumnsMenu (new .sk-email__tabletools slot).
FleetProxy keeps its renderRow layout but gains header sorting (column
visibility intentionally off — renderRow emits every cell).
Cron jobs + run history and the domains list get sortable headers (schedule,
last/next run, duration, expiry sort by real values), SortMenu/ColumnsMenu in
the in-page listheads (new .dom-listhead__tools slot), and standard footers.
Monitor list sorts by name/type/status/response/uptime/last check; check log
gains SortMenu + ColumnsMenu in its panel header (controlled mode), sortable
time/result/latency columns, and a shared footer. Choices persist via
storageKey.
ResourceListPage now owns the table chrome end to end: SortMenu + ColumnsMenu
in the toolbar, DataTableFooter with client-side page-size slicing under the
table, and a floating bottom bulk-actions pill replacing the permanent bar.
Optional storageKey persists sort/columns/page-size per page. Sorting flips
on globally (columns still opt in via sortable: true).
Extends .sk-dtable with is-sorted accent + multi-sort priority numbers and an
opt-in sticky-header wrap; adds .sk-tablemenu (sort/columns popovers),
.sk-dtable-footer, and the floating .sk-bulkbar pill. Also restores the
.wp-list chrome styles (toolbar/search/viewswitch/cards) that were lost when
the WordPress frontend moved into its extension, plus the new tabletools slot.
DataTable v2: sorts become an ordered array (datatables.net model) — plain
header click cycles asc/desc/none, shift+click stacks sort levels with a
priority badge. New useTableSort/useColumnVisibility hooks (optional
localStorage persistence), SortMenu/ColumnsMenu toolbar popovers, and a
shared DataTableFooter (Shown X of Y, page-size segment, load-more, pager).
All backward compatible; existing call sites keep working unchanged.
With the remote index memoized, re-parsing every bundled YAML on each call
was the entire remaining cost of a Templates page load. Entries are now
memoized per file and keyed on (st_mtime_ns, st_size), so a sync or an edit
invalidates exactly the file that changed: measured on the real catalog,
list_local_templates() goes from 304ms to 0.89ms (118 templates), and the
full catalog listing from 1707ms to 1.26ms.
The writers need no invalidation call - mtime keying covers create, overwrite
and delete, the last because the listing only returns what the directory scan
finds. Cache entries are pruned to what is still on disk so a long-running
panel that churns templates cannot grow it without bound.
Failed parses are cached too, keyed on the same stat tuple, so one malformed
YAML no longer costs a parse on every render; it is retried the moment the
file changes. A file that fails to parse still does not claim its id, so a
valid file of the same name in the fallback directory can supply it - pinned
by a test, since that is easy to lose in a refactor.
Entries are deep-copied out: a shallow copy would share the nested
categories/repo/engine containers, so a caller appending to categories would
corrupt every later listing.
Also swaps os.listdir for os.scandir (DirEntry.stat() reuses data the scan
already returned) and sorts directory entries, which makes a same-id
collision (foo.yaml vs foo.yml) resolve identically on every platform instead
of depending on filesystem order.
Both bugs were latent only while DEFAULT_REPOS pointed at a 404. Now that
serverkit.ai proxies a live registry, they bite on every Templates page load.
list_all_templates() concatenated local + remote with no id merge, so every
bundled template was listed twice the moment a real registry answered.
Measured against the live registry: 224 entries for 116 unique ids. It now
merges by id with local winning, matching get_template(), which already
resolves local directories before any repo; among repos, the first enabled
one to claim an id keeps it.
fetch_remote_templates() ran inline on every listing with a 30s timeout and
no memoization, so one unreachable repo stalled the whole catalog on every
render. It is now memoized with a TTL (5m success, 60s failure), a 10s
timeout, and a last-good fallback so a transient blip cannot blank a catalog
the panel has already shown. The last-good payload is carried forward across
repeated failures rather than being overwritten by the first one. The cache
is dropped in save_config(), the single choke point for repo add/remove/
enable (sync_templates routes through it too), and force=True bypasses it.
11 proving tests, including the repeated-failure case and a regression test
that a cached entry cannot be corrupted by a caller mutating it.
pytest-split falls back to splitting by test COUNT when no durations file
exists, and these tests range from 0.01s to 10s+. That produced visibly lopsided
shards on the first sharded CI run — 6m17s for the fastest against 10m46s for
the slowest, and the slowest shard is the whole job's wall clock:
pytest (4/4) 6m 17s
pytest (2/4) 8m 42s
pytest (1/4) 10m 01s
pytest (3/4) 10m 46s <- Backend CI was 11m06s because of this one
With real durations, pytest-split packs the shards to equal TIME instead, which
should pull the slowest shard down toward the ~9m average.
Generated from a full local run: 3175 entries, 20 failed / 3108 passed /
47 skipped in 11m53s. All 20 failures are pre-existing and unrelated (a
serverkit-wordpress checkout absent locally, plus Linux-only fail2ban tests) —
verified by re-running those same files against the pre-plan-64 conftest.
Regenerating this file was only affordable because plan 64 Phase 1 landed
first; before it, a full run was slow enough that Phase 0 abandoned the attempt.
Regenerate it whenever suite timings shift materially:
cd backend && python -m pytest tests --store-durations
Plan 64 Phase 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing catches a revert of `_flask_app` to scope='function'. The suite would
still pass — just ~3x slower — and CI would drift back to a 20-minute wait,
which is how it got that way in the first place. Same idea as the
collected-test-count ratchet: turn a silent regression into a red test.
Also asserts the inverse for `app`, which must stay function-scoped: it is what
resets the database between tests, and session-scoping it would leak each
test's rows into the next and fail while pointing at the wrong test.
Verified both directions — flipping _flask_app back to scope='function' fails
the guard with the explanation, and restoring it passes.
Ratchet 3173 -> 3175 for the two new tests.
Plan 64 Phase 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `app` fixture called create_app('testing') for every one of 3173 tests,
registering 90+ blueprints each time. Measured on a fixed 199-test sample
(pytest --splits 16 --group 5 --durations=0):
setup 144.9s 89.5%
call 8.8s 5.4%
teardown 8.2s 5.0%
95% of the suite's time was preparing to test; 5% was testing. The app is now
built once per process and shared.
Same 794-test slice, before and after — identical results, 2.66x faster:
before 4 failed, 773 passed, 17 skipped 593.30s
after 4 failed, 773 passed, 17 skipped 223.11s
(the 4 are pre-existing locally: serverkit-wordpress isn't checked out here.)
Sharing one app surfaces three things a per-test app hid, all handled in the
fixture rather than patched test by test:
1. create_app() is not a pure constructor — it SEEDS the bundled flagship
extension rows as a boot side effect (test_cloudflare_extraction asserts
they are "seeded on boot"). The per-test drop_all wipes them, and a shared
app never re-boots, so every test after the first saw no flagship and the
extension routes answered 503. seed_flagship_extensions() is now re-run
after each create_all. The session fixture also boots twice on purpose:
once to create the schema, once to seed into a schema that exists.
2. Config mutation now leaks where it used to die with the app (e.g.
test_demo_deploys sets DEMO_DEPLOYS_ENABLED=False and never restores it),
so config is snapshotted and restored around every test.
3. Structure mutation cannot be undone at all — Flask has no
unregister_blueprint, and refuses setup methods once an app has served a
request. Those tests opt out via `pytestmark = pytest.mark.fresh_app` and
get a private app. Three groups needed it: 13 modules calling
register_blueprint/add_url_rule directly, 11 that install plugins (
plugin_service hot-loads their blueprints onto the live app), and
test_audit_log which adds routes with @app.route.
Tests using the `wp_extension` fixture are detected from the fixture graph
instead of being marked, since that fixture mounts blueprints on whatever app
it is handed — miss one marker and the failure lands in an unrelated test much
later in the run.
Plan 64 Phase 1. Phase 2 (savepoint rollback instead of create_all/drop_all)
is NOT included: it currently takes the slice from 4 failures to 100 failures
and 201 errors, and needs its own round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>