Skip to main content

max / makenotwork

server: scan dashboard Phase 2b polish Three deferred dashboard items from the audit doc: - Bulk-promote action. Symmetric to bulk-rescan but bypasses the pipeline (admin decision, not system). Note required and audit-logged with AdminAction::BulkPromote. - Audit-log filters at /admin/uploads/audit. Query-string filters on action, admin username, and time window (24h / 7d / 30d / 90d / all). Reset link clears active filters. - Per-layer scan-detail disclosure inline on each held row. Click "scan detail" under the chip strip to expand a dl listing every layer's verdict + detail. Useful when chip-tooltip text is long or the admin wants to compare multiple layers at once. CSS additions: .audit-filter-form, .layer-detail-disclosure / .layer-detail-list dt/dd. No new routes for the per-layer expansion — data is already on the held row, so it's pure markup.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-24 22:05 UTC
Commit: d225f2372f985473aa982b386eb55f22b87196be
Parent: 1e2c4eb
8 files changed, +217 insertions, -5 deletions
@@ -198,7 +198,10 @@ pub struct AuditLogRow {
198 198 }
199 199
200 200 /// Recent audit entries joined with the admin's username, newest first.
201 - /// Phase 2b consumer for the audit-log page.
201 + /// Phase 2b consumer for the audit-log page. Superseded by `list_filtered`
202 + /// for the live route; retained as a simpler no-filter accessor for tests +
203 + /// future read-only consumers.
204 + #[allow(dead_code)]
202 205 pub async fn list_recent_with_admin(db: &PgPool, limit: i64) -> Result<Vec<AuditLogRow>, sqlx::Error> {
203 206 sqlx::query_as::<_, AuditLogRow>(
204 207 r#"
@@ -215,6 +218,38 @@ pub async fn list_recent_with_admin(db: &PgPool, limit: i64) -> Result<Vec<Audit
215 218 .await
216 219 }
217 220
221 + /// Filtered audit entries for the dashboard `/admin/uploads/audit` page.
222 + /// All filters are optional; a `None` for any field means no constraint on
223 + /// that column. Newest first, capped at `limit`.
224 + #[allow(clippy::too_many_arguments)]
225 + pub async fn list_filtered(
226 + db: &PgPool,
227 + action: Option<&str>,
228 + admin_username: Option<&str>,
229 + since_days: Option<i64>,
230 + limit: i64,
231 + ) -> Result<Vec<AuditLogRow>, sqlx::Error> {
232 + sqlx::query_as::<_, AuditLogRow>(
233 + r#"
234 + SELECT saa.version_id, saa.item_id, u.username AS admin_username,
235 + saa.action, saa.prev_status, saa.new_status, saa.note, saa.created_at
236 + FROM scan_admin_actions saa
237 + JOIN users u ON u.id = saa.admin_id
238 + WHERE ($1::TEXT IS NULL OR saa.action = $1)
239 + AND ($2::TEXT IS NULL OR u.username = $2)
240 + AND ($3::BIGINT IS NULL OR saa.created_at > NOW() - ($3 || ' days')::interval)
241 + ORDER BY saa.created_at DESC
242 + LIMIT $4
243 + "#,
244 + )
245 + .bind(action)
246 + .bind(admin_username)
247 + .bind(since_days)
248 + .bind(limit)
249 + .fetch_all(db)
250 + .await
251 + }
252 +
218 253 /// Recent audit entries for the full-log page. Phase 2 surface.
219 254 #[allow(dead_code)]
220 255 pub async fn list_recent(db: &PgPool, limit: i64) -> Result<Vec<ScanAdminActionRow>, sqlx::Error> {
@@ -46,6 +46,7 @@ pub fn admin_routes() -> Router<AppState> {
46 46 .route("/api/admin/uploads/versions/{id}/quarantine", post(uploads::admin_quarantine_version))
47 47 .route("/api/admin/uploads/versions/{id}/rescan", post(uploads::admin_rescan_version))
48 48 .route("/api/admin/uploads/bulk/rescan", post(uploads::admin_bulk_rescan_held))
49 + .route("/api/admin/uploads/bulk/promote", post(uploads::admin_bulk_promote_held))
49 50 .route("/admin/uploads/queue-summary", get(uploads::admin_queue_summary_partial))
50 51 .route("/admin/uploads/audit", get(uploads::admin_scan_audit))
51 52 .route("/api/admin/users/{id}/trust", post(users::admin_trust_user))
@@ -189,16 +189,32 @@ async fn attach_last_actions(state: &AppState, rows: &mut [AdminHeldUploadRow])
189 189 }
190 190 }
191 191
192 - /// Full audit-log page.
192 + #[derive(serde::Deserialize, Default)]
193 + pub(super) struct AuditFilters {
194 + #[serde(default)]
195 + pub action: Option<String>,
196 + #[serde(default)]
197 + pub admin: Option<String>,
198 + #[serde(default)]
199 + pub since_days: Option<i64>,
200 + }
201 +
202 + /// Full audit-log page with query-string filters.
203 + ///
204 + /// `?action=promote&admin=max&since_days=30` is the canonical example. All
205 + /// filters are optional; empty/absent means no constraint on that column.
193 206 #[tracing::instrument(skip_all, name = "admin::scan_audit")]
194 207 pub(super) async fn admin_scan_audit(
195 208 State(state): State<AppState>,
196 209 session: tower_sessions::Session,
197 210 AdminUser(user): AdminUser,
211 + axum::extract::Query(filters): axum::extract::Query<AuditFilters>,
198 212 ) -> Result<impl IntoResponse> {
199 213 let csrf_token = get_csrf_token(&session).await;
200 - let entries: Vec<AdminAuditLogRow> = db::scan_admin_actions::list_recent_with_admin(
201 - &state.db, AUDIT_LOG_ROW_LIMIT,
214 + let action = filters.action.as_deref().filter(|s| !s.is_empty());
215 + let admin = filters.admin.as_deref().filter(|s| !s.is_empty());
216 + let entries: Vec<AdminAuditLogRow> = db::scan_admin_actions::list_filtered(
217 + &state.db, action, admin, filters.since_days, AUDIT_LOG_ROW_LIMIT,
202 218 ).await
203 219 .unwrap_or_default()
204 220 .iter()
@@ -209,6 +225,9 @@ pub(super) async fn admin_scan_audit(
209 225 session_user: Some(user),
210 226 admin_active_page: "uploads",
211 227 entries,
228 + filter_action: filters.action.unwrap_or_default(),
229 + filter_admin: filters.admin.unwrap_or_default(),
230 + filter_since_days: filters.since_days.map(|d| d.to_string()).unwrap_or_default(),
212 231 })
213 232 }
214 233
@@ -406,6 +425,58 @@ pub(super) async fn admin_bulk_rescan_held(
406 425 refresh_held_uploads_partial(&state).await
407 426 }
408 427
428 + /// Bulk-promote every currently-held item and version to Clean. Sticky
429 + /// decision; requires a non-empty note for audit-trail attribution. Use
430 + /// after a manual review pass where the admin has eyes on the held set and
431 + /// has confirmed each is safe.
432 + ///
433 + /// Distinct from bulk-rescan: rescan re-runs the pipeline (system decides);
434 + /// promote bypasses the pipeline (admin decides). The note is the only
435 + /// record of *why*, so we require it.
436 + #[tracing::instrument(skip_all, name = "admin::bulk_promote_held")]
437 + pub(super) async fn admin_bulk_promote_held(
438 + State(state): State<AppState>,
439 + AdminUser(admin): AdminUser,
440 + axum::Form(form): axum::Form<BulkPromoteForm>,
441 + ) -> Result<Response> {
442 + let note = form.note.trim();
443 + if note.is_empty() {
444 + return Err(crate::error::AppError::BadRequest(
445 + "Bulk promote requires a note explaining why every held file is safe to clear.".to_string(),
446 + ));
447 + }
448 +
449 + let held_items = db::scanning::get_held_items(&state.db).await?;
450 + let held_versions = db::scanning::get_held_versions(&state.db).await?;
451 + let mut total = 0usize;
452 +
453 + for v in &held_versions {
454 + if db::scanning::update_version_scan_status(&state.db, v.version_id, FileScanStatus::Clean).await.is_ok() {
455 + db::scan_admin_actions::log_version(
456 + &state.db, v.version_id, admin.id, AdminAction::BulkPromote,
457 + Some("held_for_review"), Some("clean"), Some(note),
458 + ).await.ok();
459 + total += 1;
460 + }
461 + }
462 + for i in &held_items {
463 + if db::scanning::update_item_scan_status(&state.db, i.item_id, FileScanStatus::Clean).await.is_ok() {
464 + db::scan_admin_actions::log_item(
465 + &state.db, i.item_id, admin.id, AdminAction::BulkPromote,
466 + Some("held_for_review"), Some("clean"), Some(note),
467 + ).await.ok();
468 + total += 1;
469 + }
470 + }
471 + tracing::warn!(total, admin_id = %admin.id, note = %note, "bulk promote of held queue executed");
472 + refresh_held_uploads_partial(&state).await
473 + }
474 +
475 + #[derive(serde::Deserialize)]
476 + pub(super) struct BulkPromoteForm {
477 + pub note: String,
478 + }
479 +
409 480 // ─── Live partials ───────────────────────────────────────────────────────────
410 481
411 482 /// HTMX partial: current pending + running counts. Polled by the dashboard.
@@ -143,6 +143,9 @@ pub struct AdminScanAuditTemplate {
143 143 pub session_user: Option<SessionUser>,
144 144 pub admin_active_page: &'static str,
145 145 pub entries: Vec<AdminAuditLogRow>,
146 + pub filter_action: String,
147 + pub filter_admin: String,
148 + pub filter_since_days: String,
146 149 }
147 150
148 151 /// Admin appeals queue page.
@@ -4142,6 +4142,57 @@ form button:active {
4142 4142 margin-top: 2px;
4143 4143 }
4144 4144
4145 + .audit-filter-form {
4146 + display: flex;
4147 + flex-wrap: wrap;
4148 + align-items: flex-end;
4149 + gap: var(--space-3);
4150 + padding: var(--space-3) 0;
4151 + margin-bottom: var(--space-3);
4152 + border-bottom: 1px solid var(--border);
4153 + }
4154 +
4155 + .audit-filter-form label {
4156 + display: flex;
4157 + flex-direction: column;
4158 + gap: 2px;
4159 + }
4160 +
4161 + .audit-filter-form input,
4162 + .audit-filter-form select {
4163 + font-size: 0.9rem;
4164 + }
4165 +
4166 + .layer-chips-cell { vertical-align: top; }
4167 +
4168 + .layer-detail-disclosure {
4169 + margin-top: var(--space-2);
4170 + }
4171 +
4172 + .layer-detail-disclosure summary {
4173 + cursor: pointer;
4174 + user-select: none;
4175 + padding: 2px 0;
4176 + }
4177 +
4178 + .layer-detail-list {
4179 + margin: var(--space-2) 0 0;
4180 + display: grid;
4181 + grid-template-columns: max-content 1fr;
4182 + gap: 2px var(--space-3);
4183 + font-size: 0.8rem;
4184 + }
4185 +
4186 + .layer-detail-list dt {
4187 + font-family: var(--font-mono);
4188 + }
4189 +
4190 + .layer-detail-list dd {
4191 + margin: 0;
4192 + color: var(--text-muted);
4193 + word-break: break-word;
4194 + }
4195 +
4145 4196 .admin-page table {
4146 4197 width: 100%;
4147 4198 border-collapse: collapse;
@@ -15,6 +15,38 @@
15 15 </div>
16 16 <p class="dimmed">Every admin action against the scan pipeline. Newest first.</p>
17 17
18 + <form method="get" action="/admin/uploads/audit" class="audit-filter-form">
19 + <label>
20 + <span class="text-xs dimmed">Action</span>
21 + <select name="action">
22 + <option value=""{% if filter_action.is_empty() %} selected{% endif %}>Any</option>
23 + <option value="promote"{% if filter_action == "promote" %} selected{% endif %}>promote</option>
24 + <option value="quarantine"{% if filter_action == "quarantine" %} selected{% endif %}>quarantine</option>
25 + <option value="rescan"{% if filter_action == "rescan" %} selected{% endif %}>rescan</option>
26 + <option value="bulk_promote"{% if filter_action == "bulk_promote" %} selected{% endif %}>bulk_promote</option>
27 + <option value="bulk_rescan"{% if filter_action == "bulk_rescan" %} selected{% endif %}>bulk_rescan</option>
28 + </select>
29 + </label>
30 + <label>
31 + <span class="text-xs dimmed">Admin</span>
32 + <input type="text" name="admin" value="{{ filter_admin }}" placeholder="username">
33 + </label>
34 + <label>
35 + <span class="text-xs dimmed">Within</span>
36 + <select name="since_days">
37 + <option value=""{% if filter_since_days.is_empty() %} selected{% endif %}>All time</option>
38 + <option value="1"{% if filter_since_days == "1" %} selected{% endif %}>last 24h</option>
39 + <option value="7"{% if filter_since_days == "7" %} selected{% endif %}>last 7d</option>
40 + <option value="30"{% if filter_since_days == "30" %} selected{% endif %}>last 30d</option>
41 + <option value="90"{% if filter_since_days == "90" %} selected{% endif %}>last 90d</option>
42 + </select>
43 + </label>
44 + <button type="submit" class="btn-secondary small">Filter</button>
45 + {% if !filter_action.is_empty() || !filter_admin.is_empty() || !filter_since_days.is_empty() %}
46 + <a href="/admin/uploads/audit" class="text-sm dimmed">Reset</a>
47 + {% endif %}
48 + </form>
49 +
18 50 {% if entries.is_empty() %}
19 51 <div class="text-center dimmed empty-state">No audit entries yet.</div>
20 52 {% else %}
@@ -78,6 +78,14 @@
78 78 hx-confirm="Re-scan every held file under the current pipeline? Workers will pick them up shortly.">
79 79 Re-scan all held
80 80 </button>
81 + <button class="btn-secondary small"
82 + hx-post="/api/admin/uploads/bulk/promote"
83 + hx-target="#uploads-table"
84 + hx-swap="innerHTML"
85 + hx-prompt="Note explaining why every held file is safe to clear (required, audit-logged):"
86 + hx-vals='js:{ note: event.detail.prompt }'>
87 + Promote all held
88 + </button>
81 89 {% endif %}
82 90 </div>
83 91 </div>
@@ -39,16 +39,27 @@
39 39 </div>
40 40 {% endif %}
41 41 </td>
42 - <td class="layer-chips">
42 + <td class="layer-chips-cell">
43 43 {% if upload.layers.is_empty() %}
44 44 <span class="dimmed text-xs">no scan record</span>
45 45 {% else %}
46 + <div class="layer-chips">
46 47 {% for chip in upload.layers %}
47 48 <span class="layer-chip layer-chip-{{ chip.verdict }}"
48 49 title="{{ chip.layer }}: {{ chip.verdict }}{% if let Some(d) = chip.detail %} — {{ d }}{% endif %}">
49 50 {{ chip.layer }}
50 51 </span>
51 52 {% endfor %}
53 + </div>
54 + <details class="layer-detail-disclosure">
55 + <summary class="text-xs dimmed">scan detail</summary>
56 + <dl class="layer-detail-list">
57 + {% for chip in upload.layers %}
58 + <dt><code class="layer-chip layer-chip-{{ chip.verdict }}">{{ chip.layer }}</code> <span class="dimmed">— {{ chip.verdict }}</span></dt>
59 + <dd>{% if let Some(d) = chip.detail %}{{ d }}{% else %}<span class="dimmed">no detail</span>{% endif %}</dd>
60 + {% endfor %}
61 + </dl>
62 + </details>
52 63 {% endif %}
53 64 </td>
54 65 <td class="nowrap">