добавил суперпольззователя, и запуск прям из окна создания задачи
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s
All checks were successful
Deploy timelaps / deploy (push) Successful in 5s
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -3,9 +3,31 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<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>
|
||||
|
||||
{% 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-body">
|
||||
<div class="row g-3">
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Очередь задач</h2>
|
||||
<form method="post" action="{% url 'camlaps:start_queue' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-primary btn-sm">Старт очереди</button>
|
||||
</form>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'camlaps:worker_logs' %}">Логи</a>
|
||||
<form method="post" action="{% url 'camlaps:clear_worker_logs' %}">
|
||||
{% 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>
|
||||
|
||||
{% if queue_started == 'none' %}
|
||||
@@ -19,6 +26,12 @@
|
||||
<div class="alert alert-success py-2">Запущена задача #{{ queue_started }}.</div>
|
||||
{% 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">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
@@ -30,6 +43,7 @@
|
||||
<th>FPS</th>
|
||||
<th>Статус</th>
|
||||
<th>Прогресс</th>
|
||||
<th>Причина ошибки</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -49,12 +63,27 @@
|
||||
{{ job.progress_percent }}%
|
||||
{% endif %}
|
||||
</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">
|
||||
<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>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8">Пока нет задач.</td></tr>
|
||||
<tr><td colspan="9">Пока нет задач.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
26
camlaps/templates/camlaps/worker_logs.html
Normal file
26
camlaps/templates/camlaps/worker_logs.html
Normal 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 %}
|
||||
@@ -12,5 +12,9 @@ urlpatterns = [
|
||||
path('cameras/<int:camera_id>/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/<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'),
|
||||
]
|
||||
107
camlaps/views.py
107
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):
|
||||
|
||||
Reference in New Issue
Block a user