diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 002b286..9f761bc 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -8,6 +8,7 @@ import re import threading import time import uuid +from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast @@ -2311,7 +2312,24 @@ def job_logs_partial(job_id: str) -> str: job = _service().get_job(job_id) if not job: abort(404) - return render_template("partials/logs.html", job=job) + return render_template("partials/logs.html", job=job, static_view=False) + + +@web_bp.get("/jobs//logs/static") +def job_logs_static(job_id: str) -> str: + job = _service().get_job(job_id) + if not job: + abort(404) + log_lines = [ + f"{datetime.fromtimestamp(entry.timestamp).strftime('%Y-%m-%d %H:%M:%S')} [{entry.level.upper()}] {entry.message}" + for entry in job.logs + ] + return render_template( + "job_logs_static.html", + job=job, + log_text="\n".join(log_lines), + static_view=True, + ) @api_bp.get("/jobs/") diff --git a/abogen/web/service.py b/abogen/web/service.py index f7618ce..9542e14 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -1,8 +1,10 @@ from __future__ import annotations import json +import logging import os import shutil +import sys import threading import time import uuid @@ -24,6 +26,35 @@ def _create_set_event() -> threading.Event: STATE_VERSION = 5 +_JOB_LOGGER = logging.getLogger("abogen.jobs") +if not _JOB_LOGGER.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")) + _JOB_LOGGER.addHandler(handler) + _JOB_LOGGER.propagate = False +_JOB_LOGGER.setLevel(logging.DEBUG) + +_JOB_LEVEL_MAP: Dict[str, int] = { + "critical": logging.CRITICAL, + "error": logging.ERROR, + "warning": logging.WARNING, + "info": logging.INFO, + "success": logging.INFO, + "debug": logging.DEBUG, + "trace": logging.DEBUG, +} + + +def _emit_job_log(job_id: str, level: str, message: str) -> None: + normalized = (level or "info").lower() + log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO) + try: + _JOB_LOGGER.log(log_level, "[job %s] %s", job_id, message) + except Exception: + # Logging failures should never disrupt job processing. + pass + + class JobStatus(str, Enum): PENDING = "pending" RUNNING = "running" @@ -105,7 +136,9 @@ class Job: applied_speaker_config: Optional[str] = None def add_log(self, message: str, level: str = "info") -> None: - self.logs.append(JobLog(timestamp=time.time(), message=message, level=level)) + entry = JobLog(timestamp=time.time(), message=message, level=level) + self.logs.append(entry) + _emit_job_log(self.id, level, message) def as_dict(self) -> Dict[str, object]: return { @@ -476,6 +509,7 @@ class ConversionService: new_job.applied_speaker_config = job.applied_speaker_config new_job.add_log(f"Retry created from job {job.id}", level="info") job.add_log(f"Retry scheduled as job {new_job.id}", level="info") + self._remove_job_locked(job_id) return new_job def delete(self, job_id: str) -> bool: @@ -608,6 +642,12 @@ class ConversionService: job.queue_position = index self._persist_state() + def _remove_job_locked(self, job_id: str) -> None: + self._jobs.pop(job_id, None) + if job_id in self._queue: + self._queue.remove(job_id) + self._update_queue_positions_locked() + # Persistence ------------------------------------------------------ def _serialize_job(self, job: Job) -> Dict[str, Any]: result_audio = str(job.result.audio_path) if job.result.audio_path else None diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 696c224..c7459c1 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -478,6 +478,23 @@ body { margin-bottom: 1rem; } +.card__title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.card__title-row .card__title { + margin: 0; +} + +.card__title-row .button { + margin-left: auto; +} + .grid { display: grid; gap: 1.5rem; @@ -539,6 +556,36 @@ body { color: var(--accent); } +.log-copy { + margin-top: 1.5rem; +} + +.log-copy > summary { + cursor: pointer; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.log-copy__textarea { + display: block; + width: 100%; + min-height: 240px; + padding: 1rem; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.32); + background: rgba(15, 23, 42, 0.35); + color: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.95rem; + line-height: 1.5; + resize: vertical; +} + +.log-copy__textarea:focus { + outline: 2px solid rgba(56, 189, 248, 0.55); + outline-offset: 2px; +} + .field { display: flex; flex-direction: column; diff --git a/abogen/web/templates/job_logs_static.html b/abogen/web/templates/job_logs_static.html new file mode 100644 index 0000000..a4a500c --- /dev/null +++ b/abogen/web/templates/job_logs_static.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Logs ยท {{ job.original_filename }}{% endblock %} + +{% block content %} +
+ {% include "partials/logs.html" %} + {% if job.logs %} +
+ Copy raw log output + +
+ {% endif %} +
+{% endblock %} diff --git a/abogen/web/templates/partials/logs.html b/abogen/web/templates/partials/logs.html index 6e63984..e5221f1 100644 --- a/abogen/web/templates/partials/logs.html +++ b/abogen/web/templates/partials/logs.html @@ -1,4 +1,12 @@ -
Live log
+{% set is_static = static_view if static_view is defined and static_view else False %} +
+
Live log
+ {% if not is_static %} + Open static view + {% else %} + Back to job + {% endif %} +
{% if job.logs %}