feat: Add static job logs view and enhance logging functionality with improved UI and error handling

This commit is contained in:
JB
2025-10-09 12:29:38 -07:00
parent 205c435e72
commit 6bd301b707
6 changed files with 231 additions and 5 deletions
+19 -1
View File
@@ -8,6 +8,7 @@ import re
import threading import threading
import time import time
import uuid import uuid
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast 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) job = _service().get_job(job_id)
if not job: if not job:
abort(404) 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/<job_id>/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/<job_id>") @api_bp.get("/jobs/<job_id>")
+41 -1
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
import shutil import shutil
import sys
import threading import threading
import time import time
import uuid import uuid
@@ -24,6 +26,35 @@ def _create_set_event() -> threading.Event:
STATE_VERSION = 5 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): class JobStatus(str, Enum):
PENDING = "pending" PENDING = "pending"
RUNNING = "running" RUNNING = "running"
@@ -105,7 +136,9 @@ class Job:
applied_speaker_config: Optional[str] = None applied_speaker_config: Optional[str] = None
def add_log(self, message: str, level: str = "info") -> 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]: def as_dict(self) -> Dict[str, object]:
return { return {
@@ -476,6 +509,7 @@ class ConversionService:
new_job.applied_speaker_config = job.applied_speaker_config new_job.applied_speaker_config = job.applied_speaker_config
new_job.add_log(f"Retry created from job {job.id}", level="info") 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") job.add_log(f"Retry scheduled as job {new_job.id}", level="info")
self._remove_job_locked(job_id)
return new_job return new_job
def delete(self, job_id: str) -> bool: def delete(self, job_id: str) -> bool:
@@ -608,6 +642,12 @@ class ConversionService:
job.queue_position = index job.queue_position = index
self._persist_state() 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 ------------------------------------------------------ # Persistence ------------------------------------------------------
def _serialize_job(self, job: Job) -> Dict[str, Any]: def _serialize_job(self, job: Job) -> Dict[str, Any]:
result_audio = str(job.result.audio_path) if job.result.audio_path else None result_audio = str(job.result.audio_path) if job.result.audio_path else None
+47
View File
@@ -478,6 +478,23 @@ body {
margin-bottom: 1rem; 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 { .grid {
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
@@ -539,6 +556,36 @@ body {
color: var(--accent); 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 { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Logs · {{ job.original_filename }}{% endblock %}
{% block content %}
<section class="card logs-static">
{% include "partials/logs.html" %}
{% if job.logs %}
<details class="log-copy">
<summary>Copy raw log output</summary>
<textarea class="log-copy__textarea" readonly>{{- log_text -}}</textarea>
</details>
{% endif %}
</section>
{% endblock %}
+9 -1
View File
@@ -1,4 +1,12 @@
<div class="card__title">Live log</div> {% set is_static = static_view if static_view is defined and static_view else False %}
<div class="card__title-row">
<div class="card__title">Live log</div>
{% if not is_static %}
<a class="button button--ghost button--small" href="{{ url_for('web.job_logs_static', job_id=job.id) }}" target="_blank" rel="noopener">Open static view</a>
{% else %}
<a class="button button--ghost button--small" href="{{ url_for('web.job_detail', job_id=job.id) }}">Back to job</a>
{% endif %}
</div>
{% if job.logs %} {% if job.logs %}
<ul class="log-list"> <ul class="log-list">
{% for entry in job.logs|reverse %} {% for entry in job.logs|reverse %}
+99 -1
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
import io
import time 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): def test_service_processes_job(tmp_path):
@@ -59,3 +60,100 @@ def test_service_processes_job(tmp_path):
assert job.speaker_mode == "single" assert job.speaker_mode == "single"
assert job.chunks == [] assert job.chunks == []
assert not job.generate_epub3 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()