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 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/<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>")
+41 -1
View File
@@ -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
+47
View File
@@ -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;
+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 %}
<ul class="log-list">
{% for entry in job.logs|reverse %}
+99 -1
View File
@@ -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):
@@ -59,3 +60,100 @@ def test_service_processes_job(tmp_path):
assert job.speaker_mode == "single"
assert job.chunks == []
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()