добавил суперпольззователя, и запуск прям из окна создания задачи
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s

This commit is contained in:
ack
2026-04-19 20:05:21 +03:00
parent a8be932210
commit 837876f9ed
8 changed files with 238 additions and 12 deletions

View File

@@ -25,6 +25,9 @@ jobs:
echo "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" > .env echo "DJANGO_SECRET_KEY=${{ secrets.DJANGO_SECRET_KEY }}" > .env
echo "ENV_TYPE=${{ secrets.ENV_TYPE }}" >> .env echo "ENV_TYPE=${{ secrets.ENV_TYPE }}" >> .env
echo "DJANGO_DEBUG=${{ secrets.DJANGO_DEBUG }}" >> .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. Запускаем сборку # 4. Запускаем сборку
docker compose up -d --build docker compose up -d --build

View File

@@ -3,7 +3,7 @@ import time
from django.core.management.base import BaseCommand 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') logger = logging.getLogger('camlaps')
@@ -13,6 +13,7 @@ class Command(BaseCommand):
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('--once', action='store_true', help='Обработать только одну задачу и выйти.') parser.add_argument('--once', action='store_true', help='Обработать только одну задачу и выйти.')
parser.add_argument('--job-id', type=int, default=None, help='ID задачи для немедленного запуска.')
parser.add_argument( parser.add_argument(
'--sleep', '--sleep',
type=int, type=int,
@@ -24,6 +25,16 @@ class Command(BaseCommand):
logger.info('worker_cmd:handle:start') logger.info('worker_cmd:handle:start')
once = options['once'] once = options['once']
sleep_seconds = options['sleep'] 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: if once:
try: try:

View File

@@ -88,9 +88,10 @@ def _build_output_path(job: TimelapseJob) -> tuple[Path, str]:
export_dir = Path(settings.TIMELAPS_EXPORT_DIR) export_dir = Path(settings.TIMELAPS_EXPORT_DIR)
export_dir.mkdir(parents=True, exist_ok=True) export_dir.mkdir(parents=True, exist_ok=True)
day_mode = 'daynight' if job.include_night else 'dayonly'
filename = ( filename = (
f'{job.camera.slug}_{job.date_from.strftime("%Y%m%d")}_' f'{job.camera.slug}_{job.date_from.strftime("%Y%m%d")}_{job.date_to.strftime("%Y%m%d")}'
f'{job.date_to.strftime("%Y%m%d")}_{job.id}.mp4' f'_s{job.sampling_interval_minutes}m_fps{job.fps}_{day_mode}_job{job.id}.mp4'
) )
output_path = export_dir / filename output_path = export_dir / filename
rel_path = f'timelapses/{filename}' rel_path = f'timelapses/{filename}'
@@ -211,3 +212,30 @@ def run_one_job() -> bool:
process_job(job) process_job(job)
logger.info('worker:run_one:done processed=true') logger.info('worker:run_one:done processed=true')
return 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

View File

@@ -3,9 +3,31 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2>Задание #{{ job.id }}</h2> <h2>Задание #{{ job.id }}</h2>
<a class="btn btn-outline-secondary" href="{% url 'camlaps:job_list' %}">Назад</a> <div class="d-flex gap-2">
{% if job.status != 'running' %}
<form method="post" action="{% url 'camlaps:run_job_now' job.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-primary">Запустить сейчас</button>
</form>
{% endif %}
{% if job.status == 'error' or job.status == 'success' %}
<form method="post" action="{% url 'camlaps:retry_job' job.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-warning">Запустить снова</button>
</form>
{% endif %}
<a class="btn btn-outline-secondary" href="{% url 'camlaps:job_list' %}">Назад</a>
</div>
</div> </div>
{% if run_now == 'started' %}
<div class="alert alert-success py-2">Запуск задания инициирован. Обнови страницу через несколько секунд.</div>
{% elif run_now == 'running' %}
<div class="alert alert-warning py-2">Задача уже выполняется.</div>
{% elif run_now == 'error' %}
<div class="alert alert-danger py-2">Не удалось запустить задание. Проверь логи.</div>
{% endif %}
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">

View File

@@ -3,10 +3,17 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2>Очередь задач</h2> <h2>Очередь задач</h2>
<form method="post" action="{% url 'camlaps:start_queue' %}"> <div class="d-flex gap-2">
{% csrf_token %} <a class="btn btn-outline-secondary btn-sm" href="{% url 'camlaps:worker_logs' %}">Логи</a>
<button type="submit" class="btn btn-primary btn-sm">Старт очереди</button> <form method="post" action="{% url 'camlaps:clear_worker_logs' %}">
</form> {% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm">Очистить лог</button>
</form>
<form method="post" action="{% url 'camlaps:start_queue' %}">
{% csrf_token %}
<button type="submit" class="btn btn-primary btn-sm">Старт очереди</button>
</form>
</div>
</div> </div>
{% if queue_started == 'none' %} {% if queue_started == 'none' %}
@@ -19,6 +26,12 @@
<div class="alert alert-success py-2">Запущена задача #{{ queue_started }}.</div> <div class="alert alert-success py-2">Запущена задача #{{ queue_started }}.</div>
{% endif %} {% endif %}
{% if retried == 'running' %}
<div class="alert alert-warning py-2">Нельзя перезапустить задачу, пока она выполняется.</div>
{% elif retried %}
<div class="alert alert-success py-2">Задача #{{ retried }} снова поставлена в очередь.</div>
{% endif %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped align-middle"> <table class="table table-striped align-middle">
<thead> <thead>
@@ -30,6 +43,7 @@
<th>FPS</th> <th>FPS</th>
<th>Статус</th> <th>Статус</th>
<th>Прогресс</th> <th>Прогресс</th>
<th>Причина ошибки</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -49,12 +63,27 @@
{{ job.progress_percent }}% {{ job.progress_percent }}%
{% endif %} {% endif %}
</td> </td>
<td>
{% if job.status == 'error' and job.error_message %}
<span class="text-danger" title="{{ job.error_message }}">{{ job.error_message|truncatechars:80 }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end"> <td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="{% url 'camlaps:job_detail' job.id %}">Открыть</a> <div class="d-flex justify-content-end gap-2">
{% if job.status == 'error' or job.status == 'success' %}
<form method="post" action="{% url 'camlaps:retry_job' job.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-warning">Запустить снова</button>
</form>
{% endif %}
<a class="btn btn-sm btn-outline-primary" href="{% url 'camlaps:job_detail' job.id %}">Открыть</a>
</div>
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="8">Пока нет задач.</td></tr> <tr><td colspan="9">Пока нет задач.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Логи воркера</h2>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="{% url 'camlaps:job_list' %}">К задачам</a>
<form method="post" action="{% url 'camlaps:clear_worker_logs' %}">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm">Очистить лог</button>
</form>
</div>
</div>
{% if request.GET.cleared == '1' %}
<div class="alert alert-success py-2">Лог очищен.</div>
{% elif request.GET.cleared == '0' %}
<div class="alert alert-danger py-2">Не удалось очистить лог.</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-body">
<pre class="mb-0" style="max-height: 70vh; overflow: auto; white-space: pre-wrap;">{{ log_lines }}</pre>
</div>
</div>
{% endblock %}

View File

@@ -12,5 +12,9 @@ urlpatterns = [
path('cameras/<int:camera_id>/jobs/new/', views.job_create, name='job_create'), path('cameras/<int:camera_id>/jobs/new/', views.job_create, name='job_create'),
path('jobs/', views.job_list, name='job_list'), path('jobs/', views.job_list, name='job_list'),
path('jobs/start/', views.start_queue, name='start_queue'), path('jobs/start/', views.start_queue, name='start_queue'),
path('jobs/<int:job_id>/run-now/', views.run_job_now, name='run_job_now'),
path('jobs/<int:job_id>/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/<int:job_id>/', views.job_detail, name='job_detail'), path('jobs/<int:job_id>/', views.job_detail, name='job_detail'),
] ]

View File

@@ -64,7 +64,42 @@ def job_list(request):
if camera_id: if camera_id:
qs = qs.filter(camera_id=camera_id) qs = qs.filter(camera_id=camera_id)
jobs = qs.order_by('-created_at')[:200] 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 @require_POST
@@ -96,6 +131,38 @@ def start_queue(request):
return redirect(f"{reverse('camlaps:job_list')}?started=error") 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): def job_detail(request, job_id: int):
job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id) job = get_object_or_404(TimelapseJob.objects.select_related('camera'), pk=job_id)
video_url = None video_url = None
@@ -106,7 +173,43 @@ def job_detail(request, job_id: int):
else: else:
video_url = '/timelapses/' + rel.split('/')[-1] 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): def job_create(request, camera_id: int):