Skip to content

Make every list page use the same table - #95

Open
jhd3197 wants to merge 62 commits into
mainfrom
dev
Open

Make every list page use the same table#95
jhd3197 wants to merge 62 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

The panel had one DataTable primitive and about sixty tables that had politely declined to use it. Each page grew its own toolbar row (.dom-listhead, .cron-listhead, .bk-listhead, .servers-listhead, .wp-list__toolbar — same anatomy, a dozen names), its own pager, its own empty state, and its own idea of whether a column could be sorted. This PR moves all of them onto shared primitives and then builds the things that only become affordable once they're shared: multi-column sorting, column visibility, row grouping, native selection with a floating bulk bar, keyboard navigation, and per-user saved views backed by a real table. The toolbar split is the one design decision worth arguing about — page-level actions, advanced filters and search publish into the shared topbar, while quick filters, counts and the table menus live in the in-page ListToolbar; that rule is now written down in AGENTS.md so the next page doesn't reinvent a thirteenth listhead. Riding along are two performance fixes that were blocking work (the backend suite rebuilt the entire Flask app once per test, and the Templates page re-parsed 118 YAML files plus refetched every remote index on every render) and a fix for a git argument-injection flaw where an attacker-controlled repository URL or clone path could be parsed by git as its own options.

Contributors

  • @CaptBoykin — reported and co-authored the git argument-injection fix.

Highlights

  • Every table sorts. Click a header to sort, shift-click to stack a second and third sort level, and the active sort shows as chips above the table you can flip or drop.
  • Show and hide columns per table, and group rows under collapsible headers with counts.
  • Save the way you like a page — filter, search, sort, hidden columns, grouping, page size — as a named view, and pick one as your default. Servers, Monitors, Domains, Cron jobs, Jobs, Telemetry and Email also ship built-in views like Down, Expiring soon, No SSL and Recently run.
  • Tables remember your sorting, columns, grouping and page size between visits.
  • Select rows with real checkboxes; a floating bar appears with bulk actions and disappears when you clear the selection. Servers can now be assigned to a group in bulk.
  • Keyboard navigation on list pages: j/k or arrows move the row cursor, Enter opens, x toggles selection.
  • One footer everywhere, replacing four different homegrown pagers: "Shown X of Y", a rows-per-page switch, and Load more or page arrows where the data is paged.
  • The command palette gained Favorites and Recently visited sections, and there's a global "+" button for creating anything from anywhere.
  • Deleting a cron job or an IP list entry now offers Undo instead of being final.
  • The service detail Events tab is now a single timeline — deployments, environment-variable changes and (for admins) audit entries for that app, interleaved newest-first.
  • The Templates page loads noticeably faster and no longer shows every bundled template twice.
  • Empty states that come from a filter now offer a way out (Clear filters, Browse catalog) instead of just saying nothing is here.
Technical changes

Security — git argument injection (GHSA-8vx6-432p-h62q)

  • New backend/app/utils/git_security.py centralizes every defence for git subprocesses. List-form subprocess.run does not help here: git parses its own arguments, so ext::sh -c <cmd>, file:///… or --upload-pack=<cmd> arriving as a "URL" or "path" is executed by git itself.
  • validate_repo_url allowlists https/http/git/ssh plus scp-like user@host:path, and rejects leading -, embedded ::, whitespace/control characters and over-long values. validate_ref_name rejects leading - and git-check-ref-format metacharacters. validate_clone_path requires an absolute path that is not itself an option or transport specifier.
  • git_argv() pins -c protocol.ext.allow=never -c protocol.file.allow=never on every network-touching invocation and git_env() sets a restrictive GIT_ALLOW_PROTOCOL, so the transport tiers are closed regardless of the host git version (the protocol.ext.allow=user default only changed in git 2.52).
  • A -- positional terminator now precedes every URL/path/refspec argument. This is co-load-bearing with protocol pinning — pinning restricts transports only and does not stop --upload-pack= style option injection.
  • GitService.clone_repository, pull_repository, get_remote_branches_from_url and GitDeployService's fetch/reset path route through the helpers. POST /deploy/clone additionally confines app_path to paths.APPS_DIR via an os.path.abspath prefix check, since that route takes the path from the request body rather than deriving it server-side.
  • backend/tests/test_git_security.py covers the validators and the argv/env shape.

