feat: consolidate database management work
Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow. Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
+193
-90
@@ -17,8 +17,9 @@ import { StopConfirmDialog } from "./StopConfirmDialog";
|
||||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||
import { WorkflowBar } from "./WorkflowBar";
|
||||
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
import { Pencil, ArrowLeft, ArrowRight, Trash2 } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Pencil, ArrowLeft, ArrowRight, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { Accordion } from "@base-ui/react/accordion";
|
||||
import { Button, buttonVariants } from "../components/ui/button";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
@@ -34,7 +35,7 @@ import type { SessionScope, SessionSummary } from "../api/types";
|
||||
import { useAuthGeneration, useAuthUser } from "../auth/authState";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CSSProperties, KeyboardEvent } from "react";
|
||||
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError, type AuthOperationGuard } from "../auth/authOperation";
|
||||
|
||||
interface AppShellProps {
|
||||
@@ -43,6 +44,14 @@ interface AppShellProps {
|
||||
|
||||
type ActiveSurface = "core" | "database-management";
|
||||
type ActiveManagementPanel = "workspace" | "pi" | null;
|
||||
const SESSION_SCOPES: readonly SessionScope[] = ["mine", "all"];
|
||||
|
||||
function sessionScopeTabClass(selected: boolean): string {
|
||||
const base = "relative -mb-px flex h-8 w-full cursor-pointer items-center justify-center rounded-t-md border border-b-2 px-2 text-xs font-semibold tracking-[0.005em] outline-none focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-[oklch(var(--nav-active-border)/0.28)]";
|
||||
return selected
|
||||
? `${base} border-[oklch(var(--nav-active-border))] bg-[oklch(var(--nav-active))] text-[oklch(var(--nav-active-foreground))] hover:bg-[oklch(var(--nav-active-hover))]`
|
||||
: `${base} border-border bg-card text-muted-foreground hover:border-muted-foreground/45 hover:bg-muted/55 hover:text-foreground`;
|
||||
}
|
||||
|
||||
type DatabaseManagementPresentation = "legacy" | "fleet";
|
||||
|
||||
@@ -129,8 +138,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
|
||||
const [sessionScope, setSessionScope] = useState<SessionScope>("mine");
|
||||
const sessionScopeTabRefs = useRef<Record<SessionScope, HTMLButtonElement | null>>({
|
||||
mine: null,
|
||||
all: null,
|
||||
});
|
||||
const principal = authenticatedUser;
|
||||
const permissions = authenticatedUser?.permissions ?? [];
|
||||
const isAdmin = authenticatedUser?.isAdmin === true;
|
||||
const canReadAllSessions = permissions.includes("session.read_all");
|
||||
const canManageWorkspace = permissions.includes("workspace.manage");
|
||||
const canManageWorkspaceSecrets = permissions.includes("workspace.secrets.manage");
|
||||
@@ -164,6 +178,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
databaseNavigationRef.current = state;
|
||||
}, []);
|
||||
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
|
||||
const [adminNavigationValue, setAdminNavigationValue] = useState<string[]>([]);
|
||||
const [activeOpen, setActiveOpen] = useState(true);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
|
||||
@@ -236,6 +251,22 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set());
|
||||
}
|
||||
|
||||
function handleSessionScopeTabKeyDown(
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
current: SessionScope,
|
||||
) {
|
||||
const currentIndex = SESSION_SCOPES.indexOf(current);
|
||||
let target: SessionScope | undefined;
|
||||
if (event.key === "ArrowRight") target = SESSION_SCOPES[(currentIndex + 1) % SESSION_SCOPES.length];
|
||||
else if (event.key === "ArrowLeft") target = SESSION_SCOPES[(currentIndex - 1 + SESSION_SCOPES.length) % SESSION_SCOPES.length];
|
||||
else if (event.key === "Home") target = SESSION_SCOPES[0];
|
||||
else if (event.key === "End") target = SESSION_SCOPES.at(-1);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
setSessionScope(target);
|
||||
sessionScopeTabRefs.current[target]?.focus();
|
||||
}
|
||||
|
||||
function canLeaveDatabaseManagement(): boolean {
|
||||
if (activeSurface !== "database-management") return true;
|
||||
if (databaseNavigationRef.current.busy) {
|
||||
@@ -627,6 +658,12 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
await logoutUser();
|
||||
}
|
||||
|
||||
const currentNavigation = activeSurface === "database-management"
|
||||
? "database"
|
||||
: activeManagementPanel ?? "core";
|
||||
const adminNavigationOpen = adminNavigationValue.includes("administration");
|
||||
const managementNavigationCurrent = currentNavigation !== "core";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -808,115 +845,180 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
<div className="flex flex-col gap-2 px-4 pb-3">
|
||||
<Button
|
||||
variant="default"
|
||||
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
aria-current={currentNavigation === "core" ? "page" : undefined}
|
||||
data-navigation-state={currentNavigation === "core" ? "current" : "available"}
|
||||
onClick={startNewSession}
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel("workspace");
|
||||
}}
|
||||
>
|
||||
Workspace management
|
||||
</Button>
|
||||
{canManageDatabase && (
|
||||
<Button
|
||||
variant={activeSurface === "database-management" ? "secondary" : "outline"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
aria-current={activeSurface === "database-management" ? "page" : undefined}
|
||||
onClick={() => {
|
||||
setActiveManagementPanel(null);
|
||||
setActiveSurface("database-management");
|
||||
}}
|
||||
{isAdmin && (
|
||||
<Accordion.Root
|
||||
value={adminNavigationValue}
|
||||
onValueChange={setAdminNavigationValue}
|
||||
className="rounded-lg border border-border/80 bg-card/45 p-1.5"
|
||||
>
|
||||
Database management
|
||||
</Button>
|
||||
)}
|
||||
{canManagePi && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel("pi");
|
||||
}}
|
||||
>
|
||||
Pi management
|
||||
</Button>
|
||||
<Accordion.Item value="administration">
|
||||
<Accordion.Header className="m-0">
|
||||
<Accordion.Trigger
|
||||
className={buttonVariants({
|
||||
variant: !adminNavigationOpen && managementNavigationCurrent ? "navigationActive" : "outline",
|
||||
size: "sm",
|
||||
className: "w-full justify-between px-2.5",
|
||||
})}
|
||||
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
|
||||
>
|
||||
<span>Administration</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
data-icon="inline-end"
|
||||
className={`transition-transform duration-150 motion-reduce:transition-none ${adminNavigationOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Panel className="grid gap-1.5 px-0.5 pt-1.5">
|
||||
<Button
|
||||
variant={currentNavigation === "database" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
aria-current={currentNavigation === "database" ? "page" : undefined}
|
||||
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
|
||||
disabled={!canManageDatabase}
|
||||
title={canManageDatabase ? undefined : "Database management permission is required"}
|
||||
onClick={() => {
|
||||
setActiveManagementPanel(null);
|
||||
setActiveSurface("database-management");
|
||||
}}
|
||||
>
|
||||
Database management
|
||||
</Button>
|
||||
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
|
||||
<Button
|
||||
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
aria-current={currentNavigation === "workspace" ? "page" : undefined}
|
||||
aria-expanded={activeManagementPanel === "workspace"}
|
||||
data-navigation-state={currentNavigation === "workspace" ? "current" : "available"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel("workspace");
|
||||
}}
|
||||
>
|
||||
Workspace management
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
aria-current={currentNavigation === "pi" ? "page" : undefined}
|
||||
aria-expanded={activeManagementPanel === "pi"}
|
||||
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
||||
disabled={!canManagePi}
|
||||
title={canManagePi ? undefined : "Pi management permission is required"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
setActiveSurface("core");
|
||||
setActiveManagementPanel("pi");
|
||||
}}
|
||||
>
|
||||
Pi management
|
||||
</Button>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canReadAllSessions && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" aria-label="Session scope">
|
||||
<Button
|
||||
variant={sessionScope === "mine" ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
aria-pressed={sessionScope === "mine"}
|
||||
<div className="px-4">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Session scope"
|
||||
aria-orientation="horizontal"
|
||||
className="grid grid-cols-2 border-b border-border"
|
||||
>
|
||||
<button
|
||||
ref={(node) => { sessionScopeTabRefs.current.mine = node; }}
|
||||
id="session-scope-mine-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={sessionScope === "mine"}
|
||||
aria-controls="session-scope-panel"
|
||||
tabIndex={sessionScope === "mine" ? 0 : -1}
|
||||
data-tab-state={sessionScope === "mine" ? "active" : "inactive"}
|
||||
className={sessionScopeTabClass(sessionScope === "mine")}
|
||||
onClick={() => setSessionScope("mine")}
|
||||
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "mine")}
|
||||
>
|
||||
My sessions
|
||||
</Button>
|
||||
<Button
|
||||
variant={showingAllSessions ? "secondary" : "ghost"}
|
||||
size="xs"
|
||||
aria-pressed={showingAllSessions}
|
||||
</button>
|
||||
<button
|
||||
ref={(node) => { sessionScopeTabRefs.current.all = node; }}
|
||||
id="session-scope-all-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={showingAllSessions}
|
||||
aria-controls="session-scope-panel"
|
||||
tabIndex={showingAllSessions ? 0 : -1}
|
||||
data-tab-state={showingAllSessions ? "active" : "inactive"}
|
||||
className={sessionScopeTabClass(showingAllSessions)}
|
||||
onClick={() => setSessionScope("all")}
|
||||
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "all")}
|
||||
>
|
||||
All sessions
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
{showingAllSessions && (
|
||||
<p className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
Administrator view: all sessions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* L1 — rail title */}
|
||||
<div className="px-4 pb-1.5 pt-1">
|
||||
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
|
||||
Sessions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
|
||||
<Checkbox
|
||||
checked={allSessionsSelected}
|
||||
aria-label="Select all sessions"
|
||||
disabled={sessions.length === 0}
|
||||
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
|
||||
/>
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
{selectedSessions.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
aria-label={`Delete ${selectedSessions.length} selected sessions`}
|
||||
onClick={() => {
|
||||
requestDelete(selectedSessions);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete ({selectedSessions.length})
|
||||
</Button>
|
||||
<div
|
||||
id={canReadAllSessions ? "session-scope-panel" : undefined}
|
||||
role={canReadAllSessions ? "tabpanel" : undefined}
|
||||
aria-labelledby={canReadAllSessions ? `session-scope-${sessionScope}-tab` : undefined}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{canReadAllSessions && showingAllSessions && (
|
||||
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
Administrator view: all sessions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
|
||||
{/* L1 — rail title */}
|
||||
<div className="px-4 pb-1.5 pt-1">
|
||||
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
|
||||
Sessions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-2">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
|
||||
<Checkbox
|
||||
checked={allSessionsSelected}
|
||||
aria-label="Select all sessions"
|
||||
disabled={sessions.length === 0}
|
||||
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
|
||||
/>
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
{selectedSessions.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
aria-label={`Delete ${selectedSessions.length} selected sessions`}
|
||||
onClick={() => {
|
||||
requestDelete(selectedSessions);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete ({selectedSessions.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
{/* L2 — section toggle */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1002,6 +1104,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
showOwner={showingAllSessions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user