* feat(store): flea-market entity edit feature with version history (schema v38)
Owner + admin can now edit a store entity from a real Edit page at
/marketplace/flea/{id}/edit, replacing the prior "coming soon"
placeholder. Editable: display name, description, category, video
URL, cover photo, and an optional new bundle. Type is locked (400
type_locked). Display-name change renames the on-disk slug for both
live plugin/ and version dirs (reuses rename-on-archive helper).
Schema v38 (originally drafted as v37; renumbered after rebase onto
main where v37 was taken by the curated marketplace enrichment).
Versioning model:
* Each bundle update bakes into ${DATA_DIR}/store/<id>/versions/v<N+1>/plugin/
and runs the standard guardrails pipeline.
* DEFERRED PROMOTION: live plugin/ + entity.version_no stay at the
prior approved version through the LLM review window so existing
installers keep receiving the previously approved bundle. Live swap
+ version_no/version/file_size bump happen only on LLM approval.
Blocked verdicts leave the prior version serving forever.
* store_entities gains version_no INTEGER + version_history JSON.
Each version_history entry carries hash, sha256, size, submission_id,
created_at, created_by.
* Existing entities backfill to v1 with a single-entry history seeded
from the row's current `version` hash. Initial create also seeds
versions/v1/plugin/ so future restore can copy v1 bytes forward.
Concurrency:
* Block-while-pending: an in-flight LLM review blocks any further edit
with 409 prior_version_pending. Owner waits 5-30s; Edit button on
detail page renders disabled in the same window via the new
edit_in_flight flag (decoupled from quarantine_sub since the
deferred-promotion flow keeps visibility='approved').
Rollback:
* New endpoint POST /api/store/entities/{id}/versions/{n}/restore
(owner + admin). Copies vN bundle forward as v<max+1> and re-runs
guardrails (rules tighten over time; pre-approved bundles re-validate).
Forward-only history. Same deferred-promotion semantics — live stays
at prior version until LLM approves the restored copy.
UI:
* New /marketplace/flea/{id}/edit page (owner + admin gated).
* Versions card on plugin + item detail templates (owner/admin only)
via shared _flea_versions.html partial.
* Admin queue gains v# column with current badge + separate Hash
column. Submission detail surfaces Version + Bundle hash rows.
* Activity timeline split into per-submission + entity-wide cards;
entity-wide rows render vN chips when audit row params reference
a specific version.
* Section headers (Manifest / Static / Quality / LLM review) tag
with vN chip via shared macro.
* Reviewed-by-model field surfaces explanatory text per status.
* Banner upload-failure now redirects to detail page on
submission_blocked instead of staying stuck.
Tests: 24 in tests/test_store_entity_versions.py covering metadata-
only edit, bundle-edit version bump, type lock, block-while-pending,
name change disk rename, restore flow + 404/400/403 paths, edit page
404 for non-owner, versions card visibility gating, admin queue v#
column, admin detail Version/Hash rows, deferred-promotion installer
contract (pending review doesn't break installer / blocked verdict
keeps prior / approved promotes), admin can edit/restore non-owned,
restore deferred promotion, audit log per-version params. 214 tests
green across guardrails + edit + admin + repo + schema suites.
* docs(store): refresh update_entity docstring to match deferred-promotion + submission-status gate
Bring the docstring in sync with the actual fixes from the prior
commit. The pre-fix wording said the gate read
visibility_status='pending' AND submission status — under deferred
promotion that would never fire for v2+ edits. Now describes:
- Block-while-pending gates on submission.status DIRECTLY,
independent of visibility (so v2+ deferred-promotion edits don't
slip through).
- Display-name + bundle change defers the live rename to promotion;
metadata-only renames stay immediate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
613 lines
28 KiB
HTML
613 lines
28 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Submission {{ sub.id[:8] }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<style>
|
|
/* Width + padding come from .page-shell (style-custom.css) — same
|
|
1280px container as /dashboard, /marketplace, /admin/* peers. */
|
|
.det-back { font-size: 13px; color: var(--text-secondary, #6b7280); text-decoration: none; }
|
|
.det-back:hover { color: var(--text, #111827); text-decoration: underline; }
|
|
|
|
.det-header {
|
|
margin-top: 8px; margin-bottom: 20px;
|
|
padding: 16px 20px;
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
}
|
|
.det-header h1 { margin: 0 0 6px 0; font-size: 20px; font-weight: 600; }
|
|
.det-header .id-line {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
|
color: var(--text-secondary, #6b7280);
|
|
}
|
|
.det-meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px 24px; margin-top: 12px; font-size: 13px; }
|
|
.det-meta dt { color: var(--text-secondary, #6b7280); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; }
|
|
.det-meta dd { margin: 0 0 8px 0; }
|
|
.det-meta dd a { color: var(--text, #111827); text-decoration: none; }
|
|
.det-meta dd a:hover { text-decoration: underline; }
|
|
|
|
.det-card {
|
|
margin-bottom: 16px; padding: 16px 20px;
|
|
background: var(--surface, #fff);
|
|
border: 1px solid var(--border, #e5e7eb);
|
|
border-radius: 12px;
|
|
}
|
|
.det-card h2 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; color: var(--text-secondary, #6b7280); }
|
|
|
|
.badge {
|
|
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
|
font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
|
|
}
|
|
.badge.pass, .badge.safe, .badge.low, .badge.approved { background: #d1fae5; color: #065f46; }
|
|
.badge.warn, .badge.medium { background: #fef3c7; color: #92400e; }
|
|
.badge.fail, .badge.high, .badge.critical, .badge.blocked_inline, .badge.blocked_llm, .badge.review_error { background: #fee2e2; color: #991b1b; }
|
|
.badge.pending_inline, .badge.pending_llm { background: #fef3c7; color: #92400e; }
|
|
.badge.overridden { background: #dbeafe; color: #1e40af; }
|
|
.badge.archived { background: #e5e7eb; color: #374151; }
|
|
.badge.deleted { background: #4b5563; color: #f3f4f6; }
|
|
|
|
table.sub-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
table.sub-table th, table.sub-table td {
|
|
text-align: left; padding: 6px 10px;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
vertical-align: top;
|
|
}
|
|
table.sub-table th { color: var(--text-secondary, #6b7280); text-transform: uppercase; letter-spacing: 0.4px; font-size: 10px; }
|
|
table.sub-table code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
|
|
ul.issues { margin: 0; padding-left: 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
|
|
|
|
.det-summary { font-size: 14px; line-height: 1.5; margin: 8px 0 12px 0; }
|
|
.det-error-box {
|
|
padding: 10px 12px; border-radius: 6px;
|
|
background: #fee2e2; color: #991b1b; font-size: 13px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
word-break: break-word;
|
|
}
|
|
.det-recommendation {
|
|
padding: 10px 12px; border-radius: 6px;
|
|
background: #dbeafe; color: #1e40af; font-size: 13px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.override-block {
|
|
padding: 10px 12px; border-radius: 6px;
|
|
background: #dbeafe; color: #1e40af;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.det-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
|
.btn {
|
|
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border, #e5e7eb);
|
|
background: var(--surface, #fff); cursor: pointer;
|
|
font-size: 13px; font-weight: 500; color: var(--text, #111827);
|
|
}
|
|
.btn.primary { background: var(--text, #111827); color: var(--surface, #fff); border-color: var(--text, #111827); }
|
|
.btn.danger { color: #b91c1c; }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.empty-msg { font-size: 13px; color: var(--text-secondary, #6b7280); font-style: italic; }
|
|
|
|
.timeline { list-style: none; padding: 0; margin: 0; font-size: 13px; }
|
|
.timeline li {
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border-light, #f3f4f6);
|
|
display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: baseline;
|
|
}
|
|
.timeline li:last-child { border-bottom: none; }
|
|
.timeline .ts {
|
|
font-variant-numeric: tabular-nums; color: var(--text-secondary, #6b7280);
|
|
font-size: 12px; white-space: nowrap;
|
|
}
|
|
.timeline .action {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
|
|
}
|
|
.timeline .actor { color: var(--text-secondary, #6b7280); font-size: 12px; }
|
|
.timeline .relative { font-size: 11px; color: var(--text-secondary, #9ca3af); }
|
|
|
|
/* Pending banner — surfaces above the header when status is pending_*.
|
|
Auto-polls and reloads when status flips. */
|
|
.pending-banner {
|
|
margin-top: 8px; margin-bottom: 12px;
|
|
padding: 12px 16px;
|
|
background: #fef3c7; color: #92400e;
|
|
border: 1px solid #fde68a; border-radius: 8px;
|
|
font-size: 13px; display: flex; align-items: center; gap: 10px;
|
|
}
|
|
.pending-banner .spinner {
|
|
width: 14px; height: 14px;
|
|
border: 2px solid #fcd34d; border-top-color: #92400e;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.pending-banner .elapsed { color: #92400e; opacity: 0.7; font-variant-numeric: tabular-nums; margin-left: auto; }
|
|
|
|
/* Toast — short-lived "action queued" message after rescan/retry.
|
|
Fades out so admins know the request was accepted before the page
|
|
reloads on completion. */
|
|
.toast {
|
|
position: fixed; bottom: 24px; right: 24px;
|
|
background: var(--text, #111827); color: var(--surface, #fff);
|
|
padding: 10px 16px; border-radius: 8px;
|
|
font-size: 13px; box-shadow: 0 6px 20px rgba(0,0,0,0.18);
|
|
opacity: 0; transition: opacity .25s ease;
|
|
z-index: 9999;
|
|
}
|
|
.toast.show { opacity: 1; }
|
|
</style>
|
|
|
|
<div class="det-page page-shell">
|
|
<a class="det-back" href="/admin/store/submissions">← Back to all submissions</a>
|
|
|
|
{% if sub.status in ['pending_inline','pending_llm'] %}
|
|
<div class="pending-banner" id="pendingBanner">
|
|
<div class="spinner" aria-hidden="true"></div>
|
|
<div>
|
|
<strong>Review in progress</strong> — checks running against the bundle.
|
|
Page auto-refreshes when verdict lands.
|
|
</div>
|
|
<span class="elapsed" id="pendingElapsed" data-since="{{ sub.updated_at.isoformat() if sub.updated_at else '' }}">0s</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="det-header">
|
|
<h1>{{ sub.type }} · <strong>{{ sub.name | store_display_name }}</strong> {% if sub.version %}<span style="color: var(--text-secondary, #6b7280); font-weight: normal;">v{{ sub.version }}</span>{% endif %}</h1>
|
|
<div class="id-line">{{ sub.id }}</div>
|
|
<dl class="det-meta">
|
|
<div>
|
|
<dt>Status (verdict)</dt>
|
|
<dd><span class="badge {{ sub.status }}">{{ sub.status }}</span></dd>
|
|
</div>
|
|
<div>
|
|
<dt>Entity lifecycle</dt>
|
|
<dd>
|
|
{%- if entity_visibility_status -%}
|
|
<span class="badge {{ entity_visibility_status }}">{{ entity_visibility_status }}</span>
|
|
{%- if entity_visibility_status != sub.status %}
|
|
<span style="font-size: 11px; color: var(--text-secondary, #9ca3af); margin-left: 6px;">live state — verdict immutable</span>
|
|
{%- endif %}
|
|
{%- else -%}
|
|
<span class="empty-msg">entity gone</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Version</dt>
|
|
<dd>
|
|
{%- if submission_version_no -%}
|
|
<strong>v{{ submission_version_no }}</strong>
|
|
{%- if entity_version_no and entity_version_no == submission_version_no %}
|
|
<span class="badge approved" style="margin-left: 6px;">current</span>
|
|
{%- elif entity_version_no %}
|
|
<span style="font-size: 11px; color: var(--text-secondary, #9ca3af); margin-left: 6px;">superseded — current is v{{ entity_version_no }}</span>
|
|
{%- endif %}
|
|
{%- else -%}
|
|
<span class="empty-msg">— (legacy or hash not in history)</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Bundle hash</dt>
|
|
<dd>{% if sub.version %}<code style="font-size:12px;">{{ sub.version }}</code>{% else %}<span class="empty-msg">—</span>{% endif %}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Submitter</dt>
|
|
<dd><a href="/admin/store/submissions?submitter={{ sub.submitter_id }}">{{ sub.submitter_email or sub.submitter_id }}</a></dd>
|
|
</div>
|
|
<div>
|
|
<dt>Created</dt>
|
|
<dd>{{ sub.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") if sub.created_at else "" }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Last update</dt>
|
|
<dd>
|
|
{%- if sub.updated_at -%}
|
|
{{ sub.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}
|
|
<span class="relative" data-rel-since="{{ sub.updated_at.isoformat() }}"
|
|
style="display:block; font-size:11px; color: var(--text-secondary, #9ca3af);">just now</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Reviewed by model</dt>
|
|
<dd>
|
|
{%- if sub.reviewed_by_model -%}
|
|
<code style="font-size: 12px;">{{ sub.reviewed_by_model }}</code>
|
|
{%- elif sub.status in ['pending_llm', 'pending_inline'] -%}
|
|
<span class="empty-msg">⟳ in flight</span>
|
|
{%- elif sub.status == 'blocked_inline' -%}
|
|
<span class="empty-msg">— (inline-blocked, LLM skipped)</span>
|
|
{%- elif sub.status == 'approved' -%}
|
|
<span class="empty-msg">— (guardrails disabled or no API key)</span>
|
|
{%- else -%}
|
|
<span class="empty-msg">—</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Entity ID</dt>
|
|
<dd>{% if sub.entity_id %}<code style="font-size:12px;">{{ sub.entity_id }}</code>{% else %}<span class="empty-msg">none — bundle purged or legacy row</span>{% endif %}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Bundle size</dt>
|
|
<dd>{% if sub.file_size is not none %}{% if sub.file_size < 1024 %}{{ sub.file_size }} B{% elif sub.file_size < 1048576 %}{{ "%.1f"|format(sub.file_size / 1024) }} KB{% else %}{{ "%.1f"|format(sub.file_size / 1048576) }} MB{% endif %}{% else %}—{% endif %}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Bundle SHA256</dt>
|
|
<dd>{% if sub.bundle_sha256 %}<code style="font-size:11px; word-break: break-all;">{{ sub.bundle_sha256 }}</code>{% else %}<span class="empty-msg">—</span>{% endif %}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Bundle status</dt>
|
|
<dd>
|
|
{%- if sub.bundle_purged_at -%}
|
|
<span class="badge blocked_inline">purged</span>
|
|
<span style="font-size: 12px; color: var(--text-secondary, #6b7280); margin-left: 6px;">on {{ sub.bundle_purged_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
|
|
{%- elif sub.entity_id -%}
|
|
<span class="badge approved">on disk</span>
|
|
{%- else -%}
|
|
<span class="empty-msg">no bundle (legacy row)</span>
|
|
{%- endif -%}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
{# Action buttons — same JSON endpoints as the list page. #}
|
|
<div class="det-actions">
|
|
{% if sub.status in ['blocked_inline','blocked_llm','review_error'] and sub.entity_id %}
|
|
<button class="btn primary" data-action="override" title="Force-publish this entity. Requires reason; recorded in audit_log.">Override + publish</button>
|
|
{% endif %}
|
|
{% if sub.entity_id %}
|
|
<button class="btn" data-action="rescan" title="Re-run all checks (inline + LLM) against the current bundle. Use after check rules change.">Rescan</button>
|
|
{% endif %}
|
|
{% if sub.entity_id and not sub.bundle_purged_at %}
|
|
<a class="btn" href="/api/admin/store/submissions/{{ sub.id }}/bundle.zip"
|
|
title="Download the on-disk bundle as a ZIP for forensic inspection. Audit-logged.">Download bundle</a>
|
|
{% endif %}
|
|
{% if sub.status in ['review_error','blocked_llm'] and sub.entity_id %}
|
|
<button class="btn" data-action="retry" title="Re-queue LLM review only (skips inline checks).">Retry LLM</button>
|
|
{% endif %}
|
|
<button class="btn danger" data-action="delete">Delete</button>
|
|
</div>
|
|
{# Help text: explain why override isn't shown on this row, when applicable. #}
|
|
{% if sub.status == 'approved' %}
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 8px 0 0 0;">
|
|
Already approved — no override needed. Use <strong>Rescan</strong> to re-evaluate against current rules; it may flip the verdict.
|
|
</p>
|
|
{% elif sub.status == 'overridden' %}
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 8px 0 0 0;">
|
|
Already admin-overridden. Rescan to clear the override and re-evaluate.
|
|
</p>
|
|
{% elif sub.status in ['blocked_inline','blocked_llm','review_error'] and not sub.entity_id %}
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 8px 0 0 0;">
|
|
No override available — inline-blocked submissions are rolled back at upload time, so there's no bundle to publish. Submitter must fix and re-upload.
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# ── Override history ────────────────────────────────────────────────────── #}
|
|
{% if sub.override_by %}
|
|
<div class="det-card">
|
|
<h2>Override</h2>
|
|
<div class="override-block">
|
|
Overridden by <strong>{{ override_email or sub.override_by }}</strong>{% if sub.updated_at %} on {{ sub.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC") }}{% endif %}.
|
|
{% if sub.override_reason %}<br><em>Reason:</em> {{ sub.override_reason }}{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# ── Activity timeline ───────────────────────────────────────────────────── #}
|
|
{# v37 split: per-submission events surface here. Entity-wide events
|
|
(archive / install / hard-delete) — which apply to ALL versions of
|
|
this entity — surface in the separate "Entity activity" card below
|
|
so admin can tell version-scoped from entity-scoped at a glance. #}
|
|
{% macro _render_timeline(rows) %}
|
|
<ul class="timeline">
|
|
{% for row in rows %}
|
|
<li>
|
|
<span class="ts">{{ row.timestamp.strftime("%Y-%m-%d %H:%M:%S") if row.timestamp else "" }}</span>
|
|
<span>
|
|
<span class="action">{{ row.action }}</span>
|
|
{# Inline `vN` chip when the audit's params reference a
|
|
specific version_no — makes it clear in the entity-wide
|
|
timeline which version each event applied to. #}
|
|
{% if row.params and row.params is mapping and row.params.version_no %}
|
|
<span class="badge approved" style="margin-left: 4px; font-size: 9px;">v{{ row.params.version_no }}</span>
|
|
{% endif %}
|
|
{% if row.actor_email %}<span class="actor"> · {{ row.actor_email }}</span>{% endif %}
|
|
{% if row.params and row.params is mapping %}
|
|
{% if row.params.outcome %}<span class="actor"> · → {{ row.params.outcome }}</span>{% endif %}
|
|
{% if row.params.risk_level %}<span class="actor"> · risk={{ row.params.risk_level }}</span>{% endif %}
|
|
{% if row.params.reason %}<span class="actor"> · "{{ row.params.reason[:80] }}"</span>{% endif %}
|
|
{% if row.params.renamed_to %}<span class="actor"> · → {{ row.params.renamed_to }}</span>{% endif %}
|
|
{% if row.params.restored_from_version_no %}<span class="actor"> · from v{{ row.params.restored_from_version_no }}</span>{% endif %}
|
|
{% endif %}
|
|
</span>
|
|
<span class="relative" data-rel-since="{{ row.timestamp.isoformat() if row.timestamp else '' }}">just now</span>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% endmacro %}
|
|
|
|
{% if submission_audit_rows %}
|
|
<div class="det-card">
|
|
<h2>Activity timeline{% if submission_version_no %} <span class="badge approved" style="font-size:9px;">v{{ submission_version_no }}</span>{% endif %}</h2>
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 0 0 8px 0;">
|
|
Recorded actions for THIS submission row only — accept / verdict /
|
|
rescan / override / retry / bundle download. Use this to confirm a
|
|
verdict is fresh (timestamp on the latest <code>store.submission.rescan</code>
|
|
row matches the time you clicked Rescan).
|
|
</p>
|
|
{{ _render_timeline(submission_audit_rows) }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if entity_audit_rows %}
|
|
<div class="det-card">
|
|
<h2>Entity activity (all versions)</h2>
|
|
<p style="font-size: 12px; color: var(--text-secondary, #6b7280); margin: 0 0 8px 0;">
|
|
Entity-wide events that apply to every version of this entity —
|
|
creation, archive, install / uninstall, hard delete. Same content
|
|
shows on every submission for this entity by design.
|
|
</p>
|
|
{{ _render_timeline(entity_audit_rows) }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# ── Inline checks ───────────────────────────────────────────────────────── #}
|
|
{# Each ``det-card`` below renders verdict data scoped to THIS
|
|
submission row (i.e. one specific version). Section headers get
|
|
a ``vN`` chip when ``submission_version_no`` is known so admin
|
|
never confuses one version's verdict for another's. #}
|
|
{% set ic = sub.inline_checks or {} %}
|
|
{% macro _vchip() %}{% if submission_version_no %}<span class="badge approved" style="margin-left:6px;font-size:9px;">v{{ submission_version_no }}</span>{% endif %}{% endmacro %}
|
|
|
|
<div class="det-card">
|
|
<h2>Manifest check{{ _vchip() }} {% if ic.manifest %}<span class="badge {{ ic.manifest.status or 'pass' }}">{{ ic.manifest.status or 'pass' }}</span>{% endif %}</h2>
|
|
{% if ic.manifest and ic.manifest.issues %}
|
|
<ul class="issues">
|
|
{% for issue in ic.manifest.issues %}<li><code>{{ issue }}</code></li>{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<div class="empty-msg">No manifest issues.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="det-card">
|
|
<h2>Static security scan{{ _vchip() }} {% if ic.static_security %}<span class="badge {{ ic.static_security.status or 'pass' }}">{{ ic.static_security.status or 'pass' }}</span>{% endif %}</h2>
|
|
{% if ic.static_security and ic.static_security.findings %}
|
|
<table class="sub-table">
|
|
<thead>
|
|
<tr><th>Severity</th><th>Category</th><th>File:line</th><th>Reason</th><th>Snippet</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for f in ic.static_security.findings %}
|
|
<tr>
|
|
<td><span class="badge {{ f.severity }}">{{ f.severity }}</span></td>
|
|
<td>{{ f.category }}</td>
|
|
<td><code>{{ f.file }}:{{ f.line }}</code></td>
|
|
<td>{{ f.reason }}</td>
|
|
<td><code>{{ f.snippet }}</code></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-msg">No security findings.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="det-card">
|
|
<h2>Quality + templating{{ _vchip() }} {% if ic.quality %}<span class="badge {{ ic.quality.status or 'pass' }}">{{ ic.quality.status or 'pass' }}</span>{% endif %}</h2>
|
|
{% if ic.quality %}
|
|
<div style="font-size: 13px; color: var(--text-secondary, #6b7280); margin-bottom: 8px;">
|
|
Template placeholders found: <strong>{{ ic.quality.template_placeholders or 0 }}</strong>
|
|
</div>
|
|
{% if ic.quality.issues %}
|
|
<ul class="issues">
|
|
{% for issue in ic.quality.issues %}<li><code>{{ issue }}</code></li>{% endfor %}
|
|
</ul>
|
|
{% endif %}
|
|
{% if ic.quality.template_recommendation %}
|
|
<div class="det-recommendation">{{ ic.quality.template_recommendation }}</div>
|
|
{% endif %}
|
|
{% else %}
|
|
<div class="empty-msg">No quality data.</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# ── LLM review ──────────────────────────────────────────────────────────── #}
|
|
<div class="det-card">
|
|
<h2>LLM security review{{ _vchip() }}
|
|
{% if sub.llm_findings and sub.llm_findings.risk_level %}
|
|
<span class="badge {{ sub.llm_findings.risk_level }}">{{ sub.llm_findings.risk_level }}</span>
|
|
{% endif %}
|
|
</h2>
|
|
{% if sub.llm_findings %}
|
|
{% if sub.llm_findings.error %}
|
|
<div class="det-error-box"><strong>Error:</strong> {{ sub.llm_findings.error }}</div>
|
|
{% else %}
|
|
{% if sub.llm_findings.summary %}
|
|
<p class="det-summary">{{ sub.llm_findings.summary }}</p>
|
|
{% endif %}
|
|
{% if sub.llm_findings.findings %}
|
|
<table class="sub-table">
|
|
<thead>
|
|
<tr><th>Severity</th><th>Category</th><th>File</th><th>Explanation</th><th>Fix hint</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for f in sub.llm_findings.findings %}
|
|
<tr>
|
|
<td><span class="badge {{ f.severity }}">{{ f.severity }}</span></td>
|
|
<td>{{ f.category }}</td>
|
|
<td><code>{{ f.file }}</code></td>
|
|
<td>{{ f.explanation }}</td>
|
|
<td>{{ f.fix_hint or "" }}</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-msg">No findings — model verdict was clean.</div>
|
|
{% endif %}
|
|
{% endif %}
|
|
{% elif sub.status in ['pending_inline', 'pending_llm'] %}
|
|
<div class="empty-msg">Review still in progress…</div>
|
|
{% else %}
|
|
<div class="empty-msg">No LLM verdict (review skipped or not yet run).</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# ── Other attempts by submitter ─────────────────────────────────────────── #}
|
|
{% if other_count %}
|
|
<div class="det-card">
|
|
<h2>Other attempts</h2>
|
|
<a href="/admin/store/submissions?submitter={{ sub.submitter_id }}">
|
|
View {{ other_count }} other submission{{ "" if other_count == 1 else "s" }} by {{ sub.submitter_email or sub.submitter_id }} →
|
|
</a>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const subId = {{ sub.id|tojson }};
|
|
const currentStatus = {{ sub.status|tojson }};
|
|
|
|
// Fire-and-forget toast in the bottom-right.
|
|
function toast(msg, ms) {
|
|
const el = document.createElement('div');
|
|
el.className = 'toast';
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
requestAnimationFrame(() => el.classList.add('show'));
|
|
setTimeout(() => {
|
|
el.classList.remove('show');
|
|
setTimeout(() => el.remove(), 300);
|
|
}, ms || 3500);
|
|
}
|
|
|
|
// Tick the "Xs / Xm Ys" elapsed counter on the pending banner.
|
|
function startElapsedTicker() {
|
|
const el = document.getElementById('pendingElapsed');
|
|
if (!el || !el.dataset.since) return;
|
|
const since = new Date(el.dataset.since);
|
|
if (isNaN(since.getTime())) return;
|
|
function tick() {
|
|
const sec = Math.max(0, Math.floor((Date.now() - since.getTime()) / 1000));
|
|
el.textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`;
|
|
}
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
}
|
|
|
|
// Render relative timestamps ("3s ago", "12m ago", "2h ago", "3d ago")
|
|
// for every element with data-rel-since=ISO. Refreshes every 10s so
|
|
// an admin staring at the page sees the timer move and knows the
|
|
// render is live (and not a stale cached page).
|
|
function renderRelative() {
|
|
const now = Date.now();
|
|
document.querySelectorAll('[data-rel-since]').forEach((el) => {
|
|
const iso = el.dataset.relSince;
|
|
if (!iso) { el.textContent = ''; return; }
|
|
const t = new Date(iso).getTime();
|
|
if (isNaN(t)) return;
|
|
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
|
let txt;
|
|
if (sec < 5) txt = 'just now';
|
|
else if (sec < 60) txt = `${sec}s ago`;
|
|
else if (sec < 3600) txt = `${Math.floor(sec / 60)}m ago`;
|
|
else if (sec < 86400) txt = `${Math.floor(sec / 3600)}h ago`;
|
|
else txt = `${Math.floor(sec / 86400)}d ago`;
|
|
el.textContent = txt;
|
|
});
|
|
}
|
|
renderRelative();
|
|
setInterval(renderRelative, 10000);
|
|
|
|
// Poll status; reload when it flips out of pending_*.
|
|
// ``initialDelayMs`` lets us wait a beat after a fresh action POST so
|
|
// we don't catch the database mid-write.
|
|
function startPolling(initialDelayMs) {
|
|
let attempts = 0;
|
|
const tick = async () => {
|
|
attempts++;
|
|
try {
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}`, {
|
|
credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) return;
|
|
const sub = await r.json();
|
|
if (sub.status !== 'pending_inline' && sub.status !== 'pending_llm') {
|
|
// Verdict landed — reload to render the new state.
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
} catch (e) { /* network blip; keep polling */ }
|
|
// Back off after first 30 attempts (90s) to spare the LLM if it's
|
|
// running a long Sonnet/Opus review.
|
|
const next = attempts < 30 ? 3000 : 10000;
|
|
setTimeout(tick, next);
|
|
};
|
|
setTimeout(tick, initialDelayMs || 3000);
|
|
}
|
|
|
|
document.querySelectorAll('button[data-action]').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
const action = btn.dataset.action;
|
|
btn.disabled = true;
|
|
try {
|
|
if (action === 'override') {
|
|
const reason = prompt('Override reason (required, ≥ 4 chars):');
|
|
if (!reason || reason.length < 4) { btn.disabled = false; return; }
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}/override`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({reason}),
|
|
});
|
|
if (!r.ok) { alert('Override failed: ' + await r.text()); btn.disabled = false; return; }
|
|
window.location.reload();
|
|
} else if (action === 'retry') {
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}/retry`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) { alert('Retry failed: ' + await r.text()); btn.disabled = false; return; }
|
|
// Don't reload — show toast, let the polling pick up the
|
|
// verdict so the admin sees the in-progress state.
|
|
toast('LLM review re-queued — polling for verdict…');
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} else if (action === 'rescan') {
|
|
if (!confirm('Re-run all checks against the current bundle? Verdict may flip.')) {
|
|
btn.disabled = false; return;
|
|
}
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}/rescan`, {
|
|
method: 'POST', credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) { alert('Rescan failed: ' + await r.text()); btn.disabled = false; return; }
|
|
toast('Rescan queued — polling for verdict…');
|
|
// Reload after a beat so the page re-renders with the
|
|
// pending banner; that page will start its own poll loop.
|
|
setTimeout(() => window.location.reload(), 1500);
|
|
} else if (action === 'delete') {
|
|
if (!confirm('Delete submission and its bundle? This cannot be undone.')) {
|
|
btn.disabled = false; return;
|
|
}
|
|
const r = await fetch(`/api/admin/store/submissions/${subId}`, {
|
|
method: 'DELETE', credentials: 'same-origin',
|
|
});
|
|
if (!r.ok) { alert('Delete failed: ' + await r.text()); btn.disabled = false; return; }
|
|
window.location.href = '/admin/store/submissions';
|
|
}
|
|
} catch (e) {
|
|
alert('Action failed: ' + e);
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
if (currentStatus === 'pending_inline' || currentStatus === 'pending_llm') {
|
|
startElapsedTicker();
|
|
startPolling(3000);
|
|
}
|
|
})();
|
|
</script>
|
|
{% endblock %}
|