From 6bd301b7073c672907472ea7bfe36d6fd9886885 Mon Sep 17 00:00:00 2001 From: JB Date: Thu, 9 Oct 2025 12:29:38 -0700 Subject: [PATCH] feat: Add static job logs view and enhance logging functionality with improved UI and error handling --- abogen/web/routes.py | 20 ++++- abogen/web/service.py | 42 ++++++++- abogen/web/static/styles.css | 47 ++++++++++ abogen/web/templates/job_logs_static.html | 15 ++++ abogen/web/templates/partials/logs.html | 10 ++- tests/test_service.py | 102 +++++++++++++++++++++- 6 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 abogen/web/templates/job_logs_static.html 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 %}
    {% for entry in job.logs|reverse %} diff --git a/tests/test_service.py b/tests/test_service.py index 4c449b2..af535ff 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,7 +1,8 @@ from __future__ import annotations +import io import time -from abogen.web.service import JobStatus, build_service +from abogen.web.service import Job, JobStatus, build_service, _JOB_LOGGER def test_service_processes_job(tmp_path): @@ -58,4 +59,101 @@ def test_service_processes_job(tmp_path): assert job.chunk_level == "paragraph" assert job.speaker_mode == "single" assert job.chunks == [] - assert not job.generate_epub3 \ No newline at end of file + assert not job.generate_epub3 + + +def test_job_add_log_emits_to_stream(tmp_path): + sample = tmp_path / "sample.txt" + sample.write_text("payload", encoding="utf-8") + + job = Job( + id="job-test", + original_filename="sample.txt", + stored_path=sample, + language="a", + voice="af_alloy", + speed=1.0, + use_gpu=False, + subtitle_mode="Sentence", + output_format="wav", + save_mode="Save next to input file", + output_folder=tmp_path, + replace_single_newlines=False, + subtitle_format="srt", + created_at=time.time(), + ) + + captured_buffers = [] + for handler in list(_JOB_LOGGER.handlers): + if not hasattr(handler, "setStream"): + continue + buffer = io.StringIO() + original_stream = getattr(handler, "stream", None) + handler.setStream(buffer) # type: ignore[attr-defined] + captured_buffers.append((handler, original_stream, buffer)) + + assert captured_buffers, "Expected job logger to have stream handlers" + + try: + job.add_log("Test log line", level="error") + outputs = [buffer.getvalue() for _, _, buffer in captured_buffers] + finally: + for handler, original_stream, _ in captured_buffers: + if hasattr(handler, "setStream"): + handler.setStream(original_stream) # type: ignore[attr-defined] + + assert any("Test log line" in output for output in outputs) + assert job.logs[-1].message == "Test log line" + + +def test_retry_removes_failed_job(tmp_path): + uploads = tmp_path / "uploads" + outputs = tmp_path / "outputs" + uploads.mkdir() + outputs.mkdir() + + source = uploads / "sample.txt" + source.write_text("hello", encoding="utf-8") + + def failing_runner(job): + job.add_log("runner failing", level="error") + raise RuntimeError("boom") + + service = build_service( + runner=failing_runner, + output_root=outputs, + uploads_root=uploads, + ) + + try: + job = service.enqueue( + original_filename="sample.txt", + stored_path=source, + language="a", + voice="af_alloy", + speed=1.0, + use_gpu=False, + subtitle_mode="Sentence", + output_format="wav", + save_mode="Save next to input file", + output_folder=outputs, + replace_single_newlines=False, + subtitle_format="srt", + total_characters=len("hello"), + ) + + deadline = time.time() + 5 + while time.time() < deadline and job.status is not JobStatus.FAILED: + time.sleep(0.05) + + assert job.status is JobStatus.FAILED + + new_job = service.retry(job.id) + assert new_job is not None + assert new_job.id != job.id + + job_ids = {entry.id for entry in service.list_jobs()} + assert job.id not in job_ids + assert new_job.id in job_ids + finally: + service.shutdown() \ No newline at end of file