feat: establish unified administration and model context baseline
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
export type AdministrationSurface = "database-management" | "memory-management" | "evidence-management" | "workspace-management" | "pi-management";
|
||||
export type ActiveSurface = "core" | AdministrationSurface;
|
||||
export interface AdministrationRoute { surface: ActiveSurface; workspace?: string }
|
||||
const surfaces: Record<AdministrationSurface, string> = {
|
||||
"database-management": "database", "memory-management": "memory", "evidence-management": "evidence",
|
||||
"workspace-management": "workspace", "pi-management": "pi",
|
||||
};
|
||||
|
||||
function readRoute(): AdministrationRoute {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const entry = Object.entries(surfaces).find(([, name]) => params.get("thoth_route") === `administration/${name}`);
|
||||
return { surface: entry ? entry[0] as AdministrationSurface : "core", workspace: entry ? params.get("thoth_workspace") || undefined : undefined };
|
||||
}
|
||||
|
||||
function hrefFor(route: AdministrationRoute): string {
|
||||
const url = new URL(window.location.href);
|
||||
if (route.surface === "core") url.searchParams.delete("thoth_route");
|
||||
else url.searchParams.set("thoth_route", `administration/${surfaces[route.surface]}`);
|
||||
if (route.surface !== "core" && route.workspace) url.searchParams.set("thoth_workspace", route.workspace);
|
||||
else url.searchParams.delete("thoth_workspace");
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
interface HistoryPosition { index: number; key: string }
|
||||
function position(state: unknown): HistoryPosition | undefined {
|
||||
const value = (state as { thothNavigation?: HistoryPosition } | null)?.thothNavigation;
|
||||
return value && typeof value.index === "number" && typeof value.key === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Own only Thoth's query and history namespace, leaving the host's URL/state intact. */
|
||||
export function useAdministrationNavigation(canLeave: () => boolean, hasUnsavedWork: () => boolean) {
|
||||
const [route, setRoute] = useState<AdministrationRoute>(readRoute);
|
||||
const policy = useRef({ canLeave, hasUnsavedWork });
|
||||
policy.current = { canLeave, hasUnsavedWork };
|
||||
const current = useRef({ route, href: window.location.href, state: window.history.state });
|
||||
const restoring = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const existing = position(window.history.state);
|
||||
const state = { ...window.history.state, thothNavigation: existing ?? { index: 0, key: crypto.randomUUID() } };
|
||||
window.history.replaceState(state, "", window.location.href);
|
||||
current.current = { route: readRoute(), href: window.location.href, state };
|
||||
const onPopState = () => {
|
||||
if (restoring.current) { restoring.current = false; return; }
|
||||
const next = readRoute();
|
||||
const previous = current.current;
|
||||
const changingPage = next.surface !== previous.route.surface || next.workspace !== previous.route.workspace;
|
||||
if (changingPage && !policy.current.canLeave()) {
|
||||
const from = position(previous.state);
|
||||
const to = position(window.history.state);
|
||||
if (from && to && from.key === to.key && from.index !== to.index) {
|
||||
restoring.current = true;
|
||||
window.history.go(from.index - to.index);
|
||||
} else {
|
||||
// Host-created entries lack our position. Restore the visible URL without
|
||||
// inventing a direction or navigating outside the application's document.
|
||||
window.history.replaceState(previous.state, "", previous.href);
|
||||
}
|
||||
return;
|
||||
}
|
||||
current.current = { route: next, href: window.location.href, state: window.history.state };
|
||||
setRoute(next);
|
||||
};
|
||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!policy.current.hasUnsavedWork()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function navigate(next: AdministrationRoute, approved = false) {
|
||||
if (restoring.current) return;
|
||||
const previous = current.current;
|
||||
if (next.surface === previous.route.surface && next.workspace === previous.route.workspace) return;
|
||||
if (!approved && !policy.current.canLeave()) return;
|
||||
const oldPosition = position(window.history.state) ?? { index: 0, key: crypto.randomUUID() };
|
||||
const state = { ...window.history.state, thothNavigation: { ...oldPosition, index: oldPosition.index + 1 } };
|
||||
window.history.pushState(state, "", hrefFor(next));
|
||||
current.current = { route: next, href: window.location.href, state };
|
||||
setRoute(next);
|
||||
}
|
||||
return { route, navigate };
|
||||
}
|
||||
|
||||
const NavigationContext = createContext<((route: AdministrationRoute) => void) | undefined>(undefined);
|
||||
export function AdministrationNavigationProvider({ navigate, children }: { navigate: (route: AdministrationRoute) => void; children: ReactNode }) {
|
||||
return <NavigationContext.Provider value={navigate}>{children}</NavigationContext.Provider>;
|
||||
}
|
||||
|
||||
export function AdministrationLink({ surface, workspace, children, ...props }: {
|
||||
surface: AdministrationSurface; workspace?: string;
|
||||
} & AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const navigate = useContext(NavigationContext);
|
||||
const route = { surface, workspace };
|
||||
return <a {...props} href={hrefFor(route)} onClick={event => {
|
||||
props.onClick?.(event);
|
||||
if (!navigate || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
event.preventDefault(); navigate(route);
|
||||
}}>{children}</a>;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
.thot-app-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: var(--thoth-app-height, 100dvh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thot-administration-page,
|
||||
.thot-work-area-page {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
background: oklch(var(--background));
|
||||
color: oklch(var(--foreground));
|
||||
container: administration / inline-size;
|
||||
}
|
||||
|
||||
.thot-administration-header {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 1.65rem 1.75rem 1.35rem;
|
||||
border-bottom: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--muted) / 0.35);
|
||||
}
|
||||
.thot-administration-header__copy { min-width: 0; flex: 1 1 20rem; }
|
||||
.thot-administration-header h1 {
|
||||
margin: 0.35rem 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 1.875rem;
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.thot-administration-eyebrow {
|
||||
margin: 0;
|
||||
font: 600 0.6875rem/1.4 var(--font-mono);
|
||||
color: oklch(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.065em;
|
||||
}
|
||||
.thot-administration-description {
|
||||
max-width: 68ch;
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: oklch(var(--muted-foreground));
|
||||
}
|
||||
.thot-administration-header__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; min-width: 0; max-width: 100%; }
|
||||
.thot-administration-header__actions > label { min-width: 0; width: 15rem; max-width: 100%; }
|
||||
.thot-administration-page :where(input, select, textarea, fieldset) { min-width: 0; max-width: 100%; }
|
||||
.thot-administration-page :where(pre, code, dd) { overflow-wrap: anywhere; }
|
||||
.thot-administration-page :where(p, li) { overflow-wrap: anywhere; }
|
||||
.thot-administration-toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; padding: 1.25rem 1.75rem; }
|
||||
.thot-administration-toolbar > label { min-width: 0; flex: 1 1 10rem; }
|
||||
.thot-administration-split {
|
||||
display: grid;
|
||||
flex: none;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.4fr);
|
||||
min-height: 18rem;
|
||||
margin: 1.25rem 1.75rem;
|
||||
border: 1px solid oklch(var(--border));
|
||||
background: oklch(var(--card));
|
||||
}
|
||||
.thot-administration-index { min-width: 0; background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); }
|
||||
.thot-administration-detail { min-width: 0; padding: 1.35rem; }
|
||||
.thot-administration-index :where(button[aria-current]) { background: oklch(var(--nav-active)); color: oklch(var(--nav-active-foreground)); }
|
||||
.thot-administration-index button { overflow-wrap: anywhere; }
|
||||
.thot-administration-section-label { padding: 1rem 1.25rem 0; font: 600 0.6875rem/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: 0.06em; color: oklch(var(--muted-foreground)); }
|
||||
.thot-administration-link { color: oklch(var(--primary)); text-decoration: underline; text-underline-offset: 0.2em; font-size: 0.875rem; }
|
||||
.thot-administration-link:focus-visible { outline: 2px solid oklch(var(--ring)); outline-offset: 3px; }
|
||||
.thot-administration-dependency { padding: 1rem 1.25rem; border-block: 1px solid oklch(var(--border)); background: oklch(var(--muted) / 0.35); font-size: 0.875rem; }
|
||||
.thot-administration-dependency p { max-width: 72ch; margin-bottom: 0.5rem; }
|
||||
.thot-administration-mobile-nav { position: absolute; inset: 0 0 auto; z-index: 30; display: flex; height: 3rem; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0 1rem; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--background)); }
|
||||
.thot-app-shell[data-administration-narrow="true"] { padding-top: 3rem; }
|
||||
.thot-work-area-page .thot-workspace-manager__layout { flex: 1; min-height: 22rem; }
|
||||
.thot-work-area-page .thot-workspace-manager__content { padding: 1.4rem; }
|
||||
.thot-work-area-page__body--pi { padding: 1.5rem 1.75rem; }
|
||||
.thot-pi-workbench { display: grid; grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.5fr); border: 1px solid oklch(var(--border)); background: oklch(var(--card)); }
|
||||
.thot-pi-index { background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); padding: 1.25rem; }
|
||||
.thot-pi-index a { display: block; padding: 0.65rem 0; font-size: 0.875rem; }
|
||||
.thot-pi-detail { min-width: 0; padding: 1.35rem; }
|
||||
.thot-workspace-preparation { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
|
||||
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; }
|
||||
.thot-preprocessing-control[data-presentation="page"] { padding: 1rem; margin-top: 0.75rem; }
|
||||
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: 0.8125rem; line-height: 1.5; }
|
||||
.thot-preprocessing-control[data-presentation="page"] button { height: auto; min-height: 2.25rem; white-space: normal; }
|
||||
.thot-administration-database .thot-fleet-ledger__workspace { overflow-y: auto; }
|
||||
.thot-administration-database .thot-fleet-ledger__content { flex: 1 0 26rem; min-height: 26rem; }
|
||||
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: 1.125rem; }
|
||||
.thot-administration-database .thot-administration-dependency { flex: none; }
|
||||
.thot-database-workbench { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
|
||||
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@container administration (max-width: 48rem) {
|
||||
.thot-administration-split, .thot-pi-workbench { grid-template-columns: minmax(0, 1fr); }
|
||||
.thot-database-workbench form > .grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.thot-administration-index, .thot-pi-index { border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
||||
.thot-administration-index { max-height: 22rem; overflow-y: auto; }
|
||||
.thot-administration-header { padding: 1.25rem; }
|
||||
.thot-administration-split { margin: 1rem; }
|
||||
.thot-work-area-page .thot-workspace-manager__layout { display: flex; flex-direction: column; overflow: visible; }
|
||||
.thot-work-area-page .thot-workspace-manager__navigation { max-height: 18rem; flex: none; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
||||
.thot-work-area-page .thot-workspace-manager__content { overflow: visible; }
|
||||
}
|
||||
@container administration (max-width: 30rem) {
|
||||
.thot-administration-header h1 { font-size: 1.5rem; }
|
||||
.thot-administration-header, .thot-administration-toolbar, .thot-work-area-page__body--pi { padding: 1rem; }
|
||||
.thot-administration-detail, .thot-pi-detail { padding: 1rem; }
|
||||
.thot-administration-split { margin: 0.75rem; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./AdministrationPage.css";
|
||||
|
||||
export function AdministrationHeader({ title, description, actions, eyebrow = "Administration / Workbench" }: {
|
||||
title: ReactNode; description?: ReactNode; actions?: ReactNode; eyebrow?: ReactNode;
|
||||
}) {
|
||||
return <header className="thot-administration-header">
|
||||
<div className="thot-administration-header__copy">
|
||||
<p className="thot-administration-eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{description && <p className="thot-administration-description">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="thot-administration-header__actions">{actions}</div>}
|
||||
</header>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getWorkspacePreprocessingStatus } from "../../api/workspace-preprocessing";
|
||||
import { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
|
||||
import { AdministrationLink } from "./AdministrationNavigation";
|
||||
|
||||
export function WorkspacePreparation({ workspaceId, canManage, databaseSummary = false }: {
|
||||
workspaceId: string; canManage: boolean; databaseSummary?: boolean;
|
||||
}) {
|
||||
const query = useQuery({ queryKey: ["workspace-preprocessing", workspaceId],
|
||||
queryFn: () => getWorkspacePreprocessingStatus(workspaceId), enabled: canManage, retry: false, refetchInterval: 5000 });
|
||||
if (!canManage) return <p className="text-sm text-muted-foreground">Database management permission is required to inspect or run preprocessing.</p>;
|
||||
if (databaseSummary) return <section aria-label="Workspace preparation dependency" className="thot-administration-dependency">
|
||||
<p><strong>Workspace preparation</strong> · {workspaceId}</p>
|
||||
<p role="status">{query.isLoading ? "Checking workspace readiness…" : query.isError ? "Workspace readiness is unavailable." : query.data?.detail}</p>
|
||||
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>Retry readiness</button>}
|
||||
<AdministrationLink surface="workspace-management" workspace={workspaceId} className="thot-administration-link">Open workspace preprocessing</AdministrationLink>
|
||||
</section>;
|
||||
return <section aria-label="Workspace readiness" className="thot-workspace-preparation">
|
||||
<p className="thot-administration-eyebrow">Preparation</p>
|
||||
<h3 className="font-heading text-lg font-semibold mt-1">Ready for analysis</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Preprocessing prepares this workspace's schema, value lookup and Evidence indexes from the Database catalog. Memory remains separate.</p>
|
||||
<AdministrationLink surface="database-management" workspace={workspaceId} className="thot-administration-link">Open Database configuration and schema</AdministrationLink>
|
||||
{query.data && <dl className="mt-3 flex flex-wrap gap-5 text-sm">
|
||||
<div><dt className="text-muted-foreground">Catalog revision</dt><dd className="font-mono">{query.data.metadataRevision ?? "Unavailable"}</dd></div>
|
||||
<div><dt className="text-muted-foreground">Prepared revision</dt><dd className="font-mono">{query.data.preprocessedMetadataRevision ?? "Not prepared"}</dd></div>
|
||||
</dl>}
|
||||
<WorkspacePreprocessingControl key={workspaceId} workspaceId={workspaceId} status={query.data} loading={query.isLoading} unavailable={query.isError} canManage={canManage} presentation="page" />
|
||||
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>Retry readiness</button>}
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user