diff --git a/backend/fluksio/sdk/cli.py b/backend/fluksio/sdk/cli.py index 4b25d85..2866798 100644 --- a/backend/fluksio/sdk/cli.py +++ b/backend/fluksio/sdk/cli.py @@ -19,6 +19,7 @@ import sys import time from collections.abc import Callable, Iterable, Iterator from contextlib import contextmanager, nullcontext +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -804,7 +805,8 @@ def _status_screen(client: Client) -> Any: ) + Text( f"{str(row.get('flow', '')):<16}" - f"{(row.get('duration_ms') or 0) / 1000:7.1f}s", + f"{(row.get('duration_ms') or 0) / 1000:7.1f}s" + f" {_ago(row.get('finished_at') or row.get('created_at')):>9}", style="dim", ) ) @@ -816,6 +818,7 @@ def _status_screen(client: Client) -> Any: ) parts.append( Text(f" {where} ", style="red") + + Text(f"{_ago(event.get('ts')):>9} ", style="dim") + Text(str(event.get("detail", ""))[:100], style="dim") ) return Group(*parts) @@ -857,9 +860,9 @@ def cmd_status(args: argparse.Namespace) -> int: #: value is read. PARAMS_WIDTH = 80 -#: What the columns before the inputs take: the id, status, flow, duration and -#: stamp, with their spacing. -LISTING_WIDTH = 84 +#: What the columns before the inputs take: the id, status, flow, duration, +#: age and stamp, with their spacing. +LISTING_WIDTH = 95 def _declared(client: Client, flows: Iterable[str]) -> dict[str, dict[str, Any]]: @@ -883,6 +886,28 @@ def _declared(client: Client, flows: Iterable[str]) -> dict[str, dict[str, Any]] return known +def _ago(stamp: Any) -> str: + """How long ago something happened, in the notation the screens use. + + A duration and an age read together, so they are spelled the same way: + seconds, then minutes, hours, days. + """ + if not stamp: + return "" + try: + then = datetime.fromisoformat(str(stamp)) + except ValueError: + return "" + if then.tzinfo is None: + # Everything the engine records is UTC; only some spellings say so. + then = then.replace(tzinfo=UTC) + seconds = max((datetime.now(UTC) - then).total_seconds(), 0) + for span, unit in ((86400, "d"), (3600, "h"), (60, "min")): + if seconds >= span: + return f"{seconds / span:.0f}{unit} ago" + return f"{seconds:.0f}s ago" + + def _stamp(row: dict[str, Any]) -> str: """What code a run ran: the commit, whether it was dirty, and the digest. @@ -925,7 +950,8 @@ def cmd_runs(args: argparse.Namespace) -> int: params = params[: room - 3] + "..." _say( f"{row['id']} {_status(row['status'], 9)} {row['flow']:<16} " - f"{row['duration_ms'] / 1000:7.1f}s {_stamp(row):<22} {params}" + f"{row['duration_ms'] / 1000:7.1f}s {_ago(row.get('created_at')):>9} " + f"{_stamp(row):<22} {params}" ) return 0 diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py index 802ce09..9d6c156 100644 --- a/backend/tests/test_cli.py +++ b/backend/tests/test_cli.py @@ -520,6 +520,24 @@ def test_two_files_of_one_name_are_refused(tmp_path) -> None: _import(*_module_of(second), second) +def test_how_long_ago_reads_like_a_duration() -> None: + """A failure with no time on it says nothing about whether it is current.""" + from datetime import UTC, datetime, timedelta + + from fluksio.sdk.cli import _ago + + def then(**delta): + return (datetime.now(UTC) - timedelta(**delta)).isoformat() + + assert _ago(then(seconds=5)) == "5s ago" + assert _ago(then(minutes=3)) == "3min ago" + assert _ago(then(hours=2)) == "2h ago" + assert _ago(then(days=3)) == "3d ago" + # What the engine stores is UTC whether or not the spelling says so. + assert _ago(datetime.now(UTC).replace(tzinfo=None).isoformat()) == "0s ago" + assert _ago(None) == "" + + def test_a_serve_limit_is_refused_as_a_flag_not_as_a_traceback(capsys) -> None: """These are written into the environment before the settings are built.""" import pytest