Saved views (backend)

  • New SavedView model, saved_view_service, and views_bp blueprint at /api/v1/views (GET/POST/PUT/DELETE), with migration 082_saved_views (idempotent, guards on table presence, cascade on users.id).
  • Every read and write filters by the JWT identity's user_id, so a view id belonging to another user 404s rather than being editable. Unique constraint on (user_id, page, name); at most one default per (user, page), enforced by clearing the previous default inside the same transaction; MAX_VIEWS_PER_PAGE = 50.
  • AuditService.get_logs and GET /admin/activity accept target_id alongside target_type, which is what lets a detail page pull only its own audit entries.

Table primitives (frontend)

  • DataTable gains multi-column sorting (sorts array where order is priority, uncontrolled by default or controlled via sorts/onSortsChange), column visibility (hiddenKeys), grouping (groupBy + groupable/groupValue/groupLabel on columns), controlled selection with an indeterminate header checkbox, a footer slot, and storageKey for localStorage persistence.
  • Keyboard nav is opt-in per table and ignores keys while focus is inside input, textarea, select, [contenteditable], [role="dialog"], so it can't hijack typing or fire behind a modal. The cursor row is kept on screen with scrollIntoView({ block: 'nearest' }).
  • New hooks/useTableSort.js (exports applyTableSorts and nextSorts so controlled hosts share the upsert-toggle logic instead of duplicating it), hooks/useColumnVisibility.js, and hooks/useTableViews.js. All localStorage access is wrapped — private mode and quota errors degrade to "the choice doesn't persist".
  • New design-system components: ds/SortMenu, ds/SortChipBar, ds/ColumnsMenu, ds/GroupMenu, ds/ViewMenu, ds/DataTableFooter, ds/ListToolbar, all exported from ds/index.js.
  • useTableViews dirty-tracking normalizes state key-order-agnostically and compares hiddenKeys as a set, while sorts stay order-sensitive because order is priority; ViewMenu surfaces the dirty dot with Update/Reset.

List-page consolidation

  • ResourceListPage wires sort/columns/group/views menus, page-size slicing, native selection and the floating .sk-bulkbar, and takes searchInTopbar for pages that publish a SearchField into the shared topbar.
  • Roughly 50 hand-rolled tables move onto DataTable: security tabs (firewall, fail2ban, IP lists, quarantine, scanner, SSH keys, integrity), backups (snapshots/schedules/history), git (repos/webhooks/deploys), FTP, fleet monitoring, workspaces, DNS, packages, settings (users/invitations/API/migration history), server-detail tabs, queues, docker containers, and the servers fleet table.
  • Ad-hoc pagers on Jobs, Telemetry, Delivery Log and others are replaced by DataTableFooter.
  • Thirteen raw ui/dialog uses convert to the shared Modal.jsx wrapper and drawer widths snap to a 380/520/640/720 scale (ConnectProviderModal keeps its custom chrome; domain detail keeps its 1100 exception).
  • window.confirm and per-page confirm state give way to useConfirm() across the security tabs; per-page __empty markup gives way to the shared EmptyState.
  • DataTable now passes description rather than message to EmptyState, matching the component's actual prop.
  • The html .empty-state cascade hack is retained but re-documented: the page-partial copies it was fighting are gone (_security.scss's deleted, _servers.scss's now scoped under .servers-page), so it stands as defence-in-depth rather than a live conflict.
  • AGENTS.md documents the table primitives and codifies the list-page anatomy, including the topbar-vs-ListToolbar placement rule, drawer/modal width scale, confirm policy and row-click policy.

Navigation and record UX

  • New utils/recents.js tracks visited entities and pinned favorites in localStorage (deduped by type+id, capped at 10 recents), with useRecordVisit for detail pages and a FavoriteStar toggle in detail-page title areas. Favorite labels are refreshed on revisit so a renamed entity doesn't keep a stale star.
  • CommandPalette grows Favorites and Recently-visited groups, ranked ahead of raw entity hits on ties.
  • New QuickCreate global "+" menu; route-based flows navigate directly, modal/drawer flows use a ?focus=create:<kind> deep link the destination page opens.
  • Undo toasts on IP-list entry and cron-job deletion re-POST the captured row (comment/description preserved); the useConfirm gate stays in front of both.
  • service-detail/EventsTab merges deployments (still expandable) with environment-variable changes and admin-only audit entries filtered by target_id.

