diff --git a/archivebox/actors/templates/jobs_dashboard.html b/archivebox/actors/templates/jobs_dashboard.html new file mode 100644 index 00000000..1af2a897 --- /dev/null +++ b/archivebox/actors/templates/jobs_dashboard.html @@ -0,0 +1,146 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Job Dashboard</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; + width: 100%; + margin: 0 auto; + padding: 20px; + } + h1 { + text-align: center; + } + .dashboard { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 20px; + } + .card { + border: 1px solid #ddd; + border-radius: 8px; + padding: 15px; + background-color: #f9f9f9; + } + .card h2 { + margin-top: 0; + border-bottom: 2px solid #ddd; + padding-bottom: 10px; + font-family: monospace; + } + .scroll-area { + height: 800px; + overflow-y: auto; + border: 1px solid #ddd; + padding: 10px; + background-color: #fff; + } + .job-item { + border: 1px solid #eee; + border-radius: 4px; + padding: 10px; + margin-bottom: 10px; + } + .job-item:last-child { + margin-bottom: 0; + } + .badge { + display: inline-block; + padding: 3px 7px; + border-radius: 3px; + font-size: 12px; + font-weight: bold; + } + .badge-started { + background-color: #4CAF50; + color: white; + } + .badge-queued { + background-color: #2196F3; + color: white; + } + .badge-failed { + background-color: #f44336; + color: white; + } + .date { + font-size: 16px; + color: #666; + float: right; + } + </style> +</head> +<body> + <h1>Job Dashboard <small><a href="?refresh=true">♻️ {{now}}</a></small></h1> + <div id="dashboard" class="dashboard"></div> + + <script> + function formatDate(dateString) { + // return new Date(dateString).toLocaleString(); + return new Date(dateString).toISOString().split('T').at(-1).replace('Z', ''); + } + + function createJobElement(job) { + const jobElement = document.createElement('div'); + jobElement.className = 'job-item'; + jobElement.innerHTML = ` + <p><a href="/api/v1/core/any/${job.abid}?api_key={{api_token|default:'NONE PROVIDED BY VIEW'}}"><code>${job.abid}</code></a></p> + <p> + <span class="badge badge-${job.status}">${job.status}</span> + <span class="date">♻️ ${formatDate(job.retry_at)}</span> + </p> + <p style="font-size: 12px; color: #666;">${job.description}</p> + `; + return jobElement; + } + + function updateDashboard(data) { + const dashboard = document.getElementById('dashboard'); + dashboard.innerHTML = ''; + + data.forEach(actor => { + const card = document.createElement('div'); + card.className = 'card'; + card.innerHTML = ` + <h2>${actor.model}</h2> + <h3>Queue</h3> + <div class="scroll-area" id="queue-${actor.model}"></div> + <h3>Past Tasks</h3> + <div class="scroll-area" id="past-${actor.model}"></div> + `; + dashboard.appendChild(card); + + const queueContainer = document.getElementById(`queue-${actor.model}`); + actor.queue.forEach(job => { + queueContainer.appendChild(createJobElement(job)); + }); + + const pastContainer = document.getElementById(`past-${actor.model}`); + actor.past.forEach(job => { + pastContainer.appendChild(createJobElement(job)); + }); + }); + } + + function fetchData() { + fetch('/api/v1/jobs/actors', { + headers: { + 'Authorization': `Bearer {{api_token|default:'NONE PROVIDED BY VIEW'}}` + } + }) + .then(response => response.json()) + .then(data => updateDashboard(data)) + .catch(error => console.error('Error fetching data:', error)); + } + + fetchData(); + + setInterval(fetchData, 1000); + </script> +</body> +</html> diff --git a/archivebox/actors/views.py b/archivebox/actors/views.py index 91ea44a2..806d9f25 100644 --- a/archivebox/actors/views.py +++ b/archivebox/actors/views.py @@ -1,3 +1,20 @@ -from django.shortcuts import render -# Create your views here. +from django.views.generic import TemplateView +from django.contrib.auth.mixins import UserPassesTestMixin +from django.utils import timezone +from api.auth import get_or_create_api_token + + +class JobsDashboardView(UserPassesTestMixin, TemplateView): + template_name = "jobs_dashboard.html" + + + def test_func(self): + return self.request.user and self.request.user.is_superuser + + def get_context_data(self, **kwargs): + api_token = get_or_create_api_token(self.request.user) + context = super().get_context_data(**kwargs) + context['api_token'] = api_token.token if api_token else 'UNABLE TO GENERATE API TOKEN' + context['now'] = timezone.now().strftime("%H:%M:%S") + return context diff --git a/archivebox/core/urls.py b/archivebox/core/urls.py index 6143e566..00b33e30 100644 --- a/archivebox/core/urls.py +++ b/archivebox/core/urls.py @@ -10,6 +10,8 @@ from archivebox.misc.serve_static import serve_static from core.admin_site import archivebox_admin from core.views import HomepageView, SnapshotView, PublicIndexView, AddView, HealthCheckView +from actors.views import JobsDashboardView + # GLOBAL_CONTEXT doesn't work as-is, disabled for now: https://github.com/ArchiveBox/ArchiveBox/discussions/1306 # from archivebox.config import VERSION, VERSIONS_AVAILABLE, CAN_UPGRADE # GLOBAL_CONTEXT = {'VERSION': VERSION, 'VERSIONS_AVAILABLE': VERSIONS_AVAILABLE, 'CAN_UPGRADE': CAN_UPGRADE} @@ -21,30 +23,30 @@ urlpatterns = [ re_path(r"^static/(?P<path>.*)$", serve_static), # re_path(r"^media/(?P<path>.*)$", static.serve, {"document_root": settings.MEDIA_ROOT}), + path('health/', HealthCheckView.as_view(), name='healthcheck'), + path('error/', lambda *_: 1/0), # type: ignore path('robots.txt', static.serve, {'document_root': settings.STATICFILES_DIRS[0], 'path': 'robots.txt'}), path('favicon.ico', static.serve, {'document_root': settings.STATICFILES_DIRS[0], 'path': 'favicon.ico'}), - path('docs/', RedirectView.as_view(url='https://github.com/ArchiveBox/ArchiveBox/wiki'), name='Docs'), - - path('public/', PublicIndexView.as_view(), name='public-index'), - - path('archive/', RedirectView.as_view(url='/')), - path('archive/<path:path>', SnapshotView.as_view(), name='Snapshot'), - - path('admin/core/snapshot/add/', RedirectView.as_view(url='/add/')), - path('add/', AddView.as_view(), name='add'), - path('accounts/login/', RedirectView.as_view(url='/admin/login/')), path('accounts/logout/', RedirectView.as_view(url='/admin/logout/')), - + path('admin/core/snapshot/add/', RedirectView.as_view(url='/add/')), + path('docs/', RedirectView.as_view(url='https://github.com/ArchiveBox/ArchiveBox/wiki'), name='Docs'), + path('archive/', RedirectView.as_view(url='/')), + path('accounts/', include('django.contrib.auth.urls')), path('admin/', archivebox_admin.urls), - path("api/", include('api.urls'), name='api'), - path('health/', HealthCheckView.as_view(), name='healthcheck'), - path('error/', lambda *_: 1/0), # type: ignore + path('public/', PublicIndexView.as_view(), name='public-index'), + + path('archive/<path:path>', SnapshotView.as_view(), name='Snapshot'), + + path('add/', AddView.as_view(), name='add'), + + path("jobs/", JobsDashboardView.as_view(), name='jobs_dashboard'), + # path('jet_api/', include('jet_django.urls')), Enable to use https://www.jetadmin.io/integrations/django