diff --git a/docs/web-server.md b/docs/web-server.md index f8f983ad..1d3a6ae5 100644 --- a/docs/web-server.md +++ b/docs/web-server.md @@ -120,18 +120,40 @@ and `next_run_at` so re-runs after restarts pick up where they left off. A NERD-themed React SPA that drives the API: +- **Command palette** — `Ctrl+K` / `Cmd+K` anywhere: fuzzy jump to any + page, switch the active project, toggle the theme, open Swagger `/docs`. + Arrows + enter, esc closes. - **Dashboard** — greeting, big Kai chat input, stat tiles (projects / - agents / doctor / recent jobs), scheduled-agent activity, suggested - next steps, recent jobs panel. + agents / doctor / recent jobs / PAYG credits), scheduled-agent + activity, suggested next steps, recent jobs panel. The credits tile + reads `GET /billing/credits` for the active project and shows a muted + `n/a` on a non-PAYG project (`PAYG_NOT_AVAILABLE`). - **Projects, Branches, Doctor, Changelog** — manage local config and health. +- **Tokens** — scoped Storage tokens for the selected project + (`/token/{p}/list`): create / rotate / revoke, with the secret revealed + ONCE in a copy-to-clipboard block, and an opt-in "derive last-used" + toggle (`with_last_used=true`) that sorts dormant tokens first and + renders `never` / `unknown` / `error` as distinct pills. - **Configs, Components (AI search), Storage (with per-column data preview), Jobs (cards layout + SSE log stream), Search** — browse a selected project. + - Configs: detail Drawer with **Run job** (`POST /jobs/{p}/run`) and + **Delete** (soft-delete via `DELETE /configs/…`), plus a **Trash** + tab (`GET /configs/trash/{p}`) with per-row Restore. + - Storage: the table drawer's Info tab renders the raw `definition` + (time / range partitioning, clustering, partition filter, partition + count) when the stack reports one, and the Schema tab's Description + cell is click-to-edit through `POST /storage/columns/{p}/{id}/describe`. + - Jobs: per-row **re-run** and **terminate** (`POST /jobs/{p}/run` / + `…/terminate`), the latter behind a confirm modal. - **SQL Workspaces** — info Drawer with credentials + actions sidebar; Open SQL Editor opens a Monaco editor with a clickable Storage Explorer tree on the left. -- **Flows** — visual Mermaid builder of phase DAG + per-phase task list. +- **Flows** — visual Mermaid builder of phase DAG + per-phase task list, + plus a read-only **Notifications** tab (`GET /notifications`) listing + who gets paged for the flow, with filter-less project-wide + subscriptions shown in their own warning-pilled group. - **Schedules** — cross-project cron list + find-by-window query. - **Data Apps** — list, start/stop, secrets, validate-repo. - **Lineage** — Sharing graph (live, cross-project) + Deep lineage (UI diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 6c364ccf..343288c8 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { SearchPage } from "./pages/Search"; import { SharingPage } from "./pages/Sharing"; import { StoragePage } from "./pages/Storage"; import { StreamsPage } from "./pages/Streams"; +import { TokensPage } from "./pages/Tokens"; import { WorkspacesPage } from "./pages/Workspaces"; import { UIStateProvider, useUIState } from "./state"; import { ThemeProvider } from "./theme"; @@ -70,6 +71,8 @@ function Router() { return ; case "members": return ; + case "tokens": + return ; case "doctor": return ; case "changelog": diff --git a/web/frontend/src/components/CommandPalette.tsx b/web/frontend/src/components/CommandPalette.tsx new file mode 100644 index 00000000..6bad0ea8 --- /dev/null +++ b/web/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,326 @@ +/** + * Ctrl+K / Cmd+K command palette. + * + * One keystroke to reach anything the shell can already do: jump to a page, + * switch the active project, or fire a small action (theme, Swagger docs). + * Deliberately NOT a search over Keboola data -- that is the Search page's + * job, and mixing "navigate the app" with "query the project" makes both + * slower. Everything here resolves locally, so the list never waits on a + * network round-trip. + * + * The page list comes from the sidebar's exported SECTIONS, so a page can + * never be reachable from one surface and invisible in the other. + */ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, Boxes, CornerDownLeft, Search, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { api } from "../api/client"; +import { SECTIONS } from "../layout/Sidebar"; +import { type PageId, useUIState } from "../state"; +import { useTheme } from "../theme"; +import type { Project } from "../types"; + +type CommandKind = "page" | "project" | "action"; + +interface Command { + id: string; + kind: CommandKind; + /** Matched + rendered label. */ + label: string; + /** Muted right-hand context (section name, project name, ...). */ + hint?: string; + /** Extra text folded into the match haystack but not rendered. */ + keywords?: string; + icon: React.ComponentType<{ className?: string }>; + run: () => void; +} + +/** + * Subsequence ("fuzzy") match, case-insensitive. Returns the matched indices + * so the caller can highlight them, or null when the query does not match. + * + * Scoring favours matches that start earlier and stay contiguous, which is + * what makes "sto" rank Storage above "Semantic Layer" even though both + * contain the letters. + */ +function fuzzyMatch(query: string, text: string): { score: number; indices: number[] } | null { + if (!query) return { score: 0, indices: [] }; + const q = query.toLowerCase(); + const t = text.toLowerCase(); + const indices: number[] = []; + let ti = 0; + let score = 0; + let lastHit = -2; + for (let qi = 0; qi < q.length; qi++) { + const ch = q[qi]; + const found = t.indexOf(ch, ti); + if (found === -1) return null; + // Contiguous runs and hits at the very start of the string are cheaper. + score += found - ti; + if (found !== lastHit + 1) score += 3; + if (found === 0) score -= 2; + indices.push(found); + lastHit = found; + ti = found + 1; + } + return { score, indices }; +} + +/** Render `text` with the fuzzy-matched characters tinted cyan. */ +function Highlight({ text, indices }: { text: string; indices: number[] }) { + if (indices.length === 0) return <>{text}; + const hit = new Set(indices); + return ( + <> + {text.split("").map((ch, i) => + hit.has(i) ? ( + + {ch} + + ) : ( + {ch} + ), + )} + + ); +} + +const KIND_LABEL: Record = { + page: "page", + project: "project", + action: "action", +}; + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [cursor, setCursor] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + const { project, setProject, setBranchId, setPage } = useUIState(); + const { theme, toggle } = useTheme(); + + // Projects are already cached by the top bar under this exact key, so + // opening the palette normally costs no request. + const projectsQ = useQuery<{ projects: Project[] }>({ + queryKey: ["projects"], + queryFn: () => api.get("/projects"), + enabled: open, + }); + + const close = useCallback(() => { + setOpen(false); + setQuery(""); + setCursor(0); + }, []); + + // Global hotkey. Registered once on the shell so it works from any page. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((o) => !o); + setQuery(""); + setCursor(0); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + useEffect(() => { + if (!open) return; + const t = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(t); + }, [open]); + + const commands: Command[] = useMemo(() => { + const out: Command[] = []; + for (const section of SECTIONS) { + for (const item of section.items) { + out.push({ + id: `page:${item.id}`, + kind: "page", + label: item.label, + hint: section.title, + 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}`, + kind: "project", + label: p.alias, + hint: p.project_name || p.org_name || "switch project", + keywords: `${p.project_name ?? ""} ${p.org_name ?? ""} switch`, + icon: Boxes, + run: () => { + setProject(p.alias); + // A branch id is only meaningful inside its own project. + setBranchId(null); + }, + }); + } + out.push({ + id: "action:theme", + kind: "action", + label: `Toggle theme (now ${theme})`, + keywords: "dark light colour color scheme", + icon: Sparkles, + run: toggle, + }); + out.push({ + id: "action:docs", + kind: "action", + label: "Open Swagger /docs", + hint: "new tab", + keywords: "openapi api schema swagger", + icon: ArrowRight, + run: () => window.open("/docs", "_blank", "noopener,noreferrer"), + }); + return out; + }, [projectsQ.data, setPage, setProject, setBranchId, theme, toggle]); + + const results = useMemo(() => { + const q = query.trim(); + const scored: Array<{ cmd: Command; indices: number[]; score: number }> = []; + for (const cmd of commands) { + const onLabel = fuzzyMatch(q, cmd.label); + if (onLabel) { + scored.push({ cmd, indices: onLabel.indices, score: onLabel.score }); + continue; + } + // Fall back to the invisible haystack (project name, page id, synonyms) + // so "colour" finds the theme toggle -- but rank those below label hits. + const haystack = `${cmd.label} ${cmd.hint ?? ""} ${cmd.keywords ?? ""}`; + const onHaystack = fuzzyMatch(q, haystack); + if (onHaystack) scored.push({ cmd, indices: [], score: onHaystack.score + 50 }); + } + if (!q) return scored.slice(0, 50); + return scored.sort((a, b) => a.score - b.score).slice(0, 50); + }, [commands, query]); + + // Keep the cursor inside the (shrinking) result list as the user types. + useEffect(() => { + setCursor((c) => (c >= results.length ? 0 : c)); + }, [results.length]); + + useEffect(() => { + if (!open) return; + listRef.current + ?.querySelector(`[data-idx="${cursor}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [cursor, open]); + + if (!open) return null; + + const runAt = (idx: number) => { + const hit = results[idx]; + if (!hit) return; + hit.cmd.run(); + close(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setCursor((c) => (results.length === 0 ? 0 : (c + 1) % results.length)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setCursor((c) => (results.length === 0 ? 0 : (c - 1 + results.length) % results.length)); + } else if (e.key === "Enter") { + e.preventDefault(); + runAt(cursor); + } + }; + + return createPortal( +
+
e.stopPropagation()} + > +
+ + setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="jump to a page, switch project, run an action…" + className="flex-1 bg-transparent text-sm focus:outline-none placeholder-zinc-400 dark:placeholder-zinc-600" + aria-label="Command palette" + /> + esc +
+ +
+ {results.length === 0 ? ( +
+ Nothing matches “{query}”. +
+ ) : ( + results.map(({ cmd, indices }, i) => { + const Icon = cmd.icon; + const active = i === cursor; + return ( + + ); + }) + )} +
+ +
+ + run + + ↑↓ move + esc close + {results.length} result(s) +
+
+
, + document.body, + ); +} diff --git a/web/frontend/src/components/ConfirmModal.tsx b/web/frontend/src/components/ConfirmModal.tsx index a3574366..002bf9ca 100644 --- a/web/frontend/src/components/ConfirmModal.tsx +++ b/web/frontend/src/components/ConfirmModal.tsx @@ -1,6 +1,7 @@ import { AlertTriangle, X } from "lucide-react"; import { useEffect, useRef } from "react"; import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; /** * Lightweight confirmation dialog matching the app's modal style (see @@ -8,6 +9,14 @@ import type { ReactNode } from "react"; * deserve a clearer, on-brand prompt. Esc or a backdrop click cancels. On open * we focus Cancel for ``danger`` modals (so a stray Enter does NOT fire the * destructive action) and the confirm button otherwise. + * + * Rendered through a portal into ``document.body``, for the same reason + * ``Drawer`` is: most confirms are raised from INSIDE a drawer, whose + * ``backdrop-blur`` establishes a containing block for fixed-position + * descendants and whose body scrolls under ``overflow-auto``. Portaling makes + * the confirm's placement and stacking independent of wherever it was + * declared, and puts it after the drawer's own portal node so it always + * paints on top. Callers just render ```` inline. */ export function ConfirmModal({ title, @@ -45,7 +54,7 @@ export function ConfirmModal({ return () => window.removeEventListener("keydown", onKey); }, [danger, busy, onCancel]); - return ( + return createPortal(
{ @@ -109,6 +118,7 @@ export function ConfirmModal({
- + , + document.body, ); } diff --git a/web/frontend/src/layout/Shell.tsx b/web/frontend/src/layout/Shell.tsx index 1ef7b743..06db03d2 100644 --- a/web/frontend/src/layout/Shell.tsx +++ b/web/frontend/src/layout/Shell.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { CommandPalette } from "../components/CommandPalette"; import { Sidebar } from "./Sidebar"; import { StatusBar } from "./StatusBar"; import { TopBar } from "./TopBar"; @@ -14,6 +15,9 @@ export function Shell({ children }: { children: ReactNode }) { + {/* Mounted at the shell so Ctrl/Cmd+K works from every page. Renders + null until opened, so it costs nothing while closed. */} + ); } diff --git a/web/frontend/src/layout/Sidebar.tsx b/web/frontend/src/layout/Sidebar.tsx index b44bbacb..367d4c19 100644 --- a/web/frontend/src/layout/Sidebar.tsx +++ b/web/frontend/src/layout/Sidebar.tsx @@ -9,6 +9,7 @@ import { Database, GitBranch, Heart, + KeyRound, Layers, LayoutDashboard, Lock, @@ -24,10 +25,23 @@ import { import { clsx } from "clsx"; import { type PageId, useUIState } from "../state"; -const SECTIONS: Array<{ +export interface NavItem { + id: PageId; + label: string; + icon: React.ComponentType<{ className?: string }>; +} + +export interface NavSection { title: string; - items: Array<{ id: PageId; label: string; icon: React.ComponentType<{ className?: string }> }>; -}> = [ + items: NavItem[]; +} + +/** + * Single source of truth for the app's navigable pages. The sidebar renders + * it grouped; the command palette (Ctrl/Cmd+K) flattens it into jump targets. + * Exported so a new page can never appear in one surface and not the other. + */ +export const SECTIONS: NavSection[] = [ { title: "Home", items: [{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard }], @@ -37,6 +51,7 @@ const SECTIONS: Array<{ items: [ { id: "projects", label: "Projects", icon: Boxes }, { id: "branches", label: "Branches", icon: GitBranch }, + { id: "tokens", label: "Tokens", icon: KeyRound }, { id: "doctor", label: "Doctor", icon: Heart }, { id: "changelog", label: "Changelog", icon: Activity }, ], diff --git a/web/frontend/src/layout/StatusBar.tsx b/web/frontend/src/layout/StatusBar.tsx index f014919c..b2a70ae6 100644 --- a/web/frontend/src/layout/StatusBar.tsx +++ b/web/frontend/src/layout/StatusBar.tsx @@ -20,6 +20,9 @@ export function StatusBar() { ⬆ {v.latest_version} available ) : null} + + ctrl+k — command palette + localhost only ・ bearer auth ・ kernel: python ・ ui: typescript ); diff --git a/web/frontend/src/pages/Agents.tsx b/web/frontend/src/pages/Agents.tsx index eea6a6b9..52b02183 100644 --- a/web/frontend/src/pages/Agents.tsx +++ b/web/frontend/src/pages/Agents.tsx @@ -19,6 +19,7 @@ import { type AgentEvent, type RunSummary, } from "../components/AgentRunView"; +import { ConfirmModal } from "../components/ConfirmModal"; import { Drawer } from "../components/Drawer"; import { ErrorBox, Loading, PageTitle, TwoPathEmpty } from "../components/Empty"; import { JsonView } from "../components/JsonView"; @@ -525,6 +526,10 @@ function NewTaskDrawer({ const [testRunning, setTestRunning] = useState(false); const testHandleRef = useRef(null); + // Open state of the "discard unsaved changes?" confirmation (rendered at the + // bottom of this component). Replaces the native window.confirm(). + const [confirmDiscard, setConfirmDiscard] = useState(false); + // Dirty-form guard: snapshot the initial state on first render, then compare // on every render. If the user touched anything, Esc / backdrop / X clicks // ask for confirmation before discarding the form (drawer unmount = state loss). @@ -563,7 +568,10 @@ function NewTaskDrawer({ }); const dirty = currentSnapshot !== initialSnapshot; const handleClose = () => { - if (dirty && !window.confirm("Discard unsaved changes?")) return; + if (dirty) { + setConfirmDiscard(true); + return; + } onClose(); }; @@ -690,327 +698,346 @@ function NewTaskDrawer({ }); return ( - 0 || testRun - ? "max-w-6xl" - : "max-w-3xl" - } - actions={ - <> - {testRunning ? ( - - ) : ( + <> + 0 || testRun + ? "max-w-6xl" + : "max-w-3xl" + } + actions={ + <> + {testRunning ? ( + + ) : ( + + )} - )} - - - } - > -
- - - -
-