Spaces:
Running
Admin dashboard
Owner/maintainer control surface launched from the Dashboard activity rail. A wide modal with a top tab strip; Users, Requests, Reviews, Releases, Jobs, Announcements, and Permissions ship. The modal reopens to the last active tab (localStorage insp_admin_active_tab, validated against the live tab set; openModal() defaults to it). The Permissions tab is owner-only (ownerOnly β $isOwner); the Announcements tab is capability-gated on announcements.send (owner-only by default, but owner-toggleable to maintainers β so it filters on $canAnnounce, not the tier). Both vanish from the tab strip when ineligible, and an active-tab guard snaps back to Users on a live demotion.
Permissions compartment (owner-only)
PermissionsCompartment.svelte renders the capability matrix (GET /api/admin/permissions) grouped by category; each capability row is an On/Off Toggle (lib/components/Toggle.svelte) per tier (anonymous / contributor / maintainer), with a static always-on Owner indicator. Anonymous cells render a locked N/A marker when the capability isn't anon_eligible; the manage_permissions row is fully locked (owner-only fixed). Toggling is optimistic + revert-on-error (mirrors UsersCompartment.changeRole); a modified cell shows a reset (βΊ) affordance β POST β¦/<cap>/<tier> with {reset:true}. API client lib/api/admin-permissions.ts; the resolved capability model + endpoints live in auth-permissions.md Β§ Capabilities.
Gated on $isAdmin (maintainer or owner). Read-only data for maintainers; the role picker (Users), request resolution (Requests), and reviewer overrides (Reviews β General-drawer popover) are owner-only or use existing owner-gated routes.
The entry button (AdminDashboardButton) is badge-free β it just opens the modal. "New request" awareness moved to the per-user My Notifications rail (the owner-facing request.received alert, see notifications.md Β§ Owner review alerts); the Requests tab no longer carries an unviewed dot/count, and the marked-ready review notification was retired with the Releases-tab restructure.
Frontend
tabs/dashboard/components/admin/
| File | Role |
|---|---|
AdminDashboardButton.svelte |
Entry button above the activity rail; rendered only when $isAdmin. Opens the modal via the store. No badge. |
AdminDashboardModal.svelte |
Modal size="wide" + tab strip (adminDashboard.activeTab, persisted to localStorage). Renders UsersCompartment / RequestsCompartment / reviews/ReviewsCompartment / releases/ReleasesCompartment / jobs/JobsCompartment / AnnouncementsCompartment / PermissionsCompartment. |
AnnouncementsCompartment.svelte |
Compose a global announcement (title + optional body) + a management list of past announcements (active + revoked) with per-row Revoke. Gated on announcements.send. API client lib/api/announcements.ts; see notifications.md Β§ Announcements. |
RequestsCompartment.svelte |
Status facets (open / accepted / sent back / discarded + counts) over a review queue. Clicking a pending row expands an inline review (proposed-changes diff over ProposedEdits fields, requester note, conflict notice). Expanding a pending request marks it viewed (clears the unviewed dot/pill, decrements the badge). Owner-only inline reason + Send back / Discard; maintainers are read-only. Archived rows show a read-only resolution footer. Lazy-fetched on first activation + light visiblePoll while open. |
reviews/ReviewsCompartment.svelte |
Slimmed read-only oversight surface β two collapsible buckets (Under review [claimed, not yet marked ready] / Available for review) split FE-side from one /api/admin/reviews/list fetch. Available collapsed by default (localStorage-persisted). Filter bar (Arabic + Latin search Β· riwayah/style/channel facets Β· stalled/name sort). Marked-ready / published / staleness moved to the Releases tab. |
reviews/ReviewsRow.svelte |
One recitation row β Latin primary + Arabic muted trailing + reviewer chip; age (stale β when a claim is > 7d old) + a Segments deep-link (selectedReciter + setActiveTab('segments') + adminDashboard.close()). Row body click opens the General drawer. No Generate-TS / unread dot (retired with the restructure). |
reviews/ReviewsGeneralDrawer.svelte |
Current-claim chip is the popover trigger for owners (subtle hover ring + caret); maintainers see static text. Mark-ready submission card (between Current reviewer and Reviewer history, visible only when current_claim.mark_ready_submission is set) renders the reviewer's checklist as a 6-row β list plus the two optional comment boxes as quoted blocks β labels come from the same tabs/segments/copy/mark-ready module the reviewer saw. Bypass submissions (submission.bypass_used === true) replace the checklist with an italic bypass line + an "owner bypass" pill. Also holds the reviewer-history table (closed claims), expanded vertical timeline, and a Flagged issues count (shown only when flagged_issues_count > 0). |
reviews/ReviewerActionsPopover.svelte |
Owner-only mode-driven popover anchored to the current-claim chip β three views: menu (Change reviewer / Remove reviewer), change (debounced 300 ms HF login lookup β resolved user card + reason β Reassign; disables when picked login equals current reviewer), remove (reason β Force-release). RolePicker-style click-outside + Escape dismissal; success fires onaction so the drawer + parent list refetch. |
reviews/ExpandedTimeline.svelte |
Vertical chronological timeline with tone variants (transition / admin / job dot styles). Newest-first sort. Reused by the Releases row's Timeline expand. |
UsersCompartment.svelte |
Fetches the list once + visitor stats; people/traffic ribbon, search + role-filter toolbar, master table, detail drawer. Owns inline role-change orchestration (optimistic + revert + error banner). |
UsersTable.svelte |
Sortable master table. Role cell is a RolePicker (editable={canEditRoles}); the cell stops click-propagation only when editable so non-owners still open the drawer. |
UserDetailDrawer.svelte |
Lazy per-user detail (stats, role/claims/requests/activity timelines, HF profile link). Header pill is a RolePicker; reflects the live row role via roleOverride. |
Releases compartment (FE)
tabs/dashboard/components/admin/releases/ β the single operator lifecycle home. Sections are organized by action needed (mutually exclusive, priority order): In progress Β· Failed to publish Β· Ready to generate (marked-ready, no ts) Β· Behind edits (ts stale) Β· Republish to HF (hf stale) Β· Publish to HF (released, first publish) Β· Published & current. bucketOf() mirrors the route's _is_bucketable.
| File | Role |
|---|---|
ReleasesCompartment.svelte |
Bulk /api/admin/releases/status fetch + FE bucketing + filter bar; owns the single-open in-row expansion state (expanded = {slug, mode} β one open across the whole list), the batch-publish selection + action bar, the cut modal, and send-back on Ready-to-generate rows. Also fetches /api/admin/release-preview for the header what's-next. |
ReleasesRow.svelte |
TS/HF/GH chips + reviewer chip on Ready-to-generate rows. Action cluster: Generate/Regenerate (opens the ts expand) Β· Timeline Β· Reviewers Β· Past jobs Β· Segments redirect; Send back on Ready-to-generate; in-flight controls on running rows. Renders ReleasesRowExpansion below when this row is the open one. |
ReleasesRowExpansion.svelte |
The one shared, button-switched panel β renders ReleasesTsSettings (ts), ExpandedTimeline (timeline), ReviewerHistoryView (reviewers), or ReleasesPastJobs (jobs). Timeline + Reviewers share one lazy fetchAdminReviewDetail. |
ReleasesTsSettings.svelte |
Unified generate/regenerate form β beam 50 + probe 2, collapsible Advanced, inline Full-vs-Affected scope chooser. Launches via generateTimestamps. |
ReleasesPastJobs.svelte |
Job history + live visiblePoll log tail; on terminal success triggers a status refetch. |
ReviewerHistoryView.svelte |
Read-only current-reviewer + mark-ready submission + closed-claim history + flagged count (no claim mutation). |
ReleasesSummaryCard.svelte |
Current GH release + what's-next preview ("Adds N, refreshes M (K carried) over vX.Y.Z") + Cut / Refresh-catalog buttons + in-flight strip. |
CutReleaseModal.svelte Β· ReleasesActionBar.svelte |
Cut dry-run document; sticky batch-publish bar. |
Full release model: dataset-and-releases.md. TS gen/regen subsystem: timestamps-job.md.
Jobs compartment (FE)
tabs/dashboard/components/admin/jobs/ β the one place to see every job kind (timestamps Β· hf_publish Β· hf_publish_batch Β· cut_release Β· refresh_catalog), running and historical, across all reciters. Replaces the scatter where TS history hid in a per-reciter Past-jobs expand and release/cut/publish jobs only flashed in the Releases in-flight strip.
| File | Role |
|---|---|
JobsCompartment.svelte |
visiblePoll (~10 s) over GET /api/admin/jobs; filter chips (kind / status / reciter+id search); running pinned on top; row click opens the drawer. Gated reviews.view. |
JobDetailDrawer.svelte |
GET /api/admin/jobs/detail β summary + per-kind extras (TS settings + log tail; batch/cut members table; version/external_uri); Open on HF β (the job page url) + Cancel when running (reuses cancelReleaseJob). |
API client lib/api/admin-jobs.ts (fetchJobs, fetchJobDetail; re-exports cancelReleaseJob).
Shared job-record store (backend) β the single source the Jobs tab reads
services/admin/jobs/records.py is the one writer/reader of job records. Every kind's server-side launch() writes a running JobRecord; every complete() and every failure path (webhook non-success branches, the generic cancel route, the TS backstop) writes the terminal outcome via read-merge-write (launch fields survive). Paths reuse base.job_record_path β reciters/<slug>/jobs/<kind>/<id>.json per-slug, jobs/_global/<kind>/<id>.json global. The DB stays source-of-truth for releases; these records are the uniform observability trail (the JobRecord schema is a superset of the legacy TsJobRecord, which the TS HF-Job container still self-writes unchanged).
services/admin/jobs/registry.py aggregates: records.list_all() βͺ live base.list_in_flight_jobs(ALL_KINDS), deduped by job_id (live running wins), newest-first, TTL-cached (cache.{get,set,invalidate}_all_jobs_cache). job_detail() enriches a running TS job with its live log tail. Endpoints (gate reviews.view): GET /api/admin/jobs, GET /api/admin/jobs/detail?kind=&job_id=&slug=. Cancel reuses POST /api/admin/release-jobs/<job_id>/cancel.
Pre-store history (cut/hf_publish that predate the store) is one-shot synthesized into records by scripts/backfills/backfill_job_records.py (from gh_releases + per_recitation_releases(track='hf')), so the tab reads complete history from one store.
State:
tabs/dashboard/stores/admin-dashboard.svelte.ts(adminDashboard:open,activeTab[localStorage-persisted],openModal/close/setTab).lib/stores/reviews.svelte.ts(reviewsStore:selectedSlug,openDrawer['general'],filters,sortBy,refreshSeq;open(slug)).lib/stores/releases.svelte.ts(releasesStore:filters,sortBy,refreshSeq+requestRefresh()).
Cross-tab building blocks in lib/components/: RolePill (presentational badge), RolePicker (owner-editable wrapper β bare pill when editable=false, dropdown of role pills + click-outside/Escape when true), Avatar, Timeline. API clients lib/api/admin-users.ts, lib/api/admin-requests.ts, lib/api/admin-reviews.ts; formatters lib/utils/admin-format.ts.
Backend
Blueprint admin_users_bp at /api/admin (routes/admin/users.py), all @require_role(Role.MAINTAINER, Role.OWNER):
| Route | Purpose |
|---|---|
GET /users |
Master list. Cached on db_seq (services/admin/users.py::list_users, cache in services/storage/cache.py). |
GET /users/<hf_user_id> |
Per-user detail (lazy, uncached) β repo_admin_users aggregations. |
GET /visitor-stats |
Today's traffic rollup. Gated on env INSPECTOR_VISITOR_ANALYTICS; the compartment swallows failure. |
POST /users/<hf_user_id>/role |
Owner-only role change (@require_role(Role.OWNER) + @require_same_origin). |
Aggregation/reads: services/db/repo_admin_users.py, repo_visitors.py; visitor counting in services/admin/visitors.py. Schemas: qua_shared/schemas/wire/admin_users.py (codegen'd to FE types).
Requests compartment
Routes live in requests_bp (routes/claims/requests.py); reads in services/admin/requests.py; schema qua_shared/schemas/wire/admin_requests.py (codegen'd).
| Route | Purpose |
|---|---|
GET /admin/requests?status=open|accepted|returned|discarded |
Review-queue payload for one facet β catalog-joined (name/riwayah/style), proposed-changes diff over ProposedEdits fields, conflict flag, facet counts. Includes slugless intake rows (new-combo / new-reciter) alongside slug-based edit requests; intake rows carry source + probe. Maintainer+; tier-redacted (owners get @login+hf_id, maintainers role only). Base list cached on db_seq in cache.py (get/set_admin_requests_cache); per-caller overlay applied live. "New request" awareness is the rail's request.received alert β no unviewed badge / view-mark route. |
Resolution of slug-based edit requests stays on the existing per-slug routes, now owner-only: POST /admin/request/<slug>/reject-{soft,hard} (@require_role(Role.OWNER); the reciter.request_rejected_{soft,hard} transition handlers use _require_owner). Acceptance is implicit (the alignment pipeline β reciter.alignment_completed applies the proposed edits + resolves the request to accepted).
Intake requests (new combination / new reciter)
The Submit-recitation wizard's two slugless types β existing_reciter_new_combo and new_reciter β carry an audio source (direct links / playlist; a dropped CSV/JSON file is normalised into links[] client-side). They land in the unified requests table with slug = NULL, everything parked in payload (reciter_id, proposed_edits (ProposedEdits-shaped), source, three required attestations (distribution/links-verified/storage rights β recorded for audit), cached probe). Service: services/admin/intake.py; validation intake_validation.py; probe intake_probe.py; schemas qua_shared/schemas/wire/intake_requests.py (codegen'd).
| Route | Purpose |
|---|---|
POST /requests/intake |
Submit (any signed-in user; @require_same_origin). Structural validation only β URL format (errors list malformed chapter indices), required combination + all three attestations, with 1β114 coverage / duplicates / playlist host as warnings. 400 carries {errors, warnings}. The wizard mirrors this client-side: live invalid/missing-chapter feedback in step 2, a step-4 confirm of the attestations. |
POST /admin/requests/<id>/accept |
Owner-only. Approval only β does not write the catalog. Records the owner-confirmed canonical reciter_id (new_reciter only) onto the payload and flips the request to accepted (slug stays NULL). Source/channel/bitrate/slug are probed from the audio and can't be validly classified by a human at accept time, so the offline ingest reads accepted slugless requests, fetches + probes the audio, and creates the reciter + delivery (correct source/channel/slug) + state row, honoring auto_claim β all offline. |
POST /admin/requests/<id>/probe |
Owner-only. Reachability probe of the source (ThreadPool HEAD/ranged-GET; playlist = single check). Caches payload.probe. Never on the submit path. |
POST /admin/requests/<id>/{return,discard} |
Owner-only. Id-based resolution for slugless rows β pure request-status mutation, no state-machine transition (no delivery/state row exists). β₯10-char reason. |
The Accept-confirm dialog (AcceptIntakeDialog.svelte) is deliberately thin: for a new reciter it confirms only the canonical reciter_id (the lone human decision); for a new combination it's a plain confirm. No source/channel/slug pickers β those are ingest's job. request.intake_* are audit-only events (slugless, not in _HANDLERS) β silently dropped from both activity rails, no classification entry needed.
Request review is no longer a notification: reciter.request_rejected_{soft,hard} are HIDDEN_EVENTS and reciter.requested is public-prose-only (not ADMIN_ONLY_EVENTS) in activity_classification.py.
Reviews compartment
Routes live in admin_reviews_bp (routes/admin/reviews.py); reads + per-admin view marks in services/admin/reviews.py; schema qua_shared/schemas/wire/admin_reviews.py (codegen'd). All endpoints @require_role(Role.MAINTAINER, Role.OWNER).
| Route | Purpose |
|---|---|
GET /admin/reviews/list |
One whole-table JOIN over delivery_states + deliveries + reciters + open claims, filtered to the two states the slimmed tab covers (awaiting_review, under_review). Canonical rows (state + open-claim shape); the FE renders Under review [not marked-ready] + Available. Not cached β sub-second JOIN, refreshed on every admin action. |
GET /admin/reviews/<slug> |
General-drawer payload β base + current claim + claim history + transition timeline + timestamps_job_ids + flagged_issues_count (segments carrying a flag, from one cached load_detailed). Bounded queries + one detailed read; cheap enough to fetch eagerly on every drawer open. Also fed (lazily) by the Releases row's Timeline + Reviewers expands. 404 on unknown slug. |
The marked-ready unviewed-count / per-admin view-mark surface (
/reviews/unviewed-count,/reviews/<slug>/view, thereview_viewstable +repo_review_views, theunread/unviewed_marked_readyschema fields) was removed with the Releases restructure β marked-ready work moved to Releases without a notification.
Timestamps-generation endpoints (@require_capability("reviews.generate_timestamps"), maintainer+) β drive the Releases-tab in-row ReleasesTsSettings / ReleasesPastJobs expands. Full subsystem: timestamps-job.md.
POST /api/admin/generate-timestamps/<slug>β launch the in-container MFA (alignment-only) job. Body β_parse_ts_settingsβTsJobSettings(beam+probe_beamsβbeams, optionalchaptersfor affected-only regen, Advanced workers/flavor/timeout β no audio/peaks toggles). Valid fromunder_review+marked-ready (first publish β auto-release on success) orreleased(regen). 202{job_id, url}; 409 if a job is already running for the slug; 400 invalid; 404 unknown.@require_same_origin. Does not transition the reciter at launch.GET /api/admin/jobs/<job_id>β live status + bounded log tail (HF authoritative).GET /api/admin/jobs/<job_id>/recordβ persisted record (settings + status + full logs) fromjobs/ts/<job_id>.json; 404 if none.GET /api/admin/reciters/<slug>/ts-jobsβ persisted records for the slug (newest first), for the Past-jobs expand's history list.
Reviewer-management mutations reuse existing per-slug routes:
POST /api/admin/claim/force-release/<slug>β owner-only (_require_ownerat both route + state-machine handler level). Used by the General-drawer popover's Remove path.POST /api/admin/claim/reassign/<slug>β owner-only. Used by the popover's Change path; resolvesto_loginserver-side viahf_users.lookupbefore persistingclaim.reassigned.POST /api/admin/users/lookupβ maintainer+. Lookup-only (no mutation); the popover hits it on debounced input to render the resolved-user card.POST /api/admin/send-back/<slug>β maintainer+ (quality gate, not a claim mutation). Rejects marked-ready work for more review. Surfaced on the Releases Ready-to-generate row (the new home of marked-ready work); sending back returns the reciter tounder_review, where it reappears in the Reviews tab.- Publish / unpublish / unlock-for-revision are listed but disabled (see
docs/planning/reviews-tab-deferred.md).
The owner-only tightening on force-release + reassign was a deliberate split: owners manage who reviews; maintainers gate what ships. Maintainers retain Send-back-to-UR but cannot eject or transfer claims.
Jobs compartment (unified job view)
The Jobs tab is one place for every HF-Job kind β timestamps, hf_publish, hf_publish_batch, cut_release, refresh_catalog β running and historical, across all reciters. Clicking any row opens an in-app drawer (record + settings/logs, batch/cut members, "Open on HF", Cancel-if-running).
Shared job-record store (services/admin/jobs/records.py) β the single source the tab reads. Every kind's server-side launch() writes a running JobRecord and every terminal point (complete(), the webhook non-success branches, the cancel route) writes the succeeded/failed outcome via record_terminal (read-merge-write, so launch fields survive). The DB stays the source-of-truth for releases; these records are the uniform observability trail. Paths reuse base.job_record_path β reciters/<slug>/jobs/<kind>/ (per-slug) and jobs/_global/<kind>/ (global). JobRecord (qua_shared/schemas/bucket/jobs.py) is a superset of the legacy TsJobRecord the timestamps HF-Job container still self-writes, so old TS records parse uniformly (records.read injects kind from the path + normalizes status).
Aggregator (services/admin/jobs/registry.py) β list_all_jobs() unions records.list_all() with the live HF list (base.list_in_flight_jobs), dedups by job_id (live running wins), sorts newest-first, TTL-cached (cache.{get,set,invalidate}_all_jobs_cache, invalidated at every launch/complete/cancel). job_detail() enriches a running TS job with its live log tail.
| Endpoint | What |
|---|---|
GET /api/admin/jobs |
JobsListResponse β unified list + running_count (reviews.view). |
GET /api/admin/jobs/detail?kind=&job_id=&slug= |
One job's full JobRecord for the drawer (reviews.view). 404 if absent. |
POST /api/admin/release-jobs/<job_id>/cancel |
Cancel β reused (kind-generic, per-kind cap gates). |
History caveat:
hf_publishrows that predate the store, pluscut_release, are synthesized from the DB by the one-shotscripts/backfills/backfill_job_records.py(idempotent); going forward every kind records natively, including failures.
Role change (services/admin/users.py::set_role)
Roles are tri-state with contributor implicit (no role_assignments row). set_role reads the current role and dispatches to the existing services/auth/access.py mutations so authz + audit + durable_transaction stay centralized:
| From β To | Action |
|---|---|
| contributor β maintainer/owner | access.grant |
| maintainer β owner | access.update (in-place tier change) |
| maintainer/owner β contributor | access.revoke(cascade_release=False) β preserves the user's open claim (a contributor is a valid claim holder; demote β offboard) |
Guard: refuse to remove the last active owner (repo_access.active_owner_count) β LastOwnerError β HTTP 409. Other mappings: NotAuthorizedβ403, MemberNotFoundβ404, bad input / AccessErrorβ400.
The legacy
/api/admin/access/{grant,revoke,update}endpoints (routes/admin/access.py) remain β maintainer-capable, andrevokethere keepscascade_release=True(offboarding: auto-releases open claims). The picker is the owner-only path; offboarding is the access endpoints.
Sync
No special wiring. durable_transaction bumps db_seq β the admin-users list cache self-invalidates on next read; current_user() resolves role fresh per request (no TTL), so the affected user becomes their new role on their next request. A self-demoting owner's own FE refreshes via loadCurrentUser() immediately (and on any reload at minimum).
See auth-permissions.md for roles/predicates/edit-lock and frontend.md for the dashboard tab.