From 99d66e2f1301965848f8a27355b102f4b47a1aa4 Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 24 Aug 2026 00:21:00 +0200 Subject: [PATCH 1/4] feat(ui): register jobs-all and tokens-all pages (scaffold) Cross-project views are palette-reachable (no sidebar rows); their per-project sibling stays highlighted while open. --- web/frontend/src/App.tsx | 6 +++++ .../src/components/CommandPalette.tsx | 13 ++++++++++- web/frontend/src/layout/Sidebar.tsx | 23 ++++++++++++++++++- web/frontend/src/pages/JobsAll.tsx | 10 ++++++++ web/frontend/src/pages/TokensAll.tsx | 11 +++++++++ web/frontend/src/state.tsx | 2 ++ 6 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 web/frontend/src/pages/JobsAll.tsx create mode 100644 web/frontend/src/pages/TokensAll.tsx 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/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 index 5066653f..2a13d18a 100644 --- a/web/frontend/src/pages/JobsAll.tsx +++ b/web/frontend/src/pages/JobsAll.tsx @@ -1,10 +1,254 @@ -import { PageTitle } from "../components/Empty"; +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() { - return ; + 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 index f287305c..cd2c8b1c 100644 --- a/web/frontend/src/pages/TokensAll.tsx +++ b/web/frontend/src/pages/TokensAll.tsx @@ -1,11 +1,228 @@ -import { PageTitle } from "../components/Empty"; +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}` envelope with `project_alias` stamped * on each row. + * + * **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; +} + +interface TokensAllResp { + tokens: TokenEntry[]; + count: number; + errors?: TokenProjectError[]; +} + +/** 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() { - return ; + 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 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} + + {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/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 { From e4c12c7f93878ce24b22763167c569c0829feb9f Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 24 Aug 2026 00:36:17 +0200 Subject: [PATCH 3/4] docs(serve): regenerate endpoint reference with GET /token/list --- docs/web-server-endpoints.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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) | From d8f0827761c7b80aadd34959f1bb0d7ddd402c9f Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 24 Aug 2026 00:42:27 +0200 Subject: [PATCH 4/4] fix(serve,ui): keep per-token last-used failures out of the project error list list_tokens_all merged two different failure kinds into one errors list, so the All Tokens banner counted a healthy project with one degraded token as 'could not be listed'. Per-token lookup failures now come back under a separate token_errors key and render as their own strip. --- src/keboola_agent_cli/server/routers/token.py | 5 ++- .../services/token_service.py | 27 +++++++++++--- tests/test_token_service.py | 12 ++++-- web/frontend/src/pages/TokensAll.tsx | 37 ++++++++++++++++++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/keboola_agent_cli/server/routers/token.py b/src/keboola_agent_cli/server/routers/token.py index 0f1a16ed..61fb5403 100644 --- a/src/keboola_agent_cli/server/routers/token.py +++ b/src/keboola_agent_cli/server/routers/token.py @@ -44,7 +44,10 @@ def list_tokens_all( 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`. + 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 diff --git a/src/keboola_agent_cli/services/token_service.py b/src/keboola_agent_cli/services/token_service.py index 500daa30..82b83901 100644 --- a/src/keboola_agent_cli/services/token_service.py +++ b/src/keboola_agent_cli/services/token_service.py @@ -177,8 +177,16 @@ def list_tokens_all( thread-multiplication note on :meth:`_fetch_project_tokens`. Returns: - ``{"tokens": [...], "count": len(tokens), "errors": [...]}``. Every - token row and every error entry carries ``project_alias``. + ``{"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 @@ -194,17 +202,24 @@ def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: 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) - errors.extend(inner_errors) + 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", ""), e.get("token_id", ""))) - - return {"tokens": all_tokens, "count": len(all_tokens), "errors": errors} + 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. diff --git a/tests/test_token_service.py b/tests/test_token_service.py index a1291f08..8590a26d 100644 --- a/tests/test_token_service.py +++ b/tests/test_token_service.py @@ -621,15 +621,19 @@ def factory(url, token): result = TokenService(store, client_factory=factory).list_tokens_all(with_last_used=True) - assert len(result["errors"]) == 1 - assert result["errors"][0]["token_id"] == "1" - assert result["errors"][0]["project_alias"] == "prod" + # 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": []} + 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) diff --git a/web/frontend/src/pages/TokensAll.tsx b/web/frontend/src/pages/TokensAll.tsx index cd2c8b1c..ec0ab0a2 100644 --- a/web/frontend/src/pages/TokensAll.tsx +++ b/web/frontend/src/pages/TokensAll.tsx @@ -18,8 +18,9 @@ import { /** * 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}` envelope with `project_alias` stamped - * on each row. + * 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 @@ -37,10 +38,20 @@ interface TokenProjectError { 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. */ @@ -146,6 +157,7 @@ export function TokensAllPage() { } const projectErrors = q.data?.errors ?? []; + const tokenErrors = q.data?.token_errors ?? []; const rows = q.data?.tokens ?? []; return ( @@ -198,6 +210,27 @@ export function TokensAllPage() { ) : 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 ? (