mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Update jobs
This commit is contained in:
+172
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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." %} •
|
||||
{% trans "Ollama: qwen3-embedding:0.6b, mxbai-embed-large, etc." %} •
|
||||
{% trans "OpenRouter: any embedding model slug." %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+73
-80
@@ -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.'))
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user