__package__ = "archivebox.machine" import json import shlex from pathlib import Path from django.contrib import admin, messages from django.db import DatabaseError from django.db.models import DurationField, ExpressionWrapper, F from django.db.models.functions import Coalesce, Now from django.shortcuts import redirect from django.utils import timezone from django.utils.html import format_html from django.utils.safestring import mark_safe from django_object_actions import action from archivebox.base_models.admin import BaseModelAdmin, ConfigEditorMixin from archivebox.machine.env_util import env_to_dotenv_text from archivebox.machine.models import Binary, Machine, NetworkInterface, Process from archivebox.misc.logging_util import printable_filesize def _render_copy_block(text: str, *, multiline: bool = False): if multiline: return format_html( """
""", text, text, text, ) return format_html( """ """, text, text, text, ) def _format_process_duration_seconds(started_at, ended_at) -> str: if not started_at: return "-" end_time = ended_at or timezone.now() seconds = max((end_time - started_at).total_seconds(), 0.0) if seconds < 1: return f"{seconds:.2f}s" if seconds < 10 and seconds != int(seconds): return f"{seconds:.1f}s" return f"{int(seconds)}s" class MachineAdmin(ConfigEditorMixin, BaseModelAdmin): list_display = ( "id_display", "created_at", "hostname", "ips", "os_platform", "hw_in_docker", "hw_in_vm", "hw_manufacturer", "hw_product", "os_arch", "os_family", "os_release", "hw_uuid", "health_display", ) sort_fields = ( "id", "created_at", "hostname", "ips", "os_platform", "hw_in_docker", "hw_in_vm", "hw_manufacturer", "hw_product", "os_arch", "os_family", "os_release", "hw_uuid", ) search_fields = ( "id", "guid", "hostname", "networkinterface__ip_public", "networkinterface__ip_local", "hw_manufacturer", "hw_product", "hw_uuid", "os_platform", "os_family", "os_arch", "os_release", "os_kernel", ) readonly_fields = ("guid", "created_at", "modified_at", "ips") fieldsets = ( ( "Identity", { "fields": ("hostname", "guid", "ips"), "classes": ("card",), }, ), ( "Hardware", { "fields": ("hw_manufacturer", "hw_product", "hw_uuid", "hw_in_docker", "hw_in_vm"), "classes": ("card",), }, ), ( "Operating System", { "fields": ("os_platform", "os_family", "os_arch", "os_kernel", "os_release"), "classes": ("card",), }, ), ( "Statistics", { "fields": ("stats", "num_uses_succeeded", "num_uses_failed"), "classes": ("card",), }, ), ( "Configuration", { "fields": ("config",), "classes": ("card", "wide"), "description": mark_safe( 'data/ArchiveBox.conf on disk to match — the two stores are "
"kept in 1:1 sync, so any keys you remove here will be removed from the file "
"too. Edits to ArchiveBox.conf (or archivebox config --set) "
"propagate back into this field on the next request."
"{}',
machine.id,
", ".join(machine.networkinterface_set.values_list("ip_public", flat=True)),
)
@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)
@admin.display(description="ID", ordering="id")
def id_display(self, machine):
# Highlight the row representing the machine that ``Machine.current()``
# resolves to in this process — that's the one whose ``config`` is
# actually being applied at runtime. Important to surface here because
# a collection can accumulate stale Machine rows from prior hosts (VM
# snapshots, container rebuilds, hostname changes), and editing the
# wrong one silently produces "I set BASE_URL but it didn't stick."
from archivebox.machine.models import Machine
try:
current_id = str(Machine.current().pk)
except (DatabaseError, RuntimeError, TypeError, ValueError):
current_id = None
machine_id = str(machine.pk)
short_id = machine_id[:8]
if current_id and machine_id == current_id:
return format_html(
'★ CURRENT'
'{}',
short_id,
)
return format_html(
'{}',
short_id,
)
class NetworkInterfaceAdmin(BaseModelAdmin):
list_display = (
"id",
"created_at",
"machine_info",
"ip_public",
"dns_server",
"isp",
"country",
"region",
"city",
"iface",
"ip_local",
"mac_address",
"health_display",
)
sort_fields = (
"id",
"created_at",
"machine_info",
"ip_public",
"dns_server",
"isp",
"country",
"region",
"city",
"iface",
"ip_local",
"mac_address",
)
search_fields = (
"id",
"machine__id",
"iface",
"ip_public",
"ip_local",
"mac_address",
"dns_server",
"hostname",
"isp",
"city",
"region",
"country",
)
readonly_fields = ("machine", "created_at", "modified_at", "mac_address", "ip_public", "ip_local", "dns_server")
fieldsets = (
(
"Machine",
{
"fields": ("machine",),
"classes": ("card",),
},
),
(
"Network",
{
"fields": ("iface", "ip_public", "ip_local", "mac_address", "dns_server"),
"classes": ("card",),
},
),
(
"Location",
{
"fields": ("hostname", "isp", "city", "region", "country"),
"classes": ("card",),
},
),
(
"Usage",
{
"fields": ("num_uses_succeeded", "num_uses_failed"),
"classes": ("card",),
},
),
(
"Timestamps",
{
"fields": ("created_at", "modified_at"),
"classes": ("card",),
},
),
)
list_filter = ("isp", "country", "region")
ordering = ("-created_at",)
list_per_page = 100
actions = ("delete_selected",)
@admin.display(description="Machine", ordering="machine__id")
def machine_info(self, iface):
return format_html(
'[{}] {}',
iface.machine.id,
str(iface.machine.id)[:8],
iface.machine.hostname,
)
@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)
class BinaryAdmin(BaseModelAdmin):
list_display = ("id", "created_at", "machine_info", "name", "binprovider", "version", "abspath", "sha256", "status", "health_display")
sort_fields = ("id", "created_at", "machine_info", "name", "binprovider", "version", "abspath", "sha256", "status")
search_fields = ("id", "machine__id", "name", "binprovider", "version", "abspath", "sha256")
readonly_fields = ("created_at", "modified_at", "output_dir")
fieldsets = (
(
"Binary Info",
{
"fields": ("name", "binproviders", "binprovider", "overrides"),
"classes": ("card",),
},
),
(
"Location",
{
"fields": ("machine", "abspath"),
"classes": ("card",),
},
),
(
"Version",
{
"fields": ("version", "sha256"),
"classes": ("card",),
},
),
(
"State",
{
"fields": ("status", "retry_at", "output_dir"),
"classes": ("card",),
},
),
(
"Usage",
{
"fields": ("num_uses_succeeded", "num_uses_failed"),
"classes": ("card",),
},
),
(
"Timestamps",
{
"fields": ("created_at", "modified_at"),
"classes": ("card",),
},
),
)
list_filter = ("name", "binprovider", "status", "machine_id")
ordering = ("-created_at",)
list_per_page = 100
actions = ("delete_selected",)
@admin.display(description="Machine", ordering="machine__id")
def machine_info(self, binary):
return format_html(
'[{}] {}',
binary.machine.id,
str(binary.machine.id)[:8],
binary.machine.hostname,
)
@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)
class ProcessAdmin(BaseModelAdmin):
list_display = (
"id",
"created_at",
"machine_info",
"archiveresult_link",
"snapshot_link",
"crawl_link",
"cmd_str",
"status_badge",
"duration_display",
"exit_code",
"pid",
"output_summary",
"binary_info",
)
sort_fields = (
"id",
"created_at",
"machine_info",
"archiveresult_link",
"snapshot_link",
"crawl_link",
"cmd_str",
"status_badge",
"duration_display",
"exit_code",
"pid",
"output_summary",
"binary_info",
)
search_fields = ("id", "machine__id", "binary__name", "cmd", "pwd", "stdout", "stderr")
readonly_fields = (
"created_at",
"modified_at",
"machine",
"binary_link",
"iface_link",
"archiveresult_link",
"snapshot_link",
"crawl_link",
"cmd_display",
"env_display",
"stdout_display",
"stderr_display",
"archiveresult_output_display",
"timeout",
"pid",
"exit_code",
"url",
"started_at",
"ended_at",
"duration_display",
)
fieldsets = (
(
"Process Info",
{
"fields": ("machine", "archiveresult_link", "snapshot_link", "crawl_link", "status", "retry_at"),
"classes": ("card",),
},
),
(
"Command",
{
"fields": ("cmd_display", "pwd", "env_display", "timeout"),
"classes": ("card", "wide"),
},
),
(
"Execution",
{
"fields": ("binary_link", "iface_link", "pid", "exit_code", "url"),
"classes": ("card",),
},
),
(
"Timing",
{
"fields": ("started_at", "ended_at", "duration_display"),
"classes": ("card",),
},
),
(
"Output",
{
"fields": ("stdout_display", "stderr_display", "archiveresult_output_display"),
"classes": ("card", "wide", "collapse"),
},
),
(
"Timestamps",
{
"fields": ("created_at", "modified_at"),
"classes": ("card",),
},
),
)
list_filter = ("status", "exit_code", "machine_id")
ordering = ("-created_at",)
list_per_page = 100
actions = ("kill_processes", "delete_selected")
change_actions = ("kill_process",)
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.select_related(
"machine",
"binary",
"iface",
"archiveresult__snapshot__crawl",
)
.annotate(
runtime_sort=ExpressionWrapper(
Coalesce(F("ended_at"), Now()) - F("started_at"),
output_field=DurationField(),
),
)
)
def _terminate_processes(self, request, processes):
terminated = 0
skipped = 0
for process in processes:
if process.status == Process.StatusChoices.EXITED or not process.is_running:
skipped += 1
continue
if process.terminate():
terminated += 1
else:
skipped += 1
if terminated:
self.message_user(
request,
f"Killed {terminated} running process{'es' if terminated != 1 else ''}.",
level=messages.SUCCESS,
)
if skipped:
self.message_user(
request,
f"Skipped {skipped} process{'es' if skipped != 1 else ''} that were already exited.",
level=messages.INFO,
)
return terminated, skipped
@admin.action(description="Kill selected processes")
def kill_processes(self, request, queryset):
self._terminate_processes(request, queryset)
@action(
label="Kill",
description="Kill this process if it is still running",
attrs={"class": "deletelink"},
methods=("POST",),
)
def kill_process(self, request, obj):
self._terminate_processes(request, [obj])
return redirect("admin:machine_process_change", obj.pk)
@admin.display(description="Machine", ordering="machine__id")
def machine_info(self, process):
return format_html(
'[{}] {}',
process.machine.id,
str(process.machine.id)[:8],
process.machine.hostname,
)
@admin.display(description="Binary", ordering="binary__name")
def binary_info(self, process):
if not process.binary:
return "-"
return format_html(
'{} v{}',
process.binary.id,
process.binary.name,
process.binary.version,
)
@admin.display(description="Binary", ordering="binary__name")
def binary_link(self, process):
return self.binary_info(process)
@admin.display(description="Network Interface", ordering="iface__id")
def iface_link(self, process):
if not process.iface:
return "-"
return format_html(
'{} {}',
process.iface.id,
str(process.iface.id)[:8],
process.iface.iface or process.iface.ip_public or process.iface.ip_local,
)
@admin.display(description="ArchiveResult", ordering="archiveresult__plugin")
def archiveresult_link(self, process):
try:
ar = process.archiveresult
except Process.archiveresult.RelatedObjectDoesNotExist:
return "-"
return format_html(
'{} ← {}',
ar.id,
ar.snapshot.url[:50],
ar.plugin,
)
@admin.display(description="Snapshot", ordering="archiveresult__snapshot__id")
def snapshot_link(self, process):
try:
snapshot = process.archiveresult.snapshot
except Process.archiveresult.RelatedObjectDoesNotExist:
return "-"
return format_html(
'{}',
snapshot.id,
str(snapshot.id)[:8],
)
@admin.display(description="Crawl", ordering="archiveresult__snapshot__crawl__id")
def crawl_link(self, process):
try:
crawl = process.archiveresult.snapshot.crawl
except Process.archiveresult.RelatedObjectDoesNotExist:
return "-"
return format_html(
'{}',
crawl.id,
str(crawl.id)[:8],
)
@admin.display(description="Command", ordering="cmd")
def cmd_str(self, process):
if not process.cmd:
return "-"
# Compact the list-view rendering only — the change-page ``cmd_display``
# still shows the full original ``process.cmd`` (and the DB row is
# untouched). If the first argv token looks like an absolute path,
# collapse it to its basename so a row like
# ``/Users/.../.venv/bin/python -m archivebox foo`` reads as
# ``python -m archivebox foo`` in the column.
if isinstance(process.cmd, list):
parts = [str(arg) for arg in process.cmd[:3]]
if parts and (parts[0].startswith("/") or parts[0].startswith("~")):
parts[0] = Path(parts[0]).name
cmd = " ".join(parts)
if len(process.cmd) > 3:
cmd += " ..."
else:
cmd = str(process.cmd)
return format_html('{}', cmd[:80])
@admin.display(description="Status", ordering="status")
def status_badge(self, process):
# Pill-style badge matching the look of other admin status columns.
# Color rules requested by the operator:
# RUNNING → green
# EXITED, code == 0 → grey (clean exit)
# EXITED, code == 10 → grey (treated as a clean / "skipped" exit)
# EXITED, code other → red (failure)
# QUEUED / anything → amber so it stands out without screaming
status_value = str(process.status or "").lower()
label = (process.get_status_display() or status_value or "?").upper()
exit_code = process.exit_code
if status_value == Process.StatusChoices.RUNNING:
bg, fg = "#16a34a", "#fff"
elif status_value == Process.StatusChoices.EXITED:
if exit_code in (0, 10):
bg, fg = "#6b7280", "#fff"
else:
bg, fg = "#dc2626", "#fff"
else:
bg, fg = "#f59e0b", "#fff"
return format_html(
'{}',
bg,
fg,
label,
)
@admin.display(description="Duration", ordering="runtime_sort")
def duration_display(self, process):
return _format_process_duration_seconds(process.started_at, process.ended_at)
@admin.display(description="Output", ordering="archiveresult__output_size")
def output_summary(self, process):
try:
output_files = process.archiveresult.output_files or {}
except Process.archiveresult.RelatedObjectDoesNotExist:
output_files = {}
if isinstance(output_files, str):
try:
output_files = json.loads(output_files)
except (json.JSONDecodeError, TypeError, ValueError):
output_files = {}
file_count = 0
total_bytes = 0
if isinstance(output_files, dict):
file_count = len(output_files)
items = output_files.values()
elif isinstance(output_files, (list, tuple, set)):
file_count = len(output_files)
items = output_files
else:
items = ()
for metadata in items:
if not isinstance(metadata, dict):
continue
size = metadata.get("size", 0)
try:
total_bytes += int(size or 0)
except (TypeError, ValueError):
continue
file_label = "file" if file_count == 1 else "files"
return format_html(
'{} {} • {}',
file_count,
file_label,
printable_filesize(total_bytes),
)
@admin.display(description="Command")
def cmd_display(self, process):
if not process.cmd:
return "-"
if isinstance(process.cmd, list):
cmd = shlex.join(str(arg) for arg in process.cmd)
else:
cmd = str(process.cmd)
return _render_copy_block(cmd)
@admin.display(description="Environment")
def env_display(self, process):
env_text = env_to_dotenv_text(process.env)
if not env_text:
return "-"
return _render_copy_block(env_text, multiline=True)
@admin.display(description="Stdout")
def stdout_display(self, process):
if not process.stdout:
return "-"
return _render_copy_block(process.stdout, multiline=True)
@admin.display(description="Stderr")
def stderr_display(self, process):
if not process.stderr:
return "-"
return _render_copy_block(process.stderr, multiline=True)
@admin.display(description="ArchiveResult Output")
def archiveresult_output_display(self, process):
try:
output = process.archiveresult.output_str
except Process.archiveresult.RelatedObjectDoesNotExist:
return "-"
if not output:
return "-"
return _render_copy_block(output, multiline=True)
def register_admin(admin_site):
admin_site.register(Machine, MachineAdmin)
admin_site.register(NetworkInterface, NetworkInterfaceAdmin)
admin_site.register(Binary, BinaryAdmin)
admin_site.register(Process, ProcessAdmin)