From 837876f9ed0a133f29bd379d4ecc9ec2d649faee Mon Sep 17 00:00:00 2001 From: ack Date: Sun, 19 Apr 2026 20:05:21 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D1=81=D1=83=D0=BF=D0=B5=D1=80=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F,=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BE=D0=BA=D0=BD=D0=B0=20=D1=81=D0=BE=D0=B7?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yaml | 3 + .../commands/run_timelapse_worker.py | 13 ++- camlaps/services/timelapse_worker.py | 32 +++++- camlaps/templates/camlaps/job_detail.html | 24 +++- camlaps/templates/camlaps/job_list.html | 41 ++++++- camlaps/templates/camlaps/worker_logs.html | 26 +++++ camlaps/urls.py | 4 + camlaps/views.py | 107 +++++++++++++++++- 8 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 camlaps/templates/camlaps/worker_logs.html diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index b38035e..8e10d37 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -25,6 +25,9 @@ jobs: echo "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" > .env echo "ENV_TYPE=${{ secrets.ENV_TYPE }}" >> .env echo "DJANGO_DEBUG=${{ secrets.DJANGO_DEBUG }}" >> .env + echo "DJANGO_SUPERUSER_USERNAME=${{ secrets.DJANGO_SUPERUSER_USERNAME }}" >> .env + echo "DJANGO_SUPERUSER_EMAIL=${{ secrets.DJANGO_SUPERUSER_EMAIL }}" >> .env + echo "DJANGO_SUPERUSER_PASSWORD=${{ secrets.DJANGO_SUPERUSER_PASSWORD }}" >> .env # 4. Запускаем сборку docker compose up -d --build \ No newline at end of file diff --git a/camlaps/management/commands/run_timelapse_worker.py b/camlaps/management/commands/run_timelapse_worker.py index 634664e..9b08b06 100644 --- a/camlaps/management/commands/run_timelapse_worker.py +++ b/camlaps/management/commands/run_timelapse_worker.py @@ -3,7 +3,7 @@ import time from django.core.management.base import BaseCommand -from ...services.timelapse_worker import run_one_job +from ...services.timelapse_worker import run_one_job, run_specific_job logger = logging.getLogger('camlaps') @@ -13,6 +13,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument('--once', action='store_true', help='Обработать только одну задачу и выйти.') + parser.add_argument('--job-id', type=int, default=None, help='ID задачи для немедленного запуска.') parser.add_argument( '--sleep', type=int, @@ -24,6 +25,16 @@ class Command(BaseCommand): logger.info('worker_cmd:handle:start') once = options['once'] sleep_seconds = options['sleep'] + job_id = options['job_id'] + + if job_id is not None: + try: + run_specific_job(job_id) + logger.info('worker_cmd:handle:done mode=job_id job_id=%s', job_id) + except Exception: + logger.exception('worker_cmd:handle:error mode=job_id job_id=%s', job_id) + raise + return if once: try: diff --git a/camlaps/services/timelapse_worker.py b/camlaps/services/timelapse_worker.py index e4a4008..26194b1 100644 --- a/camlaps/services/timelapse_worker.py +++ b/camlaps/services/timelapse_worker.py @@ -88,9 +88,10 @@ def _build_output_path(job: TimelapseJob) -> tuple[Path, str]: export_dir = Path(settings.TIMELAPS_EXPORT_DIR) export_dir.mkdir(parents=True, exist_ok=True) + day_mode = 'daynight' if job.include_night else 'dayonly' filename = ( - f'{job.camera.slug}_{job.date_from.strftime("%Y%m%d")}_' - f'{job.date_to.strftime("%Y%m%d")}_{job.id}.mp4' + f'{job.camera.slug}_{job.date_from.strftime("%Y%m%d")}_{job.date_to.strftime("%Y%m%d")}' + f'_s{job.sampling_interval_minutes}m_fps{job.fps}_{day_mode}_job{job.id}.mp4' ) output_path = export_dir / filename rel_path = f'timelapses/{filename}' @@ -210,4 +211,31 @@ def run_one_job() -> bool: process_job(job) logger.info('worker:run_one:done processed=true') + return True + + +def run_specific_job(job_id: int) -> bool: + logger.info('worker:run_specific:start job_id=%s', job_id) + job = TimelapseJob.objects.select_related('camera').filter(pk=job_id).first() + if not job: + logger.info('worker:run_specific:done job_not_found=true job_id=%s', job_id) + return False + + if job.status == TimelapseJob.Status.RUNNING: + logger.info('worker:run_specific:done already_running=true job_id=%s', job_id) + return False + + TimelapseJob.objects.filter(pk=job_id).update( + status=TimelapseJob.Status.RUNNING, + started_at=timezone.now(), + finished_at=None, + progress_percent=1, + frames_processed=0, + frames_total=None, + error_message='', + ) + + job = TimelapseJob.objects.select_related('camera').get(pk=job_id) + process_job(job) + logger.info('worker:run_specific:done job_id=%s', job_id) return True \ No newline at end of file diff --git a/camlaps/templates/camlaps/job_detail.html b/camlaps/templates/camlaps/job_detail.html index 9c17196..3e1b23a 100644 --- a/camlaps/templates/camlaps/job_detail.html +++ b/camlaps/templates/camlaps/job_detail.html @@ -3,9 +3,31 @@ {% block content %}

