feat: Implement job management features with improved UI for active and finished jobs

This commit is contained in:
JB
2025-10-06 12:41:22 -07:00
parent f3aaeda37b
commit fc4c41c7cf
5 changed files with 262 additions and 37 deletions
+26 -1
View File
@@ -763,6 +763,19 @@ def enqueue_job() -> Response:
return redirect(url_for("web.job_detail", job_id=job.id)) return redirect(url_for("web.job_detail", job_id=job.id))
def _render_jobs_panel() -> str:
jobs = _service().list_jobs()
active_jobs = [job for job in jobs if job.status in {JobStatus.PENDING, JobStatus.RUNNING}]
finished_jobs = [job for job in jobs if job.status not in {JobStatus.PENDING, JobStatus.RUNNING}]
return render_template(
"partials/jobs.html",
active_jobs=active_jobs,
finished_jobs=finished_jobs[:5],
total_finished=len(finished_jobs),
JobStatus=JobStatus,
)
@web_bp.get("/jobs/<job_id>") @web_bp.get("/jobs/<job_id>")
def job_detail(job_id: str) -> str: def job_detail(job_id: str) -> str:
job = _service().get_job(job_id) job = _service().get_job(job_id)
@@ -778,15 +791,27 @@ def job_detail(job_id: str) -> str:
@web_bp.post("/jobs/<job_id>/cancel") @web_bp.post("/jobs/<job_id>/cancel")
def cancel_job(job_id: str) -> Response: def cancel_job(job_id: str) -> Response:
_service().cancel(job_id) _service().cancel(job_id)
if request.headers.get("HX-Request"):
return _render_jobs_panel()
return redirect(url_for("web.job_detail", job_id=job_id)) return redirect(url_for("web.job_detail", job_id=job_id))
@web_bp.post("/jobs/<job_id>/delete") @web_bp.post("/jobs/<job_id>/delete")
def delete_job(job_id: str) -> Response: def delete_job(job_id: str) -> Response:
_service().delete(job_id) _service().delete(job_id)
if request.headers.get("HX-Request"):
return _render_jobs_panel()
return redirect(url_for("web.index")) return redirect(url_for("web.index"))
@web_bp.post("/jobs/clear-finished")
def clear_finished_jobs() -> Response:
_service().clear_finished()
if request.headers.get("HX-Request"):
return _render_jobs_panel()
return redirect(url_for("web.queue_page"))
@web_bp.get("/jobs/<job_id>/download") @web_bp.get("/jobs/<job_id>/download")
def download_job(job_id: str) -> Response: def download_job(job_id: str) -> Response:
job = _service().get_job(job_id) job = _service().get_job(job_id)
@@ -812,7 +837,7 @@ def download_job(job_id: str) -> Response:
@web_bp.get("/partials/jobs") @web_bp.get("/partials/jobs")
def jobs_partial() -> str: def jobs_partial() -> str:
return render_template("partials/jobs.html", jobs=_service().list_jobs()) return _render_jobs_panel()
@web_bp.get("/partials/jobs/<job_id>/logs") @web_bp.get("/partials/jobs/<job_id>/logs")
+22
View File
@@ -222,6 +222,28 @@ class ConversionService:
self._update_queue_positions_locked() self._update_queue_positions_locked()
return True return True
def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int:
finished_statuses = set(statuses or {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED})
removed = 0
with self._lock:
# Remove any queued entries first to avoid stale references
filtered_queue: List[str] = []
for job_id in self._queue:
job = self._jobs.get(job_id)
if job and job.status in finished_statuses:
continue
filtered_queue.append(job_id)
self._queue = filtered_queue
for job_id, job in list(self._jobs.items()):
if job.status in finished_statuses:
self._jobs.pop(job_id)
removed += 1
if removed:
self._update_queue_positions_locked()
return removed
def shutdown(self) -> None: def shutdown(self) -> None:
self._stop_event.set() self._stop_event.set()
self._wake_event.set() self._wake_event.set()
+141
View File
@@ -144,6 +144,9 @@ body {
.form-grid > .grid { .form-grid > .grid {
align-items: start; align-items: start;
max-width: 460px;
width: min(100%, 460px);
justify-self: start;
} }
.button { .button {
@@ -367,6 +370,7 @@ body {
border-radius: 999px; border-radius: 999px;
background: rgba(148, 163, 184, 0.15); background: rgba(148, 163, 184, 0.15);
overflow: hidden; overflow: hidden;
--progress: 0%;
} }
.progress-bar__fill { .progress-bar__fill {
@@ -374,6 +378,7 @@ body {
background: linear-gradient(90deg, var(--accent), var(--accent-strong)); background: linear-gradient(90deg, var(--accent), var(--accent-strong));
border-radius: inherit; border-radius: inherit;
transition: width 0.35s ease; transition: width 0.35s ease;
width: var(--progress);
} }
.list { .list {
@@ -395,6 +400,142 @@ body {
gap: 1rem; gap: 1rem;
} }
.queue-section {
display: grid;
gap: 1rem;
margin-bottom: 1.75rem;
}
.queue-section:last-of-type {
margin-bottom: 0;
}
.queue-section__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.queue-section__header h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.job-cards {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 1rem;
}
.job-card {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 20px;
padding: 1rem 1.25rem;
background: rgba(15, 23, 42, 0.35);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
display: grid;
gap: 0.75rem;
}
.job-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.job-card__meta {
color: var(--muted);
font-size: 0.85rem;
margin-top: 0.35rem;
}
.job-card__progress {
display: grid;
gap: 0.4rem;
}
.job-card__progress small {
color: var(--muted);
}
.job-card__footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.job-card__footer .button--ghost {
padding: 0.6rem 0.95rem;
}
.job-cards--compact .job-card {
background: rgba(15, 23, 42, 0.28);
}
.job-cards--compact .job-card__meta {
margin-top: 0.25rem;
}
.job-card--info {
border-style: dashed;
border-color: rgba(148, 163, 184, 0.25);
background: rgba(15, 23, 42, 0.2);
text-align: center;
}
.job-card--info .job-card__meta {
margin-top: 0;
}
.queue-empty {
color: var(--muted);
}
.field--file input[type="file"] {
width: 100%;
border-radius: 14px;
border: 1px dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.45);
padding: 0.85rem 1rem;
font-size: 0.95rem;
color: var(--muted);
cursor: pointer;
transition: border 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.field--file input[type="file"]:hover,
.field--file input[type="file"]:focus {
border-color: var(--accent);
background: rgba(56, 189, 248, 0.1);
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12);
}
.field--file input[type="file"]::file-selector-button,
.field--file input[type="file"]::-webkit-file-upload-button {
border: none;
border-radius: 12px;
background: rgba(56, 189, 248, 0.2);
color: var(--accent);
font-weight: 600;
padding: 0.55rem 1.05rem;
margin-right: 1rem;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.field--file input[type="file"]:hover::file-selector-button,
.field--file input[type="file"]:focus::file-selector-button,
.field--file input[type="file"]:hover::-webkit-file-upload-button,
.field--file input[type="file"]:focus::-webkit-file-upload-button {
background: rgba(56, 189, 248, 0.35);
color: #fff;
}
.alert { .alert {
margin: 1rem 0; margin: 1rem 0;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
+2 -2
View File
@@ -7,8 +7,8 @@
<h1 class="card__title">Create a New Audiobook</h1> <h1 class="card__title">Create a New Audiobook</h1>
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid"> <form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two form-grid">
<div class="grid"> <div class="grid">
<div class="field"> <div class="field field--file">
<label for="source_file">Source File</label> <label for="source_file">Source File</label>
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown"> <input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
</div> </div>
<div class="field"> <div class="field">
+71 -34
View File
@@ -1,37 +1,74 @@
<div class="card__title">Queue</div> <div class="card__title">Queue</div>
{% if jobs %}
<table class="jobs-table"> <section class="queue-section">
<thead> <header class="queue-section__header">
<tr> <h3>Active jobs</h3>
<th>Job</th> </header>
<th>Status</th> {% if active_jobs %}
<th>Progress</th> <ul class="job-cards">
<th>Created</th> {% for job in active_jobs %}
<th></th> <li class="job-card">
</tr> <div class="job-card__header">
</thead> <div>
<tbody> <strong>{{ job.original_filename }}</strong>
{% for job in jobs %} <div class="job-card__meta">{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}</div>
<tr> </div>
<td> <span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
<strong>{{ job.original_filename }}</strong>
<div class="tag">
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
</div> </div>
</td> <div class="job-card__progress">
<td> <div class="progress-bar" style="--progress: {{ ((job.progress or 0) * 100)|round(1) }}%">
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span> <div class="progress-bar__fill"></div>
</td> </div>
<td> <small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
<progress value="{{ job.processed_characters or 0 }}" max="{{ job.total_characters or 1 }}" class="progress"></progress> </div>
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small> <div class="job-card__footer">
</td> <a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a>
<td><small>{{ job.created_at|datetimeformat }}</small></td> {% if job.status in [JobStatus.PENDING, JobStatus.RUNNING] %}
<td><a class="btn" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a></td> <button type="button" class="button button--ghost" hx-post="{{ url_for('web.cancel_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Cancel this conversion?">Cancel</button>
</tr> {% endif %}
</div>
</li>
{% endfor %} {% endfor %}
</tbody> </ul>
</table> {% else %}
{% else %} <p class="queue-empty">No active jobs. Drop a file or paste text to get started.</p>
<p>No jobs yet. Drop a file or paste text to get started.</p> {% endif %}
{% endif %} </section>
<section class="queue-section">
<header class="queue-section__header">
<h3>Recent results</h3>
{% if total_finished > 0 %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.clear_finished_jobs') }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove completed jobs from this list?">Clear finished</button>
{% endif %}
</header>
{% if finished_jobs %}
<ul class="job-cards job-cards--compact">
{% for job in finished_jobs %}
<li class="job-card">
<div class="job-card__header">
<div>
<strong>{{ job.original_filename }}</strong>
<div class="job-card__meta">Finished {{ job.finished_at|datetimeformat }}</div>
</div>
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
</div>
<div class="job-card__footer">
<a class="button button--ghost" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a>
{% if job.status == JobStatus.COMPLETED and job.result.audio_path %}
<a class="button button--ghost" href="{{ url_for('web.download_job', job_id=job.id) }}">Download</a>
{% endif %}
<button type="button" class="button button--ghost" hx-post="{{ url_for('web.delete_job', job_id=job.id) }}" hx-target="#jobs-panel" hx-swap="innerHTML" hx-confirm="Remove this job from the list?">Remove</button>
</div>
</li>
{% endfor %}
{% if total_finished > finished_jobs|length %}
<li class="job-card job-card--info">
<div class="job-card__meta">{{ total_finished - finished_jobs|length }} more finished job{{ 's' if (total_finished - finished_jobs|length) != 1 else '' }} hidden. Clear finished to remove them.</div>
</li>
{% endif %}
</ul>
{% else %}
<p class="queue-empty">Completed jobs will appear here.</p>
{% endif %}
</section>