mirror of
https://github.com/ArchiveBox/ArchiveBox.git
synced 2026-09-14 11:06:13 +05:00
feat: add output controls to snapshot admin
This commit is contained in:
parent
55557c5933
commit
d882c4bc0c
@ -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 & 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
|
||||
<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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -2556,5 +2556,6 @@
|
||||
}
|
||||
</script>
|
||||
<script src="{% static 'admin-inline-tags.js' %}"></script>
|
||||
{% include "includes/output_delete_controls.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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
|
||||
|
||||
78
archivebox/templates/includes/output_delete_controls.html
Normal file
78
archivebox/templates/includes/output_delete_controls.html
Normal 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>
|
||||
@ -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."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user