feat(dashboard): opt-in sort control for the Projects zone - #528
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
| Filename | Overview |
|---|---|
| src/clauster/app.py | Adds GET /api/projects/sortmeta — well-placed before the {name}/… wildcard route, guarded by is_valid_project_name, offloaded via asyncio.to_thread, and degrades to {} on OSError/SQLAlchemyError. runner is always non-None inside create_app (replaced at line 283). |
| src/clauster/templates/_dashboard_script.html | Adds projectSort/sortMeta/projectRanks state plus loadSortMeta/recomputeProjectRanks/projectOrderRank. Whole-object assignment on projectRanks is Alpine-reactive-safe. Newly created projects won't appear in PROJECT_NAMES and therefore get order:9999 when a non-name sort is active. |
| src/clauster/templates/_project_row.html | Adds :style CSS order binding for non-name sorts. p.name is HTML-escaped by Jinja autoescape, used only as a dict key inside projectOrderRank — no XSS surface. a11y trade-off (visual-only reorder) is explicitly documented in comment. |
| src/clauster/templates/dashboard.html | Adds sort select with correct x-model/@change wiring; hides 'Show all' button when non-name sort is active; wraps list-group-flush in display:flex when sorting. CSS order reorder of list-group-flush separators is called out in PR description as a reviewed trade-off. |
| tests/test_app_routes.py | Four new tests: shape/all-projects, literal-path-beats-name-route, infra-error degrades to {}, and dashboard render asserts. All use plain TestClient (no lifespan wrap), which is correct for synchronous GET routes per the custom rule. |
| .changeset/298-projects-sort.md | Changeset marking this as a minor release. Accurate description of the feature. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant Alpine as Alpine (dashboard)
participant API as GET /api/projects/sortmeta
participant DB as SessionHistoryStore
User->>Alpine: select Last used or Cost
Note over Alpine: x-model sets projectSort, @change fires
Alpine->>API: fetch /api/projects/sortmeta
API->>DB: asyncio.to_thread rollup_for x N projects
DB-->>API: name to last_used and cost_usd per project
API-->>Alpine: JSON dict or empty on error
Alpine->>Alpine: "sortMeta = response"
Alpine->>Alpine: recomputeProjectRanks builds projectRanks map
Alpine->>Alpine: CSS order applied per row
Note over Alpine: Visual reorder, no DOM moves
User->>Alpine: select Name A-Z
Alpine->>Alpine: recomputeProjectRanks clears projectRanks
Note over Alpine: CSS order removed, default A-Z restored
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User
participant Alpine as Alpine (dashboard)
participant API as GET /api/projects/sortmeta
participant DB as SessionHistoryStore
User->>Alpine: select Last used or Cost
Note over Alpine: x-model sets projectSort, @change fires
Alpine->>API: fetch /api/projects/sortmeta
API->>DB: asyncio.to_thread rollup_for x N projects
DB-->>API: name to last_used and cost_usd per project
API-->>Alpine: JSON dict or empty on error
Alpine->>Alpine: "sortMeta = response"
Alpine->>Alpine: recomputeProjectRanks builds projectRanks map
Alpine->>Alpine: CSS order applied per row
Note over Alpine: Visual reorder, no DOM moves
User->>Alpine: select Name A-Z
Alpine->>Alpine: recomputeProjectRanks clears projectRanks
Note over Alpine: CSS order removed, default A-Z restored
Reviews (2): Last reviewed commit: "feat(dashboard): opt-in sort control for..." | Re-trigger Greptile
Adds a Name / Last used / Cost sort dropdown to the Projects zone. It defaults to the existing A-Z order and never reorders on its own; only an explicit non-name choice reorders rows (via CSS `order`, no DOM moves, so Alpine x-show/idx stay intact) and reveals the full list. Projects with no recorded history sink to the bottom. Backed by a new read-only /api/projects/sortmeta batch endpoint sourced from the session-history rollup (db/stores.py rollup_for); the sort runs client-side and degrades to name order if the keys can't be read. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ecff94d to
d349b48
Compare
Merging this PR tags and publishes **v0.12.2**. Generated by knope from the `.changeset/` fragments — don't edit it by hand; add or amend a changeset on the source PR instead. --- ## Features - The Projects zone gains an optional sort control (Name / Last used / Cost). It defaults to the existing A–Z order and never reorders on its own — only when you pick a non-name sort does the list reorder (most-recent or highest-cost first, projects with no recorded history sinking to the bottom) and reveal every project. A new read-only `/api/projects/sortmeta` endpoint supplies the last-used and cost keys from the session-history rollup; the sort itself happens client-side and degrades silently to name order if the data can't be read. ([#528](#528)) - Record bridge/session lifecycle events (spawned, ready, ended, crashed) with mode and an end-of-session cost/token snapshot to a persistent session-history table, with per-project "last used / total cost" rollups readable from the DB. ([#514](#514)) - Expand the outbound webhook taxonomy beyond the four bridge events with `bg-settled` (a `claude --bg` background job settled), `permission-needed` (a hosted session parked a tool-permission prompt — the "come look" signal), and `clone-done` (a project clone finished). Each carries an `event_type` discriminator, is redacted before egress, and **defaults OFF** — opt in per-key under `webhooks.events`. ([#518](#518)) - Webhooks gain an opt-in SSRF guard. Set `webhooks.block_private_targets: true` to skip any webhook URL whose host is an internal/non-routable IP literal — loopback, link-local (incl. the `169.254.169.254` cloud-metadata IP), RFC1918 private, unspecified (`0.0.0.0`/`::`), reserved, multicast, IPv6 ULA (`fc00::/7`), and carrier-grade NAT (`100.64/10`). It also catches the non-canonical IPv4 encodings the OS resolver still dials but `ipaddress` rejects (decimal-integer `2130706433`, hex, short `127.1`), and normalizes IPv4-mapped IPv6, so none of those slip past to loopback or the metadata endpoint. Defaults to **off**, so existing LAN/private receivers keep working unchanged. DNS hostnames are not resolved (rebinding) and exotic IPv6 embeddings (NAT64, IPv4-compatible) are not normalized — out of scope for this literal-IP seam. ([#529](#529)) - New Clauster brand. A logo lockup (the carved-cells mark + `clauster` wordmark) now appears in the dashboard navbar and on the login and 404 pages, with a refreshed mark as the favicon. The lockup adapts to the theme — white on dark, black on light — and keeps the neon-green "live session" accent on both. The full logo kit (mark, wordmark, app icon, mono + accent variants, favicon) ships in `assets/logo/` alongside a brand showcase. ([#494](#494)) - Extend the bridge subprocess `PATH`/env from `clauster.yml` via `claude.path_append` and `claude.env` (both standard and pty modes), so a `claude` session can resolve user-local tools a minimal service `PATH` omits. ([#504](#504)) ## Fixes - Unify the hosted-session status badge colours with the bridge map in the shared Active list: `starting` is azure, `stopping` is orange, and `crashed` is amber (recoverable, Resume) instead of red — and the crashed badge now matches its dot. ([#505](#505)) - Serialize `SessionRunner` state persistence so a concurrent prune-races-upsert window can no longer surface a transient "could not persist bridge state" warning: the persist path now holds its own lock (mirroring the hosted manager), keeping each save atomic against interleaving startup-watch / stop / poll-loop writers. ([#501](#501)) - Harden pty-keeper sidecar parsing so a malformed `keeper_pid`/`bridge_pid` of `true`/`false` no longer resolves to PID 1 (`bool` is an `int` subclass) and is now treated as absent. ([#500](#500)) - Document the three `logs.retention_*` knobs in `clauster.yml.example`. ([#502](#502)) - Forget now clears a dead background agent even when `claude rm` soft-fails: clauster drops the orphaned job record itself, gated on the worker being confirmed dead so a live worker is never force-forgotten. ([#506](#506)) - Logs: the live-tail "reconnecting…" and "disconnected" banners are now mutually exclusive, and the disconnect banner no longer claims the bridge may have stopped while it is still running. ([#507](#507)) - Surface the config-file search order in `clauster --help`: the epilog now lists `$CLAUSTER_CONFIG` → `./clauster.yml` → `$CLAUSTER_HOME/clauster.yml`, so the resolution order is discoverable from the CLI without digging through the docs. ([#522](#522)) - A PTY-form bridge that survives a clauster restart is now reattached as a managed RUNNING instance instead of being orphaned as a STOPPED card or leaking an uncontrollable keeper process. ([#535](#535)) ## Security - Gate inline scripts with a per-request CSP nonce and drop `'unsafe-inline'` from `script-src` (`'unsafe-eval'` and `style-src 'unsafe-inline'` remain, tracked as the #442 follow-up). ([#532](#532)) - Store the `/metrics` scrape token as a SHA-256 hash at rest (`observability.metrics_token_hash`), matching the API token; mint one with the new `clauster hash-metrics-token` command. ([#503](#503)) ## Build System & Dependencies - Add a `changeset-autodraft` workflow that auto-drafts a `.changeset/*.md` fragment on trusted, same-repo PRs that touch `src/` but lack one. ([#530](#530)) Co-authored-by: clauster-ci[bot] <289303168+clauster-ci[bot]@users.noreply.github.com>
What
Adds an opt-in sort control to the dashboard's Projects zone — a
Name / Last used / Costdropdown. It defaults to the existing A–Z order and never reorders on its own; only an explicit non-name choice reorders the list (most-recent / highest-cost first; projects with no recorded history sink to the bottom) and reveals every project. The default-name path is byte-identical to today.Closes #298.
How
GET /api/projects/sortmeta— a batch endpoint returning{name: {last_used, cost_usd}}for every discovered project, sourced from the session-history rollup (db/stores.pyrollup_for). Runs off the event loop viaasyncio.to_thread; catches only infra errors (DB engine / IO) so a genuine bug surfaces as a 500 rather than a silently-empty sort (the client already falls back to name order on any non-OK response).order— no DOM moves, sox-showand the Jinjaidxcap stay intact. New stateprojectSort/sortMeta/projectRanks;recomputeProjectRanks()builds the order map (whole-object assignment, reactive-safe per test(e2e): TS-1 e2e maturity slice + fix live-log frame drop #310/fix(ui): stop the hosted live-view dropping frames + repaint through the proxy #315);projectVisible()shows the full list when a sort is active (the 6-row cap governs only the default name order).Note on the stale "blocked" label
The issue body and its 2026-06-21 triage note say blocked by #308 / #363. That prose is stale: #363 merged via #514 (2026-06-23), so the
last_used/total_cost_usdrollup this builds on exists. #308 is the open umbrella Epic, not a true blocker for this slice. Verified against git/gh, not the doc.a11y trade-off
CSS
orderreorders visually only — DOM / tab / screen-reader order stays A–Z even when the list is visually sorted. This is deliberate for an opt-in cosmetic sort (a DOM reorder is the anti-pattern the Active zone avoids) and is called out in a code comment by the:stylebinding.Tests
/api/projects/sortmetaroute tests (shape + all projects, null fields, degrade-to-empty on an infra error, literal-path-beats-name-route) plus a dashboard-render test asserting the select, its options, and theprojectSortdefault..list-group-flushseparators under CSSorder, XSS via the project-name regex).