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' %}
+
+ {% endif %}
+ {% if job.status == 'error' or job.status == 'success' %}
+
+ {% 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 %}
{% if queue_started == 'none' %}
@@ -19,6 +26,12 @@
Запущена задача #{{ queue_started }}.
{% endif %}
+{% if retried == 'running' %}
+
Нельзя перезапустить задачу, пока она выполняется.
+{% elif retried %}
+
Задача #{{ retried }} снова поставлена в очередь.
+{% endif %}
+
@@ -30,6 +43,7 @@
| FPS |
Статус |
Прогресс |
+ Причина ошибки |
|
@@ -49,12 +63,27 @@
{{ job.progress_percent }}%
{% endif %}
+
+ {% if job.status == 'error' and job.error_message %}
+ {{ job.error_message|truncatechars:80 }}
+ {% else %}
+ —
+ {% endif %}
+ |
- Открыть
+
+ {% if job.status == 'error' or job.status == 'success' %}
+
+ {% endif %}
+ Открыть
+
|
{% empty %}
- | Пока нет задач. |
+ | Пока нет задач. |
{% endfor %}
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 %}
+
+
+{% if request.GET.cleared == '1' %}
+
Лог очищен.
+{% elif request.GET.cleared == '0' %}
+
Не удалось очистить лог.
+{% endif %}
+
+
+{% 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):