diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 13998cb..4aa5da2 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -9,6 +9,7 @@ import { LineagePage } from "./pages/LineagePage"; import { LineageEntryPage } from "./pages/LineageEntryPage"; import { DiffPage } from "./pages/DiffPage"; import { CachePage } from "./pages/CachePage"; +import { CustomizationsPage } from "./pages/CustomizationsPage"; import { EmptyStatePage } from "./pages/EmptyStatePage"; import { ProjectsPage } from "./pages/ProjectsPage"; import { api } from "./api"; @@ -55,6 +56,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 9ee30f4..1676c5e 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -7,6 +7,7 @@ import type { LineageLayout, DiffData, ResultCache, + Customizations, Projects, } from "./types"; @@ -132,6 +133,9 @@ export const api = { resultCache: (project: string): Promise => get(`/${project}/api/result_cache`), + customizations: (project: string): Promise => + get(`/${project}/api/customizations`), + deleteResultCache: (project: string, hash: string): Promise<{ ok: boolean; hash: string }> => fetch(`/${project}/api/result_cache/${hash}`, { method: "DELETE" }).then((r) => { if (!r.ok) throw new Error(`delete failed: HTTP ${r.status}`); diff --git a/packages/app/src/components/Header.tsx b/packages/app/src/components/Header.tsx index 746c39e..4d9ccdb 100644 --- a/packages/app/src/components/Header.tsx +++ b/packages/app/src/components/Header.tsx @@ -81,6 +81,7 @@ export function Header() { notebook lineage cache + customizations projects )} diff --git a/packages/app/src/pages/CustomizationsPage.tsx b/packages/app/src/pages/CustomizationsPage.tsx new file mode 100644 index 0000000..1b6534f --- /dev/null +++ b/packages/app/src/pages/CustomizationsPage.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { api } from "../api"; +import { useSSE } from "../SSEContext"; +import type { CustomizationItem, Customizations } from "../types"; + +const SECTIONS: { key: keyof Omit; title: string; blurb: string }[] = [ + { + key: "summary_stats", + title: "Summary stats", + blurb: "Per-column statistics computed by Buckaroo. def compute(col) -> ibis.Expr", + }, + { + key: "display_klasses", + title: "Styling classes", + blurb: "ColAnalysis subclasses that control table rendering and pinned rows.", + }, + { + key: "post_processings", + title: "Post-processing", + blurb: "Expression transformers offered in Buckaroo's post-processing dropdown. def process(expr) -> ibis.Expr | DataFrame", + }, +]; + +function Section({ title, blurb, items }: { title: string; blurb: string; items: CustomizationItem[] }) { + const active = items.filter((i) => !i.disabled).length; + return ( +
+
+ {title} + + {items.length === 0 + ? "none" + : `${active} active${items.length - active ? ` · ${items.length - active} disabled` : ""}`} + +
+ {blurb} + + {items.length === 0 ? ( +
none defined
+ ) : ( + items.map((item) => ( +
+ + {item.name} + {item.disabled && disabled} + + {item.path} + + +
{item.source}
+
+ )) + )} +
+ ); +} + +export function CustomizationsPage() { + const { project } = useParams<{ project: string }>(); + const { version } = useSSE(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + if (!project) return; + // Refetch on `version` bumps but keep the current data on screen — don't + // flip back to the loading state, so an SSE event doesn't flash the page. + api + .customizations(project) + .then((d) => { setData(d); setError(""); setLoading(false); }) + .catch((e) => { setError(String(e)); setLoading(false); }); + }, [project, version]); + + return ( +
+
+
+ Catalog customizations + + Project-wide Buckaroo customizations. They apply to every entry in the catalog and are loaded + into each session. Read-only here — edit via the MCP tools. + +
+ + {loading ? ( +
loading…
+ ) : error && !data ? ( +
{error}
+ ) : ( + SECTIONS.map((s) => ( +
+ )) + )} +
+
+ ); +} diff --git a/packages/app/src/styles.css b/packages/app/src/styles.css index 5702d4a..5c1ae3f 100644 --- a/packages/app/src/styles.css +++ b/packages/app/src/styles.css @@ -514,6 +514,22 @@ table.cache-table .muted { color: var(--muted); } .del-btn:hover { background: #2a1b1b; color: #ffb4b4; border-color: #7a4a4a; } .del-btn:disabled { opacity: 0.5; cursor: default; } +/* catalog customizations page */ +.cust-section { margin-bottom: 22px; } +.cust-section-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 2px; } +.cust-section-head strong { font-size: 14px; } +.cust-section .cache-note { display: block; margin-bottom: 8px; } +.cust-item { border: 1px solid var(--border); border-radius: 5px; margin-bottom: 6px; background: var(--panel, transparent); } +.cust-item > summary { display: flex; align-items: baseline; gap: 10px; padding: 6px 10px; cursor: pointer; list-style: none; } +.cust-item > summary::-webkit-details-marker { display: none; } +.cust-item > summary::before { content: "▸"; color: var(--muted); font-size: 11px; } +.cust-item[open] > summary::before { content: "▾"; } +.cust-item .cust-name { font-weight: 600; font-family: ui-monospace, monospace; } +.cust-item .cust-name.muted { color: var(--muted); font-weight: 400; text-decoration: line-through; } +.cust-item .cust-path { margin-left: auto; font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 50%; } +.cust-item .cust-flag { color: var(--muted); border: 1px solid var(--border); padding: 0 6px; border-radius: 10px; font-size: 11px; } +.cust-item > pre.code { margin: 0; border-top: 1px solid var(--border); border-radius: 0 0 5px 5px; } + /* projects management page */ .projects-page { max-width: 600px; margin: 32px auto; padding: 16px; } .projects-page h2 { margin-top: 0; margin-bottom: 16px; } diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 3be96ba..6834781 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -180,6 +180,20 @@ export interface ResultCache { total_formatted: string; } +export interface CustomizationItem { + name: string; + path: string; + source: string; + disabled: boolean; +} + +export interface Customizations { + project: string; + summary_stats: CustomizationItem[]; + display_klasses: CustomizationItem[]; + post_processings: CustomizationItem[]; +} + export interface Projects { active: string | null; available: string[]; diff --git a/src/tallyman_companion/app.py b/src/tallyman_companion/app.py index 9a7cefb..b8361e4 100644 --- a/src/tallyman_companion/app.py +++ b/src/tallyman_companion/app.py @@ -1007,6 +1007,23 @@ def api_result_cache(project: str): "total_formatted": _fmt_bytes(total), } + @app.get("/{project}/api/customizations") + def api_customizations(project: str): + """Project-wide buckaroo customizations: summary stats, display + klasses and post-processing functions, each with its source. These + are global to the catalog (one set per project), not per-entry.""" + project = _validate_project(project) + from tallyman_core.display_klasses import list_display_klasses # noqa: PLC0415 + from tallyman_core.post_processing import list_post_processings # noqa: PLC0415 + from tallyman_core.summary_stats import list_stats # noqa: PLC0415 + + return { + "project": project, + "summary_stats": list_stats(project), + "display_klasses": list_display_klasses(project), + "post_processings": list_post_processings(project), + } + @app.delete("/{project}/api/result_cache/{content_hash}") async def api_delete_result_cache(project: str, content_hash: str): project = _validate_project(project)