* feat(store): flea-market upload guardrails + soft delete + JOIN-based admin queue
Adds an end-to-end guardrails pipeline for store uploads (manifest +
static-security + LLM review), persists blocked bundles for forensics,
introduces soft-delete (Archive) semantics, consolidates the legacy
/store/{id} surface into /marketplace/flea/{id}, and reworks the admin
queue so lifecycle filters read live entity visibility via LEFT JOIN
rather than a denormalized submission column.
Schema v29 → v35:
* v29 store_submissions table + store_entities.visibility_status
* v30 file_size, bundle_sha256, bundle_purged_at on submissions
* v31 reshape store_submissions (drop legacy unique on entity_id)
* v32 store_entities.archived_at/by + 'archived' visibility value
* v33 drop store_submissions.retry_count (unused)
* v34 ensure idx_store_submissions_entity exists post column-drop
* v35 broaden visibility_status enum + JOIN architecture cutover
Pipeline (src/store_guardrails/):
* Inline checks: manifest_check, static_scan, quality_check
* LLM review configurable haiku|sonnet|opus (default haiku)
* BackgroundTasks-driven async path with structured-output JSON
* Per-submitter daily quota (default 50)
* 30-day TTL purge job (POST /api/admin/run-blocked-purge)
* Bundle SHA256 + size persisted; sha256 survives purge for forensics
Visibility model:
* pending | approved | hidden | archived
* _enforce_visibility returns 404 (no leak) for non-owner non-admin
* Owner sees own non-approved entries via include_owner_id widening
* Install refused with 409 entity_not_approved when not approved
Soft-delete (DELETE /api/store/entities/{id}):
* Default = soft (visibility_status='archived'); existing installs
keep getting served the bundle so users don't lose the plugin
* ?hard=true admin-only: drops bundle + cascades user_store_installs
* Hard-delete preserves entity_id on submission as tombstone so
audit_log linkage survives for the activity timeline
Admin queue lifecycle (the JOIN refactor):
* Verdict (store_submissions.status) is immutable forensic record
* Lifecycle (store_entities.visibility_status) is live state
* /admin/store/submissions Archived chip translates to
`e.visibility_status='archived'` via LEFT JOIN — any path that
flips visibility surfaces in the queue immediately
* Detail page renders Status (verdict) and Entity lifecycle side by
side so admins see "approved at review, now archived" at a glance
URL consolidation:
* /store/{id} deleted (no redirect, stale bookmarks 404)
* /marketplace/flea/{id} is the canonical detail surface
* Three in-tree callers (upload-success, my-stack card, store
listing card) updated to point at the new URL
* Quarantine banner extracted to _quarantine_banner.html partial,
self-guarded, included from both flea detail templates
* Banner JS auto-refreshes when the verdict lands by polling
/api/marketplace/flea/{id}/detail (visibility_status +
submission_status — the latter is needed because blocked_llm
keeps the entity at visibility_status='pending')
Audit log resource format:
* runner.py emits prefixed `store_submission:{id}` (post-fix)
* Detail-page timeline query handles three patterns: prefixed
submission, helper-emitted `store_entity:{sub_id}`, and bare-id
legacy rows — all surface in the activity timeline
UX fixes:
* Owner sees Under review / Quarantined / Hidden banner with status
* Install button gray-disabled (not blue) when non-approved
* Owner cannot delete quarantined entries (403); admin can
* Admin queue: filter chips, sortable columns, paging, page-size
* Auto-refresh queue every 5s while pending rows are visible
* Store upload page file picker no longer opens twice (label →
input default action collided with explicit JS handler)
Tests: 168 passed across the guardrails suites (admin submissions,
store API, inline / LLM / purge guardrails, store repositories,
marketplace filter, schema version). New regression coverage
includes: archive surfaces via JOIN even when API path is bypassed;
deleted submission renders activity timeline (tombstone); flea
detail surfaces submission_status only for owner/admin; detail page
renders Entity lifecycle row; audit log resource format covers both
helper and runner paths.
* fix(store-guardrails): PR #233 follow-up — prompt injection, atomic PUT, BG race, schema, reaper, sort whitelist
Addresses 9 of the 23 findings from the PR #233 review (spec at
docs/superpowers/specs/2026-05-09-pr233-guardrails-fixes-spec.md).
Merge-gate items #1-#6 plus high-value mediums #7, #9-#12, #23.
Architectural items (#8 enum split, #14 factory) and pure
maintainability (#15-#22) deferred to follow-ups.
Security:
* #1 prompt injection — SYSTEM_PROMPT now passed via the SDK's
dedicated system= parameter; bundle wrapped in <bundle>...</bundle>
sentinels declared data-only by the system prompt; literal
sentinel strings in user content are escaped so an adversarial
README can't forge a close tag.
* #6 static scan honesty — module docstring + admin copy + docs
declare static scan as signal not gate; .md/.txt/.rst/.html/.json/
.yaml/.yml/.toml skipped to avoid false positives on prose.
AST mode for Python deferred (separate flag, FP comparison work).
Correctness:
* #2 PUT atomicity — bundles bake into plugin.staging-<rand>/
alongside live, atomic-rename on success; failed checks leave
live tree byte-for-byte intact.
* #3 BG-task race — set_visibility_if_pending guards verdict flips
to the (pending, hidden) review window; admin archives during
review survive; skipped flips audit-logged.
* #4 v35 NOT NULL/DEFAULT — schema v35→v36 re-applies them on
store_entities.visibility_status. CHECK constraint enforced
application-side (DuckDB ADD CHECK on existing column unsupported).
* #7 stuck-review reaper — reap_stuck_llm_reviews flips pending_llm
rows older than guardrails.stuck_review_grace_seconds (default
1800) to review_error. Scheduler runs every 15 min via new
/api/admin/run-reap-stuck-reviews. Set knob to 0 to disable.
* #9 quota counter — count_blocked_for_submitter_since now counts
blocked_inline + blocked_llm + review_error so a submitter
triggering only LLM-blocked verdicts is bounded.
* #10 missing risk_level — surfaces as review_error with
error='missing_risk_level' instead of silently defaulting to
'medium' (which looked like a model-decided block).
* #11 archived_at clear — set_visibility nulls archived_at +
archived_by when transitioning out of 'archived' so a future
read doesn't show stale archive forensics on an approved row.
Maintainability:
* #12 FSM doc comment — accurate insert/transition/lifecycle
description in src/db.py near store_submissions schema.
* #23 sort-key whitelist — admin queue rejects unknown sort keys
with 400 invalid_sort_key; substring-replace footgun removed.
Deferred (separate PRs):
* #5 quota race — proper fix requires asyncio.Lock spanning the
full pipeline; threading.Lock blocks event loop, DuckDB MVCC
doesn't help. API-level slowapi bounds worst case for now.
* #6 part 3 (AST static scan), #8 (enum split), #13 (import
bundle docs), #14 (factory consolidation), #15-#22 (maint).
Tests:
* New: tests/test_store_guardrails_prompt_injection.py (corpus +
trust-boundary invariants), tests/test_store_put_atomic.py,
tests/test_store_guardrails_reaper.py.
* Extended: test_store_guardrails_llm.py (system param, missing
risk_level, BG race), test_admin_store_submissions.py (quota
counter widening, sort whitelist 400), test_store_repositories.py
(un-archive metadata clear), test_db_schema_version.py (v36).
* Full suite: 3738 passed; 17 pre-existing baseline failures
unchanged (db migration tests, cli binary rename, catalog export,
user mgmt v5 backfill — confirmed by stash + rerun on clean tree).
337 lines
15 KiB
HTML
337 lines
15 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Store submissions — {{ config.INSTANCE_NAME }}{% endblock %}
|
||
|
||
{% block content %}
|
||
<style>
|
||
/* Width + padding come from .page-shell (style-custom.css) — same
|
||
1280px container as /dashboard, /marketplace, /admin/* peers. */
|
||
.subs-title { margin: 0 0 8px 0; font-size: 22px; font-weight: 600; }
|
||
.subs-help { color: var(--text-secondary, #6b7280); font-size: 13px; margin-bottom: 20px; }
|
||
.subs-help code { background: var(--border-light, #f3f4f6); padding: 1px 6px; border-radius: 4px; font-size: 12px; }
|
||
|
||
.subs-form {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(140px, 1fr)) auto auto;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
align-items: end;
|
||
}
|
||
.subs-form label { font-size: 11px; color: var(--text-secondary, #6b7280); text-transform: uppercase; letter-spacing: 0.4px; display: block; margin-bottom: 4px; }
|
||
.subs-form input, .subs-form select {
|
||
width: 100%; padding: 6px 10px; font-size: 13px;
|
||
border: 1px solid var(--border, #e5e7eb); border-radius: 6px;
|
||
background: var(--surface, #fff); color: var(--text, #111827);
|
||
}
|
||
.subs-form .submit-btn {
|
||
padding: 7px 14px; border-radius: 6px;
|
||
background: var(--text, #111827); color: var(--surface, #fff);
|
||
border: 1px solid var(--text, #111827); font-size: 13px; cursor: pointer;
|
||
}
|
||
.subs-form .reset-btn {
|
||
padding: 7px 14px; border-radius: 6px;
|
||
background: var(--surface, #fff); color: var(--text, #111827);
|
||
border: 1px solid var(--border, #e5e7eb); font-size: 13px; text-decoration: none;
|
||
}
|
||
|
||
.subs-status-chips { margin-bottom: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.subs-status-chips a {
|
||
text-decoration: none; padding: 6px 12px; border-radius: 6px;
|
||
border: 1px solid var(--border, #e5e7eb); color: var(--text, #111827);
|
||
font-size: 12px; font-weight: 500;
|
||
}
|
||
.subs-status-chips a.active {
|
||
background: var(--text, #111827); color: var(--surface, #fff);
|
||
border-color: var(--text, #111827);
|
||
}
|
||
|
||
.subs-filter-chip {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 4px 10px; margin-bottom: 12px;
|
||
background: #dbeafe; color: #1e40af; border-radius: 999px;
|
||
font-size: 12px; font-weight: 500;
|
||
}
|
||
.subs-filter-chip a { color: inherit; text-decoration: none; }
|
||
|
||
.subs-table-wrap {
|
||
background: var(--surface, #fff);
|
||
border: 1px solid var(--border, #e5e7eb);
|
||
border-radius: 12px;
|
||
overflow-x: auto;
|
||
}
|
||
.subs-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
.subs-table thead th {
|
||
text-align: left; padding: 12px 16px;
|
||
background: var(--border-light, #f9fafb);
|
||
border-bottom: 1px solid var(--border, #e5e7eb);
|
||
font-weight: 600; color: var(--text-secondary, #6b7280);
|
||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px;
|
||
white-space: nowrap;
|
||
}
|
||
.subs-table tbody td {
|
||
padding: 10px 16px;
|
||
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
||
vertical-align: top;
|
||
}
|
||
.subs-table tbody tr { cursor: pointer; }
|
||
.subs-table tbody tr:last-child td { border-bottom: none; }
|
||
.subs-table tbody tr:hover { background: var(--border-light, #fafafa); }
|
||
.subs-table .ts { white-space: nowrap; color: var(--text-secondary, #6b7280); font-variant-numeric: tabular-nums; }
|
||
.subs-table .submitter a { color: var(--text-secondary, #4b5563); text-decoration: none; font-size: 12px; }
|
||
.subs-table .submitter a:hover { text-decoration: underline; color: var(--text, #111827); }
|
||
|
||
.badge {
|
||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
|
||
}
|
||
.badge.pending_inline,
|
||
.badge.pending_llm {
|
||
background: #fef3c7; color: #92400e;
|
||
/* Subtle pulse so admins scanning the queue see in-flight reviews. */
|
||
animation: pulse 1.6s ease-in-out infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.55; }
|
||
}
|
||
.badge.approved { background: #d1fae5; color: #065f46; }
|
||
.badge.overridden { background: #dbeafe; color: #1e40af; }
|
||
.badge.blocked_inline,
|
||
.badge.blocked_llm,
|
||
.badge.review_error { background: #fee2e2; color: #991b1b; }
|
||
.badge.archived { background: #e5e7eb; color: #374151; }
|
||
.badge.deleted { background: #4b5563; color: #f3f4f6; }
|
||
|
||
.findings {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;
|
||
color: var(--text-secondary, #6b7280); max-width: 540px;
|
||
word-break: break-word; white-space: pre-wrap;
|
||
}
|
||
|
||
.subs-paging {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 12px 16px; font-size: 12px; color: var(--text-secondary, #6b7280);
|
||
border-top: 1px solid var(--border-light, #f3f4f6);
|
||
}
|
||
.subs-paging a, .subs-paging .disabled {
|
||
padding: 4px 10px; border-radius: 6px; text-decoration: none;
|
||
color: var(--text, #111827); border: 1px solid var(--border, #e5e7eb);
|
||
margin: 0 2px;
|
||
}
|
||
.subs-paging .disabled { opacity: 0.4; pointer-events: none; }
|
||
.subs-paging select {
|
||
padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border, #e5e7eb);
|
||
background: var(--surface, #fff); font-size: 12px;
|
||
}
|
||
|
||
.empty {
|
||
padding: 40px 16px; text-align: center;
|
||
color: var(--text-secondary, #6b7280); font-size: 13px;
|
||
}
|
||
</style>
|
||
|
||
{% set base_qs %}
|
||
{%- if status_filter %}status={{ status_filter }}&{% endif -%}
|
||
{%- if submitter_filter %}submitter={{ submitter_filter }}&{% endif -%}
|
||
{%- if type_filter %}type={{ type_filter }}&{% endif -%}
|
||
{%- if name_filter %}name={{ name_filter }}&{% endif -%}
|
||
{%- if version_filter %}version={{ version_filter }}&{% endif -%}
|
||
{%- if sort_filter %}sort={{ sort_filter }}&{% endif -%}
|
||
{%- if order_filter %}order={{ order_filter }}&{% endif -%}
|
||
{%- endset %}
|
||
|
||
{% set base_qs_no_sort %}
|
||
{%- if status_filter %}status={{ status_filter }}&{% endif -%}
|
||
{%- if submitter_filter %}submitter={{ submitter_filter }}&{% endif -%}
|
||
{%- if type_filter %}type={{ type_filter }}&{% endif -%}
|
||
{%- if name_filter %}name={{ name_filter }}&{% endif -%}
|
||
{%- if version_filter %}version={{ version_filter }}&{% endif -%}
|
||
{%- endset %}
|
||
|
||
{# Sort-link helper: clicking a sortable header toggles asc/desc on
|
||
that column while preserving every active filter. Default order on
|
||
first click of any column except created_at is ascending; clicking
|
||
the active column flips it. #}
|
||
{% macro sort_link(col, label) -%}
|
||
{% set is_active = (sort_filter == col) %}
|
||
{% set new_order = "desc" if (is_active and order_filter == "asc") else ("asc" if is_active else ("desc" if col == "created_at" else "asc")) %}
|
||
<a href="?{{ base_qs_no_sort }}sort={{ col }}&order={{ new_order }}"
|
||
style="color:inherit; text-decoration:none;">
|
||
{{ label }}{% if is_active %}{{ " ↓" if order_filter == "desc" else " ↑" }}{% endif %}
|
||
</a>
|
||
{%- endmacro %}
|
||
|
||
{# Format bytes to KB for display. Returns "—" when None. #}
|
||
{% macro fmt_size(b) -%}
|
||
{%- if b is none -%}—{%- elif b < 1024 -%}{{ b }} B{%- elif b < 1048576 -%}{{ "%.1f"|format(b / 1024) }} KB{%- else -%}{{ "%.1f"|format(b / 1048576) }} MB{%- endif -%}
|
||
{%- endmacro %}
|
||
|
||
<div class="subs-page page-shell">
|
||
<h1 class="subs-title">Store submissions</h1>
|
||
<p class="subs-help">
|
||
Every upload to <code>/api/store/entities</code> writes one row here. Inline
|
||
checks (manifest, static security, quality+templating) run synchronously;
|
||
the LLM security review runs in the background and flips
|
||
<code>pending_llm → approved</code> or <code>blocked_llm</code>. Click a
|
||
row to see the full check breakdown. Override a blocked submission to
|
||
force-publish (audit trail captured), retry a <code>review_error</code>,
|
||
or delete spam outright. See <code>docs/STORE_GUARDRAILS.md</code>.
|
||
</p>
|
||
|
||
<form class="subs-form" method="get" action="/admin/store/submissions">
|
||
{# Preserve status filter when submitting other filters. #}
|
||
{% if status_filter %}<input type="hidden" name="status" value="{{ status_filter }}">{% endif %}
|
||
{% if submitter_filter %}<input type="hidden" name="submitter" value="{{ submitter_filter }}">{% endif %}
|
||
<div>
|
||
<label for="f-type">Type</label>
|
||
<select id="f-type" name="type">
|
||
<option value="" {% if not type_filter %}selected{% endif %}>All</option>
|
||
<option value="skill" {% if type_filter == 'skill' %}selected{% endif %}>Skill</option>
|
||
<option value="agent" {% if type_filter == 'agent' %}selected{% endif %}>Agent</option>
|
||
<option value="plugin" {% if type_filter == 'plugin' %}selected{% endif %}>Plugin</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label for="f-name">Name contains</label>
|
||
<input id="f-name" name="name" type="text" value="{{ name_filter }}" placeholder="e.g. summarizer">
|
||
</div>
|
||
<div>
|
||
<label for="f-version">Version contains</label>
|
||
<input id="f-version" name="version" type="text" value="{{ version_filter }}" placeholder="e.g. 1.0">
|
||
</div>
|
||
<div>
|
||
<label for="f-limit">Per page</label>
|
||
<select id="f-limit" name="limit">
|
||
{% for n in [25, 50, 100, 200] %}
|
||
<option value="{{ n }}" {% if limit == n %}selected{% endif %}>{{ n }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<button class="submit-btn" type="submit">Apply</button>
|
||
<a class="reset-btn" href="/admin/store/submissions">Reset</a>
|
||
</form>
|
||
|
||
{% if submitter_filter %}
|
||
<div class="subs-filter-chip">
|
||
Submitter: <strong>{{ submitter_email or submitter_filter }}</strong>
|
||
<a href="/admin/store/submissions{% if status_filter or type_filter or name_filter or version_filter %}?{% if status_filter %}status={{ status_filter }}&{% endif %}{% if type_filter %}type={{ type_filter }}&{% endif %}{% if name_filter %}name={{ name_filter }}&{% endif %}{% if version_filter %}version={{ version_filter }}{% endif %}{% endif %}">✕ clear</a>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="subs-status-chips">
|
||
{% set _common %}{% if submitter_filter %}submitter={{ submitter_filter }}&{% endif %}{% if type_filter %}type={{ type_filter }}&{% endif %}{% if name_filter %}name={{ name_filter }}&{% endif %}{% if version_filter %}version={{ version_filter }}&{% endif %}{% endset %}
|
||
{# "All" excludes lifecycle-end states (archived, deleted) by default
|
||
— admin queue stays focused on actionable rows. The Archived /
|
||
Deleted chips opt back in. #}
|
||
<a href="/admin/store/submissions{% if _common %}?{{ _common[:-1] }}{% endif %}" class="{{ 'active' if not status_filter else '' }}">All</a>
|
||
{% set _pending = "pending_llm,pending_inline" %}
|
||
<a href="/admin/store/submissions?{{ _common }}status={{ _pending }}" class="{{ 'active' if status_filter == _pending else '' }}">Pending</a>
|
||
{% set _needs = "blocked_inline,blocked_llm,review_error" %}
|
||
<a href="/admin/store/submissions?{{ _common }}status={{ _needs }}" class="{{ 'active' if status_filter == _needs else '' }}">Needs review</a>
|
||
<a href="/admin/store/submissions?{{ _common }}status=approved" class="{{ 'active' if status_filter == 'approved' else '' }}">Approved</a>
|
||
<a href="/admin/store/submissions?{{ _common }}status=overridden" class="{{ 'active' if status_filter == 'overridden' else '' }}">Overridden</a>
|
||
<a href="/admin/store/submissions?{{ _common }}status=archived" class="{{ 'active' if status_filter == 'archived' else '' }}">Archived</a>
|
||
<a href="/admin/store/submissions?{{ _common }}status=deleted" class="{{ 'active' if status_filter == 'deleted' else '' }}">Deleted</a>
|
||
</div>
|
||
|
||
<div class="subs-table-wrap">
|
||
{% if items %}
|
||
<table class="subs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{{ sort_link("created_at", "When") }}</th>
|
||
<th>Submitter</th>
|
||
<th>Type / {{ sort_link("name", "name") }}</th>
|
||
<th>Version</th>
|
||
<th>{{ sort_link("status", "Status") }}</th>
|
||
<th>{{ sort_link("file_size", "Size") }}</th>
|
||
<th>Findings</th>
|
||
<th>Model</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for s in items %}
|
||
<tr data-href="/admin/store/submissions/{{ s.id }}">
|
||
<td class="ts">{{ s.created_at.strftime("%Y-%m-%d %H:%M:%S") if s.created_at else "" }}</td>
|
||
<td class="submitter">
|
||
{# Filter-by-submitter link. stopPropagation in JS so row click is suppressed when clicking the email. #}
|
||
<a href="?{{ base_qs }}submitter={{ s.submitter_id }}" data-noprop="1">{{ s.submitter_email or s.submitter_id }}</a>
|
||
</td>
|
||
<td>
|
||
<span style="color: var(--text-secondary, #6b7280); font-size: 11px;">{{ s.type }}</span>
|
||
<strong>{{ s.name | store_display_name }}</strong>
|
||
</td>
|
||
<td>{{ s.version or "" }}</td>
|
||
<td><span class="badge {{ s.status }}">{{ s.status }}</span></td>
|
||
<td style="white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--text-secondary, #6b7280);">{{ fmt_size(s.file_size) }}</td>
|
||
<td class="findings">
|
||
{%- if s.inline_checks and s.inline_checks.static_security and s.inline_checks.static_security.findings -%}
|
||
{%- for f in s.inline_checks.static_security.findings %}
|
||
[{{ f.severity }}] {{ f.file }}:{{ f.line }} — {{ f.reason }}
|
||
{%- endfor %}
|
||
{%- endif -%}
|
||
{%- if s.inline_checks and s.inline_checks.manifest and s.inline_checks.manifest.issues %}
|
||
manifest: {{ s.inline_checks.manifest.issues | join(", ") }}
|
||
{%- endif -%}
|
||
{%- if s.llm_findings and s.llm_findings.summary %}
|
||
llm: {{ s.llm_findings.risk_level }} — {{ s.llm_findings.summary }}
|
||
{%- endif -%}
|
||
{%- if s.llm_findings and s.llm_findings.error %}
|
||
error: {{ s.llm_findings.error }}
|
||
{%- endif -%}
|
||
</td>
|
||
<td><span style="font-size: 11px; color: var(--text-secondary, #6b7280);">{{ s.reviewed_by_model or "" }}</span></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
|
||
<div class="subs-paging">
|
||
<div>
|
||
{% set first = skip + 1 if total else 0 %}
|
||
{% set last = skip + items|length %}
|
||
Showing {{ first }}–{{ last }} of {{ total }}
|
||
</div>
|
||
<div>
|
||
{% set prev_skip = (skip - limit) if (skip - limit) > 0 else 0 %}
|
||
{% if skip > 0 %}
|
||
<a href="?{{ base_qs }}limit={{ limit }}&skip={{ prev_skip }}">‹ Prev</a>
|
||
{% else %}
|
||
<span class="disabled">‹ Prev</span>
|
||
{% endif %}
|
||
page {{ current_page }} of {{ pages }}
|
||
{% if current_page < pages %}
|
||
<a href="?{{ base_qs }}limit={{ limit }}&skip={{ skip + limit }}">Next ›</a>
|
||
{% else %}
|
||
<span class="disabled">Next ›</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{% else %}
|
||
<div class="empty">No submissions match the current filter.</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
// Row click → detail page; suppress when clicking the submitter-filter link.
|
||
document.querySelectorAll('.subs-table tbody tr[data-href]').forEach((tr) => {
|
||
tr.addEventListener('click', (ev) => {
|
||
if (ev.target.closest('a[data-noprop]')) return;
|
||
window.location.href = tr.dataset.href;
|
||
});
|
||
});
|
||
|
||
// If any visible row is pending_*, auto-refresh the list every 5s so
|
||
// admins watching the queue see verdicts land without a manual reload.
|
||
// Stops once no pending rows remain.
|
||
const hasPending = !!document.querySelector(
|
||
'.badge.pending_inline, .badge.pending_llm'
|
||
);
|
||
if (hasPending) {
|
||
setTimeout(() => window.location.reload(), 5000);
|
||
}
|
||
})();
|
||
</script>
|
||
{% endblock %}
|