Performance

  • backend/tests/conftest.py builds the Flask app once per session (_flask_app) instead of once per test — fixture setup was 89.5% of the suite's runtime against 5.4% actually running tests. The fixture boots create_app('testing') twice on purpose: create_app() seeds rows as a boot side effect, so the schema has to exist before the app that seeds into it.
  • Sharing one app means config mutation now leaks, so config is snapshotted and restored around every test; structure mutation (blueprint registration) can't be undone at all, so those tests take a private app via a new fresh_app marker or by depending on the wp_extension fixture, which is detected from the fixture graph rather than requiring each module to remember the marker. Flagship extension rows are reseeded after the per-test drop_all/create_all.
  • test_fixture_scope_guard.py asserts _flask_app stays session-scoped and app stays function-scoped — a revert would otherwise still pass, just slowly, which is how CI got slow the first time.
  • backend/.test_durations (3,177 entries) lets pytest-split pack shards by time; without it, pytest-split falls back to splitting by test count, and these tests range from 0.01s to 10s+.
  • TemplateService.list_all_templates dedupes by id with local winning over remote (the bundle and the registry deliberately overlap, so concatenating listed every bundled template twice).
  • Remote index fetches are memoized per repo with a 300s TTL, a 60s negative TTL, and a last-good fallback so a transient failure keeps serving the catalog the operator has already seen instead of blanking it. The per-request timeout drops from 30s to 10s, and save_config — the single choke point for repo mutations — invalidates the cache so a newly added repo appears immediately.
  • Local templates are parsed once and memoized on (st_mtime_ns, st_size) via os.scandir, so a sync or edit invalidates exactly the file that changed and writers need no explicit invalidation. Parse failures are cached negatively and deliberately do not claim the template id, so a valid file of the same name in the fallback directory can still supply it. Cached entries are returned as deep copies, and the cache is pruned to what the directory scan still finds.

jhd3197 and others added 30 commits August 6, 2026 02:59
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>
…ow again

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>
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>
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.
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.
…oter

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.
…er, floating bulk bar

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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
…aTable

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.
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.
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.
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.
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.
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.
ViewMenu in the listhead; a view captures status, group, search, sorts and
hidden columns. Also drops an unused eslint-disable in useTableViews.
jhd3197 and others added 27 commits August 9, 2026 03:10
.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.
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.
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.
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.
…ettings

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.
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.
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).
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.
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.
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.
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.
…oard nav

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.
…cursor

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.
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.
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.
Each QuickCreate destination opens its create surface via useFocusParam:
?focus=create:server|monitor|domain|workspace|project (cron lands with the
jobs commit).
Removing an IP list entry or deleting a cron job now toasts with an Undo
action that re-POSTs the captured row (comment/description preserved). The
useConfirm gate stays; cron's ?focus=create:cron deep-link rides along.
Service/Server/Monitor/Workspace/Project detail pages record visits for the
palette's Recently used section and host a FavoriteStar in their title areas
that pins the entity into the palette's Favorites group.
AuditService.get_logs gains a target_id filter (paired with target_type) and
/admin/activity/feed passes both through — the per-resource activity
timeline can now ask 'what happened to THIS app'.
The Events tab becomes the CRM record timeline: deployments (expandable,
as before) interleaved newest-first with environment-variable changes and —
for admins — audit-log entries targeting the app, via the new target_id
feed filter. Compact non-expandable rows for config/audit entries.
The fleet table gains native selection (DataTable selectable) and a floating
bulk bar with a Set group… picker (including Ungrouped) that applies via
updateServer per row.
Domains and DeliveryLog filtered empties get Clear filters; the extension
catalog filtered empty gets Clear filters and the installed empty gets a
Browse catalog button.
…ch (GHSA-8vx6-432p-h62q)

An authenticated user of any role could inject arguments into git
subprocesses running as the backend user (root under systemd):

- repo_url was passed unvalidated to git ls-remote/clone, enabling the
  ext:: transport (host command execution on git < 2.52 defaults) and
  file:// local-repository disclosure (all git versions).
