jobs table

This commit is contained in:
John Lancaster
2026-06-28 14:36:14 -05:00
parent dba96e7a72
commit 57c1d22bb9
5 changed files with 162 additions and 61 deletions
@@ -1,55 +0,0 @@
"""Reusable jobs table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from uuid import UUID
from nicegui import ui
@dataclass(frozen=True)
class JobTableRow:
"""Read model consumed by the shared jobs table component."""
id: UUID
status: str
created_at: str
updated_at: str
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, str]]:
"""Convert typed rows into table-compatible dictionaries."""
return [
{
"id": str(row.id),
"status": row.status,
"created_at": row.created_at,
"updated_at": row.updated_at,
}
for row in rows
]
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and per-row detail links."""
if not rows:
ui.label("No jobs yet.")
return
serialized_rows = _serialize_rows(rows)
ui.table(
columns=[
{"name": "id", "label": "Job ID", "field": "id"},
{"name": "status", "label": "Status", "field": "status"},
{"name": "created_at", "label": "Created", "field": "created_at"},
{"name": "updated_at", "label": "Updated", "field": "updated_at"},
],
rows=serialized_rows,
row_key="id",
).classes("w-full")
with ui.column().classes("gap-1"):
for row in serialized_rows:
ui.link(f"Open {row['id']}", f"/jobs/{row['id']}")
@@ -0,0 +1,4 @@
from .jobs import JobTableRow
from .jobs import render_jobs_table
__all__ = ["JobTableRow", "render_jobs_table"]
@@ -0,0 +1,68 @@
"""Common logic for generating table widgets."""
from collections.abc import Callable
from typing import Any
from nicegui import events
from nicegui import ui
def _extract_row_id(args: Any) -> str | None:
if isinstance(args, dict):
if isinstance(args.get("row"), dict):
row_id = args["row"].get("id")
return str(row_id) if row_id is not None else None
row_id = args.get("id")
return str(row_id) if row_id is not None else None
if isinstance(args, list):
for value in args:
if isinstance(value, dict):
row_id = value.get("id")
if row_id is not None:
return str(row_id)
return None
def _bind_row_click_handler(
table: Any,
*,
on_row_click_id: Callable[[str], None],
) -> None:
def handle_row_click(event: events.GenericEventArguments) -> None:
row_id = _extract_row_id(event.args)
if row_id is None:
return
on_row_click_id(row_id)
table.on("rowClick", handle_row_click)
def build_table(
rows: list[dict[str, Any]],
columns: list[dict[str, Any]],
*,
default_sort_by: str | None = None,
default_descending: bool = False,
classes: str = "app-table",
on_row_click_id: Callable[[str], None] | None = None,
) -> Any:
pagination: dict[str, Any] = {"rowsPerPage": 25}
if default_sort_by is not None:
pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending
table = (
ui.table(
rows=rows,
columns=columns,
row_key="id",
pagination=pagination,
)
.classes(classes)
.props('table-style="table-layout: fixed; width: 100%;"')
)
if on_row_click_id is not None:
_bind_row_click_handler(table, on_row_click_id=on_row_click_id)
return table
@@ -0,0 +1,75 @@
"""Jobs table rendering helpers."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from typing import Any
from uuid import UUID
from nicegui import ui
from .common import build_table
@dataclass(frozen=True, slots=True)
class JobTableRow:
"""Read model consumed by the jobs table component."""
id: UUID
status: str
filename: str
retry_count: int
created_at: str
updated_at: str
def _format_timestamp(value: str) -> str:
"""Return a friendly UTC timestamp for table display."""
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return value
parsed = parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
return parsed.astimezone().strftime("%b %d, %I:%M %p")
def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
return [
{
"id": str(row.id),
"status": row.status,
"filename": row.filename,
"retry_count": row.retry_count,
"created_at": _format_timestamp(row.created_at),
"updated_at": _format_timestamp(row.updated_at),
"created_sort": row.created_at,
"updated_sort": row.updated_at,
}
for row in rows
]
def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"""Render jobs table and open a detail page when clicking a row."""
if not rows:
ui.label("No jobs yet.")
return
build_table(
rows=_serialize_rows(rows),
columns=[
{"name": "id", "label": "Job ID", "field": "id", "sortable": True},
{"name": "status", "label": "Status", "field": "status", "sortable": True},
{"name": "filename", "label": "Filename", "field": "filename", "sortable": True},
{"name": "retry_count", "label": "Retries", "field": "retry_count", "sortable": True},
{"name": "created_at", "label": "Created", "field": "created_at", "sortable": True},
{"name": "updated_at", "label": "Updated", "field": "updated_at", "sortable": True},
],
default_sort_by="created_sort",
default_descending=True,
classes="app-table w-full",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
)
+15 -6
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from uuid import UUID from uuid import UUID
from nicegui import ui from nicegui import ui
from sqlalchemy.orm import selectinload
from sqlmodel import desc from sqlmodel import desc
from sqlmodel import select from sqlmodel import select
@@ -15,18 +16,26 @@ from transcription.models import Transcript
from transcription.ui.components.error_presenter import show_error from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.error_presenter import summarize_error from transcription.ui.components.error_presenter import summarize_error
from transcription.ui.components.job_detail import render_job_detail from transcription.ui.components.job_detail import render_job_detail
from transcription.ui.components.job_table import JobTableRow from transcription.ui.components.table.jobs import JobTableRow
from transcription.ui.components.job_table import render_jobs_table from transcription.ui.components.table.jobs import render_jobs_table
async def fetch_jobs() -> list[JobTableRow]: async def fetch_job_rows() -> list[JobTableRow]:
"""Return jobs for display in most-recent-first order.""" """Return jobs for display in most-recent-first order."""
async with get_session() as session: async with get_session() as session:
jobs = (await session.exec(select(Job).order_by(desc(Job.created_at)))).all() jobs = (
await session.exec(
select(Job)
.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
.order_by(desc(Job.created_at))
)
).all()
return [ return [
JobTableRow( JobTableRow(
id=job.id, id=job.id,
status=job.status.value, status=job.status.value,
filename=job.filename,
retry_count=job.retry_count,
created_at=job.created_at.isoformat(), created_at=job.created_at.isoformat(),
updated_at=job.updated_at.isoformat(), updated_at=job.updated_at.isoformat(),
) )
@@ -55,7 +64,7 @@ def register_page() -> None:
@ui.refreshable @ui.refreshable
async def render_table() -> None: async def render_table() -> None:
jobs = await fetch_jobs() jobs = await fetch_job_rows()
render_jobs_table(jobs) render_jobs_table(jobs)
async def refresh() -> None: async def refresh() -> None:
@@ -69,7 +78,7 @@ def register_page() -> None:
ui.button("Refresh", on_click=refresh) ui.button("Refresh", on_click=refresh)
await render_table() await render_table()
ui.link("Back to upload", "/") ui.link("Back to upload", "/upload")
@ui.page("/jobs/{job_id}") @ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str) -> None: async def job_detail_page(job_id: str) -> None: