",
screenshot_html,
size_txt,
build_web_url(f"/{obj.archive_path}", request=request, config=config),
obj.archive_path,
)
@admin.display(
description="Files Saved",
ordering="ar_succeeded_count",
)
def files(self, obj):
results = self._get_prefetched_results(obj)
if results is None:
results = obj.archiveresult_set.only("plugin", "status", "output_size")
plugins_with_output: dict[str, ArchiveResult] = {}
for result in results:
if result.status != ArchiveResult.StatusChoices.SUCCEEDED:
continue
if not result.output_size:
continue
plugins_with_output.setdefault(result.plugin, result)
if not plugins_with_output:
return mark_safe('...')
sorted_results = sorted(
plugins_with_output.values(),
key=lambda result: (_plugin_sort_order().get(result.plugin, 9999), result.plugin),
)
visible_results = sorted_results[:14]
output = []
request = self.request
config = request.archivebox_config
for result in visible_results:
icon = mark_safe(get_plugin_icon(result.plugin))
if not icon.strip():
continue
output.append(
format_html(
'{}',
build_web_url(f"/{obj.archive_path_from_db}/{result.plugin}/", request=request, config=config),
result.plugin,
icon,
),
)
if len(sorted_results) > len(visible_results):
output.append(
format_html(
'+{}',
len(sorted_results) - len(visible_results),
len(sorted_results) - len(visible_results),
),
)
return format_html(
'{}',
mark_safe("".join(output)),
)
@admin.display()
def size(self, obj):
request = self.request
config = request.archivebox_config
archive_size = self._get_progress_stats(obj)["output_size"] or 0
if archive_size:
size_txt = printable_filesize(archive_size)
if archive_size > 52428800:
size_txt = mark_safe(f"{size_txt}")
else:
size_txt = mark_safe('...')
return format_html(
'{}',
build_web_url(f"/{obj.archive_path}", request=request, config=config),
size_txt,
)
@admin.display(
description="Status",
ordering="status",
)
def status_with_progress(self, obj):
"""Show status with progress bar for in-progress snapshots."""
stats = self._get_progress_stats(obj)
# Status badge colors
status_colors = {
"queued": ("#f59e0b", "#fef3c7"), # amber
"started": ("#3b82f6", "#dbeafe"), # blue
"paused": ("#1d4ed8", "#dbeafe"), # blue
"sealed": ("#10b981", "#d1fae5"), # green
"succeeded": ("#10b981", "#d1fae5"), # green
"failed": ("#ef4444", "#fee2e2"), # red
"backoff": ("#f59e0b", "#fef3c7"), # amber
"skipped": ("#6b7280", "#f3f4f6"), # gray
}
fg_color, bg_color = status_colors.get(obj.status, ("#6b7280", "#f3f4f6"))
# For started snapshots, show progress bar
if obj.status == "started" and stats["total"] > 0:
percent = stats["percent"]
running = stats["running"]
succeeded = stats["succeeded"]
failed = stats["failed"]
return format_html(
"""
{}/{} hooks
✓{} ✗{} ⏳{}
""",
succeeded + failed + stats["skipped"],
stats["total"],
int(succeeded / stats["total"] * 100) if stats["total"] else 0,
int(succeeded / stats["total"] * 100) if stats["total"] else 0,
int((succeeded + failed) / stats["total"] * 100) if stats["total"] else 0,
int((succeeded + failed) / stats["total"] * 100) if stats["total"] else 0,
percent,
succeeded,
failed,
running,
)
# For other statuses, show simple badge
return format_html(
'{}',
bg_color,
fg_color,
obj.status.upper(),
)
@admin.display(
description="Size",
ordering="output_size",
)
def size_with_stats(self, obj):
"""Show archive size with output size from archive results."""
stats = self._get_progress_stats(obj)
output_size = stats["output_size"]
size_bytes = output_size or 0
if size_bytes:
size_txt = printable_filesize(size_bytes)
if size_bytes > 52428800: # 50MB
size_txt = mark_safe(f"{size_txt}")
else:
size_txt = mark_safe('...')
# Show hook statistics
if stats["total"] > 0:
return format_html(
''
"{}"
'
'
"{}/{} hooks
",
self.get_snapshot_files_url(obj),
size_txt,
stats["succeeded"],
stats["total"],
)
return format_html(
'{}',
self.get_snapshot_files_url(obj),
size_txt,
)
def _get_progress_stats(self, obj):
cached_stats = obj.__dict__.get("_admin_progress_stats")
if cached_stats is not None:
return cached_stats
results = self._get_prefetched_results(obj)
if results is None:
stats = obj.get_progress_stats()
expected_total = self._get_expected_hook_total(obj)
total = max(stats["total"], expected_total)
completed = stats["succeeded"] + stats["failed"] + stats.get("skipped", 0) + stats.get("noresults", 0)
stats["total"] = total
stats["pending"] = max(total - completed - stats["running"], 0)
stats["percent"] = int((completed / total * 100) if total > 0 else 0)
obj._admin_progress_stats = stats
return stats
expected_total = self._get_expected_hook_total(obj)
observed_total = len(results)
total = max(observed_total, expected_total)
succeeded = sum(1 for r in results if r.status == "succeeded")
failed = sum(1 for r in results if r.status == "failed")
running = sum(1 for r in results if r.status == "started")
skipped = sum(1 for r in results if r.status == "skipped")
noresults = sum(1 for r in results if r.status == "noresults")
pending = max(total - succeeded - failed - running - skipped - noresults, 0)
completed = succeeded + failed + skipped + noresults
percent = int((completed / total * 100) if total > 0 else 0)
is_sealed = obj.status not in (obj.StatusChoices.QUEUED, obj.StatusChoices.STARTED, obj.StatusChoices.PAUSED)
stats = {
"total": total,
"succeeded": succeeded,
"failed": failed,
"running": running,
"pending": pending,
"skipped": skipped,
"noresults": noresults,
"percent": percent,
"output_size": obj.output_size or 0,
"is_sealed": is_sealed,
}
obj._admin_progress_stats = stats
return stats
def _get_prefetched_results(self, obj):
if "_admin_output_results" in obj.__dict__:
return obj.__dict__["_admin_output_results"]
if "_admin_archiveresults" in obj.__dict__:
return obj.__dict__["_admin_archiveresults"]
if "archiveresult_set" in obj.__dict__.get("_prefetched_objects_cache", {}):
return obj.archiveresult_set.all()
return None
def _get_expected_hook_total(self, obj) -> int:
try:
request = self.request
if request.resolver_match.url_name in {"core_snapshot_changelist", "core_snapshot_change"}:
return 0
crawl = obj.crawl
snapshot_config = obj.config or {}
crawl_config = crawl.config or {}
has_scoped_config = bool(snapshot_config or crawl_config)
if request is not None and not has_scoped_config:
cached_total = request.__dict__.get("archivebox_expected_snapshot_hook_total")
if cached_total is None:
config = request.archivebox_config
cached_total = len(discover_hooks("Snapshot", config=config))
request.archivebox_expected_snapshot_hook_total = cached_total
return cached_total
if request is not None:
scoped_cache = request.__dict__.get("archivebox_expected_snapshot_hook_totals_by_scope")
if scoped_cache is None:
scoped_cache = {}
request.archivebox_expected_snapshot_hook_totals_by_scope = scoped_cache
if snapshot_config:
cache_key = ("snapshot", json.dumps(snapshot_config, sort_keys=True, default=str))
else:
cache_key = ("crawl", json.dumps(crawl_config, sort_keys=True, default=str))
cached_total = scoped_cache.get(cache_key)
if cached_total is None:
config = get_config(crawl=crawl, snapshot=obj if snapshot_config else None)
cached_total = len(discover_hooks("Snapshot", config=config))
scoped_cache[cache_key] = cached_total
return cached_total
return len(discover_hooks("Snapshot", config=get_config(crawl=crawl, snapshot=obj if snapshot_config else None)))
except Exception:
return 0
def _get_prefetched_tags(self, obj):
prefetched_cache = obj.__dict__.get("_prefetched_objects_cache", {})
if "tags" in prefetched_cache:
return list(prefetched_cache["tags"])
return None
def _get_ordering_fields(self, request):
ordering = request.GET.get("o")
if not ordering:
return set()
fields = set()
for part in ordering.split("."):
if not part:
continue
try:
idx = abs(int(part)) - 1
except ValueError:
continue
if 0 <= idx < len(self.list_display):
fields.add(self.list_display[idx])
return fields
@admin.display(
description="Original URL",
ordering="url",
)
def url_str(self, obj):
return format_html(
'{}',
obj.url,
obj.url[:128],
)
@admin.display(description="Health", ordering="health")
def health_display(self, obj):
h = obj.health
color = "green" if h >= 80 else "orange" if h >= 50 else "red"
return format_html('{}', color, h)
def grid_view(self, request, extra_context=None):
extra_context = extra_context or {}
extra_context["snapshot_is_grid_view"] = True
return self.changelist_view(request, extra_context=extra_context)
@admin.action(
description="🔁 Redo Failed",
)
def update_snapshots(self, request, queryset):
queued = 0
for snapshot in queryset:
queued += snapshot.retry_failed_archiveresults()
if queued:
messages.success(
request,
f"Queued {queued} failed extractors for retry. The background runner will process them.",
)
else:
messages.info(request, "No failed extractors were found in the selected snapshots.")
@admin.action(
description="🆕 Archive Now",
)
def resnapshot_snapshot(self, request, queryset):
snapshots = list(queryset)
if not snapshots:
messages.info(request, "No snapshots selected.")
return
urls = "\n".join(snapshot.url for snapshot in snapshots if snapshot.url)
if not urls:
messages.info(request, "No valid snapshot URLs were found to archive.")
return
from archivebox.cli.archivebox_add import add
# "Archive Now" is an explicit user re-archive — force ONLY_NEW=False
# on the resulting crawl so existing snapshots don't cause the crawl to
# seal immediately with zero new snapshots (the default ONLY_NEW=True
# would skip any URLs that have ever been archived before).
crawl, _ = add(urls=urls, bg=True, config={"ONLY_NEW": False})
messages.success(
request,
f"Created 1 queued crawl with {len(snapshots)} URL(s). The background runner will create snapshots and process them.",
)
# Redirect to the new crawl's admin page so the user lands on the
# work-in-progress crawl, not the old snapshot they re-archived from.
# A snapshot-view redirect would race the runner — the new snapshot
# may sit queued for a while before the runner creates the DB row.
return redirect(f"/admin/crawls/crawl/{crawl.id}/change/#snapshots")
@admin.action(
description="🔄 Redo",
)
def overwrite_snapshots(self, request, queryset):
queued = sum(snapshot.archive(overwrite=True) for snapshot in queryset)
messages.success(
request,
f"Queued {queued} snapshots for full re-archive (overwriting existing). The background runner will process them.",
)
@admin.action(
description="🗑️ Delete",
)
def delete_snapshots(self, request, queryset):
"""Delete snapshots in a single transaction to avoid SQLite concurrency issues."""
from django.db import transaction
total = queryset.count()
# Get list of IDs to delete first (outside transaction)
ids_to_delete = list(queryset.values_list("pk", flat=True))
# Delete everything in a single atomic transaction
with transaction.atomic():
deleted_count, _ = Snapshot.objects.filter(pk__in=ids_to_delete).delete()
messages.success(
request,
mark_safe(
f"Successfully deleted {total} Snapshots ({deleted_count} total objects including related records). Don't forget to scrub URLs from import logs (data/sources) and error logs (data/logs) if needed.",
),
)
@admin.action(
description="+",
)
def add_tags(self, request, queryset):
from archivebox.core.models import SnapshotTag
# Get tags from the form - now comma-separated string
tags_str = request.POST.get("tags", "")
if not tags_str:
messages.warning(request, "No tags specified.")
return
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
tags = []
for name in tag_names:
tag, _ = get_or_create_tag(
name,
created_by=request.user if request.user.is_authenticated else None,
)
tags.append(tag)
# Get snapshot IDs efficiently (works with select_across for all pages)
snapshot_ids = list(queryset.values_list("id", flat=True))
num_snapshots = len(snapshot_ids)
for tag in tags:
SnapshotTag.objects.bulk_create(
[SnapshotTag(snapshot_id=sid, tag_id=tag.pk) for sid in snapshot_ids],
ignore_conflicts=True,
batch_size=1000,
)
messages.success(
request,
f"Added {len(tags)} tag(s) to {num_snapshots} Snapshot(s).",
)
@admin.action(
description="–",
)
def remove_tags(self, request, queryset):
from archivebox.core.models import SnapshotTag
# Get tags from the form - now comma-separated string
tags_str = request.POST.get("tags", "")
if not tags_str:
messages.warning(request, "No tags specified.")
return
# Parse comma-separated tag names and find matching Tag objects (case-insensitive)
tag_names = [name.strip() for name in tags_str.split(",") if name.strip()]
tags = []
for name in tag_names:
tag = Tag.objects.filter(name__iexact=name).first()
if tag:
tags.append(tag)
if not tags:
messages.warning(request, "No matching tags found.")
return
# Get snapshot IDs efficiently (works with select_across for all pages)
snapshot_ids = list(queryset.values_list("id", flat=True))
num_snapshots = len(snapshot_ids)
tag_ids = [t.pk for t in tags]
deleted_count, _ = SnapshotTag.objects.filter(
snapshot_id__in=snapshot_ids,
tag_id__in=tag_ids,
).delete()
messages.success(
request,
f"Removed {len(tags)} tag(s) from {num_snapshots} Snapshot(s) ({deleted_count} associations deleted).",
)