- app_path on POST /deploy/clone was passed without a '--' terminator,
  enabling --upload-pack=<cmd> option injection independent of git
  version and protocol.*.allow policy.
- branch flowed into 'git fetch origin <branch>' as an unterminated
  refspec.

Fix (new app/utils/git_security.py applied at every affected sink):

- repo_url scheme allowlist (https/http/git/ssh + scp-like SSH);
  rejects ext::/file://, local paths, leading '-', whitespace.
- '-c protocol.ext.allow=never -c protocol.file.allow=never' pinned on
  every remote-touching git call, plus a restrictive GIT_ALLOW_PROTOCOL
  in the subprocess environment.
- '--' positional terminator before URL/path/refspec arguments —
  co-load-bearing with protocol pinning, which alone does not stop
  option injection such as --upload-pack=<cmd>.
- branch/ref validation (leading '-', ref-format metacharacters) for
  clone --branch, fetch refspecs and stored deploy configs.
- POST /deploy/clone now confines app_path to the managed apps root,
  mirroring how /apps/from-repository derives it server-side.

Adds 31 regression tests; bumps version to 1.7.86.

Reported-by: CaptBoykin
Co-authored-by: Tyler Boykin <25166954+CaptBoykin@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 12, 2026 00:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR standardizes list-page tables across the panel by moving pages onto shared DataTable-based primitives (toolbar/menus/footer/empty states), adds saved table views (backend + frontend API + hook + UI), and introduces local “recents/favorites” plus a global quick-create entry point.

Changes:

  • Consolidate dozens of list/detail table UIs onto shared table primitives (DataTable, ListToolbar, DataTableFooter, sort/columns/group/view menus) and shared empty-state handling.
  • Add per-user saved table views (/api/v1/views) with persistence and UI to save/apply/update/delete views.
  • Harden git subprocess invocations against argument injection and speed up backend tests via a shared session-scoped Flask app fixture with opt-out markers.

Reviewed changes

