diff --git a/docs/web-server-endpoints.md b/docs/web-server-endpoints.md index da8b0521..d7e7cd5d 100644 --- a/docs/web-server-endpoints.md +++ b/docs/web-server-endpoints.md @@ -9,7 +9,7 @@ auth, and the concepts behind these routes live in [`web-server.md`](web-server.md); a running server serves the same spec interactively at `/docs` (Swagger) and `/openapi.json`. -**227 operations** across **198 paths** and **29 routers**. +**228 operations** across **199 paths** and **29 routers**. Paths are shown as the server registers them. Reaching them through the Node BFF (or single-process `--ui` mode) prefixes every path with `/api`. @@ -78,12 +78,13 @@ PAYG credit balance across projects (read-only). Purchase history / Stripe invoi |---|---|---| | `GET` | `/billing/credits` | PAYG credit balance across projects | -### `token` (4 operations) +### `token` (5 operations) Scoped Storage API tokens -- mint (bucket read/write + component access + expiry), rotate, and revoke. A minted/rotated token's secret is returned ONCE; the acting token needs canManageTokens. Mirrors `kbagent token create|delete|refresh`. | Method | Path | Summary | |---|---|---| +| `GET` | `/token/list` | List Storage tokens across projects | | `GET` | `/token/{project}/list` | List the project's Storage tokens | | `POST` | `/token/{project}/create` | Mint a scoped Storage token | | `POST` | `/token/{project}/delete` | Revoke a Storage token (destructive) | diff --git a/docs/web-server.md b/docs/web-server.md index 0a68485a..173e25c9 100644 --- a/docs/web-server.md +++ b/docs/web-server.md @@ -175,7 +175,12 @@ A NERD-themed React SPA that drives the API: (`/token/{p}/list`): create / rotate / revoke, with the secret revealed ONCE in a copy-to-clipboard block, and an opt-in "derive last-used" toggle (`with_last_used=true`) that sorts dormant tokens first and - renders `never` / `unknown` / `error` as distinct pills. + renders `never` / `unknown` / `error` as distinct pills. A cross-project + **All Tokens** view reads `GET /token/list` (repeatable `?project=`, + same convention as `/jobs` and `/billing/credits`; omitted = every + registered project) — every row carries `project_alias`, and + `with_last_used=true` sorts dormant-first across every project's tokens + together rather than grouped per project. - **Configs, Components (AI search), Storage (with per-column data preview), Jobs (cards layout + SSE log stream), Search** — browse a selected project. diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index bc5bb998..6a66fbc6 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -4434,3 +4434,40 @@ fallback (`config examples` already resolved it correctly). text's "first available" promise does not work there. - `component sync-action` is unaffected: its `--project` is genuinely required (exit 2 without it) on every version. + +## Multi-project `job list` now merges chronologically, not grouped by project (since vNEXT) + +`kbagent job list` without `--project` (and `kbagent serve`'s `GET /jobs`) fans +out to every resolved project's Queue API in parallel. Each project's own page +already comes back sorted server-side by `--sort-by`/`--sort-order`, but the +old aggregation step then re-sorted the MERGED list by +`(project_alias, str(id))` regardless of what sort the caller asked for -- so +the output read as "every job from project A, then every job from project B", +never interleaved by time. That made the aggregate view useless for "what ran +most recently across all my projects" (the driving use case for a cross-project +"All Jobs" feed) and made multi-project `job list` non-chronological even +though single-project `job list` looked correctly time-ordered. + +- **Fixed**: the merged `jobs` list is now sorted globally by the SAME + `sort_by`/`sort_order` passed to (and already applied per-project by) the + Queue API -- default `startTime desc`, so the default view is one + chronological feed interleaved across every project. `sort_by` accepts the + same `JOB_SORT_FIELDS` as before (`startTime`, `endTime`, `createdTime`, + `durationSeconds`, `id`). +- **Missing values always sort last, in both `asc` and `desc`.** A job with no + `startTime` yet (e.g. still `waiting`) is "not yet comparable", not "the + oldest job" -- it never gets pulled to the front of a `desc` sort just + because the field is absent. Same rule for `endTime`/`createdTime` (empty or + absent) and `durationSeconds` (`None`, not `0` -- a genuine `0`-second job is + a real value and sorts normally). +- **Deterministic tiebreak**: ties on the sort field -- including the entire + missing-value group, and jobs that happen to share the exact same + timestamp across projects -- break on `(project_alias, str(id))` ascending. + The result never depends on which project's worker thread happened to + finish first. +- `id` sorts numerically when the value coerces to a number, else falls back + to string comparison (defensive only -- Queue API ids are numeric in + practice). +- Single-project `job list` is unaffected in practice (its one page was + already server-sorted); the fix only changes behavior once 2+ projects are + queried together. diff --git a/src/keboola_agent_cli/server/routers/token.py b/src/keboola_agent_cli/server/routers/token.py index 1d9ab4af..61fb5403 100644 --- a/src/keboola_agent_cli/server/routers/token.py +++ b/src/keboola_agent_cli/server/routers/token.py @@ -11,7 +11,7 @@ from typing import Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from ..dependencies import ServiceRegistry, get_registry @@ -32,6 +32,31 @@ class TokenIdBody(BaseModel): token_id: str +@router.get("/list", summary="List Storage tokens across projects") +def list_tokens_all( + project: list[str] | None = Query(None), + with_last_used: bool = False, + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """List tokens across one, many, or all registered projects. + + No CLI counterpart (`kbagent token list` is single-project); this exists + for the web UI's cross-project token audit. `project` is repeatable + (`?project=a&project=b`); omitting it queries every registered project, + matching the `GET /jobs` / `GET /billing/credits` convention. Every token + row and every error entry carries `project_alias`. Failures come back in + two separate lists: `errors` (a project could not be listed at all) and + `token_errors` (the project listed fine, but one token's last-used lookup + failed and its row degraded). + + `with_last_used` mirrors the single-project flag, but the per-token cost + now multiplies: one extra Storage API call PER TOKEN PER PROJECT. It also + switches the tokens' sort order from "grouped by project" to a single + dormant-first order spanning every project's tokens together. + """ + return registry.token.list_tokens_all(aliases=project, with_last_used=with_last_used) + + @router.get("/{project}/list", summary="List the project's Storage tokens") def list_tokens( project: str, diff --git a/src/keboola_agent_cli/services/job_service.py b/src/keboola_agent_cli/services/job_service.py index 84894730..509f6fc8 100644 --- a/src/keboola_agent_cli/services/job_service.py +++ b/src/keboola_agent_cli/services/job_service.py @@ -33,6 +33,103 @@ # Terminal statuses for which we surface a log-tail. _LOG_TAIL_STATUSES: frozenset[str] = frozenset({"error", "warning", "terminated"}) +# Time-valued Queue API sort fields; compare lexicographically as ISO 8601 +# strings, so plain string ordering already yields chronological order. +_TIME_SORT_FIELDS: frozenset[str] = frozenset({"startTime", "endTime", "createdTime"}) + + +class _DescStr: + """Wraps a string so an ascending tuple-key sort yields descending order. + + ``list.sort`` only exposes one ``reverse`` flag for the whole key, but + our key tuple mixes a direction-agnostic "is this value missing" + marker (always ascending, so missing rows land last regardless of + ``sort_order``) with a direction-sensitive field value. Numeric fields + handle that by negating the number for descending order; strings + (timestamps, non-numeric ids) can't be negated, so they get wrapped + in this instead -- it inverts ``__lt__`` so plain ascending sort of + the wrapper produces descending string order. + """ + + __slots__ = ("value",) + + def __init__(self, value: str) -> None: + self.value = value + + def __lt__(self, other: "_DescStr") -> bool: + return other.value < self.value + + def __eq__(self, other: object) -> bool: + return isinstance(other, _DescStr) and self.value == other.value + + +def _job_sort_key(job: dict[str, Any], sort_by: str, sort_order: str) -> tuple[int, Any, str, str]: + """Build a sort key so a single ``list.sort(key=...)`` call yields the + globally-correct chronological (or field-appropriate) merge order + across all queried projects. + + Returns a 4-tuple ``(missing_flag, primary, project_alias, id)``: + + - ``missing_flag``: ``0`` when the requested field has a usable value, + ``1`` when it is absent/None/uncoercible. Always compared ascending + (this key tuple is never sorted with ``reverse=True``), so missing + rows sort LAST for both "asc" and "desc" -- a job without + ``startTime`` (e.g. still ``waiting``) is "not yet comparable", not + "the oldest job". + - ``primary``: the field's own value, pre-transformed (negated for + numeric fields, wrapped in :class:`_DescStr` for string fields) so + that a single ascending sort already reflects ``sort_order``. + - ``project_alias`` / ``id``: the deterministic tiebreak. It resolves + ties on equal field values AND orders the missing-value group + itself (whose ``primary`` slot is a fixed placeholder). + + Never raises: Queue API rows are untyped dicts, so every field access + is defensively coerced. + """ + desc = sort_order == "desc" + alias = str(job.get("project_alias", "")) + id_tiebreak = str(job.get("id", "")) + + if sort_by == "durationSeconds": + raw = job.get("durationSeconds") + try: + numeric = float(raw) if raw is not None else None + except (TypeError, ValueError): + numeric = None + if numeric is None: + return (1, 0.0, alias, id_tiebreak) + return (0, -numeric if desc else numeric, alias, id_tiebreak) + + if sort_by == "id": + raw = job.get("id") + if raw is None or raw == "": + return (1, (1, ""), alias, id_tiebreak) + try: + numeric_id = float(raw) + except (TypeError, ValueError): + # Non-numeric id: string fallback, grouped after the numeric + # group via the leading 1 marker (mixed id types never need + # to compare against each other directly). + return (0, (1, _DescStr(str(raw)) if desc else str(raw)), alias, id_tiebreak) + return (0, (0, -numeric_id if desc else numeric_id), alias, id_tiebreak) + + if sort_by in _TIME_SORT_FIELDS: + raw = job.get(sort_by) + if not raw: + return (1, "", alias, id_tiebreak) + text = str(raw) + return (0, _DescStr(text) if desc else text, alias, id_tiebreak) + + # Defensive fallback for an unexpected sort_by (should not happen -- + # commands/job.py validates against JOB_SORT_FIELDS before this is + # ever called). Treat like a generic missing/string field so we + # degrade to the tiebreak order instead of crashing. + raw = job.get(sort_by) + if raw is None: + return (1, "", alias, id_tiebreak) + text = str(raw) + return (0, _DescStr(text) if desc else text, alias, id_tiebreak) + def _safe_fetch_log_tail(client: Any, job: dict[str, Any], limit: int) -> list[dict[str, Any]]: """Fetch the last ``limit`` events for a job; never raises. @@ -258,6 +355,21 @@ def list_jobs( aggregates results into a unified list. Per-project errors are collected but do not stop other projects from being queried. + Each project's page is already sorted server-side by ``sort_by``/ + ``sort_order``, but merging several already-sorted pages by simple + concatenation would leave the aggregate grouped by project instead + of globally ordered. The merged ``jobs`` list is therefore + re-sorted client-side by the SAME ``sort_by``/``sort_order`` the + caller asked for -- e.g. the default ``startTime desc`` gives a + single chronological feed interleaved across every project, which + is what both the CLI's multi-project ``job list`` and the + cross-project "All Jobs" ``serve`` view need. A job missing the + requested field (e.g. a ``waiting`` job has no ``startTime`` yet) + sorts LAST regardless of ``sort_order`` -- it is "not yet + comparable", not "the oldest job". Ties (including the entire + missing-value group) break deterministically on + ``(project_alias, str(id))``. See ``_job_sort_key``. + Args: aliases: Project aliases to query. None means all projects. component_id: Optional filter by component ID. @@ -270,7 +382,8 @@ def list_jobs( Returns: Dict with keys: - - "jobs": list of job dicts with project_alias added + - "jobs": list of job dicts with project_alias added, + globally sorted by sort_by/sort_order (missing values last) - "errors": list of error dicts with project_alias, error_code, message @@ -299,8 +412,13 @@ def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: for _alias, jobs, _ok in successes: all_jobs.extend(jobs) - # Sort for deterministic output - all_jobs.sort(key=lambda j: (j.get("project_alias", ""), str(j.get("id", "")))) + # Merge into one globally-ordered feed. Each project's Queue API + # call already sorted its own page server-side, but naively + # concatenating those pages leaves the aggregate grouped by + # project instead of chronological (or whatever sort_by asks + # for) -- see _job_sort_key for the missing-value / tiebreak + # semantics. + all_jobs.sort(key=lambda j: _job_sort_key(j, sort_by, sort_order)) errors.sort(key=lambda e: e.get("project_alias", "")) return {"jobs": all_jobs, "errors": errors} diff --git a/src/keboola_agent_cli/services/token_service.py b/src/keboola_agent_cli/services/token_service.py index af596b99..82b83901 100644 --- a/src/keboola_agent_cli/services/token_service.py +++ b/src/keboola_agent_cli/services/token_service.py @@ -33,11 +33,13 @@ from ..client import KeboolaClient from ..errors import ErrorCode, KeboolaApiError +from ..models import ProjectConfig from ._token_last_used import dormancy_rank, enrich_tokens from .base import ( BaseService, ResolvedProjectCredentials, default_client_factory, + project_error_entry, resolve_project_credentials, ) @@ -155,6 +157,105 @@ def _enrich_with_last_used( """ return enrich_tokens(client, tokens, max_workers=self._resolve_max_workers()) + def list_tokens_all( + self, *, aliases: list[str] | None = None, with_last_used: bool = False + ) -> dict[str, Any]: + """List Storage tokens across one, many, or all registered projects. + + Fans out over the shared multi-project pool (see ``JobService.list_jobs`` + / ``BillingService.get_credits`` for the same idiom) and, per project, + delegates wholesale to :meth:`list_tokens` rather than re-implementing + secret stripping or ``with_last_used`` enrichment -- both stay + byte-identical to the single-project path. Per-project failures degrade + into ``errors`` without aborting the other projects. + + Args: + aliases: Project aliases to query. ``None`` / empty means every + registered project. + with_last_used: Forwarded to :meth:`list_tokens` per project. Costs + one extra Storage API call per token, per project -- see the + thread-multiplication note on :meth:`_fetch_project_tokens`. + + Returns: + ``{"tokens": [...], "count": len(tokens), "errors": [...], + "token_errors": [...]}``. The two error lists are semantically + different and deliberately kept apart: ``errors`` holds + project-level failures (that project could not be listed at all), + while ``token_errors`` holds per-token ``with_last_used`` lookup + failures (the project listed fine; one token's event feed did + not, and its row degraded). Merging them would make a healthy + project with one degraded token read as unlistable. Every token + row and every error entry carries ``project_alias``; entries in + ``token_errors`` also carry ``token_id``. + Ordering: without ``with_last_used``, tokens are grouped by + ``project_alias`` (stable sort -- the per-project order + :meth:`list_tokens` produced is preserved within each group). With + ``with_last_used``, the dormant-first order is applied globally + across every project's tokens via the same :func:`dormancy_rank` + helper :meth:`list_tokens` uses, not per-project groups first. + """ + projects = self.resolve_projects(aliases) + + def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: + return self._fetch_project_tokens(alias, with_last_used) + + successes, errors = self._run_parallel(projects, worker) + + all_tokens: list[dict[str, Any]] = [] + token_errors: list[dict[str, Any]] = [] + for _alias, tokens, inner_errors, _ok in successes: + all_tokens.extend(tokens) + token_errors.extend(inner_errors) + + if with_last_used: + all_tokens.sort(key=dormancy_rank) + else: + all_tokens.sort(key=lambda t: t.get("project_alias", "")) + errors.sort(key=lambda e: e.get("project_alias", "")) + token_errors.sort(key=lambda e: (e.get("project_alias", ""), str(e.get("token_id", "")))) + + return { + "tokens": all_tokens, + "count": len(all_tokens), + "errors": errors, + "token_errors": token_errors, + } + + def _fetch_project_tokens(self, alias: str, with_last_used: bool) -> tuple[Any, ...]: + """Fetch + stamp one project's token listing for the cross-project fan-out. + + Calls :meth:`list_tokens` (which owns client creation/close for this + project) instead of duplicating its logic, so secret-stripping and + ``with_last_used`` semantics can never drift between the single- and + multi-project paths. + + NOTE on thread count: when ``with_last_used`` is set, ``list_tokens`` + itself fans out one thread per token (:meth:`_enrich_with_last_used`). + Nested inside this method's own caller -- the per-project + ``ThreadPoolExecutor`` in ``BaseService._run_parallel`` -- that means + up to ``max_workers`` projects each spinning up their own + ``max_workers`` per-token pool concurrently. This is deliberate and + acceptable: every request is I/O-bound (a Storage API call) and both + pools are bounded by the same ``_resolve_max_workers()`` ceiling, so + the worst case is a burst of short-lived threads, not unbounded growth. + + Returns a 4-tuple ``(alias, tokens, inner_errors, True)`` on success + (the ``BaseService._run_parallel`` contract only requires "not + length-2"), or ``(alias, error_dict)`` on failure. + """ + try: + result = self.list_tokens(alias=alias, with_last_used=with_last_used) + except KeboolaApiError as exc: + return (alias, project_error_entry(alias, exc)) + except Exception as exc: + return (alias, project_error_entry(alias, exc)) + + tokens = result["tokens"] + for token in tokens: + token["project_alias"] = alias + inner_errors = [{**error, "project_alias": alias} for error in result.get("errors", [])] + return (alias, tokens, inner_errors, True) + def delete_token(self, *, alias: str, token_id: str) -> dict[str, Any]: """Revoke a token immediately in ``alias``'s project.""" creds = self._resolve_project(alias) diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 1b0c6e76..8d530e89 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -2220,6 +2220,44 @@ def test_token_list_defaults_with_last_used_to_false(tmp_path: Path) -> None: assert token_svc.list_tokens.call_args.kwargs.get("with_last_used") is False +# --------------------------------------------------------------------------- +# token.py GET /list?project=&project=&with_last_used= +# Service: token.list_tokens_all(aliases=..., with_last_used=...) +# Cross-project token audit for the web UI's "All Tokens" page -- mirrors the +# `/jobs` and `/billing/credits` repeatable-`project` convention. +# --------------------------------------------------------------------------- + + +def test_token_list_all_forwards_repeated_project_and_with_last_used(tmp_path: Path) -> None: + """`?project=a&project=b&with_last_used=true` must reach `list_tokens_all`.""" + token_svc = MagicMock() + token_svc.list_tokens_all.return_value = {"tokens": [], "count": 0, "errors": []} + app = _make_app_with_registry(tmp_path, _mock_registry(token=token_svc)) + + with TestClient(app) as client: + res = client.get( + "/token/list", + headers=AUTH, + params=[("project", "a"), ("project", "b"), ("with_last_used", "true")], + ) + + assert res.status_code == 200, res.text + token_svc.list_tokens_all.assert_called_once_with(aliases=["a", "b"], with_last_used=True) + + +def test_token_list_all_defaults_to_none_aliases_and_false_last_used(tmp_path: Path) -> None: + """A bare `GET /token/list` must forward `aliases=None, with_last_used=False`.""" + token_svc = MagicMock() + token_svc.list_tokens_all.return_value = {"tokens": [], "count": 0, "errors": []} + app = _make_app_with_registry(tmp_path, _mock_registry(token=token_svc)) + + with TestClient(app) as client: + res = client.get("/token/list", headers=AUTH) + + assert res.status_code == 200, res.text + token_svc.list_tokens_all.assert_called_once_with(aliases=None, with_last_used=False) + + # 0.88.0 MCP-parity flags: every one must be reachable over `kbagent serve`, # not just from the CLI. Each router docstring claims it "Mirrors" its command, # so a flag the router cannot express makes that claim false (PR #632 review). diff --git a/tests/test_services.py b/tests/test_services.py index 9351099c..540a5dba 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -3066,6 +3066,220 @@ def factory(url, token): assert all(j["project_alias"] == "prod" for j in jobs) dev_client.list_jobs.assert_not_called() + def test_list_jobs_sorts_globally_by_start_time_desc(self, tmp_config_dir: Path) -> None: + """Aggregate sort is chronological across projects, not grouped by alias. + + Each project's page is already sorted server-side, but a naive + concatenation would still read as "all of project A, then all of + project B". This asserts the merged feed interleaves by + startTime desc regardless of which project a job came from. + """ + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "alpha", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-alpha-abcdefghijklmnop", + ), + ) + store.add_project( + "beta", + ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-beta-abcdefghijklmnopq", + ), + ) + + alpha_jobs = [ + {"id": 1, "startTime": "2026-02-26T10:00:00Z"}, + {"id": 2, "startTime": "2026-02-26T12:00:00Z"}, + ] + beta_jobs = [ + {"id": 3, "startTime": "2026-02-26T11:00:00Z"}, + {"id": 4, "startTime": "2026-02-26T13:00:00Z"}, + ] + + alpha_client = _make_list_jobs_client(alpha_jobs) + beta_client = _make_list_jobs_client(beta_jobs) + + def factory(url, token): + if "901" in token: + return alpha_client + return beta_client + + service = JobService(config_store=store, client_factory=factory) + + result = service.list_jobs(sort_by="startTime", sort_order="desc") + jobs = result["jobs"] + + # Newest first, interleaved across alpha/beta by actual timestamp. + assert [j["id"] for j in jobs] == [4, 2, 3, 1] + + def test_list_jobs_sorts_globally_by_start_time_asc(self, tmp_config_dir: Path) -> None: + """Same interleaved merge, ascending direction.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "alpha", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-alpha-abcdefghijklmnop", + ), + ) + store.add_project( + "beta", + ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-beta-abcdefghijklmnopq", + ), + ) + + alpha_jobs = [ + {"id": 1, "startTime": "2026-02-26T10:00:00Z"}, + {"id": 2, "startTime": "2026-02-26T12:00:00Z"}, + ] + beta_jobs = [ + {"id": 3, "startTime": "2026-02-26T11:00:00Z"}, + {"id": 4, "startTime": "2026-02-26T13:00:00Z"}, + ] + + alpha_client = _make_list_jobs_client(alpha_jobs) + beta_client = _make_list_jobs_client(beta_jobs) + + def factory(url, token): + if "901" in token: + return alpha_client + return beta_client + + service = JobService(config_store=store, client_factory=factory) + + result = service.list_jobs(sort_by="startTime", sort_order="asc") + jobs = result["jobs"] + + assert [j["id"] for j in jobs] == [1, 3, 2, 4] + + def test_list_jobs_missing_start_time_sorts_last_desc(self, tmp_config_dir: Path) -> None: + """A job with no startTime (e.g. still waiting) sorts last, not first, on desc.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-55555-fakeTestTokenDoNotUseXXXXXXXX", + ), + ) + + jobs_data = [ + {"id": 1, "status": "waiting"}, # no startTime -- must NOT read as "oldest" + {"id": 2, "startTime": "2026-02-26T10:00:00Z"}, + {"id": 3, "startTime": "2026-02-26T12:00:00Z"}, + ] + service = JobService( + config_store=store, + client_factory=lambda url, token: _make_list_jobs_client(jobs_data), + ) + + result = service.list_jobs(sort_by="startTime", sort_order="desc") + jobs = result["jobs"] + + assert [j["id"] for j in jobs] == [3, 2, 1] + + def test_list_jobs_missing_start_time_sorts_last_asc(self, tmp_config_dir: Path) -> None: + """Missing startTime also sorts last on asc -- never "earliest".""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-55555-fakeTestTokenDoNotUseXXXXXXXX", + ), + ) + + jobs_data = [ + {"id": 1, "status": "waiting"}, # no startTime + {"id": 2, "startTime": "2026-02-26T10:00:00Z"}, + {"id": 3, "startTime": "2026-02-26T12:00:00Z"}, + ] + service = JobService( + config_store=store, + client_factory=lambda url, token: _make_list_jobs_client(jobs_data), + ) + + result = service.list_jobs(sort_by="startTime", sort_order="asc") + jobs = result["jobs"] + + assert [j["id"] for j in jobs] == [2, 3, 1] + + def test_list_jobs_sort_by_duration_seconds_asc(self, tmp_config_dir: Path) -> None: + """sort_by=durationSeconds asc orders numerically, missing values last.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-55555-fakeTestTokenDoNotUseXXXXXXXX", + ), + ) + + jobs_data = [ + {"id": 1, "durationSeconds": 120}, + {"id": 2, "status": "processing"}, # no durationSeconds yet -- last + {"id": 3, "durationSeconds": 5}, + {"id": 4, "durationSeconds": 45}, + ] + service = JobService( + config_store=store, + client_factory=lambda url, token: _make_list_jobs_client(jobs_data), + ) + + result = service.list_jobs(sort_by="durationSeconds", sort_order="asc") + jobs = result["jobs"] + + assert [j["id"] for j in jobs] == [3, 4, 1, 2] + + def test_list_jobs_tiebreak_deterministic_on_equal_values(self, tmp_config_dir: Path) -> None: + """Equal sort-field values break ties by (project_alias, str(id)).""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "beta", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-beta-abcdefghijklmnop", + ), + ) + store.add_project( + "alpha", + ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="532-alpha-abcdefghijklmnop", + ), + ) + + same_time = "2026-02-26T10:00:00Z" + beta_jobs = [{"id": 20, "startTime": same_time}, {"id": 10, "startTime": same_time}] + alpha_jobs = [{"id": 15, "startTime": same_time}] + + beta_client = _make_list_jobs_client(beta_jobs) + alpha_client = _make_list_jobs_client(alpha_jobs) + + def factory(url, token): + if "901" in token: + return beta_client + return alpha_client + + service = JobService(config_store=store, client_factory=factory) + + result = service.list_jobs(sort_by="startTime", sort_order="desc") + jobs = result["jobs"] + + # All timestamps tie -- break by project_alias asc ("alpha" < "beta"), + # then within "beta" by str(id) asc ("10" < "20"). Deterministic + # regardless of which project's worker thread finished first. + assert [(j["project_alias"], j["id"]) for j in jobs] == [ + ("alpha", 15), + ("beta", 10), + ("beta", 20), + ] + class TestJobServiceGetJobDetail: """Tests for JobService.get_job_detail().""" diff --git a/tests/test_token_service.py b/tests/test_token_service.py index 87a2ae76..8590a26d 100644 --- a/tests/test_token_service.py +++ b/tests/test_token_service.py @@ -430,3 +430,222 @@ def test_client_is_closed_after_the_fan_out(self, store, client_factory) -> None _svc(store, factory).list_tokens(alias=ALIAS, with_last_used=True) mock.close.assert_called_once() + + +class TestListTokensAll: + """`list_tokens_all` -- cross-project token listing (mirrors JobService.list_jobs).""" + + def _store_with_two_projects(self, tmp_config_dir: Path) -> ConfigStore: + s = ConfigStore(config_dir=tmp_config_dir) + s.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-prod-fakeTestTokenDoNotUseXXXXXX", + ), + ) + s.add_project( + "dev", + ProjectConfig( + stack_url="https://connection.north-europe.azure.keboola.com", + token="901-dev-fakeTestTokenDoNotUseXXXXXXX", + ), + ) + return s + + def test_multi_project_aggregation_stamps_project_alias(self, tmp_config_dir: Path) -> None: + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [ + {"id": "1", "description": "prod-a"}, + {"id": "2", "description": "prod-b"}, + ] + dev_client = MagicMock() + dev_client.list_tokens.return_value = [{"id": "3", "description": "dev-a"}] + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + service = TokenService(store, client_factory=factory) + result = service.list_tokens_all() + + assert result["count"] == 3 + assert result["errors"] == [] + by_id = {t["id"]: t for t in result["tokens"]} + assert by_id["1"]["project_alias"] == "prod" + assert by_id["2"]["project_alias"] == "prod" + assert by_id["3"]["project_alias"] == "dev" + # grouped by project_alias ("dev" < "prod"), per-project order preserved + # within each group (id "1" before id "2", both from prod). + assert [t["id"] for t in result["tokens"]] == ["3", "1", "2"] + prod_client.close.assert_called_once() + dev_client.close.assert_called_once() + + def test_secrets_stripped_across_projects(self, tmp_config_dir: Path) -> None: + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [ + {"id": "1", "token": "1-liveSecretValue", "description": "master"} + ] + dev_client = MagicMock() + dev_client.list_tokens.return_value = [ + {"id": "2", "token": "2-anotherSecret", "description": "device"} + ] + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + result = TokenService(store, client_factory=factory).list_tokens_all() + + assert "liveSecretValue" not in str(result) + assert "anotherSecret" not in str(result) + for token in result["tokens"]: + assert "token" not in token + + def test_partial_failure_degrades_into_errors(self, tmp_config_dir: Path) -> None: + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [{"id": "1", "description": "ok"}] + dev_client = MagicMock() + dev_client.list_tokens.side_effect = KeboolaApiError( + message="Token expired", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + result = TokenService(store, client_factory=factory).list_tokens_all() + + assert result["count"] == 1 + assert result["tokens"][0]["project_alias"] == "prod" + assert len(result["errors"]) == 1 + assert result["errors"][0]["project_alias"] == "dev" + assert result["errors"][0]["error_code"] == "INVALID_TOKEN" + + def test_aliases_none_resolves_all_registered_projects(self, tmp_config_dir: Path) -> None: + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [] + dev_client = MagicMock() + dev_client.list_tokens.return_value = [] + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + TokenService(store, client_factory=factory).list_tokens_all(aliases=None) + + prod_client.list_tokens.assert_called_once() + dev_client.list_tokens.assert_called_once() + + def test_alias_filter_only_queries_specified_projects(self, tmp_config_dir: Path) -> None: + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [{"id": "1"}] + dev_client = MagicMock() + dev_client.list_tokens.return_value = [{"id": "2"}] + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + result = TokenService(store, client_factory=factory).list_tokens_all(aliases=["prod"]) + + assert result["count"] == 1 + assert result["tokens"][0]["project_alias"] == "prod" + dev_client.list_tokens.assert_not_called() + + def test_unknown_alias_raises(self, tmp_config_dir: Path) -> None: + store = self._store_with_two_projects(tmp_config_dir) + service = TokenService(store, client_factory=MagicMock()) + with pytest.raises(ConfigError): + service.list_tokens_all(aliases=["nope"]) + + def test_with_last_used_global_dormant_first_ordering(self, tmp_config_dir: Path) -> None: + """Dormant-first ordering spans ALL projects together, not grouped per project. + + `prod` has a token used recently and one never used; `dev` has one + token whose activity has aged out of retention. The global order must + be never -> unknown -> recent-use, regardless of which project each + token belongs to. + """ + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [ + {"id": "prod-recent", "created": _ago(days=10)}, + {"id": "prod-never", "created": _ago(days=10)}, + ] + dev_client = MagicMock() + dev_client.list_tokens.return_value = [ + {"id": "dev-unknown", "created": _ago(days=400)}, + ] + + prod_events = { + "prod-recent": [ + {"created": "2026-08-20T09:00:00+0200", "event": "storage.tablesListed"} + ], + "prod-never": [], + } + dev_events: dict[str, list] = {"dev-unknown": []} + prod_client.list_token_events.side_effect = lambda token_id: prod_events[token_id] + dev_client.list_token_events.side_effect = lambda token_id: dev_events[token_id] + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + result = TokenService(store, client_factory=factory).list_tokens_all(with_last_used=True) + + assert [t["id"] for t in result["tokens"]] == [ + "prod-never", + "dev-unknown", + "prod-recent", + ] + + def test_with_last_used_per_token_errors_carry_project_alias( + self, tmp_config_dir: Path + ) -> None: + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [ + {"id": "1", "description": "boom", "created": _ago(days=10)} + ] + prod_client.list_token_events.side_effect = KeboolaApiError( + "nope", error_code=ErrorCode.API_ERROR + ) + dev_client = MagicMock() + dev_client.list_tokens.return_value = [] + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + result = TokenService(store, client_factory=factory).list_tokens_all(with_last_used=True) + + # Per-token lookup failures land in token_errors, NOT errors: the + # project itself listed fine, so reporting it as unlistable (the + # errors[] meaning) would be wrong. + assert result["errors"] == [] + assert len(result["token_errors"]) == 1 + assert result["token_errors"][0]["token_id"] == "1" + assert result["token_errors"][0]["project_alias"] == "prod" + + def test_no_projects_configured_returns_empty(self, tmp_config_dir: Path) -> None: + store = ConfigStore(config_dir=tmp_config_dir) + service = TokenService(store, client_factory=MagicMock()) + result = service.list_tokens_all() + assert result == {"tokens": [], "count": 0, "errors": [], "token_errors": []} + + def test_clients_are_closed_for_every_project(self, tmp_config_dir: Path) -> None: + store = self._store_with_two_projects(tmp_config_dir) + prod_client = MagicMock() + prod_client.list_tokens.return_value = [] + dev_client = MagicMock() + dev_client.list_tokens.return_value = [] + + def factory(url, token): + return prod_client if "prod" in token else dev_client + + TokenService(store, client_factory=factory).list_tokens_all() + + prod_client.close.assert_called_once() + dev_client.close.assert_called_once() diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 343288c8..f7e9e808 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -10,6 +10,7 @@ import { DoctorPage } from "./pages/Doctor"; import { EncryptPage } from "./pages/Encrypt"; import { FlowsPage } from "./pages/Flows"; import { JobsPage } from "./pages/Jobs"; +import { JobsAllPage } from "./pages/JobsAll"; import { LineagePage } from "./pages/Lineage"; import { LocalAiPage } from "./pages/LocalAi"; import { SemanticLayerPage } from "./pages/SemanticLayer"; @@ -22,6 +23,7 @@ import { SharingPage } from "./pages/Sharing"; import { StoragePage } from "./pages/Storage"; import { StreamsPage } from "./pages/Streams"; import { TokensPage } from "./pages/Tokens"; +import { TokensAllPage } from "./pages/TokensAll"; import { WorkspacesPage } from "./pages/Workspaces"; import { UIStateProvider, useUIState } from "./state"; import { ThemeProvider } from "./theme"; @@ -43,6 +45,8 @@ function Router() { return ; case "jobs": return ; + case "jobs-all": + return ; case "branches": return ; case "workspaces": @@ -73,6 +77,8 @@ function Router() { return ; case "tokens": return ; + case "tokens-all": + return ; case "doctor": return ; case "changelog": diff --git a/web/frontend/src/components/CommandPalette.tsx b/web/frontend/src/components/CommandPalette.tsx index f36a052c..2f2fdf08 100644 --- a/web/frontend/src/components/CommandPalette.tsx +++ b/web/frontend/src/components/CommandPalette.tsx @@ -40,7 +40,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { api } from "../api/client"; -import { SECTIONS } from "../layout/Sidebar"; +import { PALETTE_ONLY_PAGES, SECTIONS } from "../layout/Sidebar"; import { buildStorageSel } from "../pages/Storage"; import { type PageId, useUIState } from "../state"; import { useTheme } from "../theme"; @@ -234,6 +234,17 @@ export function CommandPalette() { }); } } + for (const item of PALETTE_ONLY_PAGES) { + out.push({ + id: `page:${item.id}`, + kind: "page", + label: item.label, + hint: "All projects", + keywords: item.id, + icon: item.icon, + run: () => setPage(item.id as PageId), + }); + } for (const p of projectsQ.data?.projects ?? []) { out.push({ id: `project:${p.alias}`, diff --git a/web/frontend/src/config/credits.test.ts b/web/frontend/src/config/credits.test.ts new file mode 100644 index 00000000..418006fd --- /dev/null +++ b/web/frontend/src/config/credits.test.ts @@ -0,0 +1,190 @@ +/** + * The credit model is a table of substrings applied IN ORDER, so the risky + * part is not the arithmetic -- it is which rule a component id lands on. + * These cases pin the orderings that are easy to break by re-sorting the + * table (redshift vs the generic `r-`, transformations vs `ex-`/`wr-`). + */ +import { describe, expect, it } from "vitest"; +import { + calculateJobCredits, + creditRate, + formatCredits, + getContainerSize, + rateForComponent, + sumJobCredits, +} from "./credits"; + +describe("rateForComponent", () => { + it("rates SQL transformations at the warehouse tier", () => { + expect(rateForComponent("keboola.snowflake-transformation")).toEqual({ + xsmall: 6, + small: 6, + medium: 12, + large: 26, + }); + }); + + it("keeps redshift on the SQL tier, not the script tier", () => { + // Near miss worth pinning: the script rule matches the literal + // `r-transformation`, and redshift's id reads `...t-transformation`, so it + // escapes by one character. Rule 1 naming redshift explicitly is what + // actually decides it -- this asserts the tier, not the spelling luck. + expect(creditRate("keboola.redshift-transformation", "medium")).toBe(12); + }); + + it("rates script transformations far cheaper", () => { + expect(creditRate("keboola.python-transformation-v2", "small")).toBe(0.4); + expect(creditRate("keboola.r-transformation-v2", "large")).toBe(2); + }); + + it("does not mistake snowflake for an r-transformation", () => { + expect(creditRate("keboola.snowflake-transformation", "small")).toBe(6); + }); + + it("rates dbt remote below local, and unknown sizes at its small rate", () => { + expect(creditRate("keboola.dbt-transformation-snowflake", "remote")).toBe(2); + expect(creditRate("keboola.dbt-transformation-snowflake", "small")).toBe(6); + expect(creditRate("keboola.dbt-transformation-snowflake", "jumbo")).toBe(6); + }); + + it("flat-rates writers at 1 and extractors at 2, every size", () => { + for (const size of ["xsmall", "small", "medium", "large"]) { + expect(creditRate("keboola.wr-google-bigquery", size)).toBe(1); + expect(creditRate("keboola.ex-db-mysql", size)).toBe(2); + } + expect(creditRate("some.vendor-extractor", "medium")).toBe(2); + }); + + it("takes the FIRST matching rule when an id matches two", () => { + // Synthetic id, chosen because it genuinely matches both the extractor + // rule ("ex-") and the sandbox rule ("sandbox"). The extractor rule is + // listed first, so 2/hr wins over the sandbox tier's 26 at large. + expect(creditRate("vendor.ex-sandbox-loader", "large")).toBe(2); + }); + + it("rates sandboxes and data apps on their own tiers", () => { + expect(creditRate("keboola.sandboxes", "xsmall")).toBe(0.2); + expect(creditRate("keboola.sandboxes", "large")).toBe(26); + expect(creditRate("keboola.data-apps", "medium")).toBe(0.5); + expect(creditRate("some.streamlit-runner", "xsmall")).toBe(0.1); + }); + + it("rates orchestration containers at zero -- children are billed", () => { + expect(creditRate("keboola.orchestrator", "small")).toBe(0); + expect(creditRate("keboola.flow", "large")).toBe(0); + }); + + it("falls back to a flat 1 for an unknown component", () => { + expect(creditRate("acme.something-new", "medium")).toBe(1); + expect(rateForComponent("acme.something-new")).toEqual({ small: 1 }); + }); + + it("matches case-insensitively", () => { + expect(creditRate("Keboola.EX-DB-MySQL", "SMALL")).toBe(2); + }); +}); + +describe("getContainerSize", () => { + it("prefers containerSize", () => { + expect(getContainerSize({ backend: { containerSize: "large", size: "small" } })).toBe("large"); + }); + + it("falls back to the legacy size key", () => { + expect(getContainerSize({ backend: { size: "medium" } })).toBe("medium"); + }); + + it("defaults to small when metrics are missing or empty", () => { + expect(getContainerSize(undefined)).toBe("small"); + expect(getContainerSize(null)).toBe("small"); + expect(getContainerSize({})).toBe("small"); + expect(getContainerSize({ backend: {} })).toBe("small"); + expect(getContainerSize({ backend: { containerSize: "" } })).toBe("small"); + }); +}); + +describe("calculateJobCredits", () => { + it("bills duration against the (component, size) rate", () => { + // 1h on a medium Snowflake transformation = 12 credits. + expect( + calculateJobCredits({ + component: "keboola.snowflake-transformation", + durationSeconds: 3600, + metrics: { backend: { containerSize: "medium" } }, + }), + ).toBeCloseTo(12, 10); + }); + + it("uses the small rate when the job reports no backend", () => { + // 30 min on an extractor at the flat 2/hr rate. + expect( + calculateJobCredits({ component: "keboola.ex-db-mysql", durationSeconds: 1800 }), + ).toBeCloseTo(1, 10); + }); + + it("estimates nothing for a job with no usable duration", () => { + expect(calculateJobCredits({ component: "keboola.ex-db-mysql" })).toBe(0); + expect(calculateJobCredits({ component: "keboola.ex-db-mysql", durationSeconds: 0 })).toBe(0); + expect( + calculateJobCredits({ component: "keboola.ex-db-mysql", durationSeconds: Number.NaN }), + ).toBe(0); + }); + + it("never bills a flow for its children", () => { + expect( + calculateJobCredits({ component: "keboola.flow", durationSeconds: 7200 }), + ).toBe(0); + }); +}); + +describe("sumJobCredits", () => { + it("adds up a mixed list", () => { + const total = sumJobCredits([ + // 12 credits + { + component: "keboola.snowflake-transformation", + durationSeconds: 3600, + metrics: { backend: { containerSize: "medium" } }, + }, + // 2 credits + { component: "keboola.ex-db-mysql", durationSeconds: 3600 }, + // 0 -- orchestration container + { component: "keboola.flow", durationSeconds: 3600 }, + // 0 -- never started + { component: "keboola.wr-db-snowflake" }, + ]); + expect(total).toBeCloseTo(14, 10); + }); + + it("is 0 for an empty list", () => { + expect(sumJobCredits([])).toBe(0); + }); +}); + +describe("formatCredits", () => { + it("collapses nothing-at-all to a bare 0", () => { + expect(formatCredits(0)).toBe("0"); + expect(formatCredits(-1)).toBe("0"); + expect(formatCredits(Number.NaN)).toBe("0"); + }); + + it("marks a sub-cent value rather than rendering it as 0.00", () => { + expect(formatCredits(0.0001)).toBe("<0.01"); + expect(formatCredits(0.009)).toBe("<0.01"); + }); + + it("shows two decimals from 0.01 up to 1", () => { + expect(formatCredits(0.01)).toBe("0.01"); + expect(formatCredits(0.09)).toBe("0.09"); + expect(formatCredits(0.994)).toBe("0.99"); + }); + + it("shows one decimal from 1 up to 10", () => { + expect(formatCredits(1)).toBe("1.0"); + expect(formatCredits(9.94)).toBe("9.9"); + }); + + it("rounds and groups from 10 up", () => { + expect(formatCredits(10)).toBe("10"); + expect(formatCredits(1234.6)).toBe("1,235"); + }); +}); diff --git a/web/frontend/src/config/credits.ts b/web/frontend/src/config/credits.ts new file mode 100644 index 00000000..6969bac7 --- /dev/null +++ b/web/frontend/src/config/credits.ts @@ -0,0 +1,136 @@ +/** + * Client-side credit ESTIMATE for a Queue API job. + * + * The Queue API does not report what a job cost, and the billing endpoints + * only expose a project-level PAYG balance -- there is no per-job figure to + * fetch. So this is a MODEL, not a reading: it multiplies the job's wall-clock + * duration by a published per-hour rate for the (component family, container + * size) pair. Every surface that shows a number derived from here must label + * it as an estimate. + * + * Known limits of the model, by construction: + * - Rates are a static table maintained here, not fetched from the platform, + * so a pricing change lands in the UI only when this file is updated. + * - `durationSeconds` is wall clock. Queue wait time is excluded (good), but + * so is any billing floor / minimum increment the platform applies (bad). + * - Flows and orchestrators are rated 0 on purpose: their children are the + * jobs that get billed, and they show up as their own rows. Counting the + * parent too would double-bill every orchestrated run. + */ + +/** + * Credits per HOUR, keyed by container size. `small` doubles as the fallback + * for any size not listed (see `creditRate`), which is how a flat-rate family + * is expressed: a single `small` entry. + */ +export type CreditRates = Record; + +export interface RateRule { + /** Substrings tried against the component id, case-insensitively. */ + match: string[]; + rates: CreditRates; +} + +/** + * ORDER IS PART OF THE CONTRACT -- first match wins. + * + * `redshift-transformation` has to be tested before the generic + * `r-transformation`, and the transformation families before the broad + * `ex-` / `wr-` prefixes, or a rate would be picked by accident of spelling. + */ +export const RATE_RULES: RateRule[] = [ + { + match: ["snowflake-transformation", "redshift-transformation"], + rates: { xsmall: 6, small: 6, medium: 12, large: 26 }, + }, + { + match: ["python-transformation", "r-transformation"], + rates: { xsmall: 0.2, small: 0.4, medium: 0.6, large: 2 }, + }, + // dbt runs remote (against the warehouse) or locally sized; anything else + // falls back to `small` like every other family. + { match: ["dbt"], rates: { small: 6, remote: 2 } }, + // Writers and extractors are flat-rated: one entry, used for every size. + { match: ["wr-"], rates: { small: 1 } }, + { match: ["ex-", "extractor"], rates: { small: 2 } }, + { match: ["sandbox"], rates: { xsmall: 0.2, small: 6, medium: 12, large: 26 } }, + { match: ["data-app", "streamlit"], rates: { xsmall: 0.1, small: 0.2, medium: 0.5, large: 1 } }, + // Orchestration containers themselves are free -- the children are billed. + { match: ["orchestrator", "keboola.flow"], rates: { small: 0 } }, +]; + +/** Applied to a component id that matches no rule at all. */ +export const DEFAULT_RATES: CreditRates = { small: 1 }; + +/** Container size assumed when the job carries no metrics at all. */ +export const DEFAULT_CONTAINER_SIZE = "small"; + +/** The rate table for a component id -- first matching rule, else the default. */ +export function rateForComponent(componentId: string): CreditRates { + const id = (componentId ?? "").toLowerCase(); + for (const rule of RATE_RULES) { + if (rule.match.some((needle) => id.includes(needle))) return rule.rates; + } + return DEFAULT_RATES; +} + +/** + * Container size out of a job's `metrics` passthrough. + * + * The Queue API has used both spellings over time (`containerSize` is the + * current one, `size` the older), and a job that never started carries + * neither -- hence the documented default rather than a throw. + */ +export function getContainerSize(metrics: unknown): string { + const backend = (metrics as { backend?: Record } | undefined)?.backend; + const raw = backend?.containerSize ?? backend?.size; + if (raw === undefined || raw === null || raw === "") return DEFAULT_CONTAINER_SIZE; + return String(raw); +} + +/** + * Credits-per-hour for one (component, size) pair. An unlisted size falls back + * to the family's `small` rate -- `??`, not `||`, so a genuine 0 (flows) is + * kept rather than treated as "missing". + */ +export function creditRate(componentId: string, size: string): number { + const rates = rateForComponent(componentId); + return rates[(size ?? "").toLowerCase()] ?? rates[DEFAULT_CONTAINER_SIZE] ?? 0; +} + +/** The shape `calculateJobCredits` needs -- a structural subset of `Job`. */ +export interface CreditableJob { + component: string; + durationSeconds?: number; + metrics?: unknown; +} + +/** + * Estimated credits for one job. A job with no duration yet (queued, or still + * running with no reported elapsed time) estimates to 0 rather than to a guess. + */ +export function calculateJobCredits(job: CreditableJob): number { + const seconds = job.durationSeconds; + if (seconds === undefined || seconds === null || !Number.isFinite(seconds) || seconds <= 0) { + return 0; + } + return (seconds / 3600) * creditRate(job.component, getContainerSize(job.metrics)); +} + +/** Sum of the estimates over a set of jobs. */ +export function sumJobCredits(jobs: CreditableJob[]): number { + return jobs.reduce((total, job) => total + calculateJobCredits(job), 0); +} + +/** + * Render an estimate at a precision that matches its magnitude: a sub-cent + * value never renders as a bare "0.00" (which reads as free), and a four-digit + * total never renders with meaningless decimals. + */ +export function formatCredits(credits: number): string { + if (!Number.isFinite(credits) || credits <= 0) return "0"; + if (credits < 0.01) return "<0.01"; + if (credits < 1) return credits.toFixed(2); + if (credits < 10) return credits.toFixed(1); + return Math.round(credits).toLocaleString("en-US"); +} diff --git a/web/frontend/src/layout/Sidebar.tsx b/web/frontend/src/layout/Sidebar.tsx index 367d4c19..09e24cf3 100644 --- a/web/frontend/src/layout/Sidebar.tsx +++ b/web/frontend/src/layout/Sidebar.tsx @@ -107,6 +107,27 @@ export const SECTIONS: NavSection[] = [ }, ]; +/** + * Pages reachable from the command palette but deliberately absent from the + * sidebar: the cross-project views are entered through the "All projects" + * button on their per-project sibling, so a second permanent nav entry would + * only duplicate it. The palette still needs them as jump targets. + */ +export const PALETTE_ONLY_PAGES: NavItem[] = [ + { id: "jobs-all", label: "All Jobs (all projects)", icon: PlayCircle }, + { id: "tokens-all", label: "All Tokens (all projects)", icon: KeyRound }, +]; + +/** + * While a cross-project page is open, its per-project sibling stays + * highlighted in the sidebar -- the palette-only pages have no row of their + * own, and a nav with nothing lit reads as broken. + */ +const ACTIVE_ALIASES: Partial> = { + "jobs-all": "jobs", + "tokens-all": "tokens", +}; + export function Sidebar() { const { page, setPage } = useUIState(); return ( @@ -128,7 +149,7 @@ export function Sidebar() {
    {section.items.map((item) => { const Icon = item.icon; - const active = page === item.id; + const active = page === item.id || ACTIVE_ALIASES[page] === item.id; return (
  • + } />
    {[null, "success", "error", "processing", "warning"].map((s) => ( @@ -157,6 +110,10 @@ export function JobsPage() { ))}
    + {/* Single-project requests fan out over exactly one project, so this is + normally empty -- but the envelope carries the same `errors` list and + dropping it would make a failing project look like an idle one. */} + {!project ? ( ) : q.isLoading ? ( @@ -207,421 +164,3 @@ export function JobsPage() { ); } - -/** - * Per-job Re-run / Terminate actions, shared by the table row and the detail - * drawer header. - * - * Re-run posts the job's OWN component + config + branch to - * `POST /jobs/{p}/run`, i.e. it starts a fresh job from the configuration as - * it stands NOW -- it does not replay the historical `configData` the old job - * ran with. That is the same semantics as `kbagent job run`, and the only - * thing the Queue API offers. The branch IS preserved, though: see - * `jobBranchId` and the comment on the mutation body. - * - * Terminate goes through `POST /jobs/{p}/terminate` with an explicit - * `job_ids` list; the filter form of that endpoint (status / component) is - * deliberately not exposed here -- one row, one job. - */ -function JobActions({ job, compact = true }: { job: Job; compact?: boolean }) { - const qc = useQueryClient(); - const [confirm, setConfirm] = useState<"terminate" | null>(null); - const [error, setError] = useState(null); - - const invalidate = () => { - qc.invalidateQueries({ queryKey: ["jobs"] }); - qc.invalidateQueries({ queryKey: ["dashboard-jobs"] }); - }; - - const rerun = useMutation({ - mutationFn: () => - api.post(`/jobs/${encodeURIComponent(job.project_alias)}/run`, { - component_id: job.component, - config_id: job.config, - // Branch fidelity: omitting this resolves to the DEFAULT branch - // server-side, so a job that originally ran against a dev-branch - // config would silently re-run against the production one -- a - // different configuration, writing to different tables. The row - // already carries the branch, so pass it straight back. - branch_id: jobBranchId(job), - }), - onError: (e) => setError((e as Error).message), - onSuccess: () => { - setError(null); - invalidate(); - }, - }); - - const terminate = useMutation({ - mutationFn: () => - api.post(`/jobs/${encodeURIComponent(job.project_alias)}/terminate`, { - job_ids: [String(job.id)], - dry_run: false, - }), - onError: (e) => setError((e as Error).message), - onSuccess: () => { - setError(null); - setConfirm(null); - invalidate(); - }, - }); - - // A job started from an inline `configData` payload has no stored - // configuration to re-run, so `config` is null and the button is hidden. - const canRerun = !!job.component && !!job.config; - const canTerminate = TERMINABLE_STATUSES.has(job.status); - const btn = `nerd-btn ${compact ? "text-[10px] py-0.5 px-1.5" : "text-xs"} flex items-center gap-1 disabled:opacity-50`; - - return ( - e.stopPropagation()} - role="presentation" - > - {error ? ( - - {error} - - ) : null} - {canRerun ? ( - - ) : null} - {canTerminate ? ( - - ) : null} - {confirm === "terminate" ? ( - - Job {String(job.id)} ( - {jobLabel(job)}) is {job.status}. - Terminating stops it where it is — partially written output stays written. - - } - confirmLabel="Terminate" - onConfirm={() => terminate.mutate()} - onCancel={() => setConfirm(null)} - /> - ) : null} - - ); -} - -function formatDuration(sec: number): string { - if (sec < 60) return `${sec}s`; - const m = Math.floor(sec / 60); - const s = sec % 60; - if (m < 60) return `${m}m ${s}s`; - const h = Math.floor(m / 60); - const mr = m % 60; - return `${h}h ${mr}m`; -} - -function JobDetailDrawer({ job, onClose }: { job: Job; onClose: () => void }) { - const detailQ = useQuery>({ - queryKey: ["job-detail", job.project_alias, job.id], - queryFn: () => - api.get( - `/jobs/${encodeURIComponent(job.project_alias)}/${encodeURIComponent(String(job.id))}`, - ), - }); - const [logs, setLogs] = useState< - Array<{ id: number | string; message: string; type?: string }> - >([]); - const [streaming, setStreaming] = useState(false); - const esRef = useRef(null); - - useEffect(() => { - return () => { - esRef.current?.close(); - }; - }, []); - - const startStream = () => { - setLogs([]); - setStreaming(true); - const es = sseSubscribe( - `/jobs/${encodeURIComponent(job.project_alias)}/${encodeURIComponent(String(job.id))}/stream`, - undefined, - { - log: (data) => { - const ev = data as { id: number | string; message: string; type?: string }; - setLogs((l) => [...l, ev]); - }, - status: (data) => { - const ev = data as { status: string }; - setLogs((l) => [...l, { id: `s-${Date.now()}`, message: `→ status: ${ev.status}` }]); - }, - done: (data) => { - const ev = data as { final: string }; - setLogs((l) => [...l, { id: `d-${Date.now()}`, message: `✓ done: ${ev.final}` }]); - setStreaming(false); - es.close(); - }, - }, - ); - esRef.current = es; - }; - - const detail = detailQ.data ?? {}; - - return ( - - {/* Status comes from the freshly fetched detail when available, so a - job that finished while the drawer was open loses its Terminate - button on the next poll instead of offering a doomed call. */} - - - - } - > - {detailQ.isLoading ? : null} - {detailQ.error ? : null} - {detailQ.data ? ( -
    - - - {logs.length > 0 ? ( -
    -
    Live log tail (SSE)
    -
    -                {logs.map((l) => `[${l.type ?? "log"}] ${l.message}`).join("\n")}
    -              
    -
    - ) : null} -
    - raw JSON - -
    -
    - ) : null} -
    - ); -} - -function JobCards({ - detail, - job, -}: { - detail: Record; - job: Job; -}) { - const status = String(detail.status ?? job.status); - const start = String(detail.startTime ?? ""); - const end = String(detail.endTime ?? ""); - const duration = (detail.durationSeconds as number | undefined) ?? job.durationSeconds; - const created = String(detail.createdTime ?? job.createdTime ?? ""); - const tokenDesc = - (detail.tokenDescription as string | undefined) ?? - (detail.token as { description?: string } | undefined)?.description ?? - ""; - const url = (detail.url as string | undefined) ?? ""; - // Empty string, not null: KV drops a falsy value, so a configData-only job - // renders no "Config ID" row at all instead of a literal "null". - const config = (detail.config as string | undefined) ?? job.config ?? ""; - const branchId = (detail.branchId as number | undefined) ?? null; - const params = (detail.params as Record | undefined) ?? {}; - const backendSize = String( - (params?.backend as Record | undefined)?.context ?? - params?.size ?? - "—", - ); - - const statusBadge = STATUS_COLORS[status] ?? "nerd-pill"; - - return ( -
    - } label="Status"> - {status} - {url ? ( - - open in Keboola UI → - - ) : null} - - } label="Duration"> -
    - {duration != null ? formatDuration(duration) : "-"} -
    -
    - } label="Times"> - - - - - } label="Created by"> -
    {tokenDesc || "—"}
    -
    - } label="Configuration"> - - - {branchId ? : null} - - } label="Backend"> -
    {backendSize}
    -
    - } label="Run IDs"> - - - - } label="Project"> - - -
    - ); -} - -function Card({ - icon, - label, - children, -}: { - icon?: React.ReactNode; - label: string; - children: React.ReactNode; -}) { - return ( -
    -
    - {icon} - {label} -
    - {children} -
    - ); -} - -function KV({ k, v }: { k: string; v: string }) { - if (!v) return null; - return ( -
    - {k}:{" "} - {v} -
    - ); -} - -function ParametersAndMapping({ detail }: { detail: Record }) { - const params = (detail.params as Record | undefined) ?? {}; - const result = (detail.result as Record | undefined) ?? {}; - const config = (detail.configData as Record | undefined) ?? {}; - const storage = (config.storage as Record | undefined) ?? {}; - const inputTables = ( - (storage.input as { tables?: Array> } | undefined)?.tables ?? [] - ) as Array>; - const outputTables = ( - (storage.output as { tables?: Array> } | undefined)?.tables ?? [] - ) as Array>; - - return ( -
    -
    -
    - Parameters -
    - {Object.keys(params).length === 0 ? ( -
    No parameters.
    - ) : ( -
    -            {JSON.stringify(params, null, 2)}
    -          
    - )} - {Object.keys(result).length > 0 ? ( -
    - result -
    -              {JSON.stringify(result, null, 2)}
    -            
    -
    - ) : null} -
    -
    -
    - Mapping -
    -
    - Input ({inputTables.length}) -
    - {inputTables.length === 0 ? ( -
    No tables.
    - ) : ( -
      - {inputTables.map((t, i) => ( -
    • - {String(t.source ?? "")} → {String(t.destination ?? "")} -
    • - ))} -
    - )} -
    - Output ({outputTables.length}) -
    - {outputTables.length === 0 ? ( -
    No tables.
    - ) : ( -
      - {outputTables.map((t, i) => ( -
    • - {String(t.source ?? "")} → {String(t.destination ?? "")} -
    • - ))} -
    - )} -
    -
    - ); -} diff --git a/web/frontend/src/pages/JobsAll.tsx b/web/frontend/src/pages/JobsAll.tsx new file mode 100644 index 00000000..2a13d18a --- /dev/null +++ b/web/frontend/src/pages/JobsAll.tsx @@ -0,0 +1,254 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { api } from "../api/client"; +import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; +import { DataTable } from "../components/Table"; +import { calculateJobCredits, formatCredits, sumJobCredits } from "../config/credits"; +import { formatRelativeTime } from "../lib/time"; +import { useUIState } from "../state"; +import { useHashSelection } from "../useHashSelection"; +import type { Job } from "../types"; +import { + formatDuration, + JobActions, + JobDetailDrawer, + ProjectErrorsBanner, + STATUS_COLORS, + type JobsResp, +} from "./jobsShared"; + +/** + * Cross-project jobs feed: one `GET /jobs` call with no `project` param, the + * server fans out over every registered project in parallel and returns the + * merged `{jobs, errors}` envelope with `project_alias` stamped on each row. + * + * The page deliberately ignores the active project in the top bar -- switching + * projects must not change what "all jobs" means. + */ + +/** + * `limit` is PER PROJECT, not for the merged list: the server asks each + * project for this many rows and then merges. 50 keeps a twenty-project + * install answering in reasonable time while still covering more than a day + * of activity for most projects. + */ +const PER_PROJECT_LIMIT = 50; + +/** + * Statuses the Queue API accepts. Passed straight through per project; the + * leading `null` is the unfiltered view. + */ +const STATUS_FILTERS: Array = [ + null, + "processing", + "waiting", + "success", + "error", + "warning", + "terminated", + "cancelled", +]; + +export function JobsAllPage() { + const { setPage } = useUIState(); + // Deep link: `?sel=/`. Job ids are only unique WITHIN a + // project, so the alias is part of the key -- a bare id would open the wrong + // project's job on a merged list. + const [sel, setSel] = useHashSelection(); + const [statusFilter, setStatusFilter] = useState(null); + const [selected, setSelected] = useState(null); + + const q = useQuery({ + queryKey: ["jobs-all", statusFilter], + queryFn: () => + api.get("/jobs", { + query: { + // No `project`: that omission IS the fan-out switch server-side. + status: statusFilter ?? undefined, + limit: PER_PROJECT_LIMIT, + sort_by: "createdTime", + sort_order: "desc", + }, + }), + // Deliberately slower than the per-project page's 8s: every tick here is + // one Queue API call PER REGISTERED PROJECT, so the same cadence would + // multiply the load on the stack by the size of the install. + refetchInterval: 15_000, + }); + + const jobs = q.data?.jobs ?? []; + const errors = q.data?.errors ?? []; + // Sum over the rows actually on screen, so the headline figure moves with + // the status filter instead of claiming to describe the whole project. + const totalCredits = sumJobCredits(jobs); + + // Restore a deep-linked selection ONCE, after the first list load. Guarded + // by a ref rather than by `selected`, so closing the drawer does not + // immediately re-open it on the next poll. + const restoredRef = useRef(false); + useEffect(() => { + if (restoredRef.current) return; + if (!sel) { + restoredRef.current = true; + return; + } + if (q.isLoading) return; + restoredRef.current = true; + const slash = sel.indexOf("/"); + if (slash <= 0) { + // Not a `/` pair -- nothing addressable. + setSel(null); + return; + } + const alias = sel.slice(0, slash); + const jobId = sel.slice(slash + 1); + const hit = q.data?.jobs.find( + (j) => j.project_alias === alias && String(j.id) === jobId, + ); + if (hit) { + setSelected(hit); + return; + } + if (q.data) { + // The list is capped per project, so a shared link to an older job will + // miss. The drawer fetches its own detail by alias+id anyway, so fall + // back to a minimal row: the header stays sparse until that detail + // lands, and the row-level actions (which need the component/config) + // stay hidden. + setSelected({ + project_alias: alias, + id: jobId, + status: "", + component: "", + config: null, + createdTime: "", + }); + } else { + // The list itself errored, so we cannot tell whether that alias is even + // registered here. Pinning an errored detail fetch to it would show a + // second failure with no more information; drop the deep link instead. + setSel(null); + } + }, [sel, setSel, q.isLoading, q.data]); + + const openJob = (j: Job) => { + setSelected(j); + setSel(`${j.project_alias}/${j.id}`); + }; + const closeJob = () => { + setSelected(null); + setSel(null); + }; + + return ( +
    + 0 + ? `Jobs across all projects · ~${formatCredits(totalCredits)} credits (shown jobs, estimated)` + : "Jobs across all projects" + } + actions={ + + } + /> +
    + {STATUS_FILTERS.map((s) => ( + + ))} +
    + + + + {q.isLoading ? ( + + ) : q.error ? ( + + ) : jobs.length === 0 ? ( + + ) : ( + `${j.project_alias}-${j.id}`} + onRowClick={openJob} + columns={[ + { + header: "Project", + cell: (j) => {j.project_alias}, + }, + { + header: "Job ID", + cell: (j) => {j.id}, + }, + { + header: "Status", + cell: (j) => ( + {j.status} + ), + }, + { header: "Component", cell: (j) => {j.component} }, + { + header: "Config", + cell: (j) => {j.config ?? "—"}, + }, + { + header: "Duration", + align: "right", + cell: (j) => ( + + {j.durationSeconds != null ? formatDuration(j.durationSeconds) : "-"} + + ), + }, + { + header: "Credits", + align: "right", + cell: (j) => ( + // Estimate, not a billing figure -- see config/credits.ts. A + // job with no duration has nothing to estimate from. + + {j.durationSeconds != null ? formatCredits(calculateJobCredits(j)) : "—"} + + ), + }, + { + header: "Created", + cell: (j) => ( + + {formatRelativeTime(j.createdTime)} + + ), + }, + { + header: "Actions", + align: "right", + cell: (j) => , + }, + ]} + /> + )} + + {selected ? : null} +
    + ); +} diff --git a/web/frontend/src/pages/Tokens.tsx b/web/frontend/src/pages/Tokens.tsx index 2449e560..2c8c5161 100644 --- a/web/frontend/src/pages/Tokens.tsx +++ b/web/frontend/src/pages/Tokens.tsx @@ -1,13 +1,20 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { Check, Copy, Globe, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { api, ApiError } from "../api/client"; +import { api } from "../api/client"; import { ConfirmModal } from "../components/ConfirmModal"; import { Drawer } from "../components/Drawer"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { DataTable } from "../components/Table"; import type { Column } from "../components/Table"; import { useUIState } from "../state"; +import { + errMessage, + LAST_USED_CAVEAT, + ScopeCell, + StatusCell, + type TokenEntry, +} from "./tokensShared"; /** * Scoped Storage tokens -- the UI half of `kbagent token list|create|delete|refresh`. @@ -27,26 +34,12 @@ import { useUIState } from "../state"; * 2. **The secret is shown exactly once.** `create` and `refresh` are the only * responses that ever carry a `token` value; the listing strips it. Nothing * persists it, so the reveal panel is the user's single chance to copy it. + * + * The read-only vocabulary this page shares with the cross-project audit view + * (`TokensAll`) lives in `tokensShared.tsx`; the mutations below stay here, + * because each of them is scoped to one project's token. */ -interface TokenEntry { - id: string | number; - description?: string; - created?: string; - refreshed?: string; - expires?: string | null; - isMasterToken?: boolean; - canManageTokens?: boolean; - canReadAllFileUploads?: boolean; - bucketPermissions?: Record; - componentAccess?: string[]; - // present only with with_last_used=true - lastUsed?: string | null; - lastUsedEvent?: string | null; - lastUsedStatus?: "used" | "never" | "unknown" | "error"; - [key: string]: unknown; -} - interface TokenListResp { alias: string; count: number; @@ -70,24 +63,6 @@ interface RevealedSecret { subtitle: string; } -const LAST_USED_CAVEAT = - "one extra API call per token ・ dev-branch activity is invisible (the events endpoint always resolves to the default branch)"; - -const STATUS_TITLES: Record = { - used: "This token performed at least one event -- the date is its most recent one.", - never: - "Minted INSIDE the ~6-month event retention window with no activity since -- proven unused.", - unknown: - "Older than the ~6-month event retention window -- the API cannot say whether it was used.", - error: "The per-token lookup failed; this row degraded so the rest of the audit could complete.", -}; - -function errMessage(err: unknown): string { - if (err instanceof ApiError) return err.message; - if (err instanceof Error) return err.message; - return String(err); -} - /** "a, b , ,c" -> ["a","b","c"]; empty input -> undefined (key omitted from the body). */ function splitList(raw: string): string[] | undefined { const items = raw @@ -97,44 +72,8 @@ function splitList(raw: string): string[] | undefined { return items.length > 0 ? items : undefined; } -function ScopeCell({ t }: { t: TokenEntry }) { - const buckets = Object.keys(t.bucketPermissions ?? {}).length; - const components = (t.componentAccess ?? []).length; - if (t.isMasterToken) return master; - if (t.canManageTokens) return manage tokens; - if (buckets === 0 && components === 0) { - return ; - } - return ( - - {buckets > 0 ? `${buckets} bucket(s)` : "—"} - {components > 0 ? ` ・ ${components} component(s)` : ""} - - ); -} - -function StatusCell({ t }: { t: TokenEntry }) { - // A real date (or an explicit "used") is the only green case. `never` and - // `unknown` are deliberately NOT collapsed -- "proven unused, safe to revoke" - // and "the API cannot say" lead to opposite decisions. - const status = t.lastUsedStatus ?? (t.lastUsed ? "used" : "unknown"); - const cls = - status === "used" - ? "nerd-pill-green" - : status === "never" - ? "nerd-pill-amber" - : status === "error" - ? "nerd-pill-red" - : "nerd-pill"; - return ( - - {status} - - ); -} - export function TokensPage() { - const { project } = useUIState(); + const { project, setPage } = useUIState(); const qc = useQueryClient(); const [withLastUsed, setWithLastUsed] = useState(false); const [showCreate, setShowCreate] = useState(false); @@ -269,17 +208,27 @@ export function TokensPage() { title="Tokens" description={`Scoped Storage API tokens in ${project ?? "(no project)"}. Secrets are revealed once, at mint -- kbagent never stores them.`} actions={ - + <> + + + } /> diff --git a/web/frontend/src/pages/TokensAll.tsx b/web/frontend/src/pages/TokensAll.tsx new file mode 100644 index 00000000..ec0ab0a2 --- /dev/null +++ b/web/frontend/src/pages/TokensAll.tsx @@ -0,0 +1,261 @@ +import { useQuery } from "@tanstack/react-query"; +import { KeyRound, RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { api } from "../api/client"; +import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; +import { DataTable } from "../components/Table"; +import type { Column } from "../components/Table"; +import { useUIState } from "../state"; +import { + errMessage, + expiresLabel, + LAST_USED_CAVEAT_ALL_PROJECTS, + ScopeCell, + StatusCell, + type TokenEntry, +} from "./tokensShared"; + +/** + * Cross-project token audit: one `GET /token/list` call with no `project` + * param, the server fans out over every registered project in parallel and + * returns the merged `{tokens, errors, token_errors}` envelope with + * `project_alias` stamped on each row (`errors` = whole project unlistable, + * `token_errors` = one token's last-used lookup degraded). + * + * **Deliberately READ-ONLY.** Minting, rotating and revoking stay on the + * per-project page. Two reasons: every one of those calls needs a single + * project's credentials anyway (there is no cross-project mutation to batch), + * and a destructive click on a merged list is one mis-read `project_alias` + * away from revoking the right-looking token in the wrong project. A row click + * therefore navigates INTO that project's Tokens page, where the actions live + * next to the context that makes them safe. + */ + +interface TokenProjectError { + project_alias: string; + /** Optional: a transport-level failure has no kbagent error code. */ + error_code?: string; + message: string; +} + +/** A last-used lookup that failed for ONE token while its project listed fine. */ +interface TokenLookupError { + project_alias: string; + token_id?: string | number; + message: string; +} + +interface TokensAllResp { + tokens: TokenEntry[]; + count: number; + /** Project-level failures: that project could not be listed at all. */ + errors?: TokenProjectError[]; + /** Per-token `with_last_used` lookup failures: the row degraded, the project did not. */ + token_errors?: TokenLookupError[]; +} + +/** Tone -> NERD pill/text classes for the Expires cell. */ +const EXPIRY_CLASS: Record = { + none: "text-zinc-500 text-xs", + later: "text-zinc-500 text-xs", + unknown: "text-zinc-500 text-xs", + soon: "text-amber-700 dark:text-neon-amber text-xs", + expired: "text-red-600 dark:text-red-400 text-xs", +}; + +/** Hover text that makes the row's only interaction -- navigation -- discoverable. */ +function rowTitle(t: TokenEntry): string { + return t.project_alias + ? `Open in project view (${t.project_alias})` + : "Open in project view"; +} + +export function TokensAllPage() { + const { setPage, setProject } = useUIState(); + const [withLastUsed, setWithLastUsed] = useState(false); + + // No polling: tokens are minted and revoked by hand, not by a running job. + const q = useQuery({ + queryKey: ["tokens-all", withLastUsed], + queryFn: () => + api.get("/token/list", { + // Omitting `project` entirely is what asks for every registered one. + query: { with_last_used: withLastUsed || undefined }, + }), + }); + + const openInProject = (t: TokenEntry) => { + if (!t.project_alias) return; + setProject(t.project_alias); + setPage("tokens"); + }; + + const columns: Column[] = [ + { + header: "Project", + cell: (t) => ( + + {t.project_alias ?? "—"} + + ), + }, + { + header: "ID", + cell: (t) => ( + + {String(t.id)} + + ), + }, + { + header: "Description", + cell: (t) => ( + + {t.description || "(no description)"} + + ), + }, + { header: "Scope", cell: (t) => }, + { + header: "Created", + cell: (t) => {t.created || "—"}, + }, + { + header: "Refreshed", + cell: (t) => {t.refreshed || "—"}, + }, + { + header: "Expires", + cell: (t) => { + const label = expiresLabel(t.expires); + return ( + + {label.text} + + ); + }, + }, + ]; + + if (withLastUsed) { + columns.push( + { + header: "Last used", + cell: (t) => {t.lastUsed || "—"}, + }, + { + header: "Last event", + cell: (t) => ( + {t.lastUsedEvent || "—"} + ), + }, + { header: "Status", cell: (t) => }, + ); + } + + const projectErrors = q.data?.errors ?? []; + const tokenErrors = q.data?.token_errors ?? []; + const rows = q.data?.tokens ?? []; + + return ( +
    + + + + + } + /> + + {withLastUsed ? ( +
    + {LAST_USED_CAVEAT_ALL_PROJECTS} +
    + ) : null} + + {projectErrors.length > 0 ? ( +
    +
    + {projectErrors.length} project(s) could not be listed +
    +
      + {projectErrors.map((e) => ( +
    • + {e.project_alias} — {e.message} + {e.error_code ? ({e.error_code}) : null} +
    • + ))} +
    +
    + ) : null} + + {tokenErrors.length > 0 ? ( + // Kept apart from the project banner above: these projects DID list -- + // only single tokens' last-used lookups failed and degraded their rows. +
    +
    + {tokenErrors.length} token last-used lookup(s) failed +
    +
      + {tokenErrors.map((e) => ( +
    • + {e.project_alias} + {e.token_id != null ? ( + / token {String(e.token_id)} + ) : null}{" "} + — {e.message} +
    • + ))} +
    +
    + ) : null} + + {q.isLoading ? ( + + ) : q.error ? ( + + ) : rows.length === 0 && projectErrors.length === 0 ? ( + + ) : ( + `${t.project_alias ?? "?"}-${String(t.id)}`} + emptyMessage="No tokens. The acting token needs canManageTokens to list them." + // No client-side sort: with `with_last_used` the server returns + // dormant-first globally, otherwise grouped by project -- either way + // reading order is the order the audit wants. + onRowClick={openInProject} + columns={columns} + /> + )} +
    + ); +} diff --git a/web/frontend/src/pages/jobsShared.tsx b/web/frontend/src/pages/jobsShared.tsx new file mode 100644 index 00000000..475d415b --- /dev/null +++ b/web/frontend/src/pages/jobsShared.tsx @@ -0,0 +1,528 @@ +/** + * Pieces shared by the per-project Jobs page and the cross-project All Jobs + * page: status colouring, the terminate guard, duration formatting, the + * row/drawer action buttons and the detail drawer itself. + * + * Everything here takes the project alias from the JOB ROW (`project_alias`), + * never from the page's active project. That is what makes the same drawer and + * the same action buttons work on a merged, multi-project list -- and it costs + * the per-project page nothing, because its rows carry the same field. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Activity, + Clock, + Cpu, + FileCode, + Play, + RotateCw, + Server, + Square, + Timer, + User, + XOctagon, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { api, sseSubscribe } from "../api/client"; +import { ConfirmModal } from "../components/ConfirmModal"; +import { Drawer } from "../components/Drawer"; +import { ErrorBox, Loading } from "../components/Empty"; +import { JsonView } from "../components/JsonView"; +import type { Job, ProjectError } from "../types"; + +/** + * `GET /jobs` envelope. `errors` is per-project and always present: with no + * `project` param the server fans out over every registered project, and one + * project failing must not blank the other twenty rows. + */ +export interface JobsResp { + jobs: Job[]; + errors: ProjectError[]; +} + +export const STATUS_COLORS: Record = { + success: "nerd-pill-green", + error: "nerd-pill-red", + warning: "nerd-pill-amber", + processing: "nerd-pill-amber", + cancelled: "nerd-pill", + terminated: "nerd-pill", +}; + +/** + * Statuses the Queue API will actually accept a terminate for. A terminal job + * (success / error / ...) has nothing left to stop, so we hide the button + * rather than let the user discover that by getting a 4xx back. + */ +export const TERMINABLE_STATUSES = new Set(["created", "waiting", "processing"]); + +/** + * Human label for a job's target: `component ・ config `, dropping the + * config half entirely when the job carries none. A job run from an inline + * `configData` payload has no stored configuration, and rendering the raw + * value there produced a literal "config undefined" in the drawer header. + */ +export function jobLabel(job: Job): string { + return job.config ? `${job.component} ・ config ${job.config}` : job.component; +} + +/** + * The job's branch as the `JobRun` body wants it: an integer, or `undefined` + * for the default branch. The Queue API is inconsistent about whether + * `branchId` arrives numeric or as a string, and the router declares + * `branch_id: int | None`, so anything non-numeric is dropped rather than + * sent as a value FastAPI would reject. + */ +export function jobBranchId(job: Job): number | undefined { + if (job.branchId === null || job.branchId === undefined) return undefined; + const n = Number(job.branchId); + return Number.isFinite(n) ? n : undefined; +} + +export function formatDuration(sec: number): string { + if (sec < 60) return `${sec}s`; + const m = Math.floor(sec / 60); + const s = sec % 60; + if (m < 60) return `${m}m ${s}s`; + const h = Math.floor(m / 60); + const mr = m % 60; + return `${h}h ${mr}m`; +} + +/** + * Per-job Re-run / Terminate actions, shared by the table row and the detail + * drawer header. + * + * Re-run posts the job's OWN component + config + branch to + * `POST /jobs/{p}/run`, i.e. it starts a fresh job from the configuration as + * it stands NOW -- it does not replay the historical `configData` the old job + * ran with. That is the same semantics as `kbagent job run`, and the only + * thing the Queue API offers. The branch IS preserved, though: see + * `jobBranchId` and the comment on the mutation body. + * + * Terminate goes through `POST /jobs/{p}/terminate` with an explicit + * `job_ids` list; the filter form of that endpoint (status / component) is + * deliberately not exposed here -- one row, one job. + */ +export function JobActions({ job, compact = true }: { job: Job; compact?: boolean }) { + const qc = useQueryClient(); + const [confirm, setConfirm] = useState<"terminate" | null>(null); + const [error, setError] = useState(null); + + // Both job lists are invalidated unconditionally, not just the one this + // button happens to be rendered on: re-running or terminating a job changes + // what the per-project list AND the cross-project list should show, and the + // component cannot tell which of them mounted it. + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["jobs"] }); + qc.invalidateQueries({ queryKey: ["jobs-all"] }); + qc.invalidateQueries({ queryKey: ["dashboard-jobs"] }); + }; + + const rerun = useMutation({ + mutationFn: () => + api.post(`/jobs/${encodeURIComponent(job.project_alias)}/run`, { + component_id: job.component, + config_id: job.config, + // Branch fidelity: omitting this resolves to the DEFAULT branch + // server-side, so a job that originally ran against a dev-branch + // config would silently re-run against the production one -- a + // different configuration, writing to different tables. The row + // already carries the branch, so pass it straight back. + branch_id: jobBranchId(job), + }), + onError: (e) => setError((e as Error).message), + onSuccess: () => { + setError(null); + invalidate(); + }, + }); + + const terminate = useMutation({ + mutationFn: () => + api.post(`/jobs/${encodeURIComponent(job.project_alias)}/terminate`, { + job_ids: [String(job.id)], + dry_run: false, + }), + onError: (e) => setError((e as Error).message), + onSuccess: () => { + setError(null); + setConfirm(null); + invalidate(); + }, + }); + + // A job started from an inline `configData` payload has no stored + // configuration to re-run, so `config` is null and the button is hidden. + const canRerun = !!job.component && !!job.config; + const canTerminate = TERMINABLE_STATUSES.has(job.status); + const btn = `nerd-btn ${compact ? "text-[10px] py-0.5 px-1.5" : "text-xs"} flex items-center gap-1 disabled:opacity-50`; + + return ( + e.stopPropagation()} + role="presentation" + > + {error ? ( + + {error} + + ) : null} + {canRerun ? ( + + ) : null} + {canTerminate ? ( + + ) : null} + {confirm === "terminate" ? ( + + Job {String(job.id)} ( + {jobLabel(job)}) is {job.status}. + Terminating stops it where it is — partially written output stays written. + + } + confirmLabel="Terminate" + onConfirm={() => terminate.mutate()} + onCancel={() => setConfirm(null)} + /> + ) : null} + + ); +} + +export function JobDetailDrawer({ job, onClose }: { job: Job; onClose: () => void }) { + const detailQ = useQuery>({ + queryKey: ["job-detail", job.project_alias, job.id], + queryFn: () => + api.get( + `/jobs/${encodeURIComponent(job.project_alias)}/${encodeURIComponent(String(job.id))}`, + ), + }); + const [logs, setLogs] = useState< + Array<{ id: number | string; message: string; type?: string }> + >([]); + const [streaming, setStreaming] = useState(false); + const esRef = useRef(null); + + useEffect(() => { + return () => { + esRef.current?.close(); + }; + }, []); + + const startStream = () => { + setLogs([]); + setStreaming(true); + const es = sseSubscribe( + `/jobs/${encodeURIComponent(job.project_alias)}/${encodeURIComponent(String(job.id))}/stream`, + undefined, + { + log: (data) => { + const ev = data as { id: number | string; message: string; type?: string }; + setLogs((l) => [...l, ev]); + }, + status: (data) => { + const ev = data as { status: string }; + setLogs((l) => [...l, { id: `s-${Date.now()}`, message: `→ status: ${ev.status}` }]); + }, + done: (data) => { + const ev = data as { final: string }; + setLogs((l) => [...l, { id: `d-${Date.now()}`, message: `✓ done: ${ev.final}` }]); + setStreaming(false); + es.close(); + }, + }, + ); + esRef.current = es; + }; + + const detail = detailQ.data ?? {}; + + return ( + + {/* Status comes from the freshly fetched detail when available, so a + job that finished while the drawer was open loses its Terminate + button on the next poll instead of offering a doomed call. */} + + + + } + > + {detailQ.isLoading ? : null} + {detailQ.error ? : null} + {detailQ.data ? ( +
    + + + {logs.length > 0 ? ( +
    +
    Live log tail (SSE)
    +
    +                {logs.map((l) => `[${l.type ?? "log"}] ${l.message}`).join("\n")}
    +              
    +
    + ) : null} +
    + raw JSON + +
    +
    + ) : null} +
    + ); +} + +function JobCards({ + detail, + job, +}: { + detail: Record; + job: Job; +}) { + const status = String(detail.status ?? job.status); + const start = String(detail.startTime ?? ""); + const end = String(detail.endTime ?? ""); + const duration = (detail.durationSeconds as number | undefined) ?? job.durationSeconds; + const created = String(detail.createdTime ?? job.createdTime ?? ""); + const tokenDesc = + (detail.tokenDescription as string | undefined) ?? + (detail.token as { description?: string } | undefined)?.description ?? + ""; + const url = (detail.url as string | undefined) ?? ""; + // Empty string, not null: KV drops a falsy value, so a configData-only job + // renders no "Config ID" row at all instead of a literal "null". + const config = (detail.config as string | undefined) ?? job.config ?? ""; + const branchId = (detail.branchId as number | undefined) ?? null; + const params = (detail.params as Record | undefined) ?? {}; + const backendSize = String( + (params?.backend as Record | undefined)?.context ?? + params?.size ?? + "—", + ); + + const statusBadge = STATUS_COLORS[status] ?? "nerd-pill"; + + return ( +
    + } label="Status"> + {status} + {url ? ( + + open in Keboola UI → + + ) : null} + + } label="Duration"> +
    + {duration != null ? formatDuration(duration) : "-"} +
    +
    + } label="Times"> + + + + + } label="Created by"> +
    {tokenDesc || "—"}
    +
    + } label="Configuration"> + + + {branchId ? : null} + + } label="Backend"> +
    {backendSize}
    +
    + } label="Run IDs"> + + + + } label="Project"> + + +
    + ); +} + +function Card({ + icon, + label, + children, +}: { + icon?: React.ReactNode; + label: string; + children: React.ReactNode; +}) { + return ( +
    +
    + {icon} + {label} +
    + {children} +
    + ); +} + +function KV({ k, v }: { k: string; v: string }) { + if (!v) return null; + return ( +
    + {k}:{" "} + {v} +
    + ); +} + +function ParametersAndMapping({ detail }: { detail: Record }) { + const params = (detail.params as Record | undefined) ?? {}; + const result = (detail.result as Record | undefined) ?? {}; + const config = (detail.configData as Record | undefined) ?? {}; + const storage = (config.storage as Record | undefined) ?? {}; + const inputTables = ( + (storage.input as { tables?: Array> } | undefined)?.tables ?? [] + ) as Array>; + const outputTables = ( + (storage.output as { tables?: Array> } | undefined)?.tables ?? [] + ) as Array>; + + return ( +
    +
    +
    + Parameters +
    + {Object.keys(params).length === 0 ? ( +
    No parameters.
    + ) : ( +
    +            {JSON.stringify(params, null, 2)}
    +          
    + )} + {Object.keys(result).length > 0 ? ( +
    + result +
    +              {JSON.stringify(result, null, 2)}
    +            
    +
    + ) : null} +
    +
    +
    + Mapping +
    +
    + Input ({inputTables.length}) +
    + {inputTables.length === 0 ? ( +
    No tables.
    + ) : ( +
      + {inputTables.map((t, i) => ( +
    • + {String(t.source ?? "")} → {String(t.destination ?? "")} +
    • + ))} +
    + )} +
    + Output ({outputTables.length}) +
    + {outputTables.length === 0 ? ( +
    No tables.
    + ) : ( +
      + {outputTables.map((t, i) => ( +
    • + {String(t.source ?? "")} → {String(t.destination ?? "")} +
    • + ))} +
    + )} +
    +
    + ); +} + +/** + * One-line amber strip listing the projects whose fan-out leg failed. + * + * Both job lists render this: the merged envelope always carries `errors`, and + * silently dropping it means a project that is down looks identical to a + * project with no jobs. + */ +export function ProjectErrorsBanner({ errors }: { errors: ProjectError[] }) { + if (errors.length === 0) return null; + return ( +
    +
    + {errors.length} project(s) failed +
    +
      + {errors.map((e) => ( +
    • + {e.project_alias} — {e.message} +
    • + ))} +
    +
    + ); +} diff --git a/web/frontend/src/pages/tokensShared.test.ts b/web/frontend/src/pages/tokensShared.test.ts new file mode 100644 index 00000000..8a949ec4 --- /dev/null +++ b/web/frontend/src/pages/tokensShared.test.ts @@ -0,0 +1,109 @@ +/** + * Pure helpers behind both token surfaces. + * + * Worth their own suite because both feed a security decision: `expiresLabel` + * decides whether a row reads as "fine" or "clean this up", and + * `lastUsedStatusOf` decides whether a token is reported as PROVEN unused + * (safe to revoke) or merely UNKNOWN (the API was never asked / cannot say). + * Collapsing either distinction is silent and destructive. + */ +import { describe, expect, it } from "vitest"; +import { + describeLastUsed, + EXPIRY_SOON_DAYS, + expiresLabel, + lastUsedStatusOf, + STATUS_TITLES, + type TokenEntry, +} from "./tokensShared"; + +const NOW = Date.parse("2026-08-24T12:00:00Z"); +const DAY = 86_400_000; + +function token(extra: Partial = {}): TokenEntry { + return { id: 1, ...extra }; +} + +describe("expiresLabel", () => { + it("treats a missing expiry as 'never', not as a problem", () => { + for (const empty of [null, undefined, ""]) { + expect(expiresLabel(empty, NOW)).toEqual({ text: "never", tone: "none" }); + } + }); + + it("flags a lapsed token as expired", () => { + expect(expiresLabel("2026-08-24T11:59:00Z", NOW)).toEqual({ + text: "expired", + tone: "expired", + }); + }); + + it("treats the exact expiry instant as already expired", () => { + // A token whose expiry equals `now` is dead, not "in 0d". + expect(expiresLabel("2026-08-24T12:00:00Z", NOW).tone).toBe("expired"); + }); + + it("warns on an expiry inside the soon window, in whole days", () => { + expect(expiresLabel(new Date(NOW + 3 * DAY).toISOString(), NOW)).toEqual({ + text: "in 3d", + tone: "soon", + }); + // Partial days round UP -- 36h left is "in 2d", never "in 1d". + expect(expiresLabel(new Date(NOW + 1.5 * DAY).toISOString(), NOW).text).toBe("in 2d"); + }); + + it("puts the soon/later boundary at EXPIRY_SOON_DAYS inclusive", () => { + expect(expiresLabel(new Date(NOW + EXPIRY_SOON_DAYS * DAY).toISOString(), NOW).tone).toBe( + "soon", + ); + expect( + expiresLabel(new Date(NOW + (EXPIRY_SOON_DAYS + 1) * DAY).toISOString(), NOW).tone, + ).toBe("later"); + }); + + it("shows a far-off expiry as a plain calendar date", () => { + expect(expiresLabel("2027-01-15T08:30:00Z", NOW)).toEqual({ + text: "2027-01-15", + tone: "later", + }); + }); + + it("reports an unparsable value verbatim instead of guessing", () => { + // Calling garbage "never" would hide exactly the row worth investigating. + expect(expiresLabel("not-a-date", NOW)).toEqual({ text: "not-a-date", tone: "unknown" }); + }); +}); + +describe("lastUsedStatusOf", () => { + it("trusts an explicit status from the server", () => { + expect(lastUsedStatusOf(token({ lastUsedStatus: "never" }))).toBe("never"); + expect(lastUsedStatusOf(token({ lastUsedStatus: "error" }))).toBe("error"); + // Explicit `never` wins even if a stale date rode along. + expect(lastUsedStatusOf(token({ lastUsedStatus: "never", lastUsed: "2026-01-01" }))).toBe( + "never", + ); + }); + + it("infers 'used' from a bare date", () => { + expect(lastUsedStatusOf(token({ lastUsed: "2026-08-01T00:00:00Z" }))).toBe("used"); + }); + + it("falls back to 'unknown', never to 'never', when nothing was derived", () => { + // A listing fetched WITHOUT with_last_used carries no evidence at all; + // reporting that as "never" would read as "proven unused, safe to revoke". + expect(lastUsedStatusOf(token())).toBe("unknown"); + expect(lastUsedStatusOf(token({ lastUsed: null }))).toBe("unknown"); + }); +}); + +describe("describeLastUsed", () => { + it("keeps 'never' and 'unknown' distinguishable in the hover text", () => { + expect(describeLastUsed("never")).toBe(STATUS_TITLES.never); + expect(describeLastUsed("unknown")).toBe(STATUS_TITLES.unknown); + expect(describeLastUsed("never")).not.toBe(describeLastUsed("unknown")); + }); + + it("lets an unrecognized status describe itself", () => { + expect(describeLastUsed("brand-new-status")).toBe("brand-new-status"); + }); +}); diff --git a/web/frontend/src/pages/tokensShared.tsx b/web/frontend/src/pages/tokensShared.tsx new file mode 100644 index 00000000..8431b951 --- /dev/null +++ b/web/frontend/src/pages/tokensShared.tsx @@ -0,0 +1,153 @@ +import { ApiError } from "../api/client"; + +/** + * Shared vocabulary for the two token surfaces: the per-project `Tokens` page + * (list + mint + rotate + revoke) and the cross-project `TokensAll` audit page. + * + * Only the READ half lives here. Minting, rotating and revoking stay on the + * per-project page, because every one of those calls is scoped to a single + * project's token and there is no cross-project equivalent to share. + * + * The two non-obvious facts both surfaces depend on: + * + * 1. **`lastUsed` is DERIVED, not read.** The Storage API's token listing + * carries no `lastUsed` field at all. The backend synthesizes it per token + * from that token's OWN event feed -- one extra API call PER TOKEN, which is + * why it is opt-in behind a toggle on both pages (and why the cost is + * multiplied by the project count on the cross-project page). + * 2. **Secrets are never in a listing.** Only `create` / `refresh` responses + * ever carry a token value, and only on the per-project page. + */ + +/** Days of remaining lifetime under which an expiry is worth flagging. */ +export const EXPIRY_SOON_DAYS = 30; + +const MS_PER_DAY = 86_400_000; + +export interface TokenEntry { + id: string | number; + description?: string; + created?: string; + refreshed?: string; + expires?: string | null; + isMasterToken?: boolean; + canManageTokens?: boolean; + canReadAllFileUploads?: boolean; + bucketPermissions?: Record; + componentAccess?: string[]; + // present only with with_last_used=true + lastUsed?: string | null; + lastUsedEvent?: string | null; + lastUsedStatus?: LastUsedStatus; + /** Stamped by the cross-project listing only; absent on a single-project row. */ + project_alias?: string; + [key: string]: unknown; +} + +export type LastUsedStatus = "used" | "never" | "unknown" | "error"; + +export const LAST_USED_CAVEAT = + "one extra API call per token ・ dev-branch activity is invisible (the events endpoint always resolves to the default branch)"; + +/** Same caveat, plus the cost multiplier that only bites on the global view. */ +export const LAST_USED_CAVEAT_ALL_PROJECTS = + "one extra API call per token, across EVERY registered project ・ dev-branch activity is invisible (the events endpoint always resolves to the default branch)"; + +export const STATUS_TITLES: Record = { + used: "This token performed at least one event -- the date is its most recent one.", + never: + "Minted INSIDE the ~6-month event retention window with no activity since -- proven unused.", + unknown: + "Older than the ~6-month event retention window -- the API cannot say whether it was used.", + error: "The per-token lookup failed; this row degraded so the rest of the audit could complete.", +}; + +export function errMessage(err: unknown): string { + if (err instanceof ApiError) return err.message; + if (err instanceof Error) return err.message; + return String(err); +} + +/** + * The status a row should render under. + * + * A row that carries no explicit `lastUsedStatus` (an older backend, or a + * listing fetched without `with_last_used`) is `unknown`, NOT `never`: "the + * API was never asked" and "the API answered no activity" lead to opposite + * decisions, and only the latter is evidence a token is safe to revoke. + */ +export function lastUsedStatusOf(t: TokenEntry): LastUsedStatus { + if (t.lastUsedStatus) return t.lastUsedStatus; + return t.lastUsed ? "used" : "unknown"; +} + +/** Hover text for a status pill; unrecognized values describe themselves. */ +export function describeLastUsed(status: string): string { + return STATUS_TITLES[status] ?? status; +} + +export type ExpiryTone = "none" | "expired" | "soon" | "later" | "unknown"; + +export interface ExpiryLabel { + text: string; + tone: ExpiryTone; +} + +/** + * Turn a raw `expires` value into an audit-readable label. + * + * The raw timestamp answers "when", but a cross-project audit asks "is this a + * problem": a token that already lapsed is dead weight to clean up, one + * lapsing within {@link EXPIRY_SOON_DAYS} is a break waiting to happen in + * whatever CI job holds it, and a never-expiring token is the normal case, not + * an alarm. An unparsable value is reported verbatim rather than guessed at -- + * silently calling it "never" would hide exactly the row worth looking at. + */ +export function expiresLabel( + expires: string | null | undefined, + now: number = Date.now(), +): ExpiryLabel { + if (!expires) return { text: "never", tone: "none" }; + const ms = Date.parse(expires); + if (Number.isNaN(ms)) return { text: expires, tone: "unknown" }; + if (ms <= now) return { text: "expired", tone: "expired" }; + const days = Math.ceil((ms - now) / MS_PER_DAY); + if (days <= EXPIRY_SOON_DAYS) return { text: `in ${days}d`, tone: "soon" }; + return { text: new Date(ms).toISOString().slice(0, 10), tone: "later" }; +} + +export function ScopeCell({ t }: { t: TokenEntry }) { + const buckets = Object.keys(t.bucketPermissions ?? {}).length; + const components = (t.componentAccess ?? []).length; + if (t.isMasterToken) return master; + if (t.canManageTokens) return manage tokens; + if (buckets === 0 && components === 0) { + return ; + } + return ( + + {buckets > 0 ? `${buckets} bucket(s)` : "—"} + {components > 0 ? ` ・ ${components} component(s)` : ""} + + ); +} + +export function StatusCell({ t }: { t: TokenEntry }) { + // A real date (or an explicit "used") is the only green case. `never` and + // `unknown` are deliberately NOT collapsed -- "proven unused, safe to revoke" + // and "the API cannot say" lead to opposite decisions. + const status = lastUsedStatusOf(t); + const cls = + status === "used" + ? "nerd-pill-green" + : status === "never" + ? "nerd-pill-amber" + : status === "error" + ? "nerd-pill-red" + : "nerd-pill"; + return ( + + {status} + + ); +} diff --git a/web/frontend/src/state.tsx b/web/frontend/src/state.tsx index 6bedc104..41527381 100644 --- a/web/frontend/src/state.tsx +++ b/web/frontend/src/state.tsx @@ -23,6 +23,7 @@ export const PAGE_IDS = [ "storage", "stream", "jobs", + "jobs-all", "branches", "workspaces", "flows", @@ -39,6 +40,7 @@ export const PAGE_IDS = [ "org", "members", "tokens", + "tokens-all", "doctor", "changelog", ] as const; diff --git a/web/frontend/src/types.ts b/web/frontend/src/types.ts index 05715437..e561b230 100644 --- a/web/frontend/src/types.ts +++ b/web/frontend/src/types.ts @@ -92,6 +92,15 @@ export interface Job { endTime?: string; durationSeconds?: number; url?: string; + /** + * Queue API metrics passthrough. Deliberately untyped: the platform adds + * keys here freely and the row is the API resource verbatim. The only part + * the UI reads is the container size -- + * `metrics.backend.containerSize ?? metrics.backend.size` -- and it goes + * through `getContainerSize()` in `config/credits.ts` rather than being + * indexed at call sites. + */ + metrics?: Record; } export interface Branch {