tweak job runs

This commit is contained in:
Abel Luck 2026-03-30 14:14:59 +02:00
parent 2b2a3f1cc0
commit c210168d65
6 changed files with 94 additions and 12 deletions

View file

@ -112,5 +112,6 @@ uv run repub crawl -c repub.toml
- Runtime ffmpeg availability is provided by the flake package and devshell.
- Tests live under `tests/`.
- `prompts/` is git ignored intentionally
- Never search the web for this repo. If an external resource, document, or reference is needed, stop and ask the user to provide it.
- Treat the repo-root `republisher.db` as user-owned local state. Do not delete or reset it as part of routine testing or verification.
- For automated tests or isolated verification, use a separate database path via `REPUBLISHER_DB_PATH` instead of mutating or removing the repo-root database.

View file

@ -403,7 +403,7 @@ def status_badge(*, label: str, tone: str) -> Renderable:
"scheduled": "bg-sky-100 text-sky-800",
"idle": "bg-slate-200 text-slate-700",
"failed": "bg-rose-100 text-rose-800",
"done": "bg-amber-100 text-amber-800",
"done": "bg-emerald-100 text-emerald-800",
}
return h.span(
class_=f"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold {tones[tone]}"

View file

@ -504,10 +504,11 @@ def _project_upcoming_job(
"slug": job.source.slug,
"job_id": job_id,
"next_run": (
next_run.strftime("%Y-%m-%d %H:%M UTC")
_humanize_future_time(reference_time, next_run)
if next_run is not None
else ("Running now" if running_execution is not None else "Not scheduled")
),
"next_run_at": next_run.isoformat() if next_run is not None else None,
"schedule": " ".join(
(
str(job.cron_minute),
@ -641,3 +642,22 @@ def _format_bytes(value: int) -> str:
if value < 1024 * 1024 * 1024:
return f"{value / (1024 * 1024):.1f} MB"
return f"{value / (1024 * 1024 * 1024):.1f} GB"
def _humanize_future_time(reference_time: datetime, target_time: datetime) -> str:
delta_seconds = int(round((target_time - reference_time).total_seconds()))
if delta_seconds <= 0:
return "now"
units = (
("day", 24 * 60 * 60),
("hour", 60 * 60),
("minute", 60),
)
for label, size in units:
if delta_seconds >= size:
count = max(1, round(delta_seconds / size))
suffix = "" if count == 1 else "s"
return f"in {count} {label}{suffix}"
return f"in {delta_seconds} seconds"

View file

@ -70,9 +70,6 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
h.p(class_="font-medium text-slate-900")[
f"#{_text(execution, 'execution_id')}"
],
h.p(class_="mt-1 text-xs text-slate-500")[
f"job {_text(execution, 'job_id')}"
],
],
h.div[
h.p(class_="font-medium text-slate-900")[_text(execution, "started_at")],
@ -99,15 +96,26 @@ def _running_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
def _upcoming_row(job: Mapping[str, object]) -> tuple[Node, ...]:
next_run_at = _maybe_text(job, "next_run_at")
next_run_label: Node = h.p(class_="font-medium text-slate-900")[
_text(job, "next_run")
]
if next_run_at is not None:
next_run_label = h.time(
{
"data-next-run-at": next_run_at,
"title": next_run_at,
},
datetime=next_run_at,
class_="font-medium text-slate-900",
)[_text(job, "next_run")]
return (
h.div[
h.div(class_="font-semibold text-slate-950")[_text(job, "source")],
h.p(class_="mt-1 font-mono text-xs text-slate-500")[_text(job, "slug")],
],
h.div[
h.p(class_="font-medium text-slate-900")[_text(job, "next_run")],
h.p(class_="mt-1 text-xs text-slate-500")[f"job {_text(job, 'job_id')}"],
],
h.div[next_run_label,],
h.p(class_="font-mono text-xs text-slate-600")[_text(job, "schedule")],
status_badge(
label=_text(job, "enabled_label"),
@ -147,9 +155,6 @@ def _completed_row(execution: Mapping[str, object]) -> tuple[Node, ...]:
h.p(class_="font-medium text-slate-900")[
f"#{_text(execution, 'execution_id')}"
],
h.p(class_="mt-1 text-xs text-slate-500")[
f"job {_text(execution, 'job_id')}"
],
],
h.div[
h.p(class_="font-medium text-slate-900")[_text(execution, "ended_at")],
@ -232,6 +237,48 @@ def runs_page(
),
rows=completed_rows,
),
h.script[
"""
window.repubFormatNextRuns = window.repubFormatNextRuns || (() => {
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const absoluteFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
timeZoneName: 'short',
});
const formatRelative = (targetDate) => {
const diffSeconds = Math.round((targetDate.getTime() - Date.now()) / 1000);
const units = [
['day', 86400],
['hour', 3600],
['minute', 60],
['second', 1],
];
for (const [unit, size] of units) {
if (Math.abs(diffSeconds) >= size || unit === 'second') {
return relativeFormatter.format(Math.round(diffSeconds / size), unit);
}
}
return relativeFormatter.format(0, 'second');
};
const format = () => {
document.querySelectorAll('time[data-next-run-at]').forEach((element) => {
const nextRunAt = element.getAttribute('data-next-run-at');
if (!nextRunAt) return;
const targetDate = new Date(nextRunAt);
if (Number.isNaN(targetDate.getTime())) return;
element.textContent = formatRelative(targetDate);
element.title = absoluteFormatter.format(targetDate);
});
};
format();
if (!window.repubNextRunTimer) {
window.repubNextRunTimer = window.setInterval(format, 30000);
}
});
window.repubFormatNextRuns();
"""
],
),
)

View file

@ -1,4 +1,8 @@
/*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */
@view-transition {
navigation: auto;
}
@layer properties;
@layer theme, base, components, utilities;
@layer theme {

View file

@ -4,6 +4,7 @@ import asyncio
from pathlib import Path
from typing import Any, cast
from repub.components import status_badge
from repub.datastar import RefreshBroker, render_sse_event, render_stream
from repub.model import (
Job,
@ -26,6 +27,13 @@ from repub.web import (
)
def test_status_badge_uses_green_done_tone() -> None:
badge = str(status_badge(label="Succeeded", tone="done"))
assert "bg-emerald-100 text-emerald-800" in badge
assert "Succeeded" in badge
def test_root_get_serves_datastar_shim() -> None:
async def run() -> None:
client = create_app().test_client()
@ -618,6 +626,8 @@ def test_render_runs_shows_running_upcoming_and_completed_tables(
assert "Completed job executions" in body
assert "runs-render-source" in body
assert f"/job/{job.id}/execution/{execution.get_id()}/logs" in body
assert "data-next-run-at" in body
assert "in " in body
assert "Already running" not in body
asyncio.run(run())