Задание #{{ job.id }}

- Назад +
+ {% if job.status != 'running' %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if job.status == 'error' or job.status == 'success' %} +
+ {% csrf_token %} + +
+ {% endif %} + Назад +
+{% if run_now == 'started' %} +
Запуск задания инициирован. Обнови страницу через несколько секунд.
+{% elif run_now == 'running' %} +
Задача уже выполняется.
+{% elif run_now == 'error' %} +
Не удалось запустить задание. Проверь логи.
+{% endif %} +
diff --git a/camlaps/templates/camlaps/job_list.html b/camlaps/templates/camlaps/job_list.html index 082748d..309e228 100644 --- a/camlaps/templates/camlaps/job_list.html +++ b/camlaps/templates/camlaps/job_list.html @@ -3,10 +3,17 @@ {% block content %}

Очередь задач

-
- {% csrf_token %} - -
+
+ Логи +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+
{% if queue_started == 'none' %} @@ -19,6 +26,12 @@
Запущена задача #{{ queue_started }}.
{% endif %} +{% if retried == 'running' %} +
Нельзя перезапустить задачу, пока она выполняется.
+{% elif retried %} +
Задача #{{ retried }} снова поставлена в очередь.
+{% endif %} +
@@ -30,6 +43,7 @@ + @@ -49,12 +63,27 @@ {{ job.progress_percent }}% {% endif %} + {% empty %} - + {% endfor %}
FPS Статус ПрогрессПричина ошибки
+ {% if job.status == 'error' and job.error_message %} + {{ job.error_message|truncatechars:80 }} + {% else %} + + {% endif %} + - Открыть +
+ {% if job.status == 'error' or job.status == 'success' %} +
+ {% csrf_token %} + +
+ {% endif %} + Открыть +
Пока нет задач.
Пока нет задач.
diff --git a/camlaps/templates/camlaps/worker_logs.html b/camlaps/templates/camlaps/worker_logs.html new file mode 100644 index 0000000..d983d30 --- /dev/null +++ b/camlaps/templates/camlaps/worker_logs.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Логи воркера

+
+ К задачам +
+ {% csrf_token %} + +
+
+
+ +{% if request.GET.cleared == '1' %} +
Лог очищен.
+{% elif request.GET.cleared == '0' %} +
Не удалось очистить лог.
+{% endif %} + +
+
+
{{ log_lines }}
+
+
+{% endblock %} \ No newline at end of file diff --git a/camlaps/urls.py b/camlaps/urls.py index ab00e89..2ab84c7 100644 --- a/camlaps/urls.py +++ b/camlaps/urls.py @@ -12,5 +12,9 @@ urlpatterns = [ path('cameras//jobs/new/', views.job_create, name='job_create'), path('jobs/', views.job_list, name='job_list'), path('jobs/start/', views.start_queue, name='start_queue'), + path('jobs//run-now/', views.run_job_now, name='run_job_now'), + path('jobs//retry/', views.retry_job, name='retry_job'), + path('jobs/logs/', views.worker_logs, name='worker_logs'), + path('jobs/logs/clear/', views.clear_worker_logs, name='clear_worker_logs'), path('jobs//', views.job_detail, name='job_detail'), ] \ No newline at end of file diff --git a/camlaps/views.py b/camlaps/views.py index ba640bb..1f25393 100644 --- a/camlaps/views.py +++ b/camlaps/views.py @@ -64,7 +64,42 @@ def job_list(request): if camera_id: qs = qs.filter(camera_id=camera_id) jobs = qs.order_by('-created_at')[:200] - return render(request, 'camlaps/job_list.html', {'jobs': jobs, 'queue_started': request.GET.get('started')}) + return render( + request, + 'camlaps/job_list.html', + { + 'jobs': jobs, + 'queue_started': request.GET.get('started'), + 'retried': request.GET.get('retried'), + }, + ) + + +def worker_logs(request): + logger.info('logs:view:start') + log_file = settings.LOG_DIR / 'camlaps.log' + lines = [] + + if log_file.exists(): + with log_file.open('r', encoding='utf-8', errors='replace') as f: + lines = f.readlines()[-300:] + + logger.info('logs:view:done lines=%s', len(lines)) + return render(request, 'camlaps/worker_logs.html', {'log_lines': ''.join(lines)}) + + +@require_POST +def clear_worker_logs(request): + logger.info('logs:clear:start') + try: + log_file = settings.LOG_DIR / 'camlaps.log' + log_file.parent.mkdir(parents=True, exist_ok=True) + log_file.write_text('', encoding='utf-8') + logger.info('logs:clear:done') + return redirect(f"{reverse('camlaps:worker_logs')}?cleared=1") + except Exception: + logger.exception('logs:clear:error') + return redirect(f"{reverse('camlaps:worker_logs')}?cleared=0") @require_POST @@ -96,6 +131,38 @@ def start_queue(request): return redirect(f"{reverse('camlaps:job_list')}?started=error") +@require_POST +def retry_job(request, job_id: int): + logger.info('job:retry:start job_id=%s', job_id) + job = get_object_or_404(TimelapseJob, pk=job_id) + + if job.status == TimelapseJob.Status.RUNNING: + logger.info('job:retry:done job_id=%s blocked=running', job_id) + return redirect(f"{reverse('camlaps:job_list')}?retried=running") + + job.status = TimelapseJob.Status.PLANNED + job.progress_percent = 0 + job.frames_total = None + job.frames_processed = 0 + job.error_message = '' + job.started_at = None + job.finished_at = None + job.save( + update_fields=[ + 'status', + 'progress_percent', + 'frames_total', + 'frames_processed', + 'error_message', + 'started_at', + 'finished_at', + ] + ) + + logger.info('job:retry:done job_id=%s', job_id) + return redirect(f"{reverse('camlaps:job_list')}?retried={job_id}") + + def job_detail(request, job_id: int): job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id) video_url = None @@ -106,7 +173,43 @@ def job_detail(request, job_id: int): else: video_url = '/timelapses/' + rel.split('/')[-1] - return render(request, 'camlaps/job_detail.html', {'job': job, 'video_url': video_url}) + return render( + request, + 'camlaps/job_detail.html', + { + 'job': job, + 'video_url': video_url, + 'run_now': request.GET.get('run_now'), + }, + ) + + +@require_POST +def run_job_now(request, job_id: int): + logger.info('job:run_now:start job_id=%s', job_id) + job = get_object_or_404(TimelapseJob, pk=job_id) + + if job.status == TimelapseJob.Status.RUNNING: + logger.info('job:run_now:done job_id=%s blocked=running', job_id) + return redirect(f"{reverse('camlaps:job_detail', kwargs={'job_id': job_id})}?run_now=running") + + cmd = [sys.executable, 'manage.py', 'run_timelapse_worker', '--job-id', str(job_id)] + kwargs = { + 'cwd': settings.BASE_DIR, + 'stdout': subprocess.DEVNULL, + 'stderr': subprocess.DEVNULL, + } + + if sys.platform.startswith('win'): + kwargs['creationflags'] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + + try: + subprocess.Popen(cmd, **kwargs) + logger.info('job:run_now:done job_id=%s worker_started=true', job_id) + return redirect(f"{reverse('camlaps:job_detail', kwargs={'job_id': job_id})}?run_now=started") + except Exception: + logger.exception('job:run_now:error job_id=%s', job_id) + return redirect(f"{reverse('camlaps:job_detail', kwargs={'job_id': job_id})}?run_now=error") def job_create(request, camera_id: int):