feat: add output controls to snapshot admin

This commit is contained in:
Nick Sweeting 2026-08-28 14:35:40 -07:00
parent 55557c5933
commit d882c4bc0c
No known key found for this signature in database
6 changed files with 134 additions and 74 deletions

View File

@ -24,6 +24,7 @@ from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.core.routes_util import build_snapshot_url
from archivebox.core.widgets import InlineTagEditorWidget
from archivebox.machine.env_util import env_to_shell_exports
from archivebox.misc.logging_util import printable_filesize
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.plugins.discovery import get_plugin_icon
from archivebox.plugins.views import LIVE_PLUGIN_BASE_URL
@ -79,7 +80,7 @@ def get_process_link_label(process) -> str:
return str(process.id)[-8:]
def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
def render_archiveresults_list(archiveresults_qs, limit=50, config=None, can_delete=False):
"""Render a nice inline list view of archive results with status, plugin, output, and actions."""
results = list(
@ -112,6 +113,7 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
}
rows = []
delete_url = html.escape(reverse("admin:core_archiveresult_changelist"), quote=True)
for idx, result in enumerate(results):
status = result.status or "queued"
color, bg = status_colors.get(status, ("#6b7280", "#f3f4f6"))
@ -126,6 +128,8 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
output_file_count = 0
else:
output_file_count = 0
output_size = int(result.output_size or 0)
output_size_display = html.escape(printable_filesize(output_size))
# Get plugin icon
icon = get_plugin_icon(result.plugin)
@ -190,9 +194,16 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
# Unique ID for this row's expandable output
row_id = f"output_{idx}_{str(result.id)[:8]}"
delete_button = ""
if can_delete:
delete_button = f'''
<button type="button" data-archive-result-ids="{result.id}" data-delete-url="{delete_url}"
style="padding: 4px 8px; border: 0; background: #f1f5f9; border-radius: 4px; color: #475569; font-size: 11px; cursor: pointer;"
title="Delete this output"></button>
'''
rows.append(f'''
<tr style="border-bottom: 1px solid #f1f5f9; transition: background 0.15s;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='transparent'">
<tr data-output-size="{output_size}" style="border-bottom: 1px solid #f1f5f9; transition: background 0.15s;" onmouseover="this.style.background='#f8fafc'" onmouseout="this.style.background='transparent'">
<td style="padding: 10px 12px; white-space: nowrap;">
<a href="{reverse("admin:core_archiveresult_change", args=[result.id])}"
style="color: #2563eb; text-decoration: none; font-family: ui-monospace, monospace; font-size: 11px;"
@ -228,6 +239,9 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
<td class="archive-results-files" style="padding: 10px 12px; color: #64748b; font-size: 12px; text-align: right;">
{output_file_count}
</td>
<td class="archive-results-size" style="padding: 10px 12px; color: #64748b; font-size: 12px; text-align: right; white-space: nowrap;">
{output_size_display}
</td>
<td class="archive-results-completed" style="padding: 10px 12px; color: #64748b; font-size: 12px;">
{end_time}
</td>
@ -248,11 +262,12 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
<a href="{reverse("admin:core_archiveresult_change", args=[result.id])}"
style="padding: 4px 8px; background: #f1f5f9; border-radius: 4px; color: #475569; text-decoration: none; font-size: 11px;"
title="Edit"></a>
{delete_button}
</div>
</td>
</tr>
<tr style="border-bottom: 1px solid #e2e8f0;">
<td colspan="11" style="padding: 0 12px 10px 12px;">
<td colspan="12" style="padding: 0 12px 10px 12px;">
<details id="{row_id}" style="margin: 0;">
<summary style="cursor: pointer; font-size: 11px; color: #94a3b8; user-select: none;">
Details &amp; Output
@ -289,8 +304,8 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
footer = ""
if total_count > limit:
footer = f"""
<tr>
<td colspan="11" style="padding: 12px; text-align: center; color: #64748b; font-size: 13px; background: #f8fafc;">
<tr data-output-footer>
<td colspan="12" style="padding: 12px; text-align: center; color: #64748b; font-size: 13px; background: #f8fafc;">
Showing {limit} of {total_count} results &nbsp;
<a href="/admin/core/archiveresult/?snapshot__id__exact={results[0].snapshot_id if results else ""}"
style="color: #2563eb;">View all </a>
@ -309,6 +324,9 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
<th class="archive-results-plugin" style="padding: 10px 12px; text-align: left; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Plugin</th>
<th class="archive-results-output" style="padding: 10px 12px; text-align: left; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Output</th>
<th class="archive-results-files" style="padding: 10px 12px; text-align: right; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Files</th>
<th class="archive-results-size" style="padding: 10px 12px; text-align: right; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">
<button type="button" data-output-size-sort style="all: unset; cursor: pointer;">Size </button>
</th>
<th class="archive-results-completed" style="padding: 10px 12px; text-align: left; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Completed</th>
<th style="padding: 10px 12px; text-align: left; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Process</th>
<th style="padding: 10px 12px; text-align: left; font-weight: 600; color: #475569; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">Machine</th>

View File

@ -873,7 +873,12 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(description="Archive Results")
def archiveresults_list(self, obj):
request = self.request
return render_archiveresults_list(obj.archiveresult_set.all(), limit=8, config=request.archivebox_config)
return render_archiveresults_list(
obj.archiveresult_set.all(),
limit=8,
config=request.archivebox_config,
can_delete=request.user.is_superuser,
)
@admin.display(
description="Title",

View File

@ -2556,5 +2556,6 @@
}
</script>
<script src="{% static 'admin-inline-tags.js' %}"></script>
{% include "includes/output_delete_controls.html" %}
</body>
</html>

View File

@ -1712,7 +1712,7 @@
<a href="{{display_url}}" data-no-preview="1" title="Download output file" download>⬇️</a>
{% endif %}
{% if can_delete_outputs and result.result %}
<button type="button" data-no-preview="1" data-archive-result-ids="{{result.result_ids}}" title="Delete this output"></button>
<button type="button" data-no-preview="1" data-archive-result-ids="{{result.result_ids}}" data-delete-url="{% admin_base_url %}/admin/core/archiveresult/" title="Delete this output"></button>
{% endif %}
</div>
{% if display_path %}
@ -1801,78 +1801,12 @@
{% if can_delete_outputs %}<input type="hidden" id="delete-output-csrf" value="{{csrf_token}}">{% endif %}
<script src="{% static 'jquery.min.js' %}" type="text/javascript"></script>
{% if can_delete_outputs %}{% include "includes/output_delete_controls.html" %}{% endif %}
<script>
const snapshotBaseUrl = "{% snapshot_base_url snapshot %}";
const snapshotFilesUrl = `${snapshotBaseUrl}/?files=1`;
{% if can_delete_outputs %}
const queuedOutputIds = new Set()
const deleteOutputButtons = [...document.querySelectorAll('[data-archive-result-ids]')]
let deleteOutputTimer = null
function renderDeleteCountdown(label='') {
for (const button of deleteOutputButtons) {
const queued = button.dataset.archiveResultIds.split(',').every((id) => queuedOutputIds.has(id))
button.textContent = queued ? label : '❌'
button.classList.toggle('delete-pending', queued)
}
}
function runDeleteCountdown(count=3) {
if (count) {
renderDeleteCountdown(`[${count}]`)
deleteOutputTimer = window.setTimeout(() => runDeleteCountdown(count - 1), 1000)
return
}
renderDeleteCountdown('[deleting]')
deleteOutputTimer = window.setTimeout(deleteQueuedOutputs, 1000)
}
function toggleOutputDeletion(button) {
if (button.disabled) return
const resultIds = button.dataset.archiveResultIds.split(',')
const remove = resultIds.every((id) => queuedOutputIds.has(id))
resultIds.forEach((id) => remove ? queuedOutputIds.delete(id) : queuedOutputIds.add(id))
window.clearTimeout(deleteOutputTimer)
if (!queuedOutputIds.size) {
renderDeleteCountdown()
return
}
renderDeleteCountdown('[4]')
deleteOutputTimer = window.setTimeout(runDeleteCountdown, 1000)
}
function deleteQueuedOutputs() {
deleteOutputButtons.forEach((button) => button.disabled = true)
const body = new URLSearchParams({
action: 'delete_selected',
post: 'yes',
csrfmiddlewaretoken: document.getElementById('delete-output-csrf').value,
})
queuedOutputIds.forEach((id) => body.append('_selected_action', id))
fetch("{% admin_base_url %}/admin/core/archiveresult/", {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body,
}).then((response) => {
if (!response.ok) throw new Error(`Delete failed (${response.status})`)
window.location.reload()
}).catch((error) => {
deleteOutputButtons.forEach((button) => button.disabled = false)
queuedOutputIds.clear()
renderDeleteCountdown()
window.alert(error.message)
})
}
for (const button of deleteOutputButtons) {
button.addEventListener('click', () => toggleOutputDeletion(button))
}
window.addEventListener('pagehide', () => window.clearTimeout(deleteOutputTimer))
{% endif %}
function tryCenterImageFrame(frame) {
try {
const doc = frame.contentDocument || frame.contentWindow.document

View File

@ -0,0 +1,78 @@
<script>
(() => {
for (const sortButton of document.querySelectorAll('[data-output-size-sort]')) {
sortButton.addEventListener('click', () => {
const tbody = sortButton.closest('table').tBodies[0]
const direction = sortButton.dataset.direction === 'desc' ? 'asc' : 'desc'
const groups = [...tbody.querySelectorAll('tr[data-output-size]')].map((row) => [row, row.nextElementSibling])
groups.sort((left, right) => (Number(left[0].dataset.outputSize) - Number(right[0].dataset.outputSize)) * (direction === 'asc' ? 1 : -1))
groups.forEach((rows) => rows.forEach((row) => tbody.appendChild(row)))
const footer = tbody.querySelector('[data-output-footer]')
if (footer) tbody.appendChild(footer)
sortButton.dataset.direction = direction
sortButton.textContent = `Size ${direction === 'asc' ? '▲' : '▼'}`
sortButton.closest('th').setAttribute('aria-sort', direction === 'asc' ? 'ascending' : 'descending')
})
}
const queuedOutputIds = new Set()
const deleteOutputButtons = [...document.querySelectorAll('[data-archive-result-ids]')]
let deleteOutputTimer = null
if (!deleteOutputButtons.length) return
function renderDeleteCountdown(label='') {
for (const button of deleteOutputButtons) {
const queued = button.dataset.archiveResultIds.split(',').every((id) => queuedOutputIds.has(id))
button.textContent = queued ? label : '❌'
button.style.color = queued ? '#b91c1c' : ''
}
}
function runDeleteCountdown(count=3) {
if (count) {
renderDeleteCountdown(`[${count}]`)
deleteOutputTimer = window.setTimeout(() => runDeleteCountdown(count - 1), 1000)
return
}
renderDeleteCountdown('[deleting]')
deleteOutputTimer = window.setTimeout(deleteQueuedOutputs, 1000)
}
function toggleOutputDeletion(button) {
if (button.disabled) return
const resultIds = button.dataset.archiveResultIds.split(',')
const remove = resultIds.every((id) => queuedOutputIds.has(id))
resultIds.forEach((id) => remove ? queuedOutputIds.delete(id) : queuedOutputIds.add(id))
window.clearTimeout(deleteOutputTimer)
if (!queuedOutputIds.size) return renderDeleteCountdown()
renderDeleteCountdown('[4]')
deleteOutputTimer = window.setTimeout(runDeleteCountdown, 1000)
}
function deleteQueuedOutputs() {
const csrf = document.querySelector('#delete-output-csrf, input[name="csrfmiddlewaretoken"]')?.value
const deleteUrl = deleteOutputButtons.find((button) => button.dataset.deleteUrl)?.dataset.deleteUrl
if (!csrf || !deleteUrl) return window.alert('Delete failed: missing admin request data')
deleteOutputButtons.forEach((button) => button.disabled = true)
const body = new URLSearchParams({action: 'delete_selected', post: 'yes', csrfmiddlewaretoken: csrf})
queuedOutputIds.forEach((id) => body.append('_selected_action', id))
fetch(deleteUrl, {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body,
}).then((response) => {
if (!response.ok) throw new Error(`Delete failed (${response.status})`)
window.location.reload()
}).catch((error) => {
deleteOutputButtons.forEach((button) => button.disabled = false)
queuedOutputIds.clear()
renderDeleteCountdown()
window.alert(error.message)
})
}
deleteOutputButtons.forEach((button) => button.addEventListener('click', () => toggleOutputDeletion(button)))
window.addEventListener('pagehide', () => window.clearTimeout(deleteOutputTimer))
})()
</script>

View File

@ -831,6 +831,30 @@ class TestSnapshotOutputDeletion:
assert "screenshot" not in {output["name"] for output in snapshot.discover_outputs()}
assert '"plugin": "screenshot"' not in (Path(snapshot.output_dir) / "index.jsonl").read_text()
def test_admin_inline_shows_sortable_output_sizes_and_delete_controls(self, client, snapshot, admin_user):
first = self._create_output(snapshot, plugin="screenshot", size=11)
second = self._create_output(snapshot, plugin="pdf", hook_name="on_Snapshot__60_pdf.py", size=2048)
assert client.login(username=admin_user.username, password="testpassword")
response = client.get(
reverse("admin:core_snapshot_change", args=[snapshot.pk]),
HTTP_HOST=ADMIN_TEST_HOST,
)
html = response.content.decode()
assert response.status_code == 200
assert "data-output-size-sort" in html
assert 'data-output-size="11"' in html
assert 'data-output-size="2048"' in html
assert "11.0 Bytes" in html
assert "2.0 KB" in html
assert f'data-archive-result-ids="{first.id}"' in html
assert f'data-archive-result-ids="{second.id}"' in html
assert html.count('title="Delete this output"') == 2
assert reverse("admin:core_archiveresult_changelist") in html
assert "const queuedOutputIds = new Set()" in html
assert "action: 'delete_selected'" in html
class TestAdminSnapshotListView:
"""Tests for the admin snapshot list view."""