import { useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, CheckCircle2, ClipboardCheck, FlaskConical, GitPullRequest, KeyRound, Trash2, X, } from "lucide-react"; import { asWorkspaceApiError, forgetWorkspaceSecret, getWorkspace, getWorkspaceRegistryStatus, getWorkspaceRuntimeConfiguration, listWorkspaces, pullWorkspaceRegistry, saveWorkspaceSecrets, testWorkspace, validateWorkspace, type WorkspaceRuntimeConfiguration, } from "../api/workspaces"; import { Button } from "../components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../components/ui/dialog"; function QueryError({ name, message, retryLabel, onRetry }: { name: string; message: string; retryLabel: string; onRetry: () => void; }) { return (

{message}

); } function publicError(error: unknown, fallback: string): string { const safe = asWorkspaceApiError(error); return safe ? `${safe.code}: ${safe.message}` : fallback; } function stateLabel(state: "ready" | "configuration_required"): string { return state === "ready" ? "Ready" : "Runtime configuration required"; } export function WorkspaceManager({ open, onClose }: { open: boolean; onClose: () => void }) { const queryClient = useQueryClient(); const [selectedId, setSelectedId] = useState(); const [secretValues, setSecretValues] = useState>({}); const [notice, setNotice] = useState(); const [diagnostics, setDiagnostics] = useState([]); const [busyAction, setBusyAction] = useState(); const statusQuery = useQuery({ queryKey: ["workspace-repository-status"], queryFn: getWorkspaceRegistryStatus, enabled: open, }); const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open, }); const workspaces = workspacesQuery.data ?? []; const selectedSummary = useMemo( () => workspaces.find(({ id }) => id === selectedId), [selectedId, workspaces], ); const detailQuery = useQuery({ queryKey: ["workspace", selectedId], queryFn: () => getWorkspace(selectedId!), enabled: Boolean(open && selectedId), }); const runtimeQuery = useQuery({ queryKey: ["workspace-runtime-configuration", selectedId], queryFn: () => getWorkspaceRuntimeConfiguration(selectedId!), enabled: Boolean(open && selectedId), }); const clearMessages = () => { setNotice(undefined); setDiagnostics([]); }; const close = () => { setSecretValues({}); clearMessages(); onClose(); }; const selectWorkspace = (id: string) => { setSelectedId(id); setSecretValues({}); clearMessages(); }; async function updateRepository() { setBusyAction("repository"); clearMessages(); try { await pullWorkspaceRegistry(); await Promise.all([ statusQuery.refetch(), workspacesQuery.refetch(), selectedId ? detailQuery.refetch() : Promise.resolve(), selectedId ? runtimeQuery.refetch() : Promise.resolve(), ]); setNotice("Workspace repository updated and validated."); } catch (error) { setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]); } finally { setBusyAction(undefined); } } async function validateSource() { if (!detailQuery.data) return; setBusyAction("validate"); clearMessages(); try { await validateWorkspace(detailQuery.data.workspace); setNotice("Workspace source is valid."); } catch (error) { setDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]); } finally { setBusyAction(undefined); } } async function testConnections() { if (!selectedId) return; setBusyAction("test"); clearMessages(); try { const result = await testWorkspace(selectedId); setDiagnostics(result.diagnostics.map(({ code, message }) => `${code}: ${message}`)); if (result.diagnostics.length === 0) { setNotice(result.activatable ? "Workspace connections are valid." : "Workspace connection test completed."); } } catch (error) { setDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]); } finally { setBusyAction(undefined); } } async function saveSecrets() { if (!selectedId) return; const values = Object.fromEntries( Object.entries(secretValues).filter(([, value]) => value.length > 0), ); if (Object.keys(values).length === 0) return; setBusyAction("save-secrets"); clearMessages(); try { const configuration = await saveWorkspaceSecrets(selectedId, values); queryClient.setQueryData( ["workspace-runtime-configuration", selectedId], configuration, ); setSecretValues({}); await workspacesQuery.refetch(); setNotice("Runtime secrets saved. Stored values remain hidden."); } catch (error) { setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]); } finally { setBusyAction(undefined); } } async function forgetSecret(requirementId: string) { if (!selectedId) return; setBusyAction(`forget:${requirementId}`); clearMessages(); try { const configuration = await forgetWorkspaceSecret(selectedId, requirementId); queryClient.setQueryData( ["workspace-runtime-configuration", selectedId], configuration, ); setSecretValues((current) => ({ ...current, [requirementId]: "" })); await workspacesQuery.refetch(); setNotice("Stored secret forgotten."); } catch (error) { setDiagnostics([publicError(error, "workspace_invalid: Stored secret could not be forgotten")]); } finally { setBusyAction(undefined); } } const repository = statusQuery.data?.repository; const repositoryLabel = repository ? `${repository.host}/${repository.repository}` : "the repository configured for this ThothII installation"; const runtime = runtimeQuery.data; const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0); return ( { if (!nextOpen) close(); }}> Workspace management Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.
{notice && (

{notice}

)} {diagnostics.length > 0 && (
{diagnostics.map((diagnostic) => (

{diagnostic}

))}
)} {!selectedSummary ? (

Level 1 · Repository

How workspaces reach ThothII

  1. Create a local workspace in its own source directory. It must contain workspace.yaml and every required subdirectory, including any versioned Evidence files.
  2. Publish that source by committing and pushing it to a repository hosted by a Git server such as GitHub, GitLab, or Gitea.
  3. The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads {repositoryLabel} on branch {statusQuery.data?.branch ?? "main"}.
  4. ThothII fetches the configured branch into its managed read-only checkout, validates the complete candidate revision, and activates it only when validation succeeds. It never edits, commits, pushes, or publishes workspace source.

Update workspace repository

Fetches the configured branch directly into the managed read-only checkout and validates it. No workspace selection is required. If candidate validation fails, the current active revision remains unchanged.

Select a workspace from the left only for workspace-specific validation, runtime credentials, and connection tests.

) : (

Level 2 · Selected workspace

{selectedSummary.displayName}

{selectedSummary.id}

{(detailQuery.isLoading || runtimeQuery.isLoading) &&

Loading workspace configuration…

} {(detailQuery.isError || runtimeQuery.isError) && ( { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} /> )} {detailQuery.data && runtime && ( <>

Workspace-specific actions

The actions below apply only to {selectedSummary.displayName}. ThothII reads this revision without modifying or publishing it.

Source file
{selectedSummary.file}
Active revision
{detailQuery.data.revision.commit}
Data warehouse
{detailQuery.data.workspace.dwh.engine} · {detailQuery.data.workspace.dwh.database}/{detailQuery.data.workspace.dwh.schema}
Runtime status
{stateLabel(runtime.configurationState)}

Validate workspace source

Checks workspace.yaml and the required workspace directories against the supported workspace schema. No source file is changed.

Test workspace connections

Uses temporary decrypted credentials to verify the configured data warehouse and Evidence source. Temporary files are deleted after the test.

Runtime secrets

Enter only new or replacement values. Stored values are never displayed. Saving replaces the selected secret and clears the form field.

{runtime.requirements.length === 0 ? (

This workspace does not require user-provided runtime secrets for its selected connectors.

) : (
{runtime.requirements.map((requirement) => (
{requirement.configured ? "Configured" : "Not configured"}

{requirement.description}{requirement.required ? " Required for this workspace." : " Optional."}

{requirement.input === "textarea" ? (