Copilot reviewed 167 out of 168 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
VERSION Bump app version
frontend/src/utils/recents.js Local recents/favorites storage
frontend/src/styles/pages/_telemetry.scss Remove unused loading style
frontend/src/styles/pages/_settings.scss Remove legacy empty style
frontend/src/styles/pages/_service-detail.scss Add compact row styling
frontend/src/styles/pages/_servers.scss Align servers page styles
frontend/src/styles/pages/_server-survey.scss Note shared ListToolbar
frontend/src/styles/pages/_server-services.scss Note shared ListToolbar
frontend/src/styles/pages/_server-packages.scss Note shared ListToolbar
frontend/src/styles/pages/_security.scss Add table-actions cluster style
frontend/src/styles/pages/_remote-access.scss Remove legacy empty style
frontend/src/styles/pages/_notification-center.scss Remove legacy empty style
frontend/src/styles/pages/_monitors.scss Remove legacy listhead/empty
frontend/src/styles/pages/_jobs.scss Remove custom pager styles
frontend/src/styles/pages/_ftp-server.scss Add header actions layout
frontend/src/styles/pages/_email.scss Align filters with ListToolbar
frontend/src/styles/pages/_domains.scss Remove legacy listhead/empty
frontend/src/styles/pages/_docker.scss Remove legacy sort control
frontend/src/styles/pages/_deployments.scss Remove legacy toolbar/empty
frontend/src/styles/pages/_cron.scss Remove legacy listhead styles
frontend/src/styles/pages/_backups.scss Remove legacy listhead/empty
frontend/src/styles/components/_notification-center.scss Style quick-create + fav star
frontend/src/styles/components/_empty-state.scss Update empty-state cascade note
frontend/src/services/api/views.js Saved views API client
frontend/src/services/api/index.js Register views API methods
frontend/src/services/api/auth.js Add activity feed target filters
frontend/src/pages/WorkspaceDetail.jsx Record visits + favorite star
frontend/src/pages/Vaults.jsx Convert dialogs to Modal
frontend/src/pages/Templates.jsx Standardize drawer width
frontend/src/pages/ServiceDetail.jsx Record visits + favorite star
frontend/src/pages/ServerDetail.jsx Record visits + favorite star
frontend/src/pages/RemoteAccess.jsx Use EmptyState for empty
frontend/src/pages/Projects.jsx Convert dialog to Modal + focus link
frontend/src/pages/ProjectDetail.jsx Record visits + favorite + Modal
frontend/src/pages/MonitorDetail.jsx Add sort/columns menus + favorites
frontend/src/pages/Marketplace.jsx Add empty-state escape actions
frontend/src/pages/Incidents.jsx Replace listhead with ListToolbar
frontend/src/pages/FleetProxy.jsx Enable sortable columns
frontend/src/pages/Documentation.jsx Use EmptyState for empty
frontend/src/pages/Deployments.jsx Use ListToolbar + EmptyState
frontend/src/pages/DeliveryLog.jsx Convert table + pager to DataTable
frontend/src/hooks/useTableViews.js Saved views state management
frontend/src/hooks/useTableSort.js Multi-column sorting utilities
frontend/src/hooks/useRecordVisit.js Hook to record recent visits
frontend/src/hooks/useColumnVisibility.js Column visibility persistence
frontend/src/components/workspaces/WorkspaceSitesTab.jsx DataTable conversion
frontend/src/components/workspaces/WorkspaceServicesTab.jsx DataTable conversion
frontend/src/components/workspaces/WorkspaceServersTab.jsx DataTable conversion
frontend/src/components/workspaces/WorkspaceMembersTab.jsx DataTable conversion
frontend/src/components/Sidebar.jsx Add QuickCreate control
frontend/src/components/settings/WebhooksTab.jsx Convert dialogs to Modal
frontend/src/components/settings/MigrationHistoryTab.jsx DataTable conversion + EmptyState
frontend/src/components/settings/InvitationsTab.jsx DataTable conversion + EmptyState
frontend/src/components/settings/connections/ConnectionsHub.jsx Replace window.confirm with useConfirm
frontend/src/components/settings/ActivityTab.jsx Use EmptyState + DataTableFooter pager
frontend/src/components/service-detail/SettingsTab.jsx Replace confirms with useConfirm
frontend/src/components/service-detail/PackagesTab.jsx DataTable conversion
frontend/src/components/serverdetail/SurveyTab.jsx ListToolbar + DataTable conversion
frontend/src/components/serverdetail/ServerSettingsTab.jsx Convert Dialog to Modal
frontend/src/components/serverdetail/PackagesTab.jsx Replace toolbar with ListToolbar
frontend/src/components/security/VulnerabilityTab.jsx Use EmptyState component
frontend/src/components/security/IntegrityTab.jsx DataTable conversion
frontend/src/components/security/AutoUpdatesTab.jsx Use EmptyState component
frontend/src/components/security/AuditTab.jsx Use EmptyState component
frontend/src/components/QuickCreate.jsx Global quick-create menu
frontend/src/components/monitoring/FleetThresholdsPanel.jsx Sort/columns + DataTable conversion
frontend/src/components/monitoring/FleetCapacityPanel.jsx DataTable conversion
frontend/src/components/monitoring/DoctorPanel.jsx Convert Dialog to Modal
frontend/src/components/MobileTopBar.jsx Add QuickCreate control
frontend/src/components/FavoriteStar.jsx Favorite toggle component
frontend/src/components/EnvironmentVariables.jsx Env history DataTable conversion
frontend/src/components/ds/ViewMenu.jsx Saved views menu UI
frontend/src/components/ds/SortMenu.jsx Sort menu UI
frontend/src/components/ds/SortChipBar.jsx Sort chips UI
frontend/src/components/ds/ListToolbar.jsx Shared list toolbar primitive
frontend/src/components/ds/index.js Export new DS components
frontend/src/components/ds/GroupMenu.jsx Grouping menu UI
frontend/src/components/ds/DataTableFooter.jsx Shared table footer
frontend/src/components/ds/ColumnsMenu.jsx Column visibility menu UI
frontend/src/components/databases/EngineInstallDrawer.jsx Standardize drawer width
frontend/src/components/databases/EngineCatalogDrawer.jsx Standardize drawer width
frontend/src/components/dashboard/grid/WidgetLibrary.jsx Standardize drawer width
frontend/src/components/CommandPalette.jsx Favorites + recents integration
frontend/src/components/backups/BackupsOverview.jsx Latest snapshots DataTable conversion
frontend/src/components/backups/BackupHistoryList.jsx DataTable conversion
frontend/src/components/backups/BackupDetailDrawer.jsx Standardize drawer width
backend/tests/test_wp_hook_inversions.py Mark fresh_app for structure mutation
backend/tests/test_views.py Tests for saved views API
backend/tests/test_trusted_client_ip.py Mark fresh_app for structure mutation
backend/tests/test_support_bundle.py Mark fresh_app for structure mutation
backend/tests/test_status_extraction.py Mark fresh_app for structure mutation
backend/tests/test_speed_test.py Mark fresh_app for structure mutation
backend/tests/test_site_import.py Mark fresh_app for structure mutation
backend/tests/test_shared_resources.py Mark fresh_app for structure mutation
backend/tests/test_serverkit_gui_agent_gate.py Mark fresh_app for structure mutation
backend/tests/test_remote_access_extraction.py Mark fresh_app for structure mutation
backend/tests/test_plugins_pipeline.py Mark fresh_app for structure mutation
backend/tests/test_plugin_store_sdk.py Mark fresh_app for structure mutation
backend/tests/test_managed_db_users.py Mark fresh_app for structure mutation
backend/tests/test_htaccess_converter.py Mark fresh_app for structure mutation
backend/tests/test_ftp_extraction.py Mark fresh_app for structure mutation
backend/tests/test_fixture_scope_guard.py Guard fixture scopes
backend/tests/test_extension_signing.py Mark fresh_app for structure mutation
backend/tests/test_extension_platform.py Mark fresh_app for structure mutation
backend/tests/test_extension_migration.py Mark fresh_app for structure mutation
backend/tests/test_email_extraction.py Mark fresh_app for structure mutation
backend/tests/test_drift_doctor.py Mark fresh_app for structure mutation
backend/tests/test_db_admin_sso.py Mark fresh_app for structure mutation
backend/tests/test_cloud_provision_extraction.py Mark fresh_app for structure mutation
backend/tests/test_bandwidth.py Mark fresh_app for structure mutation
backend/tests/test_audit_log.py Mark fresh_app + add target_id test
backend/tests/test_api_scopes.py Mark fresh_app for structure mutation
backend/tests/test_agent_features_honest.py Mark fresh_app for structure mutation
backend/tests/conftest.py Session-scoped app + fresh_app marker
backend/tests/BASELINE_COUNT Update baseline test count
backend/migrations/versions/082_saved_views.py Add saved_views table migration
backend/app/services/saved_view_service.py Saved view CRUD service
backend/app/services/git_service.py Route git calls through hardening helpers
backend/app/services/git_deploy_service.py Harden git fetch/reset
backend/app/services/audit_service.py Add target_id filter support
backend/app/models/saved_view.py SavedView model
backend/app/models/init.py Export SavedView
backend/app/api/views.py Saved views API endpoints
backend/app/api/deploy.py Constrain clone path to apps dir
backend/app/api/admin.py Pass target filters through
backend/app/init.py Register views blueprint
AGENTS.md Document list-page/table conventions

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +36 to +40
const av = getValue(a);
const bv = getValue(b);
if (av == null && bv == null) continue;
if (av == null) return direction;
if (bv == null) return -direction;
Comment on lines 177 to +180
if target_type:
query = query.filter(AuditLog.target_type == target_type)
if target_id:
query = query.filter(AuditLog.target_id == target_id)
Comment on lines +1 to +15
import { useState } from 'react';
import { Star } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isFavorite, toggleFavorite } from '@/utils/recents';

// Pin/unpin an entity as a favorite (surfaced in the command palette's
// Favorites section). Sits in detail-page title areas.
//
// <FavoriteStar type="service" id={service.id} path={`/services/${service.id}`} label={service.name} />
export function FavoriteStar({ type, id, path, label, className }) {
const [fav, setFav] = useState(() => isFavorite(type, id));

const toggle = () => {
setFav(toggleFavorite({ type, id, path, label }));
};
Comment on lines +12 to +15
useEffect(() => {
if (entry) recordVisit(entry);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry?.type, entry?.id, entry?.label]);
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