+
{q.data.errors.length} project error(s) -- some configs may be missing.
) : null}
@@ -66,7 +111,7 @@ export function ConfigsPage() {
onRowClick={(c) => setSelected(c)}
columns={[
{ header: "Component", cell: (c) =>
{c.component_id} },
- { header: "Config ID", cell: (c) =>
{c.config_id} },
+ { header: "Config ID", cell: (c) =>
{c.config_id} },
{ header: "Name", cell: (c) =>
{c.config_name} },
{ header: "Folder", cell: (c) =>
{c.folder ?? ""} },
{ header: "Modified", cell: (c) =>
{c.last_modified ?? ""} },
@@ -80,6 +125,7 @@ export function ConfigsPage() {
alias={selected.project_alias}
componentId={selected.component_id}
configId={selected.config_id}
+ name={selected.config_name}
onClose={() => setSelected(null)}
/>
) : null}
@@ -87,18 +133,119 @@ export function ConfigsPage() {
);
}
+/**
+ * Trash view (#643). A `config delete` is a SOFT delete: the Storage API moves
+ * the configuration here and it stays restorable. (The same DELETE issued at
+ * something already in the trash purges it permanently, which is exactly why
+ * the server refuses to re-delete and why this view only ever restores.)
+ */
+function TrashTab() {
+ const { project, branchId } = useUIState();
+ const qc = useQueryClient();
+ const [error, setError] = useState
(null);
+ const [restoring, setRestoring] = useState(null);
+
+ const q = useQuery({
+ queryKey: ["config-trash", project, branchId],
+ queryFn: () =>
+ api.get(`/configs/trash/${encodeURIComponent(project!)}`, {
+ query: { branch_id: branchId ?? undefined },
+ }),
+ enabled: !!project,
+ });
+
+ const restore = useMutation({
+ mutationFn: (entry: TrashEntry) =>
+ api.post(
+ `/configs/${encodeURIComponent(project!)}/${encodeURIComponent(entry.component_id)}/${encodeURIComponent(entry.config_id)}/restore`,
+ undefined,
+ { query: { branch_id: branchId ?? undefined } },
+ ),
+ onMutate: (entry) => {
+ setError(null);
+ setRestoring(`${entry.component_id}/${entry.config_id}`);
+ },
+ onError: (e) => setError((e as Error).message),
+ onSettled: () => {
+ setRestoring(null);
+ qc.invalidateQueries({ queryKey: ["config-trash"] });
+ qc.invalidateQueries({ queryKey: ["configs"] });
+ },
+ });
+
+ if (q.isLoading) return ;
+ if (q.error) return ;
+
+ const rows = q.data?.trash ?? [];
+ if (rows.length === 0) {
+ return ;
+ }
+
+ return (
+
+ {error ? : null}
+ `${t.component_id}/${t.config_id}`}
+ columns={[
+ { header: "Component", cell: (t) => {t.component_id} },
+ { header: "Config ID", cell: (t) => {t.config_id} },
+ { header: "Name", cell: (t) => {t.name} },
+ {
+ header: "Deleted at",
+ cell: (t) => {t.deleted_at ?? "—"} ,
+ },
+ {
+ header: "Version",
+ align: "right",
+ cell: (t) => (
+ {t.version != null ? `v${t.version}` : "—"}
+ ),
+ },
+ {
+ header: "Actions",
+ align: "right",
+ cell: (t) => {
+ const key = `${t.component_id}/${t.config_id}`;
+ return (
+ restore.mutate(t)}
+ title={t.deleted_change_description ?? "Restore this configuration"}
+ >
+
+ {restoring === key ? "restoring…" : "restore"}
+
+ );
+ },
+ },
+ ]}
+ />
+
+ );
+}
+
function ConfigDetail({
alias,
componentId,
configId,
+ name,
onClose,
}: {
alias: string;
componentId: string;
configId: string;
+ name: string;
onClose: () => void;
}) {
- const { branchId } = useUIState();
+ const { branchId, setPage } = useUIState();
+ const qc = useQueryClient();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [actionError, setActionError] = useState(null);
+ const [startedJobId, setStartedJobId] = useState(null);
+
const detailQ = useQuery({
queryKey: ["config-detail", alias, componentId, configId, branchId],
queryFn: () =>
@@ -107,19 +254,118 @@ function ConfigDetail({
{ query: { branch_id: branchId ?? undefined } },
),
});
+
+ // Fire-and-return: `wait` stays false so the drawer never blocks on a job
+ // that can run for an hour. The user follows it on the Jobs page instead.
+ const runJob = useMutation<{ id?: string | number }>({
+ mutationFn: () =>
+ api.post(`/jobs/${encodeURIComponent(alias)}/run`, {
+ component_id: componentId,
+ config_id: configId,
+ branch_id: branchId ?? undefined,
+ }),
+ onError: (e) => setActionError((e as Error).message),
+ onSuccess: (data) => {
+ setActionError(null);
+ setStartedJobId(data?.id != null ? String(data.id) : "");
+ qc.invalidateQueries({ queryKey: ["jobs"] });
+ qc.invalidateQueries({ queryKey: ["dashboard-jobs"] });
+ },
+ });
+
+ const del = useMutation({
+ mutationFn: () =>
+ api.delete(
+ `/configs/${encodeURIComponent(alias)}/${encodeURIComponent(componentId)}/${encodeURIComponent(configId)}`,
+ { query: { branch_id: branchId ?? undefined, dry_run: false } },
+ ),
+ onError: (e) => {
+ setActionError((e as Error).message);
+ setConfirmDelete(false);
+ },
+ onSuccess: () => {
+ setConfirmDelete(false);
+ qc.invalidateQueries({ queryKey: ["configs"] });
+ qc.invalidateQueries({ queryKey: ["config-trash"] });
+ onClose();
+ },
+ });
+
return (
-
-
-
- {componentId} / {configId}
-
-
- Close
-
+
+ runJob.mutate()}
+ title={`Queue a job for ${componentId} / ${configId}`}
+ >
+
+ {runJob.isPending ? "starting…" : "Run job"}
+
+ setConfirmDelete(true)}
+ >
+ Delete
+
+ >
+ }
+ >
+
+ {actionError ?
: null}
+ {startedJobId !== null ? (
+
+
+
+ Job {startedJobId ? {startedJobId} : null}{" "}
+ queued. It runs asynchronously — follow it on the Jobs page.
+
+
{
+ onClose();
+ setPage("jobs");
+ }}
+ >
+ open Jobs →
+
+
+ ) : null}
+ {detailQ.isLoading ?
: null}
+ {detailQ.error ?
: null}
+ {detailQ.data ?
: null}
- {detailQ.isLoading ? : null}
- {detailQ.error ? : null}
- {detailQ.data ? : null}
-
+
+ {confirmDelete ? (
+
+
+ {componentId}/{configId}
+ {" "}
+ moves to the trash. This is reversible — restore it from the Trash tab. Any schedule
+ or flow still pointing at it will start failing until it is restored.
+ >
+ }
+ confirmLabel="Move to trash"
+ onConfirm={() => del.mutate()}
+ onCancel={() => setConfirmDelete(false)}
+ />
+ ) : null}
+
);
}
diff --git a/web/frontend/src/pages/Dashboard.tsx b/web/frontend/src/pages/Dashboard.tsx
index 06ffbd91..dcdf2bba 100644
--- a/web/frontend/src/pages/Dashboard.tsx
+++ b/web/frontend/src/pages/Dashboard.tsx
@@ -1,7 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { UseQueryResult } from "@tanstack/react-query";
import {
Activity,
Bot,
+ Coins,
Heart,
Network,
Play,
@@ -32,6 +34,26 @@ interface DoctorResp {
summary: { total: number; passed: number; failed: number; warnings?: number };
}
+/**
+ * PAYG credit balance for one project. A project without the
+ * `pay-as-you-go` owner feature never reaches the billing host at all -- it
+ * comes back as a per-project error with `error_code: "PAYG_NOT_AVAILABLE"`,
+ * which is a normal state on most stacks, not a failure worth shouting about.
+ */
+interface CreditRow {
+ project_alias: string;
+ remaining: number;
+ consumed: number;
+ total: number;
+ remaining_minutes: number;
+ consumed_minutes: number;
+}
+
+interface CreditsResp {
+ credits: CreditRow[];
+ errors: Array<{ project_alias: string; error_code: string; message: string }>;
+}
+
function greeting(): string {
const h = new Date().getHours();
if (h < 12) return "Good Morning";
@@ -65,6 +87,15 @@ export function DashboardPage() {
api.get("/jobs", { query: { project: project ?? undefined, limit: 5 } }),
enabled: !!project,
});
+ // Scoped to the active project so the tile answers "how much is left HERE",
+ // and so a 30-project config does not fan out 30 billing calls on every
+ // dashboard visit.
+ const creditsQ = useQuery({
+ queryKey: ["billing-credits", project],
+ queryFn: () => api.get("/billing/credits", { query: { project: project ?? undefined } }),
+ enabled: !!project,
+ staleTime: 5 * 60_000,
+ });
/**
* Hand the typed message off to the Local AI page (#300). Dashboard
@@ -139,7 +170,7 @@ export function DashboardPage() {
{/* Stats row */}
-
+
}
onClick={() => setPage("jobs")}
/>
+
{/* Two-column: agent activity + suggested actions */}
@@ -300,6 +332,59 @@ export function DashboardPage() {
);
}
+/**
+ * Fifth stat tile: PAYG credit balance for the active project.
+ *
+ * Degrades quietly by design. Most stacks are not pay-as-you-go, so the
+ * common outcome is a per-project `PAYG_NOT_AVAILABLE` -- that renders as a
+ * muted "n/a" pill, never as a red error, because there is nothing for the
+ * user to fix. Any other per-project error gets the same muted treatment with
+ * the server message in the tooltip: a billing hiccup must not make the whole
+ * dashboard look broken.
+ */
+function CreditsTile({
+ project,
+ q,
+}: {
+ project: string | null;
+ q: UseQueryResult
;
+}) {
+ const row = q.data?.credits?.[0];
+ const err = q.data?.errors?.[0];
+ const unavailable = !row && !!err;
+
+ let value: React.ReactNode;
+ let subtle: string | undefined;
+ if (!project) {
+ value = n/a ;
+ subtle = "(no project)";
+ } else if (q.isLoading) {
+ value = … ;
+ } else if (row) {
+ value = row.remaining.toLocaleString(undefined, { maximumFractionDigits: 2 });
+ // The Keboola UI shows the same balance in minutes (1 credit = 60 min);
+ // the API's native unit is credits, so we surface both.
+ subtle = `${Math.round(row.remaining_minutes).toLocaleString()} min remaining`;
+ } else if (unavailable) {
+ value = n/a ;
+ subtle = err?.error_code === "PAYG_NOT_AVAILABLE" ? "not a PAYG project" : "unavailable";
+ } else {
+ value = n/a ;
+ subtle = "no balance reported";
+ }
+
+ return (
+ }
+ tone={row && row.remaining <= 0 ? "amber" : "default"}
+ title={err?.message ?? (row ? `${row.consumed} of ${row.total} credits consumed` : undefined)}
+ />
+ );
+}
+
function StatTile({
label,
value,
@@ -307,10 +392,12 @@ function StatTile({
icon,
tone = "default",
onClick,
+ title,
}: {
label: string;
- value: string;
+ value: React.ReactNode;
subtle?: string;
+ title?: string;
icon?: React.ReactNode;
tone?: "default" | "red" | "amber" | "green";
onClick?: () => void;
@@ -327,7 +414,12 @@ function StatTile({
{label}
diff --git a/web/frontend/src/pages/Flows.tsx b/web/frontend/src/pages/Flows.tsx
index 1f2a375e..44e14c12 100644
--- a/web/frontend/src/pages/Flows.tsx
+++ b/web/frontend/src/pages/Flows.tsx
@@ -32,6 +32,34 @@ interface FlowsResp {
errors: ProjectError[];
}
+interface NotificationSubscription {
+ project_alias: string;
+ subscription_id: string;
+ /** kebab-case event name, e.g. "job-failed". */
+ event: string;
+ /** "" when the subscription carries no component filter. */
+ component_id: string;
+ /** "" when the subscription is project-wide (no config filter). */
+ config_id: string;
+ branch_id: string;
+ phase_id: string;
+ /** "email" | "webhook" | ... */
+ channel: string;
+ /** Email address OR webhook URL, depending on `channel`. */
+ address: string;
+ expires_at: string;
+ config_name: string;
+ filters: Array>;
+ /** "config" | "project-wide" */
+ scope: string;
+}
+
+interface NotificationsResp {
+ subscriptions: NotificationSubscription[];
+ errors: ProjectError[];
+ project_wide_excluded: number;
+}
+
export function FlowsPage() {
const { project, branchId } = useUIState();
const [selected, setSelected] = useState(null);
@@ -87,7 +115,7 @@ export function FlowsPage() {
function FlowDrawer({ flow, onClose }: { flow: Flow; onClose: () => void }) {
const { branchId } = useUIState();
- const [tab, setTab] = useState<"builder" | "raw">("builder");
+ const [tab, setTab] = useState<"builder" | "raw" | "notifications">("builder");
const q = useQuery({
queryKey: ["flow-detail", flow.project_alias, flow.component_id, flow.config_id, branchId],
queryFn: () =>
@@ -119,11 +147,26 @@ function FlowDrawer({ flow, onClose }: { flow: Flow; onClose: () => void }) {
>
Raw JSON
+ setTab("notifications")}
+ >
+ Notifications
+
- {q.isLoading ? : null}
- {q.error ? : null}
- {q.data && tab === "builder" ? : null}
- {q.data && tab === "raw" ? : null}
+ {/* The Notifications tab owns its own query (a different platform
+ service), so it must not be gated on the flow-detail request. */}
+ {tab === "notifications" ? (
+
+ ) : (
+ <>
+ {q.isLoading ? : null}
+ {q.error ? : null}
+ {q.data && tab === "builder" ? : null}
+ {q.data && tab === "raw" ? : null}
+ >
+ )}
);
}
@@ -201,6 +244,131 @@ function FlowBuilder({ detail }: { detail: FlowDetail }) {
);
}
+/** Pill styling per event name. Exact matches win before the substring rules
+ * so "job-succeeded-with-warning" reads amber, not green. */
+function eventPillClass(event: string): string {
+ if (event === "job-failed") return "nerd-pill-red";
+ if (event === "job-succeeded") return "nerd-pill-green";
+ if (event.includes("warning") || event.includes("long")) return "nerd-pill-amber";
+ return "nerd-pill";
+}
+
+const BRANCH_NOTE =
+ "Branch: the UI always writes a branch.id filter, and for production that value is the " +
+ "DEFAULT branch's numeric id — so a value here does NOT by itself mean the subscription is " +
+ "dev-branch-only. Compare it against the project's branch list.";
+
+function NotificationTable({ rows }: { rows: NotificationSubscription[] }) {
+ return (
+ s.subscription_id}
+ columns={[
+ {
+ header: "Event",
+ cell: (s) => {s.event} ,
+ },
+ { header: "Channel", cell: (s) => {s.channel || "—"} },
+ {
+ header: "Address",
+ cell: (s) => (
+ {s.address || "—"}
+ ),
+ },
+ {
+ header: "Branch",
+ cell: (s) => (
+
+ {s.branch_id || "—"}
+
+ ),
+ },
+ ]}
+ />
+ );
+}
+
+/**
+ * Read-only "Notifications" tab — who actually gets paged about this flow.
+ *
+ * These recipients are the ones behind the Flow Builder's Notifications tab
+ * (bell icon). They live in a SEPARATE platform service
+ * (notification.{stack}) and are NOT part of the flow's configuration JSON,
+ * which is why `flow detail` — and therefore the Builder and Raw JSON tabs —
+ * never showed them. (The in-flow `type: "notification"` TASK is a different
+ * mechanism and stays visible in the Builder.)
+ *
+ * We deliberately fetch the project's subscriptions UNFILTERED (only
+ * `project`) and split them client-side. Passing `config_id` to the API drops
+ * the filter-less, project-wide catch-alls SERVER-SIDE — and those fire for
+ * every job in the project, this flow included — so a filtered fetch would
+ * silently under-report the recipient list.
+ */
+function FlowNotifications({ flow }: { flow: Flow }) {
+ // Keyed by project only: the response is the project's full, unfiltered
+ // subscription list, so every flow in the project shares one cache entry.
+ const q = useQuery({
+ queryKey: ["flow-notifications", flow.project_alias],
+ queryFn: () => api.get("/notifications", { query: { project: [flow.project_alias] } }),
+ });
+
+ if (q.isLoading) return ;
+ if (q.error) return ;
+
+ const subs = q.data?.subscriptions ?? [];
+ const errors = q.data?.errors ?? [];
+ // Mutually exclusive by construction: a subscription either carries a
+ // config filter (scoped) or carries none at all (project-wide catch-all).
+ const forThisFlow = subs.filter((s) => s.config_id && s.config_id === flow.config_id);
+ const projectWide = subs.filter((s) => !s.config_id);
+
+ return (
+
+ {errors.map((e) => (
+
+ ))}
+
+
+
+
This flow
+
+ {forThisFlow.length} recipient(s)
+
+
+ {forThisFlow.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
Project-wide
+ project-wide
+ {projectWide.length} recipient(s)
+
+
+ No config filter — these fire for every job in the project, this flow included.
+
+ {projectWide.length === 0 ? (
+
+ ) : (
+
+ )}
+
+
+
{BRANCH_NOTE}
+
+ );
+}
+
function FlowMermaid({
phases,
tasksByPhase,
diff --git a/web/frontend/src/pages/Jobs.tsx b/web/frontend/src/pages/Jobs.tsx
index 14420c36..beb6036c 100644
--- a/web/frontend/src/pages/Jobs.tsx
+++ b/web/frontend/src/pages/Jobs.tsx
@@ -1,7 +1,20 @@
-import { useQuery } from "@tanstack/react-query";
-import { Activity, Clock, Cpu, FileCode, Play, Server, Square, Timer, User } from "lucide-react";
+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 { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty";
import { JsonView } from "../components/JsonView";
@@ -23,6 +36,36 @@ const STATUS_COLORS: Record = {
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.
+ */
+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.
+ */
+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.
+ */
+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 JobsPage() {
const { project } = useUIState();
const [statusFilter, setStatusFilter] = useState(null);
@@ -82,7 +125,10 @@ export function JobsPage() {
),
},
{ header: "Component", cell: (j) => {j.component} },
- { header: "Config", cell: (j) => {j.configId} },
+ {
+ header: "Config",
+ cell: (j) => {j.config ?? "—"} ,
+ },
{
header: "Created",
cell: (j) => {j.createdTime} ,
@@ -96,6 +142,11 @@ export function JobsPage() {
),
},
+ {
+ header: "Actions",
+ align: "right",
+ cell: (j) => ,
+ },
]}
/>
)}
@@ -105,6 +156,130 @@ 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 ? (
+ rerun.mutate()}
+ // Name the branch in the tooltip: the whole point of threading
+ // branch_id through is that the user can trust where this lands.
+ title={`Start a new job for ${job.component} / ${job.config} on ${
+ jobBranchId(job) === undefined ? "the default branch" : `branch #${jobBranchId(job)}`
+ }`}
+ >
+
+ {rerun.isPending ? "starting…" : "re-run"}
+
+ ) : null}
+ {canTerminate ? (
+ setConfirm("terminate")}
+ title="Terminate this job"
+ >
+ terminate
+
+ ) : 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);
@@ -168,24 +343,33 @@ function JobDetailDrawer({ job, onClose }: { job: Job; onClose: () => void }) {
open={true}
onClose={onClose}
title={`Job ${job.id}`}
- subtitle={`${job.component} ・ config ${job.configId}`}
+ subtitle={jobLabel(job)}
width="max-w-5xl"
actions={
- (streaming ? esRef.current?.close() : startStream())}
- >
- {streaming ? (
- <>
- stop stream
- >
- ) : (
- <>
- stream logs
- >
- )}
-
+ <>
+ {/* 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. */}
+
+ (streaming ? esRef.current?.close() : startStream())}
+ >
+ {streaming ? (
+ <>
+ stop stream
+ >
+ ) : (
+ <>
+ stream logs
+ >
+ )}
+
+ >
}
>
{detailQ.isLoading ? : null}
@@ -229,7 +413,9 @@ function JobCards({
(detail.token as { description?: string } | undefined)?.description ??
"";
const url = (detail.url as string | undefined) ?? "";
- const config = (detail.config as string | undefined) ?? job.configId;
+ // 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(
diff --git a/web/frontend/src/pages/Storage.tsx b/web/frontend/src/pages/Storage.tsx
index 81007411..c81a432b 100644
--- a/web/frontend/src/pages/Storage.tsx
+++ b/web/frontend/src/pages/Storage.tsx
@@ -1,5 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { AlertTriangle, Check, Download, Eye, Info, Layers, Loader2, Trash2 } from "lucide-react";
+import {
+ AlertTriangle,
+ Check,
+ Download,
+ Eye,
+ Info,
+ Layers,
+ Loader2,
+ Pencil,
+ Trash2,
+ X,
+} from "lucide-react";
import { useState } from "react";
import { api, ApiError } from "../api/client";
import { Drawer } from "../components/Drawer";
@@ -40,6 +51,24 @@ interface TableDetail {
last_change_date: string;
created: string;
metadata: Array>;
+ /**
+ * Raw Storage API `definition`, passed through verbatim (issue #621). On
+ * BigQuery it is the ONLY readable record of the registered partition /
+ * clustering layout. Present on EVERY response -- an untyped table gets one
+ * too -- so `null` means the stack omitted the key, never "untyped".
+ */
+ definition?: {
+ timePartitioning?: { type?: string; field?: string; expirationMs?: string | number } | null;
+ rangePartitioning?: {
+ field?: string;
+ range?: { start?: string | number; end?: string | number; interval?: string | number };
+ } | null;
+ clustering?: { fields?: string[] } | null;
+ requirePartitionFilter?: boolean | null;
+ partitions?: Array> | null;
+ [key: string]: unknown;
+ } | null;
+ legacy_column_descriptions?: string[];
}
interface BucketsResp {
@@ -774,6 +803,73 @@ function InfoTab({ d }: { d: TableDetail }) {
+
+
+ );
+}
+
+// Render the raw Storage API `definition` (issue #621). On BigQuery this object
+// is the only readable record of the registered partition/clustering layout, so
+// it is how a create-table + swap-tables repartition is VERIFIED -- the table id
+// is unchanged either way. Every sub-key is optional, and a table with no layout
+// at all must render NOTHING (not an empty heading), so each row is guarded and
+// the section is dropped when none of them produced anything.
+function TableLayout({ definition }: { definition: TableDetail["definition"] }) {
+ if (!definition) return null;
+
+ const rows: Array<{ label: string; value: string; mono?: boolean }> = [];
+
+ const tp = definition.timePartitioning;
+ if (tp) {
+ const type = tp.type ?? "?";
+ let value = tp.field ? `${type} on ${tp.field}` : `${type} (ingestion time)`;
+ if (tp.expirationMs !== undefined && tp.expirationMs !== null && tp.expirationMs !== "") {
+ value += ` ・ expires ${tp.expirationMs} ms`;
+ }
+ rows.push({ label: "Time partitioning", value, mono: true });
+ }
+
+ const rp = definition.rangePartitioning;
+ if (rp) {
+ const range = rp.range ?? {};
+ const bounds = `[${range.start ?? "?"}, ${range.end ?? "?"})`;
+ rows.push({
+ label: "Range partitioning",
+ value: `${rp.field ?? "?"} ${bounds} step ${range.interval ?? "?"}`,
+ mono: true,
+ });
+ }
+
+ const clusteringFields = definition.clustering?.fields;
+ if (clusteringFields && clusteringFields.length > 0) {
+ rows.push({ label: "Clustering", value: clusteringFields.join(", "), mono: true });
+ }
+
+ if (typeof definition.requirePartitionFilter === "boolean") {
+ rows.push({
+ label: "Partition filter required",
+ value: definition.requirePartitionFilter ? "yes" : "no",
+ });
+ }
+
+ // The COUNT only: `partitions` is unbounded (one entry per physical partition
+ // from INFORMATION_SCHEMA.PARTITIONS) and must never be dumped into the grid.
+ // Non-empty only, matching the CLI's `render_table_layout`: an empty list is
+ // "no physical partitions reported", not a meaningful count of zero.
+ if (definition.partitions && definition.partitions.length > 0) {
+ rows.push({ label: "Partitions", value: definition.partitions.length.toLocaleString() });
+ }
+
+ if (rows.length === 0) return null;
+
+ return (
+
+
Table layout
+
+ {rows.map((r) => (
+
+ ))}
+
);
}
@@ -787,41 +883,181 @@ function Field({ label, value, mono = false }: { label: string; value: string; m
);
}
+interface DescribeVars {
+ column: string;
+ description: string;
+ /** Override in effect before the optimistic write, for rollback on error. */
+ previous?: string;
+}
+
function SchemaTab({ d }: { d: TableDetail }) {
+ const { project, branchId } = useUIState();
+ const qc = useQueryClient();
+
+ // Only one column is editable at a time; `overrides` is the optimistic layer
+ // merged over the server's `c.description` until the refetch lands.
+ const [editing, setEditing] = useState(null);
+ const [draft, setDraft] = useState("");
+ const [overrides, setOverrides] = useState>({});
+ const [error, setError] = useState(null);
+
+ // Before 0.88.0 kbagent wrote a flat `KBC.column.{name}.description` key on the
+ // TABLE's metadata -- read by nothing but kbagent, so a documented column still
+ // looked blank in the UI, the MCP server and the warehouse. This route is the
+ // NATIVE write (PUT .../tables/{id}/definition, isDescriptionSystemManaged:
+ // false), which the backend mirrors into columnMetadata KBC.description -- so
+ // everything downstream sees it, and the next Output Mapping run does not
+ // overwrite it.
+ const describe = useMutation({
+ mutationFn: (vars: DescribeVars) =>
+ api.post(
+ `/storage/columns/${encodeURIComponent(project!)}/${encodeURIComponent(d.table_id)}/describe`,
+ { columns: { [vars.column]: vars.description }, branch_id: branchId ?? undefined },
+ ),
+ onError: (e, vars) => {
+ setError(e instanceof ApiError ? e.message : String(e));
+ setOverrides((cur) => {
+ const next = { ...cur };
+ if (vars.previous === undefined) delete next[vars.column];
+ else next[vars.column] = vars.previous;
+ return next;
+ });
+ },
+ onSuccess: async (_data, vars) => {
+ // Drop the optimistic entry only AFTER the refetch lands, otherwise the
+ // cell flashes the stale server description for a render. Leaving it in
+ // place is worse still: it would mask every later server value for that
+ // column for as long as the drawer stays mounted.
+ await qc.invalidateQueries({ queryKey: ["table-detail"] });
+ setOverrides((cur) => {
+ const next = { ...cur };
+ delete next[vars.column];
+ return next;
+ });
+ },
+ });
+
+ const save = (column: string) => {
+ const previous = overrides[column];
+ setOverrides((cur) => ({ ...cur, [column]: draft }));
+ setEditing(null);
+ setError(null);
+ describe.mutate({ column, description: draft, previous });
+ };
+
+ const legacyCount = d.legacy_column_descriptions?.length ?? 0;
+
return (
-
-
-
-
- Column
- Type
- Native
- Length
- Null
- PK
- Default
- Description
-
-
-
- {d.column_details.map((c) => (
-
- {c.name}
- {c.type ?? "-"}
- {c.native_type ?? "-"}
- {c.length ?? "-"}
-
- {c.nullable === undefined ? "-" : c.nullable ? "✓" : ""}
-
-
- {d.primary_key.includes(c.name) ? "🔑" : ""}
-
- {c.default ?? "-"}
- {c.description ?? ""}
+
+ {legacyCount > 0 ? (
+
+
+
+ {legacyCount} column(s) still carry a legacy description key — run{" "}
+
+ kbagent storage describe-migrate
+
+ .
+
+
+ ) : null}
+ {error ?
: null}
+
+
+
+
+ Column
+ Type
+ Native
+ Length
+ Null
+ PK
+ Default
+ Description
- ))}
-
-
+
+
+ {d.column_details.map((c) => {
+ const current = overrides[c.name] ?? c.description ?? "";
+ return (
+
+ {c.name}
+ {c.type ?? "-"}
+ {c.native_type ?? "-"}
+ {c.length ?? "-"}
+
+ {c.nullable === undefined ? "-" : c.nullable ? "✓" : ""}
+
+
+ {d.primary_key.includes(c.name) ? "🔑" : ""}
+
+ {c.default ?? "-"}
+
+ {editing === c.name ? (
+
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ save(c.name);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ setEditing(null);
+ }
+ }}
+ />
+ save(c.name)}
+ >
+
+
+ setEditing(null)}
+ >
+
+
+
+ ) : (
+ {
+ setEditing(c.name);
+ setDraft(current);
+ }}
+ >
+
+ {current || "—"}
+
+ {project ? (
+
+ ) : null}
+
+ )}
+
+
+ );
+ })}
+
+
+
);
}
diff --git a/web/frontend/src/pages/Tokens.tsx b/web/frontend/src/pages/Tokens.tsx
new file mode 100644
index 00000000..2449e560
--- /dev/null
+++ b/web/frontend/src/pages/Tokens.tsx
@@ -0,0 +1,599 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Check, Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { api, ApiError } 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";
+
+/**
+ * Scoped Storage tokens -- the UI half of `kbagent token list|create|delete|refresh`.
+ *
+ * Two things about this page are not obvious from the API shape:
+ *
+ * 1. **`lastUsed` is DERIVED, not read.** The Storage API's token listing
+ * carries no `lastUsed` field at all (only the Manage API's PAT response
+ * does). The backend synthesizes it per token from that token's OWN event
+ * feed -- `GET /v2/storage/tokens/{id}/events?q=token.id:{id}`, narrowed
+ * SERVER-SIDE to events the token PERFORMED, not events performed ON it
+ * (the raw feed also carries `storage.tokenCreated`, which would make a
+ * freshly minted, never-used token read as "used today"). That is one extra
+ * API call PER TOKEN, which is why it is opt-in behind the toggle rather
+ * than part of the default listing.
+ *
+ * 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.
+ */
+
+interface TokenEntry {
+ id: string | number;
+ description?: string;
+ created?: string;
+ refreshed?: string;
+ expires?: string | null;
+ isMasterToken?: boolean;
+ canManageTokens?: boolean;
+ canReadAllFileUploads?: boolean;
+ bucketPermissions?: Record