feat: add read-only workspace and secret management UI
This commit is contained in:
@@ -1,28 +1,37 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, ClipboardCheck, Download, FlaskConical, GitPullRequest, Upload, X } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
FlaskConical,
|
||||
GitPullRequest,
|
||||
KeyRound,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
asWorkspaceApiError,
|
||||
exportWorkspace,
|
||||
forgetWorkspaceSecret,
|
||||
getWorkspace,
|
||||
getWorkspaceRegistryStatus,
|
||||
importWorkspace,
|
||||
getWorkspaceRuntimeConfiguration,
|
||||
listWorkspaces,
|
||||
pullWorkspaceRegistry,
|
||||
saveWorkspaceSecrets,
|
||||
testWorkspace,
|
||||
validateWorkspace,
|
||||
type CanonicalWorkspace,
|
||||
type PublishWorkspaceRequest,
|
||||
type WorkspaceRecord,
|
||||
type WorkspaceSummary,
|
||||
type WorkspaceRuntimeConfiguration,
|
||||
} from "../api/workspaces";
|
||||
import { workspaceBootstrapDrafts, type WorkspaceBootstrapDraft } from "../workspaces/drafts";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
|
||||
import { WorkspaceEditor } from "./WorkspaceEditor";
|
||||
import { WorkspacePublishDialog } from "./WorkspacePublishDialog";
|
||||
|
||||
const EMPTY_COMMIT = "0".repeat(40);
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
|
||||
function QueryError({ name, message, retryLabel, onRetry }: {
|
||||
name: string;
|
||||
@@ -30,278 +39,375 @@ function QueryError({ name, message, retryLabel, onRetry }: {
|
||||
retryLabel: string;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return <div role="alert" aria-label={name} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm"><p>{message}</p><div><Button size="sm" variant="outline" onClick={onRetry}>{retryLabel}</Button></div></div>;
|
||||
return (
|
||||
<div role="alert" aria-label={name} className="grid gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
|
||||
<p>{message}</p>
|
||||
<div><Button size="sm" variant="outline" onClick={onRetry}>{retryLabel}</Button></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultBootstrapWorkspace(summary: WorkspaceSummary): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: {
|
||||
schema_version: 3,
|
||||
id: summary.id,
|
||||
name: summary.displayName,
|
||||
...(summary.description ? { description: summary.description } : {}),
|
||||
language: "en",
|
||||
},
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: "database",
|
||||
schema: "public",
|
||||
supported_transports: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant",
|
||||
collection: summary.id.replaceAll("-", "_"),
|
||||
dimensions: 1024,
|
||||
distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_internal",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"], default: "zai/glm-5.2" },
|
||||
};
|
||||
function publicError(error: unknown, fallback: string): string {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
return safe ? `${safe.code}: ${safe.message}` : fallback;
|
||||
}
|
||||
|
||||
function bootstrapDraftFor(summary: WorkspaceSummary, baseCommit: string): WorkspaceBootstrapDraft {
|
||||
return {
|
||||
workspaceId: summary.id,
|
||||
baseCommit,
|
||||
workspace: defaultBootstrapWorkspace(summary),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
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<string>();
|
||||
const [localDraft, setLocalDraft] = useState<WorkspaceBootstrapDraft>();
|
||||
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
||||
const [notice, setNotice] = useState<string>();
|
||||
const [diagnostics, setDiagnostics] = useState<string[]>([]);
|
||||
const [publishRequest, setPublishRequest] = useState<PublishWorkspaceRequest>();
|
||||
const [transferring, setTransferring] = useState(false);
|
||||
const [busyAction, setBusyAction] = useState<string>();
|
||||
|
||||
const statusQuery = useQuery({ queryKey: ["workspace-registry-status"], queryFn: getWorkspaceRegistryStatus, enabled: open });
|
||||
const workspacesQuery = useQuery({ queryKey: ["workspaces"], queryFn: listWorkspaces, enabled: open });
|
||||
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((workspace) => workspace.id === selectedId), [selectedId, workspaces]);
|
||||
const selectedSummary = useMemo(
|
||||
() => workspaces.find(({ id }) => id === selectedId),
|
||||
[selectedId, workspaces],
|
||||
);
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ["workspace", selectedId],
|
||||
queryFn: () => getWorkspace(selectedId!),
|
||||
enabled: Boolean(open && selectedId && selectedSummary?.configurationState === "ready"),
|
||||
enabled: Boolean(open && selectedId),
|
||||
});
|
||||
const runtimeQuery = useQuery({
|
||||
queryKey: ["workspace-runtime-configuration", selectedId],
|
||||
queryFn: () => getWorkspaceRuntimeConfiguration(selectedId!),
|
||||
enabled: Boolean(open && selectedId),
|
||||
});
|
||||
const record = detailQuery.data;
|
||||
|
||||
const activeBootstrapDraft = selectedSummary?.configurationState === "configuration_required"
|
||||
? (localDraft?.workspaceId === selectedSummary.id ? localDraft : workspaceBootstrapDrafts.load(selectedSummary.id) ?? bootstrapDraftFor(selectedSummary, statusQuery.data?.head ?? EMPTY_COMMIT))
|
||||
: undefined;
|
||||
|
||||
function resetTransientState() {
|
||||
const clearMessages = () => {
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
setPublishRequest(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
function selectWorkspace(id: string) {
|
||||
const close = () => {
|
||||
setSecretValues({});
|
||||
clearMessages();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const selectWorkspace = (id: string) => {
|
||||
setSelectedId(id);
|
||||
setLocalDraft(undefined);
|
||||
resetTransientState();
|
||||
}
|
||||
setSecretValues({});
|
||||
clearMessages();
|
||||
};
|
||||
|
||||
function saveDraft(draft: WorkspaceBootstrapDraft) {
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setLocalDraft(draft);
|
||||
setSelectedId(draft.workspaceId);
|
||||
setNotice("Draft saved in this browser.");
|
||||
setDiagnostics([]);
|
||||
}
|
||||
|
||||
function requestCreate(request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) {
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setLocalDraft(draft);
|
||||
setSelectedId(draft.workspaceId);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
setPublishRequest(request);
|
||||
}
|
||||
|
||||
async function pullLatest() {
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
async function updateRepository() {
|
||||
setBusyAction("repository");
|
||||
clearMessages();
|
||||
try {
|
||||
await pullWorkspaceRegistry();
|
||||
await Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]);
|
||||
setNotice("Registry updated. Reload a workspace to review its latest revision.");
|
||||
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) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "git_unavailable: Registry pull could not be completed"]);
|
||||
setDiagnostics([publicError(error, "git_unavailable: Workspace repository could not be updated")]);
|
||||
} finally {
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateSelectedWorkspace() {
|
||||
if (!record) return;
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
async function validateSource() {
|
||||
if (!detailQuery.data) return;
|
||||
setBusyAction("validate");
|
||||
clearMessages();
|
||||
try {
|
||||
await validateWorkspace(record.workspace);
|
||||
setNotice("Workspace definition is valid.");
|
||||
await validateWorkspace(detailQuery.data.workspace);
|
||||
setNotice("Workspace source is valid.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed"]);
|
||||
setDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
|
||||
} finally {
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSelectedWorkspace() {
|
||||
if (!record) return;
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
async function testConnections() {
|
||||
if (!selectedId) return;
|
||||
setBusyAction("test");
|
||||
clearMessages();
|
||||
try {
|
||||
const result = await testWorkspace(record.workspace.workspace.id);
|
||||
setDiagnostics(result.diagnostics.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`));
|
||||
const result = await testWorkspace(selectedId);
|
||||
setDiagnostics(result.diagnostics.map(({ code, message }) => `${code}: ${message}`));
|
||||
if (result.diagnostics.length === 0) {
|
||||
setNotice(result.activatable ? "Installation test passed." : "Installation test completed.");
|
||||
setNotice(result.activatable
|
||||
? "Workspace connections are valid."
|
||||
: "Workspace connection test completed.");
|
||||
}
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "connector_unavailable: Installation test could not be completed"]);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBundle() {
|
||||
if (!record) return;
|
||||
setTransferring(true);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
try {
|
||||
const bundle = await exportWorkspace(record.workspace.workspace.id);
|
||||
const url = URL.createObjectURL(bundle);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${record.workspace.workspace.id}.zip`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setNotice("Workspace bundle downloaded.");
|
||||
} catch (error) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be exported"]);
|
||||
setDiagnostics([publicError(error, "connector_unavailable: Workspace connections could not be tested")]);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function importBundle(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setTransferring(true);
|
||||
setNotice(undefined);
|
||||
setDiagnostics([]);
|
||||
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 result = await importWorkspace(file);
|
||||
const importedId = result.draft.workspace.workspace.id;
|
||||
const catalog = workspaces.length > 0 ? workspaces : ((await workspacesQuery.refetch()).data ?? []);
|
||||
const matchingSummary = catalog.find((workspace) => workspace.id === importedId && workspace.configurationState === "configuration_required");
|
||||
if (!matchingSummary) {
|
||||
setDiagnostics(["workspace_invalid: Imported bundle can only bootstrap a matching catalog slot"]);
|
||||
return;
|
||||
}
|
||||
const draft: WorkspaceBootstrapDraft = {
|
||||
workspaceId: importedId,
|
||||
baseCommit: statusQuery.data?.head ?? EMPTY_COMMIT,
|
||||
workspace: result.draft.workspace,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
workspaceBootstrapDrafts.save(draft);
|
||||
setNotice("Imported bootstrap draft saved in this browser. Validate it before creating the descriptor.");
|
||||
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) {
|
||||
const safe = asWorkspaceApiError(error);
|
||||
setDiagnostics([safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Workspace bundle could not be imported"]);
|
||||
setDiagnostics([publicError(error, "workspace_invalid: Runtime secrets could not be saved")]);
|
||||
} finally {
|
||||
setTransferring(false);
|
||||
setBusyAction(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function published() {
|
||||
if (publishRequest) {
|
||||
workspaceBootstrapDrafts.discard(publishRequest.workspace.workspace.id);
|
||||
setSelectedId(publishRequest.workspace.workspace.id);
|
||||
setNotice(`To change this workspace, edit ${publishRequest.workspace.workspace.id}/workspace.yaml, commit/push, then Pull.`);
|
||||
} else {
|
||||
setNotice("Workspace created.");
|
||||
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);
|
||||
}
|
||||
setLocalDraft(undefined);
|
||||
setPublishRequest(undefined);
|
||||
setDiagnostics([]);
|
||||
void Promise.all([statusQuery.refetch(), workspacesQuery.refetch()]).then(() => detailQuery.refetch());
|
||||
}
|
||||
|
||||
const titleReady = workspacesQuery.isSuccess || workspacesQuery.isError;
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
|
||||
<DialogContent showCloseButton={false} className="max-h-[min(90vh,54rem)] max-w-[min(94vw,74rem)] overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
|
||||
<DialogTitle>{titleReady ? "Workspace management" : ""}</DialogTitle>
|
||||
<DialogDescription>{titleReady ? "Draft bootstrap-only workspace definitions locally. Existing published descriptors stay read-only." : ""}</DialogDescription>
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) close(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="h-[70vh] w-[94vw] max-h-[90vh] max-w-[94vw] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:w-[70vw] sm:max-w-[94vw]"
|
||||
>
|
||||
<DialogHeader className="border-b border-border/70 px-6 pb-4 pt-5 pr-12">
|
||||
<DialogTitle>Workspace management</DialogTitle>
|
||||
<DialogDescription>
|
||||
Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<button type="button" aria-label="Close workspace management" onClick={onClose} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"><X className="size-4" /></button>
|
||||
<div className="grid max-h-[calc(min(90vh,54rem)-6.75rem)] min-h-[32rem] grid-cols-[14rem_minmax(0,1fr)] overflow-hidden">
|
||||
<button type="button" aria-label="Close workspace management" onClick={close} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className="grid min-h-0 grid-cols-[15rem_minmax(0,1fr)] overflow-hidden">
|
||||
<nav aria-label="Workspaces" className="flex min-h-0 flex-col border-r border-border/70 bg-muted/30 p-3">
|
||||
<label className="mb-2 inline-flex h-7 cursor-pointer items-center justify-center gap-1 rounded-md border border-input px-3 text-[0.8rem] font-semibold text-foreground shadow-xs hover:bg-muted has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50"><Upload className="size-3.5" />Import bundle<input aria-label="Import workspace bundle" type="file" accept="application/zip,.zip" className="sr-only" disabled={transferring} onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; void importBundle(file); }} /></label>
|
||||
<Button size="sm" variant="outline" className="mb-3 w-full" disabled={transferring} onClick={() => { void pullLatest(); }}><GitPullRequest />Pull latest registry</Button>
|
||||
<p className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Available workspaces</p>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{workspacesQuery.isLoading ? <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p> : workspacesQuery.isError ? <QueryError name="Workspace list failed" message="Could not load workspaces." retryLabel="Retry workspace list" onRetry={() => { void workspacesQuery.refetch(); }} /> : workspaces.map((workspace) => (
|
||||
<button key={workspace.id} type="button" aria-label={workspace.displayName} aria-current={selectedId === workspace.id ? "page" : undefined} onClick={() => selectWorkspace(workspace.id)} className="mb-1 w-full rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted aria-[current=page]:bg-primary/10 aria-[current=page]:font-semibold">
|
||||
{workspacesQuery.isLoading && <p className="p-2 text-xs text-muted-foreground">Loading workspaces…</p>}
|
||||
{workspacesQuery.isError && (
|
||||
<QueryError name="Workspace list failed" message="Could not load workspaces." retryLabel="Retry workspace list" onRetry={() => { void workspacesQuery.refetch(); }} />
|
||||
)}
|
||||
{workspaces.map((workspace) => (
|
||||
<button
|
||||
key={workspace.id}
|
||||
type="button"
|
||||
aria-label={workspace.displayName}
|
||||
aria-current={selectedId === workspace.id ? "page" : undefined}
|
||||
onClick={() => selectWorkspace(workspace.id)}
|
||||
className="mb-1 w-full rounded-md px-2.5 py-2 text-left text-sm hover:bg-muted aria-[current=page]:bg-primary/10 aria-[current=page]:font-semibold"
|
||||
>
|
||||
<span className="block truncate">{workspace.displayName}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{workspace.id}</span>
|
||||
<span className="mt-1 block text-[0.7rem] text-muted-foreground">{stateLabel(workspace.configurationState)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 overflow-y-auto px-5 py-5">
|
||||
{notice && <p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm"><CheckCircle2 className="size-4 text-primary" />{notice}</p>}
|
||||
{diagnostics.length > 0 && <div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">{diagnostics.map((diagnostic) => <p key={diagnostic} className="flex items-start gap-2"><AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}</p>)}</div>}
|
||||
<main className="min-w-0 overflow-y-auto px-6 py-5">
|
||||
{notice && (
|
||||
<p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
<CheckCircle2 className="size-4 text-primary" />{notice}
|
||||
</p>
|
||||
)}
|
||||
{diagnostics.length > 0 && (
|
||||
<div data-testid="workspace-diagnostics" aria-live="polite" className="mb-4 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
|
||||
{diagnostics.map((diagnostic) => (
|
||||
<p key={diagnostic} className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />{diagnostic}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedSummary && !workspacesQuery.isLoading && !workspacesQuery.isError && <div className="grid min-h-64 place-items-center text-center"><div><h3 className="font-heading font-semibold">Select a workspace</h3><p className="mt-1 text-sm text-muted-foreground">Review an existing definition or bootstrap a configuration-required slot.</p></div></div>}
|
||||
|
||||
{selectedSummary?.configurationState === "configuration_required" && activeBootstrapDraft && (
|
||||
<>
|
||||
<div className="mb-5 border-b border-border/70 pb-4">
|
||||
<p className="thot-label">Bootstrap workspace</p>
|
||||
<h2 className="font-heading text-lg font-semibold">{selectedSummary.displayName}</h2>
|
||||
{!selectedSummary ? (
|
||||
<section data-testid="workspace-overview" className="mx-auto max-w-3xl space-y-5">
|
||||
<div>
|
||||
<p className="thot-label">Level 1 · Repository</p>
|
||||
<h2 className="font-heading text-xl font-semibold">How workspaces reach ThothII</h2>
|
||||
</div>
|
||||
<ol className="grid list-decimal gap-3 pl-5 text-sm leading-6 text-muted-foreground">
|
||||
<li>Prepare the workspace source in its own directory. It must contain <code>workspace.yaml</code> and every required subdirectory, including any versioned Evidence files.</li>
|
||||
<li>Publish that source by committing and pushing it to a repository hosted by a Git server such as GitHub, GitLab, or Gitea.</li>
|
||||
<li>The repository address, branch, and read-only Git credentials are configured during ThothII installation. This installation reads <span className="font-medium text-foreground">{repositoryLabel}</span> on branch <span className="font-mono text-foreground">{statusQuery.data?.branch ?? "main"}</span>.</li>
|
||||
<li>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.</li>
|
||||
</ol>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="max-w-2xl">
|
||||
<h3 className="font-heading font-semibold">Update workspace repository</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">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.</p>
|
||||
</div>
|
||||
<Button disabled={busyAction === "repository"} onClick={() => { void updateRepository(); }}>
|
||||
<GitPullRequest />Update workspace repository
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Select a workspace from the left only for workspace-specific validation, runtime credentials, and connection tests.</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="mx-auto max-w-4xl space-y-5">
|
||||
<div className="border-b border-border/70 pb-4">
|
||||
<p className="thot-label">Level 2 · Selected workspace</p>
|
||||
<h2 className="font-heading text-xl font-semibold">{selectedSummary.displayName}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
|
||||
</div>
|
||||
<WorkspaceEditor
|
||||
mode={{ kind: "bootstrap", catalog: selectedSummary, draft: activeBootstrapDraft }}
|
||||
onSaveDraft={saveDraft}
|
||||
onRequestCreate={publishRequest ? undefined : requestCreate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedSummary?.configurationState === "ready" && (
|
||||
detailQuery.isError ? <QueryError name="Workspace details failed" message="Could not load workspace details." retryLabel="Retry workspace details" onRetry={() => { void detailQuery.refetch(); }} /> : detailQuery.isLoading || !record ? <p className="text-sm text-muted-foreground">Loading workspace definition…</p> : (
|
||||
<>
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-border/70 pb-4">
|
||||
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
|
||||
{(detailQuery.isError || runtimeQuery.isError) && (
|
||||
<QueryError name="Workspace configuration failed" message="Could not load this workspace configuration." retryLabel="Retry workspace configuration" onRetry={() => { void Promise.all([detailQuery.refetch(), runtimeQuery.refetch()]); }} />
|
||||
)}
|
||||
|
||||
{detailQuery.data && runtime && (
|
||||
<>
|
||||
<div>
|
||||
<p className="thot-label">Workspace definition</p>
|
||||
<h2 className="font-heading text-lg font-semibold">{record.workspace.workspace.name}</h2>
|
||||
<p className="font-mono text-xs text-muted-foreground">{record.workspace.workspace.id}</p>
|
||||
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">The actions below apply only to <span className="font-medium text-foreground">{selectedSummary.displayName}</span>. ThothII reads this revision without modifying or publishing it.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={transferring} onClick={() => { void downloadBundle(); }}><Download />Export workspace bundle</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { void validateSelectedWorkspace(); }}><ClipboardCheck />Validate workspace</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { void testSelectedWorkspace(); }}><FlaskConical />Test on this installation</Button>
|
||||
|
||||
<dl className="grid gap-3 rounded-lg border border-border bg-muted/20 p-4 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-xs text-muted-foreground">Source file</dt><dd className="font-mono">{selectedSummary.file}</dd></div>
|
||||
<div><dt className="text-xs text-muted-foreground">Active revision</dt><dd className="truncate font-mono">{detailQuery.data.revision.commit}</dd></div>
|
||||
<div><dt className="text-xs text-muted-foreground">Data warehouse</dt><dd>{detailQuery.data.workspace.dwh.engine} · {detailQuery.data.workspace.dwh.database}/{detailQuery.data.workspace.dwh.schema}</dd></div>
|
||||
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h4 className="font-heading font-semibold">Validate workspace source</h4>
|
||||
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Checks workspace.yaml and the required workspace directories against the supported workspace schema. No source file is changed.</p>
|
||||
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "validate"} onClick={() => { void validateSource(); }}>
|
||||
<ClipboardCheck />Validate workspace source
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<h4 className="font-heading font-semibold">Test workspace connections</h4>
|
||||
<p className="mt-1 min-h-12 text-sm leading-5 text-muted-foreground">Uses temporary decrypted credentials to verify the configured data warehouse and Evidence source. Temporary files are deleted after the test.</p>
|
||||
<Button className="mt-3" size="sm" variant="outline" disabled={busyAction === "test"} onClick={() => { void testConnections(); }}>
|
||||
<FlaskConical />Test workspace connections
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceEditor mode={{ kind: "read_only", catalog: selectedSummary, record: record as WorkspaceRecord }} />
|
||||
</>
|
||||
)
|
||||
|
||||
<div className="rounded-lg border border-border p-4">
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<KeyRound className="mt-0.5 size-5 text-primary" />
|
||||
<div>
|
||||
<h3 className="font-heading font-semibold">Runtime secrets</h3>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">Enter only new or replacement values. Stored values are never displayed. Saving replaces the selected secret and clears the form field.</p>
|
||||
</div>
|
||||
</div>
|
||||
{runtime.requirements.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">This workspace does not require user-provided runtime secrets for its selected connectors.</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{runtime.requirements.map((requirement) => (
|
||||
<div key={requirement.id} className="grid gap-2 rounded-md bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label htmlFor={`workspace-secret-${requirement.id}`} className="font-medium">{requirement.label}</label>
|
||||
<span className={requirement.configured ? "text-xs font-medium text-emerald-700" : "text-xs font-medium text-amber-700"}>
|
||||
{requirement.configured ? "Configured" : "Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{requirement.description}{requirement.required ? " Required for this workspace." : " Optional."}</p>
|
||||
{requirement.input === "textarea" ? (
|
||||
<textarea
|
||||
id={`workspace-secret-${requirement.id}`}
|
||||
value={secretValues[requirement.id] ?? ""}
|
||||
placeholder="Enter a new or replacement value"
|
||||
autoComplete="off"
|
||||
rows={4}
|
||||
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`workspace-secret-${requirement.id}`}
|
||||
type="password"
|
||||
value={secretValues[requirement.id] ?? ""}
|
||||
placeholder="Enter a new or replacement value"
|
||||
autoComplete="new-password"
|
||||
onChange={(event) => setSecretValues((current) => ({ ...current, [requirement.id]: event.target.value }))}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
)}
|
||||
{requirement.configured && (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busyAction === `forget:${requirement.id}`}
|
||||
aria-label={`Forget stored ${requirement.label}`}
|
||||
onClick={() => { void forgetSecret(requirement.id); }}
|
||||
>
|
||||
<Trash2 />Forget stored value
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button disabled={!hasEnteredSecrets || busyAction === "save-secrets"} onClick={() => { void saveSecrets(); }}>
|
||||
<KeyRound />Save entered secrets
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</DialogContent>
|
||||
{publishRequest && <WorkspacePublishDialog open request={publishRequest} onOpenChange={(nextOpen) => { if (!nextOpen) setPublishRequest(undefined); }} onPublished={published} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user