agnes-the-ai-analyst/app/web/templates/admin_store_submissions.html
Vojtech 4501c9c3dd
fix(store-guardrails): post-#290 review follow-up — purge tuple, filter chip, stale docs, lazy bundle_meta, logger.exception (#295)
Addresses post-merge review findings on #290:

- Admin Rescan is the only post-v30 producer of status='blocked_inline'.
  Re-add it to admin queue 'Needs review' filter chip and to
  TERMINAL_BLOCKED_STATUSES in the bundle-purge job so rescan-produced
  rows surface in the default operator view and bundles get TTL-swept
  instead of lingering indefinitely.

- Update three doc-drift sites still referring to the pre-#290 spam
  counter scope (counted blocked_inline). The counter now narrows to
  blocked_llm + review_error; fix the comment in app/api/store.py,
  the docstring in get_guardrails_blocked_quota_per_day(), and the
  operator-facing hint rendered on /admin/server-config.

- Add positive test for _reject_inline_or_continue validation branch
  (code='validation_failed', checks payload shape, no-DB-write
  contract). Locks the frontend wizard's detail.checks contract.

- Tighten test_quota_disabled_with_zero — assert (200, 201) explicitly
  instead of !=429 so a 500 regression no longer passes.

- _reject_inline_or_continue takes plugin_dir and lazy-computes
  bundle_meta only on the security branch. Validation rejects no
  longer pay for a SHA256 walk on the bundle.

- Surface store.upload.security_blocked audit-log write failures via
  logger.exception instead of swallowing — that audit row is the only
  forensic trace by design.
2026-05-14 08:02:44 +02:00

356 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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>
{# Inline failures on the upload path are hard-rejected and create
no rows — the only producer of `blocked_inline` post-v30 is the
admin Rescan flow, which re-runs inline checks against an
already-quarantined bundle. Include it here so an admin who
triggers a rescan sees the result in their default queue
instead of having to scroll to 'All'. Legacy `blocked_inline`
rows from pre-v30 instances also surface here, which is fine —
same actionable state. #}
{% 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 title="Human version index (v1, v2, …) derived from the entity's version_history. Hash shown below.">v#</th>
<th>Hash</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 style="white-space: nowrap; font-variant-numeric: tabular-nums;">
{%- if s.version_no -%}
<strong>v{{ s.version_no }}</strong>
{%- if s.entity_version_no and s.entity_version_no == s.version_no %}
<span style="display:inline-block;padding:1px 5px;margin-left:4px;border-radius:999px;background:#d1fae5;color:#065f46;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.3px;">current</span>
{%- endif %}
{%- else -%}
<span style="color: var(--text-secondary, #9ca3af);"></span>
{%- endif -%}
</td>
<td><code style="font-size:11px;">{{ s.version[:12] if s.version else "" }}</code></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 %}