Update jobs

This commit is contained in:
2026-03-30 19:51:12 +11:00
parent 029ed423c9
commit bd37986b21
7 changed files with 524 additions and 235 deletions
+172
View File
@@ -8,3 +8,175 @@ class LinksConfig(AppConfig):
def ready(self):
# Import signals to register them
import links.signals
self._register_job_types()
def _register_job_types(self):
from . import job_registry
if job_registry.all_types():
return # already registered (e.g. double-init in test runner)
from .models import Screenshot, Page, FileUpload
from django.urls import reverse
# ── Screenshots ──────────────────────────────────────────────────
def ss_stats():
return {
'total': Screenshot.objects.count(),
'pending': Screenshot.objects.filter(status=Screenshot.Status.PENDING).count(),
'processing': Screenshot.objects.filter(status=Screenshot.Status.PROCESSING).count(),
'completed': Screenshot.objects.filter(status=Screenshot.Status.COMPLETED).count(),
'failed': Screenshot.objects.filter(status=Screenshot.Status.FAILED).count(),
}
def ss_queryset(sf):
qs = Screenshot.objects.select_related('page').order_by('-updated_at')
return qs if sf == 'all' else qs.filter(status=sf)
def ss_serialize(obj):
return {
'id': str(obj.id),
'title': (obj.page.title or obj.page.url) if obj.page else '?',
'detail_url': reverse('page-detail', args=[obj.page.pk]) if obj.page else '#',
'status': obj.status,
'retry': obj.retry_count,
'retry_max': 3,
'error': obj.error or '',
'updated_at': obj.updated_at,
'extra': {},
}
job_registry.register({
'id': 'screenshots',
'label': 'Screenshots',
'icon_color': 'text-indigo-500',
'icon_path': (
'M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86'
'a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2'
' 2H5a2 2 0 01-2-2V9z M15 13a3 3 0 11-6 0 3 3 0 016 0z'
),
'title_label': 'Page',
'status_choices': [
('all', 'All'), ('pending', 'Pending'), ('processing', 'Processing'),
('completed', 'Completed'), ('failed', 'Failed'),
],
'columns': ['id', 'title', 'status', 'retry', 'error', 'updated'],
'get_stats': ss_stats,
'get_queryset': ss_queryset,
'serialize': ss_serialize,
'bulk_actions': {
'retry': 'bulk_retry_screenshots',
'fail': 'bulk_fail_screenshots',
'delete': 'bulk_delete_screenshots',
},
})
# ── Page Processing ─────────────────────────────────────────────
def pg_stats():
return {
'total': Page.objects.count(),
'pending': Page.objects.filter(process_status=Page.ProcessStatus.PENDING).count(),
'processing': Page.objects.filter(process_status=Page.ProcessStatus.PROCESSING).count(),
'completed': Page.objects.filter(process_status=Page.ProcessStatus.COMPLETED).count(),
'failed': Page.objects.filter(process_status=Page.ProcessStatus.FAILED).count(),
}
def pg_queryset(sf):
qs = Page.objects.order_by('-updated_at')
return qs if sf == 'all' else qs.filter(process_status=sf)
def pg_serialize(obj):
return {
'id': str(obj.id),
'title': obj.title or obj.url,
'detail_url': reverse('page-detail', args=[obj.pk]),
'status': obj.process_status,
'retry': obj.retry_count,
'retry_max': 3,
'error': obj.error_message or '',
'updated_at': obj.updated_at,
'extra': {},
}
job_registry.register({
'id': 'pages',
'label': 'Page Processing',
'icon_color': 'text-purple-500',
'icon_path': (
'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2'
'V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2'
),
'title_label': 'Title / URL',
'status_choices': [
('all', 'All'), ('pending', 'Pending'), ('processing', 'Processing'),
('completed', 'Completed'), ('failed', 'Failed'),
],
'columns': ['id', 'title', 'status', 'retry', 'error', 'updated'],
'get_stats': pg_stats,
'get_queryset': pg_queryset,
'serialize': pg_serialize,
'bulk_actions': {
'retry': 'bulk_retry_pages',
'fail': 'bulk_fail_pages',
'delete': 'bulk_delete_pages',
},
})
# ── Image Imports ──────────────────────────────────────────────
def ii_stats():
base = FileUpload.objects.filter(source_url__isnull=False).exclude(source_url='')
return {
'total': base.count(),
'pending': base.filter(size=0).count(),
'completed': base.filter(size__gt=0).count(),
}
def ii_queryset(sf):
base = FileUpload.objects.filter(
source_url__isnull=False,
).exclude(source_url='').order_by('-updated_at')
if sf == 'pending':
return base.filter(size=0)
if sf == 'completed':
return base.filter(size__gt=0)
return base
def ii_serialize(obj):
return {
'id': str(obj.id),
'title': obj.name,
'detail_url': getattr(obj, 'download_url', '#') or '#',
'status': 'completed' if obj.size > 0 else 'pending',
'retry': None,
'retry_max': None,
'error': '',
'updated_at': obj.updated_at,
'extra': {
'source_url': obj.source_url or '',
'formatted_size': (
getattr(obj, 'formatted_size', '') if obj.size > 0 else ''
),
},
}
job_registry.register({
'id': 'image_imports',
'label': 'Image Imports',
'icon_color': 'text-green-500',
'icon_path': (
'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828'
' 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12'
'a2 2 0 002 2z'
),
'title_label': 'Filename',
'status_choices': [
('all', 'All'), ('pending', 'Pending'), ('completed', 'Done'),
],
'columns': ['id', 'title', 'source_url', 'status', 'size', 'updated'],
'get_stats': ii_stats,
'get_queryset': ii_queryset,
'serialize': ii_serialize,
'bulk_actions': {
'retry': 'bulk_retry_image_imports',
'delete': 'bulk_delete_image_imports',
},
})
+50
View File
@@ -0,0 +1,50 @@
"""
Job type registry for the /ui/jobs/ page.
To add a new job tab:
1. Call ``register(job_type_dict)`` from ``LinksConfig.ready()`` in links/apps.py.
2. Add the corresponding bulk action handler in ``JobsView.post()``.
Each job type dict must have:
id (str) unique tab slug, used as ?tab= value
label (str) display name shown in the tab bar
icon_color (str) Tailwind text-colour class, e.g. 'text-indigo-500'
icon_path (str) SVG <path d="..."> value for the stats card icon
title_label (str) column header for the title/name column
status_choices (list) [(value, label), ...]; first entry should be ('all', 'All')
columns (list[str]) ordered column ids from:
['id','title','source_url','status','retry','error','size','updated']
get_stats (callable) () -> dict with 'total' plus any of:
pending / processing / completed / failed
(omit keys that do not apply to this type)
get_queryset (callable) (status_filter: str) -> QuerySet
serialize (callable) (obj) -> dict with keys:
id, title, detail_url, status,
retry (int|None), retry_max (int|None),
error (str), updated_at (datetime),
extra (dict for source_url, formatted_size, etc.)
bulk_actions (dict) subset of {'retry': action_name,
'fail': action_name,
'delete': action_name}
"""
import logging
logger = logging.getLogger(__name__)
_registry: list[dict] = []
def register(job_type: dict) -> None:
"""Register a job type. Call from LinksConfig.ready()."""
_registry.append(job_type)
logger.debug("Registered job type: %s", job_type.get("id"))
def all_types() -> list[dict]:
"""Return all registered job types in registration order."""
return list(_registry)
def get_type(type_id: str) -> dict | None:
"""Return the job type dict with the given id, or None."""
return next((j for j in _registry if j["id"] == type_id), None)
+152 -147
View File
@@ -41,102 +41,67 @@
{% endif %}
<!-- Stats Row -->
<div class="grid grid-cols-2 gap-4 mb-6">
<!-- Screenshot stats -->
<div class="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{% for stat in job_type_stats %}
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-600 flex items-center gap-1.5">
<svg class="w-4 h-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
<svg class="w-4 h-4 {{ stat.icon_color }}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ stat.icon_path }}"/>
</svg>
{% trans "Screenshots" %}
{{ stat.label }}
</h2>
</div>
<div class="flex gap-4 text-center">
<a href="?tab=screenshots&status=all" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-gray-700">{{ stats.screenshots.total }}</div>
<div class="flex gap-3 text-center flex-wrap">
<a href="?tab={{ stat.id }}&status=all" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
<div class="text-xl font-bold text-gray-700">{{ stat.total }}</div>
<div class="text-xs text-gray-400">{% trans "Total" %}</div>
</a>
<a href="?tab=screenshots&status=pending" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-yellow-500">{{ stats.screenshots.pending }}</div>
{% if stat.pending is not None %}
<a href="?tab={{ stat.id }}&status=pending" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
<div class="text-xl font-bold text-yellow-500">{{ stat.pending }}</div>
<div class="text-xs text-gray-400">{% trans "Pending" %}</div>
</a>
<a href="?tab=screenshots&status=processing" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-blue-500">{{ stats.screenshots.processing }}</div>
{% endif %}
{% if stat.processing is not None %}
<a href="?tab={{ stat.id }}&status=processing" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
<div class="text-xl font-bold text-blue-500">{{ stat.processing }}</div>
<div class="text-xs text-gray-400">{% trans "Running" %}</div>
</a>
<a href="?tab=screenshots&status=completed" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-green-500">{{ stats.screenshots.completed }}</div>
{% endif %}
{% if stat.completed is not None %}
<a href="?tab={{ stat.id }}&status=completed" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
<div class="text-xl font-bold text-green-500">{{ stat.completed }}</div>
<div class="text-xs text-gray-400">{% trans "Done" %}</div>
</a>
<a href="?tab=screenshots&status=failed" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-red-500">{{ stats.screenshots.failed }}</div>
<div class="text-xs text-gray-400">{% trans "Failed" %}</div>
</a>
</div>
</div>
<!-- Page stats -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-600 flex items-center gap-1.5">
<svg class="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2"/>
</svg>
{% trans "Page Processing" %}
</h2>
</div>
<div class="flex gap-4 text-center">
<a href="?tab=pages&status=all" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-gray-700">{{ stats.pages.total }}</div>
<div class="text-xs text-gray-400">{% trans "Total" %}</div>
</a>
<a href="?tab=pages&status=pending" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-yellow-500">{{ stats.pages.pending }}</div>
<div class="text-xs text-gray-400">{% trans "Pending" %}</div>
</a>
<a href="?tab=pages&status=processing" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-blue-500">{{ stats.pages.processing }}</div>
<div class="text-xs text-gray-400">{% trans "Running" %}</div>
</a>
<a href="?tab=pages&status=completed" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-green-500">{{ stats.pages.completed }}</div>
<div class="text-xs text-gray-400">{% trans "Done" %}</div>
</a>
<a href="?tab=pages&status=failed" class="flex-1 hover:bg-gray-50 rounded p-1">
<div class="text-xl font-bold text-red-500">{{ stats.pages.failed }}</div>
{% endif %}
{% if stat.failed is not None %}
<a href="?tab={{ stat.id }}&status=failed" class="flex-1 hover:bg-gray-50 rounded p-1 min-w-0">
<div class="text-xl font-bold text-red-500">{{ stat.failed }}</div>
<div class="text-xs text-gray-400">{% trans "Failed" %}</div>
</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Tab + Filter Bar -->
<div class="bg-white rounded-lg shadow mb-0 rounded-b-none border-b-0">
<div class="flex items-center justify-between px-4 pt-3 pb-0">
<!-- Tabs -->
<!-- Tabs (driven by job registry) -->
<div class="flex gap-1">
<a href="?tab=screenshots&status={{ status_filter }}"
{% for tab_info in job_type_tabs %}
<a href="?tab={{ tab_info.id }}&status=all"
class="px-4 py-2 text-sm font-medium rounded-t-md border-b-2 transition-colors
{% if tab == 'screenshots' %}border-blue-500 text-blue-600 bg-white{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50{% endif %}">
{% trans "Screenshots" %}
{% if tab_info.is_active %}border-blue-500 text-blue-600 bg-white{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50{% endif %}">
{{ tab_info.label }}
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full
{% if tab == 'screenshots' %}bg-blue-100 text-blue-600{% else %}bg-gray-100 text-gray-500{% endif %}">
{{ stats.screenshots.total }}
</span>
</a>
<a href="?tab=pages&status={{ status_filter }}"
class="px-4 py-2 text-sm font-medium rounded-t-md border-b-2 transition-colors
{% if tab == 'pages' %}border-blue-500 text-blue-600 bg-white{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50{% endif %}">
{% trans "Pages" %}
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full
{% if tab == 'pages' %}bg-blue-100 text-blue-600{% else %}bg-gray-100 text-gray-500{% endif %}">
{{ stats.pages.total }}
{% if tab_info.is_active %}bg-blue-100 text-blue-600{% else %}bg-gray-100 text-gray-500{% endif %}">
{{ tab_info.count }}
</span>
</a>
{% endfor %}
<a href="?tab=scheduler"
class="px-4 py-2 text-sm font-medium rounded-t-md border-b-2 transition-colors
{% if tab == 'scheduler' %}border-blue-500 text-blue-600 bg-white{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50{% endif %}">
@@ -146,32 +111,29 @@
{{ scheduled_jobs|length }}
</span>
</a>
<a href="?tab=image_imports&status=all"
class="px-4 py-2 text-sm font-medium rounded-t-md border-b-2 transition-colors
{% if tab == 'image_imports' %}border-blue-500 text-blue-600 bg-white{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:bg-gray-50{% endif %}">
{% trans "Image Imports" %}
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full
{% if tab == 'image_imports' %}bg-blue-100 text-blue-600{% else %}bg-gray-100 text-gray-500{% endif %}">
{{ stats.image_imports.total }}
</span>
</a>
</div>
<!-- Status filter pills (only for data tabs) -->
{% if tab == 'screenshots' or tab == 'pages' %}
<!-- Status filter pills (from registry status_choices) -->
{% if status_choices %}
<div class="flex gap-1.5 pb-1">
{% for s, label, pill_active_style, pill_inactive_style in filter_options %}
<a href="?tab={{ tab }}&status={{ s }}"
style="{% if status_filter == s %}{{ pill_active_style }}{% else %}{{ pill_inactive_style }}{% endif %}padding:.3rem .8rem;border-radius:9999px;font-size:.8rem;font-weight:500;text-decoration:none;display:inline-block;white-space:nowrap;">
{{ label }}
</a>
{% endfor %}
</div>
{% elif tab == 'image_imports' %}
<div class="flex gap-1.5 pb-1">
{% for s, label, pill_active_style, pill_inactive_style in import_filter_options %}
<a href="?tab={{ tab }}&status={{ s }}"
style="{% if status_filter == s %}{{ pill_active_style }}{% else %}{{ pill_inactive_style }}{% endif %}padding:.3rem .8rem;border-radius:9999px;font-size:.8rem;font-weight:500;text-decoration:none;display:inline-block;white-space:nowrap;">
{% for value, label in status_choices %}
<a href="?tab={{ tab }}&status={{ value }}"
style="padding:.3rem .8rem;border-radius:9999px;font-size:.8rem;font-weight:500;text-decoration:none;display:inline-block;white-space:nowrap;
{% if status_filter == value %}
{% if value == 'all' %}background:#1f2937;color:#fff;
{% elif value == 'pending' %}background:#d97706;color:#fff;
{% elif value == 'processing' %}background:#2563eb;color:#fff;
{% elif value == 'completed' %}background:#16a34a;color:#fff;
{% elif value == 'failed' %}background:#dc2626;color:#fff;
{% else %}background:#374151;color:#fff;{% endif %}
{% else %}
{% if value == 'all' %}background:#f3f4f6;color:#374151;
{% elif value == 'pending' %}background:#fef3c7;color:#92400e;
{% elif value == 'processing' %}background:#eff6ff;color:#1e40af;
{% elif value == 'completed' %}background:#f0fdf4;color:#166534;
{% elif value == 'failed' %}background:#fff1f2;color:#991b1b;
{% else %}background:#f3f4f6;color:#374151;{% endif %}
{% endif %}">
{{ label }}
</a>
{% endfor %}
@@ -193,7 +155,7 @@
style="background:#1d4ed8;color:#fff;padding:.3rem .85rem;border-radius:.25rem;font-size:.8rem;cursor:pointer;font-weight:500;">
↺ {% trans "Retry" %}
</button>
<button @click="bulkAction('fail')" x-show="tab !== 'image_imports'"
<button @click="bulkAction('fail')" x-show="'fail' in bulkActionsMap"
style="background:#d97706;color:#fff;padding:.3rem .85rem;border-radius:.25rem;font-size:.8rem;cursor:pointer;font-weight:500;">
✕ {% trans "Mark Failed" %}
</button>
@@ -220,6 +182,19 @@
{% if tab == 'scheduler' %}
<!-- Scheduler Jobs -->
<div class="px-4 pt-3 pb-2 flex items-center gap-2">
<label class="text-xs text-gray-500">{% trans "Filter" %}:</label>
<select x-model="schedulerFilter"
class="text-xs rounded border border-gray-200 px-2 py-1 text-gray-700 bg-white shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-300">
<option value="all">{% trans "All functions" %}</option>
<template x-for="name in schedulerJobTypes" :key="name">
<option :value="name" x-text="name"></option>
</template>
</select>
<span class="text-xs text-gray-400" x-show="schedulerFilter !== 'all'">
(<span x-text="filteredScheduledJobs.length"></span> {% trans "job(s)" %})
</span>
</div>
{% if scheduled_jobs %}
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
@@ -232,26 +207,28 @@
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for job in scheduled_jobs %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-mono text-gray-500" style="font-size:.8rem;">{{ job.id }}</td>
<td class="px-4 py-3 font-medium" style="font-size:.85rem;">{{ job.name }}</td>
<td class="px-4 py-3 text-gray-400" style="font-size:.85rem;">{{ job.trigger }}</td>
<td class="px-4 py-3 text-gray-400 whitespace-nowrap" style="font-size:.85rem;">
{% if job.next_run %}{{ job.next_run }}{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
<template x-for="job in filteredScheduledJobs" :key="job.id">
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-mono text-gray-500" style="font-size:.8rem;" x-text="job.id"></td>
<td class="px-4 py-3 font-medium" style="font-size:.85rem;" x-text="job.name"></td>
<td class="px-4 py-3 text-gray-400" style="font-size:.85rem;" x-text="job.trigger"></td>
<td class="px-4 py-3 text-gray-400 whitespace-nowrap" style="font-size:.85rem;" x-text="job.next_run || '\u2014'"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="filteredScheduledJobs.length === 0"
class="px-6 py-8 text-center text-gray-400 text-sm">
{% trans "No jobs match this filter." %}
</div>
{% else %}
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No scheduled jobs." %}</div>
{% endif %}
{% elif tab == 'screenshots' %}
<!-- Screenshots Table -->
{% if page_obj.object_list %}
{% else %}
<!-- Unified job table (all registered types) -->
{% if page_rows %}
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
@@ -262,64 +239,87 @@
:checked="allIds.length > 0 && selectedIds.length === allIds.length">
</th>
<th class="px-3 py-3 text-left">ID</th>
<th class="px-3 py-3 text-left">{% trans "Page" %}</th>
<th class="px-3 py-3 text-left">{% trans "Status" %}</th>
<th class="px-3 py-3 text-left">{% trans "Retries" %}</th>
<th class="px-3 py-3 text-left">{% trans "Error" %}</th>
<th class="px-3 py-3 text-left">{{ current_tab_config.title_label }}</th>
{% if 'source_url' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Source URL" %}</th>{% endif %}
{% if 'status' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Status" %}</th>{% endif %}
{% if 'retry' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Retries" %}</th>{% endif %}
{% if 'error' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Error" %}</th>{% endif %}
{% if 'size' in current_tab_config.columns %}<th class="px-3 py-3 text-left">{% trans "Size" %}</th>{% endif %}
<th class="px-3 py-3 text-left">{% trans "Updated" %}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{% for ss in page_obj.object_list %}
<tr class="hover:bg-gray-50" :class="{'bg-blue-50': selectedIds.includes('{{ ss.id }}') }">
{% for row in page_rows %}
<tr class="hover:bg-gray-50" :class="{'bg-blue-50': selectedIds.includes('{{ row.id }}') }">
<td class="px-3 py-2">
<input type="checkbox" class="rounded text-blue-600"
value="{{ ss.id }}"
@change="toggle('{{ ss.id }}')"
:checked="selectedIds.includes('{{ ss.id }}')">
value="{{ row.id }}"
@change="toggle('{{ row.id }}')"
:checked="selectedIds.includes('{{ row.id }}')">
</td>
<td class="px-3 py-2 font-mono text-gray-400" style="font-size:.75rem;">{{ ss.id }}</td>
<td class="px-3 py-2 font-mono text-gray-400" style="font-size:.75rem;">{{ row.id|truncatechars:12 }}</td>
<td class="px-3 py-2 max-w-xs">
<a href="{% url 'page-detail' ss.page.pk %}"
class="text-blue-600 hover:underline block truncate max-w-xs"
style="font-size:.85rem;"
title="{{ ss.page.url }}">
{{ ss.page.title|default:ss.page.url|truncatechars:55 }}
{% if row.detail_url and row.detail_url != '#' %}
<a href="{{ row.detail_url }}" class="text-blue-600 hover:underline block truncate max-w-xs" style="font-size:.85rem;" title="{{ row.title }}">
{{ row.title|truncatechars:55 }}
</a>
{% else %}
<span class="text-gray-700 block truncate max-w-xs" style="font-size:.85rem;">{{ row.title|truncatechars:55 }}</span>
{% endif %}
</td>
{% if 'source_url' in current_tab_config.columns %}
<td class="px-3 py-2 max-w-xs">
<a href="{{ row.extra.source_url }}" target="_blank" rel="noopener"
class="text-gray-400 hover:text-blue-500 block truncate max-w-xs" style="font-size:.8rem;" title="{{ row.extra.source_url }}">
{{ row.extra.source_url|truncatechars:50 }}
</a>
</td>
{% endif %}
{% if 'status' in current_tab_config.columns %}
<td class="px-3 py-2">
<span style="display:inline-flex;align-items:center;padding:.15rem .55rem;border-radius:9999px;font-size:.75rem;font-weight:500;
{% if ss.status == 'completed' %}background:#dcfce7;color:#166534;
{% elif ss.status == 'failed' %}background:#fee2e2;color:#991b1b;
{% elif ss.status == 'processing' %}background:#dbeafe;color:#1e40af;
{% if row.status == 'completed' %}background:#dcfce7;color:#166534;
{% elif row.status == 'failed' %}background:#fee2e2;color:#991b1b;
{% elif row.status == 'processing' %}background:#dbeafe;color:#1e40af;
{% else %}background:#fef9c3;color:#854d0e;{% endif %}">
{{ ss.status }}
{{ row.status }}
</span>
</td>
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">{{ ss.retry_count }}/3</td>
{% endif %}
{% if 'retry' in current_tab_config.columns %}
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
{% if ss.error %}
{% if row.retry is not None %}{{ row.retry }}/{{ row.retry_max }}{% else %}<span class="text-gray-300"></span>{% endif %}
</td>
{% endif %}
{% if 'error' in current_tab_config.columns %}
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
{% if row.error %}
<button type="button"
@click="showError('{{ ss.id }}', `{{ ss.error|escapejs }}`)"
@click="showError('{{ row.id }}', `{{ row.error|escapejs }}`)"
style="text-align:left;color:#ef4444;text-decoration:underline;text-decoration-style:dotted;cursor:pointer;background:none;border:none;font-size:.85rem;max-width:18rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;"
title="{{ ss.error }}">
{{ ss.error|truncatechars:60 }}
title="{{ row.error }}">
{{ row.error|truncatechars:60 }}
</button>
{% else %}<span class="text-gray-300"></span>{% endif %}
</td>
<td class="px-3 py-2 text-gray-400 whitespace-nowrap" style="font-size:.8rem;">{{ ss.updated_at|timesince }} {% trans "ago" %}</td>
{% endif %}
{% if 'size' in current_tab_config.columns %}
<td class="px-3 py-2 text-gray-500" style="font-size:.85rem;">
{% if row.extra.formatted_size %}{{ row.extra.formatted_size }}{% else %}<span class="text-gray-300"></span>{% endif %}
</td>
{% endif %}
<td class="px-3 py-2 text-gray-400 whitespace-nowrap" style="font-size:.8rem;">{{ row.updated_at|timesince }} {% trans "ago" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No screenshots match this filter." %}</div>
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No items match this filter." %}</div>
{% endif %}
{% elif tab == 'pages' %}
<!-- Pages Table -->
{% if page_obj.object_list %}
{% comment %}pages table removed — unified table above handles all types{% endcomment %}
{% if False %}
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="bg-gray-50 text-xs text-gray-500 uppercase tracking-wide">
@@ -385,8 +385,8 @@
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No pages match this filter." %}</div>
{% endif %}
{% elif tab == 'image_imports' %}
<!-- Image Imports Table -->
{% comment %}image_imports table removed — unified table above handles all types{% endcomment %}
{% if False %}<!-- image_imports -->
{% if page_obj.object_list %}
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
@@ -450,6 +450,7 @@
<div class="px-6 py-12 text-center text-gray-400 text-sm">{% trans "No image imports match this filter." %}</div>
{% endif %}
{% endif %}
{% endif %}
<!-- Pagination -->
{% if page_obj.has_other_pages %}
@@ -510,6 +511,8 @@
</div>
{{ all_ids|json_script:"jobs-all-ids" }}
{{ scheduled_jobs|json_script:"scheduled-jobs-data" }}
{{ bulk_actions|json_script:"bulk-actions-data" }}
<script>
function jobsManager() {
@@ -518,6 +521,16 @@ function jobsManager() {
allIds: JSON.parse(document.getElementById('jobs-all-ids').textContent),
errorModal: { open: false, id: '', error: '' },
tab: '{{ tab }}',
scheduledJobs: JSON.parse(document.getElementById('scheduled-jobs-data')?.textContent || '[]'),
schedulerFilter: 'all',
bulkActionsMap: JSON.parse(document.getElementById('bulk-actions-data').textContent),
get schedulerJobTypes() {
return [...new Set(this.scheduledJobs.map(j => j.name))].sort();
},
get filteredScheduledJobs() {
if (this.schedulerFilter === 'all') return this.scheduledJobs;
return this.scheduledJobs.filter(j => j.name === this.schedulerFilter);
},
init() {},
@@ -541,18 +554,10 @@ function jobsManager() {
bulkAction(type) {
if (this.selectedIds.length === 0) return;
const tab = this.tab;
const actionMap = {
retry: tab === 'screenshots' ? 'bulk_retry_screenshots'
: tab === 'image_imports' ? 'bulk_retry_image_imports'
: 'bulk_retry_pages',
fail: tab === 'screenshots' ? 'bulk_fail_screenshots' : 'bulk_fail_pages',
delete: tab === 'screenshots' ? 'bulk_delete_screenshots'
: tab === 'image_imports' ? 'bulk_delete_image_imports'
: 'bulk_delete_pages',
};
const actionName = this.bulkActionsMap[type];
if (!actionName) return;
if (type === 'delete' && !confirm(`Delete ${this.selectedIds.length} item(s)?`)) return;
document.getElementById('bulk-action-input').value = actionMap[type];
document.getElementById('bulk-action-input').value = actionName;
document.getElementById('bulk-ids-input').value = this.selectedIds.join(',');
document.getElementById('bulk-form').submit();
},
+2 -2
View File
@@ -140,10 +140,10 @@
<label class="block text-sm font-medium text-gray-700 mb-1">{% trans "Embedding Model" %}</label>
<input type="text" name="llm_model" id="llm_model"
value="{{ site_settings.llm_model }}"
placeholder="nomic-embed-text"
placeholder="qwen3-embedding:0.6b"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-xs text-gray-400">
{% trans "Ollama: nomic-embed-text, mxbai-embed-large, etc." %}&nbsp;&bull;&nbsp;
{% trans "Ollama: qwen3-embedding:0.6b, mxbai-embed-large, etc." %}&nbsp;&bull;&nbsp;
{% trans "OpenRouter: any embedding model slug." %}
</p>
</div>
+73 -80
View File
@@ -596,7 +596,7 @@ class SiteSettingsView(View):
if llm_provider in ('none', 'ollama', 'openrouter'):
site_settings.llm_provider = llm_provider
site_settings.llm_base_url = request.POST.get('llm_base_url', '').strip() or 'http://localhost:11434'
site_settings.llm_model = request.POST.get('llm_model', '').strip() or 'nomic-embed-text'
site_settings.llm_model = request.POST.get('llm_model', '').strip() or 'qwen3-embedding:0.6b'
site_settings.llm_api_key = request.POST.get('llm_api_key', '').strip()
# ── Knowledge Graph schedule settings ─────────────────────────────
@@ -644,75 +644,43 @@ class JobsView(View):
template_name = 'links/jobs.html'
PAGE_SIZE = 50
def _get_stats(self):
from .models import Screenshot, Page, FileUpload
imports_qs = FileUpload.objects.filter(source_url__isnull=False).exclude(source_url='')
return {
'screenshots': {
'total': Screenshot.objects.count(),
'pending': Screenshot.objects.filter(status=Screenshot.Status.PENDING).count(),
'processing': Screenshot.objects.filter(status=Screenshot.Status.PROCESSING).count(),
'completed': Screenshot.objects.filter(status=Screenshot.Status.COMPLETED).count(),
'failed': Screenshot.objects.filter(status=Screenshot.Status.FAILED).count(),
},
'pages': {
'total': Page.objects.count(),
'pending': Page.objects.filter(process_status=Page.ProcessStatus.PENDING).count(),
'processing': Page.objects.filter(process_status=Page.ProcessStatus.PROCESSING).count(),
'completed': Page.objects.filter(process_status=Page.ProcessStatus.COMPLETED).count(),
'failed': Page.objects.filter(process_status=Page.ProcessStatus.FAILED).count(),
},
'image_imports': {
'total': imports_qs.count(),
'pending': imports_qs.filter(size=0).count(),
'completed': imports_qs.filter(size__gt=0).count(),
},
}
def get(self, request):
from .models import Screenshot, Page, FileUpload, SiteSettings as SS
from . import job_registry
from .models import SiteSettings as SS
from core.scheduler import scheduler
from django.core.paginator import Paginator
tab = request.GET.get('tab', 'screenshots')
job_types = job_registry.all_types()
tab = request.GET.get('tab', job_types[0]['id'] if job_types else 'scheduler')
status_filter = request.GET.get('status', 'all')
page_num = request.GET.get('page', 1)
# Build screenshot queryset
ss_qs = Screenshot.objects.select_related('page').order_by('-updated_at')
if status_filter != 'all':
ss_qs = ss_qs.filter(status=status_filter)
# Build stats for all registered job types
job_type_stats = []
for jt in job_types:
raw = jt['get_stats']()
job_type_stats.append({
'id': jt['id'],
'label': jt['label'],
'icon_color': jt['icon_color'],
'icon_path': jt['icon_path'],
'total': raw.get('total', 0),
'pending': raw.get('pending'),
'processing': raw.get('processing'),
'completed': raw.get('completed'),
'failed': raw.get('failed'),
})
# Build page queryset
pg_qs = Page.objects.order_by('-updated_at')
if status_filter != 'all':
pg_qs = pg_qs.filter(process_status=status_filter)
# Build image imports queryset
import_qs = FileUpload.objects.filter(
source_url__isnull=False,
).exclude(source_url='').order_by('-updated_at')
if status_filter == 'pending':
import_qs = import_qs.filter(size=0)
elif status_filter == 'completed':
import_qs = import_qs.filter(size__gt=0)
# Paginate the active tab's queryset
if tab == 'screenshots':
paginator = Paginator(ss_qs, self.PAGE_SIZE)
page_obj = paginator.get_page(page_num)
all_ids = [str(obj.id) for obj in page_obj.object_list]
elif tab == 'pages':
paginator = Paginator(pg_qs, self.PAGE_SIZE)
page_obj = paginator.get_page(page_num)
all_ids = [str(obj.id) for obj in page_obj.object_list]
elif tab == 'image_imports':
paginator = Paginator(import_qs, self.PAGE_SIZE)
page_obj = paginator.get_page(page_num)
all_ids = [str(obj.id) for obj in page_obj.object_list]
else:
page_obj = None
all_ids = []
# Tab navigation list
job_type_tabs = [
{
'id': jt['id'],
'label': jt['label'],
'count': next((s['total'] for s in job_type_stats if s['id'] == jt['id']), 0),
'is_active': tab == jt['id'],
}
for jt in job_types
]
# APScheduler jobs
scheduled_jobs = []
@@ -721,35 +689,54 @@ class JobsView(View):
scheduled_jobs.append({
'id': job.id,
'name': job.func.__name__ if callable(job.func) else str(job.func),
'next_run': job.next_run_time,
'next_run': str(job.next_run_time) if job.next_run_time else None,
'trigger': str(job.trigger),
})
# 4-tuples: (slug, label, active_inline_style, inactive_inline_style)
filter_options = [
('all', 'All', 'background:#1f2937;color:#fff;', 'background:#f3f4f6;color:#374151;'),
('pending', 'Pending', 'background:#d97706;color:#fff;', 'background:#fef3c7;color:#92400e;'),
('processing', 'Processing', 'background:#2563eb;color:#fff;', 'background:#eff6ff;color:#1e40af;'),
('completed', 'Completed', 'background:#16a34a;color:#fff;', 'background:#f0fdf4;color:#166534;'),
('failed', 'Failed', 'background:#dc2626;color:#fff;', 'background:#fff1f2;color:#991b1b;'),
]
import_filter_options = [
('all', 'All', 'background:#1f2937;color:#fff;', 'background:#f3f4f6;color:#374151;'),
('pending', 'Pending', 'background:#d97706;color:#fff;', 'background:#fef3c7;color:#92400e;'),
('completed', 'Done', 'background:#16a34a;color:#fff;', 'background:#f0fdf4;color:#166534;'),
]
# Handle scheduler tab or unrecognised tab
current_jt = job_registry.get_type(tab)
if tab == 'scheduler' or current_jt is None:
return render(request, self.template_name, {
'job_type_tabs': job_type_tabs,
'job_type_stats': job_type_stats,
'tab': 'scheduler',
'status_filter': status_filter,
'page_obj': None,
'page_rows': [],
'all_ids': [],
'bulk_actions': {},
'status_choices': [],
'current_tab_config': None,
'scheduled_jobs': scheduled_jobs,
'scheduler_running': scheduler.running,
'site_settings': SS.get(),
})
# Paginate and serialize rows for the active job type
qs = current_jt['get_queryset'](status_filter)
paginator = Paginator(qs, self.PAGE_SIZE)
page_obj = paginator.get_page(page_num)
page_rows = [current_jt['serialize'](obj) for obj in page_obj.object_list]
return render(request, self.template_name, {
'stats': self._get_stats(),
'job_type_tabs': job_type_tabs,
'job_type_stats': job_type_stats,
'tab': tab,
'status_filter': status_filter,
'page_obj': page_obj,
'all_ids': all_ids,
'page_rows': page_rows,
'all_ids': [r['id'] for r in page_rows],
'bulk_actions': current_jt.get('bulk_actions', {}),
'status_choices': current_jt.get('status_choices', []),
'current_tab_config': {
'id': current_jt['id'],
'label': current_jt['label'],
'title_label': current_jt.get('title_label', 'Title'),
'columns': current_jt.get('columns', ['id', 'title', 'status', 'updated']),
},
'scheduled_jobs': scheduled_jobs,
'scheduler_running': scheduler.running,
'site_settings': SS.get(),
'filter_options': filter_options,
'import_filter_options': import_filter_options,
})
def post(self, request):
@@ -819,6 +806,12 @@ class JobsView(View):
n += 1
messages.success(request, _(f'Deleted {n} image import record(s).'))
elif action == 'bulk_delete_netscan_runs':
from netscan.models import ScanRun
qs = ScanRun.objects.filter(pk__in=ids) if ids else ScanRun.objects.none()
n = qs.delete()[0]
messages.success(request, _(f'Deleted {n} netscan run(s).'))
else:
messages.error(request, _('Unknown action.'))
+69
View File
@@ -10,6 +10,7 @@ class NetscanConfig(AppConfig):
def ready(self):
import netscan.signals # noqa: F401
self._register_job_type()
try:
from netscan.tasks import schedule_profile
from netscan.models import ScanProfile
@@ -18,3 +19,71 @@ class NetscanConfig(AppConfig):
logger.info(f'Scheduled netscan profile: {profile.name}')
except Exception as e:
logger.warning(f'Could not schedule netscan profiles on startup: {e}')
def _register_job_type(self):
from links import job_registry
from netscan.models import ScanRun
from django.urls import reverse
def ns_stats():
return {
'total': ScanRun.objects.count(),
'pending': ScanRun.objects.filter(status='pending').count(),
'processing': ScanRun.objects.filter(status='running').count(),
'completed': ScanRun.objects.filter(status='success').count(),
'failed': ScanRun.objects.filter(status='failed').count(),
}
def ns_queryset(sf):
qs = ScanRun.objects.select_related('profile').order_by('-started_at')
if sf == 'pending':
return qs.filter(status='pending')
if sf == 'processing':
return qs.filter(status='running')
if sf == 'completed':
return qs.filter(status='success')
if sf == 'failed':
return qs.filter(status='failed')
return qs
def ns_serialize(obj):
try:
detail_url = reverse('netscan-run-detail', args=[obj.pk])
except Exception:
detail_url = '#'
duration = obj.duration_seconds
return {
'id': str(obj.id),
'title': obj.profile.name if obj.profile else f'Run #{obj.pk}',
'detail_url': detail_url,
'status': obj.status,
'retry': None,
'retry_max': None,
'error': obj.summary.get('error', '') if isinstance(obj.summary, dict) else '',
'updated_at': obj.finished_at or obj.started_at,
'extra': {
'duration': f'{duration}s' if duration is not None else '',
},
}
job_registry.register({
'id': 'netscan',
'label': 'Netscan',
'icon_color': 'text-blue-500',
'icon_path': (
'M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v10m0 0h10M9 13H5'
'm4 0v6m10-6v6m-5-6v6'
),
'title_label': 'Profile',
'status_choices': [
('all', 'All'), ('pending', 'Pending'), ('processing', 'Running'),
('completed', 'Success'), ('failed', 'Failed'),
],
'columns': ['id', 'title', 'status', 'error', 'updated'],
'get_stats': ns_stats,
'get_queryset': ns_queryset,
'serialize': ns_serialize,
'bulk_actions': {
'delete': 'bulk_delete_netscan_runs',
},
})
+6 -6
View File
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
# Configuration
DJANGO_BASE_URL = os.getenv("DJANGO_API_BASE", "http://links.apps.svc.cluster.local")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.2:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "nomic-embed-text")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3-embedding:0.6b")
QDRANT_HOST = os.getenv("QDRANT_HOST", "192.168.1.2")
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
QDRANT_API_KEY = os.getenv("QDRANT_API_KEY", "sss@&9Mnef7#Yd0a")
@@ -30,11 +30,11 @@ def fetch_data(endpoint):
def sync():
client = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT, api_key=QDRANT_API_KEY, https=False)
# Check Ollama & Get Dimension
sample_vec = get_embedding("test")
dim = len(sample_vec)
if not any(c.name == COLLECTION_NAME for c in client.get_collections().collections):
client.create_collection(
collection_name=COLLECTION_NAME,
@@ -42,19 +42,19 @@ def sync():
)
points = []
# Sync Posts
posts = fetch_data("posts")
for p in (posts if isinstance(posts, list) else posts.get('results', [])):
text = f"{p.get('title')} {p.get('summary')}"
points.append(PointStruct(id=f"post-{p['id']}", vector=get_embedding(text),
points.append(PointStruct(id=f"post-{p['id']}", vector=get_embedding(text),
payload={"id": p['id'], "type": "post", "title": p['title'], "summary": p.get('summary', '')}))
# Sync Pages
pages = fetch_data("pages")
for p in (pages if isinstance(pages, list) else pages.get('results', [])):
text = f"{p.get('title')} {p.get('summary')}"
points.append(PointStruct(id=f"page-{p['id']}", vector=get_embedding(text),
points.append(PointStruct(id=f"page-{p['id']}", vector=get_embedding(text),
payload={"id": p['id'], "type": "page", "title": p.get('title', 'No Title'), "summary": p.get('summary', '')}))
if points: