mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add static job logs view and enhance logging functionality with improved UI and error handling
This commit is contained in:
+19
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
+100
-2
@@ -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):
|
||||||
@@ -58,4 +59,101 @@ def test_service_processes_job(tmp_path):
|
|||||||
assert job.chunk_level == "paragraph"
|
assert job.chunk_level == "paragraph"
|
||||||
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()
|
||||||
Reference in New Issue
Block a user