feat: add read-only workspace and secret management UI

This commit is contained in:
2026-08-14 17:38:24 +02:00
parent e902f758b1
commit 3978008aed
8 changed files with 586 additions and 1181 deletions
-114
View File
@@ -1,114 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import type { CanonicalWorkspace } from "../api/workspaces";
import type { WorkspaceBootstrapDraft } from "../workspaces/drafts";
import { workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import { WorkspaceEditor } from "./WorkspaceEditor";
const workspace: CanonicalWorkspace = {
workspace: { schema_version: 3, id: "bootstrap-slot", name: "Bootstrap slot", description: "Needs configuration", language: "en" },
dwh: {
engine: "postgres", database: "clinical", schema: "datawarehouse", port: 5432,
supported_transports: ["postgres_direct"],
},
semantic_index: {
vector_store: {
engine: "qdrant", collection: "clinical", dimensions: 1024, distance: "cosine",
},
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
},
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
};
const evidenceWorkspace: CanonicalWorkspace = {
...workspace,
workspace: { ...workspace.workspace, id: "psd-clinical", name: "PSD Clinical", description: "Clinical workspace" },
evidence: {
source: {
type: "filesystem",
uri: "psd-clinical/evidence",
patterns: ["documents/**/*.pdf"],
max_bytes: 12_000_000,
},
policy: { max_chunk_chars: 8_000, retain_published_generations: 5 },
},
};
const draft: WorkspaceBootstrapDraft = {
workspaceId: "bootstrap-slot",
baseCommit: "a".repeat(40),
workspace,
updatedAt: "2026-08-04T10:00:00.000Z",
};
test("bootstrap mode locks catalog metadata and saves only the local bootstrap draft", async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
render(
<WorkspaceEditor
mode={{
kind: "bootstrap",
catalog: workspaceSummaryFixture("bootstrap-slot", {
displayName: "Bootstrap slot",
description: "Needs configuration",
configurationState: "configuration_required",
}),
draft,
}}
onSaveDraft={onSaveDraft}
/>,
);
expect(screen.getByLabelText("Workspace ID")).toBeDisabled();
expect(screen.getByLabelText("Workspace name")).toHaveValue("Bootstrap slot");
expect(screen.getByLabelText("Workspace name")).toBeDisabled();
expect(screen.getByLabelText("Description")).toHaveValue("Needs configuration");
expect(screen.getByLabelText("Description")).toBeDisabled();
await user.clear(screen.getByLabelText("Vector collection"));
await user.type(screen.getByLabelText("Vector collection"), "research-docs");
await user.click(screen.getByRole("button", { name: "Save draft" }));
expect(onSaveDraft).toHaveBeenCalledWith(expect.objectContaining({
baseCommit: "a".repeat(40),
workspaceId: "bootstrap-slot",
workspace: expect.objectContaining({
semantic_index: expect.objectContaining({
vector_store: expect.objectContaining({ collection: "research-docs" }),
}),
}),
}));
expect(onSaveDraft.mock.calls[0]?.[0]).not.toHaveProperty("baseBlob");
});
test("read-only mode shows evidence and curator git guidance without mutation actions", () => {
render(
<WorkspaceEditor
mode={{
kind: "read_only",
catalog: workspaceSummaryFixture("psd-clinical", {
displayName: "PSD Clinical",
description: "Clinical workspace",
revision: workspaceRevisionFixture("psd-clinical"),
}),
record: {
workspace: evidenceWorkspace,
revision: workspaceRevisionFixture("psd-clinical"),
},
}}
/>,
);
expect(screen.getByLabelText("DWH database")).toHaveValue("clinical");
expect(screen.getByLabelText("DWH database")).toHaveAttribute("readonly");
const summary = screen.getByRole("region", { name: "Evidence" });
expect(summary).toHaveTextContent("filesystem");
expect(summary).toHaveTextContent("psd-clinical/evidence");
expect(summary).toHaveTextContent("8,000");
expect(summary).toHaveTextContent("5");
expect(screen.getByText("Curator workflow")).toBeVisible();
expect(screen.getByText(/edit psd-clinical\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible();
expect(screen.queryByRole("button", { name: "Save draft" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /create workspace|publish/i })).not.toBeInTheDocument();
});
-273
View File
@@ -1,273 +0,0 @@
import { useEffect, useId, useMemo, useState, type ChangeEvent, type ReactNode } from "react";
import type { CanonicalWorkspace, PublishWorkspaceRequest, WorkspaceRecord, WorkspaceSummary } from "../api/workspaces";
import type { WorkspaceBootstrapDraft } from "../workspaces/drafts";
import { Button } from "../components/ui/button";
type FieldErrors = Record<string, string>;
export type WorkspaceEditorMode =
| { kind: "bootstrap"; catalog: WorkspaceSummary; draft: WorkspaceBootstrapDraft }
| { kind: "read_only"; catalog: WorkspaceSummary; record: WorkspaceRecord };
type BootstrapEditorProps = {
mode: Extract<WorkspaceEditorMode, { kind: "bootstrap" }>;
onSaveDraft?: (draft: WorkspaceBootstrapDraft) => void;
onRequestCreate?: (request: PublishWorkspaceRequest, draft: WorkspaceBootstrapDraft) => void;
};
type ReadOnlyEditorProps = {
mode: Extract<WorkspaceEditorMode, { kind: "read_only" }>;
/** Absent in read-only mode; declared so the union is uniformly addressable. */
onSaveDraft?: never;
onRequestCreate?: never;
};
export type WorkspaceEditorProps = BootstrapEditorProps | ReadOnlyEditorProps;
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 aria-invalid:border-destructive";
function positiveInteger(value: number | undefined, label: string, max = Number.MAX_SAFE_INTEGER): string | undefined {
if (value === undefined) return undefined;
if (!Number.isInteger(value) || value < 1 || value > max) {
return max === 65_535 ? "Port must be between 1 and 65535" : `${label} must be a positive whole number`;
}
return undefined;
}
function validate(workspace: CanonicalWorkspace): FieldErrors {
const errors: FieldErrors = {};
if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.workspace.id)) {
errors["workspace.id"] = "Use 3–63 lowercase letters, numbers, or hyphens; start with a letter";
}
if (!workspace.workspace.name.trim()) errors["workspace.name"] = "Workspace name is required";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.database)) errors["dwh.database"] = "Use a database identifier";
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(workspace.dwh.schema)) errors["dwh.schema"] = "Use a schema identifier";
if (!workspace.dwh.supported_transports.length) errors["dwh.transport"] = "Choose at least one DWH transport";
const dwhPort = positiveInteger(workspace.dwh.port, "DWH port", 65_535);
if (dwhPort) errors["dwh.port"] = dwhPort;
const dwhTimeout = positiveInteger(workspace.dwh.timeout_ms, "DWH timeout");
if (dwhTimeout) errors["dwh.timeout"] = dwhTimeout;
if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspace.semantic_index.vector_store.collection)) errors["vector.collection"] = "Use a canonical collection name";
if (!workspace.llm_policy.allowed.length || workspace.llm_policy.allowed.some((model) => !/^[^/\s]+\/[^/\s]+$/.test(model))) {
errors["llm.allowed"] = "Use provider/model entries separated by commas";
}
if (workspace.llm_policy.default && !workspace.llm_policy.allowed.includes(workspace.llm_policy.default)) {
errors["llm.default"] = "Default model must be in the allowlist";
}
return errors;
}
function selectedValues(event: ChangeEvent<HTMLSelectElement>): string[] {
return Array.from(event.currentTarget.selectedOptions, (option) => option.value);
}
function numberOrUndefined(value: string): number | undefined {
return value.trim() === "" ? undefined : Number(value);
}
function coerceCatalogMetadata(workspace: CanonicalWorkspace, catalog: WorkspaceSummary): CanonicalWorkspace {
return {
...workspace,
workspace: {
...workspace.workspace,
id: catalog.id,
name: catalog.displayName,
description: catalog.description,
},
};
}
function Field({
label,
error,
hint,
children,
}: {
label: string;
error?: string;
hint?: string;
children: (props: { id: string; describedBy?: string; invalid: boolean }) => ReactNode;
}) {
const id = useId();
const errorId = `${id}-error`;
const hintId = `${id}-hint`;
const describedBy = [hint ? hintId : undefined, error ? errorId : undefined].filter(Boolean).join(" ") || undefined;
return (
<div className="grid gap-1.5">
<label className="text-xs font-semibold text-foreground" htmlFor={id}>{label}</label>
{children({ id, describedBy, invalid: Boolean(error) })}
{hint && <p id={hintId} className="text-xs text-muted-foreground">{hint}</p>}
{error && <p id={errorId} role="alert" className="text-xs font-medium text-destructive">{error}</p>}
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="border-t border-border/70 pt-5 first:border-t-0 first:pt-0" aria-label={title}>
<h3 className="font-heading text-sm font-semibold text-foreground">{title}</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-2">{children}</div>
</section>
);
}
function EvidenceSummary({ evidence }: { evidence: NonNullable<CanonicalWorkspace["evidence"]> }) {
const sourceIdentity = evidence.source.type === "http"
? `${evidence.source.uris.length} canonical URI${evidence.source.uris.length === 1 ? "" : "s"}`
: evidence.source.uri;
return (
<Section title="Evidence">
<dl className="grid gap-2 text-sm sm:col-span-2 sm:grid-cols-2">
<div><dt className="font-medium text-foreground">Source type</dt><dd className="text-muted-foreground">{evidence.source.type}</dd></div>
<div><dt className="font-medium text-foreground">Source</dt><dd className="break-all text-muted-foreground">{sourceIdentity}</dd></div>
<div><dt className="font-medium text-foreground">Chunk size</dt><dd className="text-muted-foreground">{evidence.policy.max_chunk_chars.toLocaleString("en-US")}</dd></div>
<div><dt className="font-medium text-foreground">Retention</dt><dd className="text-muted-foreground">{evidence.policy.retain_published_generations.toLocaleString("en-US")}</dd></div>
</dl>
<p className="sm:col-span-2 text-sm text-muted-foreground">Evidence summary is read-only. Curate source files or URIs in Git.</p>
</Section>
);
}
export function WorkspaceEditor(props: WorkspaceEditorProps) {
const bootstrapMode = props.mode.kind === "bootstrap";
const initialWorkspace = props.mode.kind === "bootstrap"
? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog)
: props.mode.record.workspace;
const [workspace, setWorkspace] = useState<CanonicalWorkspace>(initialWorkspace);
const [errors, setErrors] = useState<FieldErrors>({});
const readOnly = !bootstrapMode;
const allowedModels = useMemo(() => workspace.llm_policy.allowed.join(", "), [workspace.llm_policy.allowed]);
useEffect(() => {
setWorkspace(props.mode.kind === "bootstrap"
? coerceCatalogMetadata(props.mode.draft.workspace, props.mode.catalog)
: props.mode.record.workspace);
setErrors({});
}, [bootstrapMode, props.mode]);
function update(change: (previous: CanonicalWorkspace) => CanonicalWorkspace) {
if (readOnly) return;
setWorkspace((previous) => {
const next = coerceCatalogMetadata(change(previous), props.mode.catalog);
setErrors(validate(next));
return next;
});
}
function currentDraft(): WorkspaceBootstrapDraft {
if (props.mode.kind !== "bootstrap") throw new Error("Read-only workspaces cannot create drafts");
return {
workspaceId: props.mode.catalog.id,
baseCommit: props.mode.draft.baseCommit,
workspace: coerceCatalogMetadata(workspace, props.mode.catalog),
updatedAt: new Date().toISOString(),
};
}
function saveDraft() {
if (props.mode.kind !== "bootstrap") return;
const nextErrors = validate(workspace);
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
props.onSaveDraft?.(currentDraft());
}
function requestCreate() {
if (props.mode.kind !== "bootstrap") return;
const nextErrors = validate(workspace);
setErrors(nextErrors);
if (Object.keys(nextErrors).length > 0) return;
const draft = currentDraft();
props.onRequestCreate?.({ action: "create", workspace: draft.workspace, baseCommit: draft.baseCommit }, draft);
}
return (
<form className="grid gap-6" onSubmit={(event) => { event.preventDefault(); saveDraft(); }} noValidate>
<Section title="General">
<Field label="Workspace ID" error={errors["workspace.id"]} hint="Catalog-owned technical identifier.">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace ID" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} className={fieldClass} value={workspace.workspace.id} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, id: event.target.value } }))} />}
</Field>
<Field label="Workspace name" error={errors["workspace.name"]} hint={bootstrapMode ? "Catalog-owned display metadata." : undefined}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Workspace name" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} className={fieldClass} value={workspace.workspace.name} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, name: event.target.value } }))} />}
</Field>
<Field label="Description">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Description" aria-describedby={describedBy} aria-invalid={invalid} disabled={bootstrapMode} readOnly={readOnly} className={fieldClass} value={workspace.workspace.description ?? ""} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, description: event.target.value || undefined } }))} />}
</Field>
<Field label="Workspace language">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Workspace language" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.workspace.language} onChange={(event) => update((value) => ({ ...value, workspace: { ...value.workspace, language: event.target.value as "en" | "it" } }))} disabled={readOnly}><option value="en">English</option><option value="it">Italian</option></select>}
</Field>
</Section>
<Section title="DWH">
<Field label="DWH engine">
{({ id, describedBy, invalid }) => <select id={id} aria-label="DWH engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="postgres" disabled><option value="postgres">PostgreSQL</option></select>}
</Field>
<Field label="DWH transport" error={errors["dwh.transport"]} hint="Choose supported installation transports.">
{({ id, describedBy, invalid }) => <select id={id} multiple aria-label="DWH transport" aria-describedby={describedBy} aria-invalid={invalid} className={`${fieldClass} h-24 py-1`} value={workspace.dwh.supported_transports} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, supported_transports: selectedValues(event) as CanonicalWorkspace["dwh"]["supported_transports"] } }))} disabled={readOnly}><option value="postgres_direct">postgres_direct</option><option value="rest_api">rest_api</option><option value="ssh_tunnel">ssh_tunnel</option></select>}
</Field>
<Field label="DWH database" error={errors["dwh.database"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH database" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.database} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, database: event.target.value } }))} />}
</Field>
<Field label="DWH schema" error={errors["dwh.schema"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="DWH schema" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.schema} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, schema: event.target.value } }))} />}
</Field>
<Field label="DWH port" error={errors["dwh.port"]}>
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="65535" aria-label="DWH port" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.port ?? ""} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, port: numberOrUndefined(event.target.value) } }))} />}
</Field>
<Field label="DWH timeout (ms)" error={errors["dwh.timeout"]}>
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" aria-label="DWH timeout (ms)" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.dwh.timeout_ms ?? ""} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, dwh: { ...value.dwh, timeout_ms: numberOrUndefined(event.target.value) } }))} />}
</Field>
</Section>
<Section title="Semantic index">
<Field label="Vector store engine">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector store engine" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qdrant" readOnly />}
</Field>
<Field label="Vector collection" error={errors["vector.collection"]}>
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector collection" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.collection} readOnly={readOnly} onChange={(event) => update((value) => ({ ...value, semantic_index: { ...value.semantic_index, vector_store: { ...value.semantic_index.vector_store, collection: event.target.value } } }))} />}
</Field>
<Field label="Vector distance">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Vector distance" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="cosine" readOnly />}
</Field>
<Field label="Semantic index dimensions" hint="One value is shared by the vector collection and embedding model.">
{({ id, describedBy, invalid }) => <input id={id} type="number" min="1" max="32768" aria-label="Semantic index dimensions" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.semantic_index.vector_store.dimensions} readOnly />}
</Field>
<Field label="Embedding provider">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding provider" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="ollama_internal" readOnly />}
</Field>
<Field label="Embedding model">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Embedding model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value="qwen3-embedding:0.6b" readOnly />}
</Field>
</Section>
<Section title="LLM policy">
<Field label="Allowed models" error={errors["llm.allowed"]} hint="Comma-separated provider/model identifiers.">
{({ id, describedBy, invalid }) => <input id={id} aria-label="Allowed models" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={allowedModels} readOnly={readOnly} onChange={(event) => update((value) => {
const allowed = event.target.value.split(",").map((model) => model.trim()).filter(Boolean) as `${string}/${string}`[];
return { ...value, llm_policy: { allowed, ...(value.llm_policy.default && allowed.includes(value.llm_policy.default) ? { default: value.llm_policy.default } : {}) } };
})} />}
</Field>
<Field label="Default model" error={errors["llm.default"]} hint="Only models in the allowlist are selectable.">
{({ id, describedBy, invalid }) => <select id={id} aria-label="Default model" aria-describedby={describedBy} aria-invalid={invalid} className={fieldClass} value={workspace.llm_policy.default ?? ""} onChange={(event) => update((value) => ({ ...value, llm_policy: { ...value.llm_policy, ...(event.target.value ? { default: event.target.value as `${string}/${string}` } : {}) } }))} disabled={readOnly}><option value="">No default</option>{workspace.llm_policy.allowed.map((model) => <option key={model} value={model}>{model}</option>)}</select>}
</Field>
</Section>
{workspace.evidence && <EvidenceSummary evidence={workspace.evidence} />}
<Section title="Installation requirements">
<p className="sm:col-span-2 text-sm text-muted-foreground">Hosts, users, secret-file paths, and credentials are installation bindings. They are intentionally not editable or stored in browser drafts.</p>
</Section>
{readOnly ? (
<Section title="Curator workflow">
<p className="sm:col-span-2 text-sm text-muted-foreground">To change this workspace, edit {workspace.workspace.id}/workspace.yaml, commit/push, then Pull.</p>
</Section>
) : (
<div className="flex justify-end gap-2 border-t border-border/70 pt-4">
<Button type="submit">Save draft</Button>
{"onRequestCreate" in props && props.onRequestCreate && <Button type="button" variant="outline" onClick={requestCreate}>Create workspace</Button>}
</div>
)}
</form>
);
}
+122 -170
View File
@@ -1,213 +1,165 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { beforeEach, expect, test, vi } from "vitest";
import { server } from "../test/msw";
import { workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import { canonicalWorkspaceFixture, workspaceRevisionFixture, workspaceSummaryFixture } from "../test/workspace-fixtures";
import { WorkspaceManager } from "./WorkspaceManager";
const readyWorkspace = {
workspace: {
schema_version: 3,
id: "psd-clinical",
name: "PSD Clinical",
description: "Clinical data",
language: "en" as const,
},
dwh: {
engine: "postgres" as const,
database: "clinical",
schema: "datawarehouse",
port: 5432,
supported_transports: ["postgres_direct"] as const,
},
semantic_index: {
vector_store: { engine: "qdrant" as const, collection: "clinical", dimensions: 1024 as const, distance: "cosine" as const },
embedding: { provider: "ollama_internal" as const, model: "qwen3-embedding:0.6b" as const, dimensions: 1024 as const },
},
llm_policy: { default: "zai/glm-5.2" as const, allowed: ["zai/glm-5.2"] as const },
evidence: {
source: {
type: "filesystem" as const,
uri: "psd-clinical/evidence",
patterns: ["documents/**/*.pdf"],
max_bytes: 12_000_000,
},
policy: { max_chunk_chars: 8_000, retain_published_generations: 5 },
},
const workspace = canonicalWorkspaceFixture("psd-clinical");
const revision = workspaceRevisionFixture("psd-clinical");
const requirement = {
id: "dwh.password",
connector: "dwh",
label: "Data warehouse password",
description: "Password used by the selected data warehouse connection.",
input: "password",
required: true,
configured: false,
};
const bootstrapWorkspace = {
...readyWorkspace,
workspace: {
...readyWorkspace.workspace,
id: "bootstrap-slot",
name: "Bootstrap slot",
description: "Needs configuration",
},
semantic_index: {
...readyWorkspace.semantic_index,
vector_store: { ...readyWorkspace.semantic_index.vector_store, collection: "bootstrap-slot" },
},
};
function runtimeConfiguration(configured = false) {
return {
workspaceId: "psd-clinical",
revision,
configurationState: configured ? "ready" : "configuration_required",
requirements: [{ ...requirement, configured }],
};
}
function renderManager() {
function renderManager(onClose = vi.fn()) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}><WorkspaceManager open onClose={() => undefined} /></QueryClientProvider>);
return {
onClose,
...render(
<QueryClientProvider client={client}>
<WorkspaceManager open onClose={onClose} />
</QueryClientProvider>,
),
};
}
beforeEach(() => {
localStorage.clear();
server.use(
http.get("/api/workspace-registry/status", () =>
HttpResponse.json({ branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false })),
http.get("/api/workspace-registry/status", () => HttpResponse.json({
branch: "main",
head: "a".repeat(40),
ahead: 0,
behind: 0,
degraded: false,
repository: {
host: "git.example.test",
repository: "analytics/thoth-workspaces",
transport: "ssh",
},
})),
http.get("/api/workspaces", () => HttpResponse.json([
workspaceSummaryFixture("bootstrap-slot", {
displayName: "Bootstrap slot",
description: "Needs configuration",
configurationState: "configuration_required",
}),
workspaceSummaryFixture("psd-clinical", {
displayName: "PSD Clinical",
description: "Clinical data",
revision: workspaceRevisionFixture("psd-clinical"),
configurationState: "configuration_required",
revision,
}),
])),
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({
workspace: readyWorkspace,
revision: workspaceRevisionFixture("psd-clinical"),
})),
http.get("/api/workspaces/psd-clinical", () => HttpResponse.json({ workspace, revision })),
http.get("/api/workspaces/psd-clinical/runtime-configuration", () => (
HttpResponse.json(runtimeConfiguration())
)),
);
});
afterEach(() => vi.unstubAllGlobals());
test("uses at least sixty percent of the viewport on desktop", () => {
renderManager();
test("renders catalog slots in order and opens a bootstrap form for configuration_required entries", async () => {
expect(screen.getByRole("dialog")).toHaveClass(
"h-[70vh]",
"w-[94vw]",
"sm:w-[70vw]",
"max-w-[94vw]",
);
});
test("level one explains the read-only Git sequence and the repository update button", async () => {
const user = userEvent.setup();
server.use(http.post("/api/workspace-registry/pull", () => HttpResponse.json({
branch: "main", head: "b".repeat(40), ahead: 0, behind: 0, degraded: false,
})));
renderManager();
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
expect(screen.getByRole("button", { name: "Bootstrap slot" })).toBeVisible();
expect(screen.getByRole("button", { name: "PSD Clinical" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Bootstrap slot" }));
const overview = screen.getByTestId("workspace-overview");
expect(within(overview).getByText(/Git server such as GitHub, GitLab, or Gitea/i)).toBeVisible();
expect(within(overview).getByText(/configured during ThothII installation/i)).toBeVisible();
expect(within(overview).getAllByText(/managed read-only checkout/i)).toHaveLength(2);
expect(within(overview).getByText(/current active revision remains unchanged/i)).toBeVisible();
expect(within(overview).getByText(/No workspace selection is required/i)).toBeVisible();
expect(await within(overview).findByText("git.example.test/analytics/thoth-workspaces")).toBeVisible();
expect(await screen.findByLabelText("Workspace ID")).toHaveValue("bootstrap-slot");
expect(screen.getByLabelText("Workspace ID")).toBeDisabled();
expect(screen.getByLabelText("Workspace name")).toHaveValue("Bootstrap slot");
expect(screen.getByLabelText("Workspace name")).toBeDisabled();
expect(screen.getByLabelText("Description")).toHaveValue("Needs configuration");
expect(screen.getByRole("button", { name: "Save draft" })).toBeVisible();
expect(screen.getByRole("button", { name: "Create workspace" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Update workspace repository" }));
expect(await screen.findByText("Workspace repository updated and validated.")).toBeVisible();
expect(screen.queryByText(/import|export|bundle|create a local workspace/i)).not.toBeInTheDocument();
});
test("saves a bootstrap draft locally for a configuration_required slot", async () => {
test("workspace-specific commands remain isolated until a workspace is selected", async () => {
const user = userEvent.setup();
renderManager();
await user.click(await screen.findByRole("button", { name: "Bootstrap slot" }));
await user.clear(screen.getByLabelText("Vector collection"));
await user.type(screen.getByLabelText("Vector collection"), "bootstrap-docs");
await user.click(screen.getByRole("button", { name: "Save draft" }));
await waitFor(() => expect(screen.getByText("Draft saved in this browser.")).toBeVisible());
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).toContain('"baseCommit"');
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).not.toContain("baseBlob");
});
test("successful create discards the bootstrap draft and reloads the workspace as read-only", async () => {
const user = userEvent.setup();
let workspacesCalls = 0;
server.use(
http.get("/api/workspaces", () => {
workspacesCalls += 1;
return HttpResponse.json(workspacesCalls === 1 ? [
workspaceSummaryFixture("bootstrap-slot", {
displayName: "Bootstrap slot",
description: "Needs configuration",
configurationState: "configuration_required",
}),
] : [
workspaceSummaryFixture("bootstrap-slot", {
displayName: "Bootstrap slot",
description: "Needs configuration",
revision: workspaceRevisionFixture("bootstrap-slot"),
}),
]);
}),
http.get("/api/workspaces/bootstrap-slot", () => HttpResponse.json({
workspace: bootstrapWorkspace,
revision: workspaceRevisionFixture("bootstrap-slot"),
})),
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: bootstrapWorkspace, contract: {} })),
http.post("/api/workspaces/publish", () => HttpResponse.json({ revision: workspaceRevisionFixture("bootstrap-slot") })),
);
localStorage.setItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot", JSON.stringify({
workspaceId: "bootstrap-slot",
baseCommit: "a".repeat(40),
workspace: bootstrapWorkspace,
updatedAt: "2026-08-04T10:00:00.000Z",
}));
renderManager();
await user.click(await screen.findByRole("button", { name: "Bootstrap slot" }));
await user.click(screen.getByRole("button", { name: "Create workspace" }));
await user.click(screen.getByRole("button", { name: "Validate draft" }));
await user.click(await screen.findByRole("button", { name: "Create workspace" }));
await user.click(screen.getByRole("button", { name: "Confirm create" }));
await waitFor(() => expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).toBeNull());
expect(await screen.findByText(/edit bootstrap-slot\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible();
expect(screen.queryByRole("button", { name: "Save draft" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create workspace" })).not.toBeInTheDocument();
});
test("ready workspaces stay read-only while keeping pull, export, validate, and installation test actions", async () => {
const user = userEvent.setup();
const createObjectURL = vi.fn(() => "blob:workspace-bundle");
const revokeObjectURL = vi.fn();
class DownloadUrl extends URL {
static createObjectURL = createObjectURL;
static revokeObjectURL = revokeObjectURL;
}
vi.stubGlobal("URL", DownloadUrl);
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
server.use(
http.post("/api/workspace-registry/pull", () => HttpResponse.json({ branch: "main", head: "c".repeat(40), ahead: 0, behind: 0, degraded: false })),
http.get("/api/workspaces/psd-clinical/export", () => new HttpResponse(new Blob(["bundle"], { type: "application/zip" }))),
http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace: readyWorkspace, contract: {} })),
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
activatable: false,
diagnostics: [{ level: "warning", code: "binding_missing", field: "dwh", message: "DWH binding is not configured" }],
})),
);
localStorage.setItem("thothii.workspace-registry.v1.draft.psd-clinical", JSON.stringify({ foo: "bar" }));
renderManager();
expect(screen.queryByRole("heading", { name: "Workspace-specific actions" })).not.toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
expect(await screen.findByText(/edit psd-clinical\/workspace\.yaml, commit\/push, then Pull/i)).toBeVisible();
expect(screen.queryByRole("button", { name: /duplicate workspace|delete workspace|publish draft/i })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Pull latest registry" }));
expect(await screen.findByText("Registry updated. Reload a workspace to review its latest revision.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Export workspace bundle" }));
await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1));
await user.click(screen.getByRole("button", { name: "Validate workspace" }));
expect(await screen.findByText("Workspace definition is valid.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Test on this installation" }));
expect(await screen.findByText("binding_missing: DWH binding is not configured")).toBeVisible();
expect(within(screen.getByTestId("workspace-diagnostics")).queryByText(/password|token|secret/i)).not.toBeInTheDocument();
expect(await screen.findByRole("heading", { name: "Workspace-specific actions" })).toBeVisible();
expect(screen.getByText(/reads this revision without modifying or publishing it/i)).toBeVisible();
expect(screen.getByText(/checks workspace.yaml and the required workspace directories/i)).toBeVisible();
expect(screen.getByText(/temporary decrypted credentials/i)).toBeVisible();
expect(screen.getByRole("button", { name: "Validate workspace source" })).toBeVisible();
expect(screen.getByRole("button", { name: "Test workspace connections" })).toBeVisible();
});
test("import populates only a matching configuration_required slot and refuses existing workspaces", async () => {
test("secret fields are write-only, clear after blind save, and may be forgotten", async () => {
const user = userEvent.setup();
let savedBody: unknown;
server.use(
http.put("/api/workspaces/psd-clinical/secrets", async ({ request }) => {
savedBody = await request.json();
return HttpResponse.json(runtimeConfiguration(true));
}),
http.delete("/api/workspaces/psd-clinical/secrets/dwh.password", () => (
HttpResponse.json(runtimeConfiguration(false))
)),
);
renderManager();
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace: readyWorkspace, contract: {} } })));
await user.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" }));
expect(await screen.findByText(/workspace_invalid: Imported bundle can only bootstrap a matching catalog slot/i)).toBeVisible();
const input = await screen.findByLabelText("Data warehouse password");
expect(input).toHaveValue("");
expect(input).toHaveAttribute("type", "password");
expect(screen.getByText("Not configured")).toBeVisible();
await user.type(input, "one-time-password");
await user.click(screen.getByRole("button", { name: "Save entered secrets" }));
server.use(http.post("/api/workspaces/import", () => HttpResponse.json({ draft: { workspace: bootstrapWorkspace, contract: {} } })));
await user.upload(screen.getByLabelText("Import workspace bundle"), new File(["bundle"], "workspace.zip", { type: "application/zip" }));
expect(await screen.findByText("Imported bootstrap draft saved in this browser. Validate it before creating the descriptor.")).toBeVisible();
expect(localStorage.getItem("thothii.workspace-registry.v2.bootstrap.bootstrap-slot")).not.toBeNull();
await waitFor(() => expect(savedBody).toEqual({
values: { "dwh.password": "one-time-password" },
}));
expect(input).toHaveValue("");
expect(await screen.findByText("Configured")).toBeVisible();
expect(screen.queryByDisplayValue("one-time-password")).not.toBeInTheDocument();
expect(localStorage.length).toBe(0);
await user.click(screen.getByRole("button", { name: "Forget stored Data warehouse password" }));
expect(await screen.findByText("Not configured")).toBeVisible();
});
test("closing clears unsaved secret fields", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderManager(onClose);
await user.click(await screen.findByRole("button", { name: "PSD Clinical" }));
await user.type(await screen.findByLabelText("Data warehouse password"), "unsaved-value");
await user.click(screen.getByRole("button", { name: "Close workspace management" }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByDisplayValue("unsaved-value")).not.toBeInTheDocument();
});
+320 -214
View File
@@ -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>
);
}
@@ -1,66 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { beforeEach, expect, test, vi } from "vitest";
import { canonicalWorkspaceFixture, workspaceRevisionFixture } from "../test/workspace-fixtures";
import { server } from "../test/msw";
import { WorkspacePublishDialog } from "./WorkspacePublishDialog";
const workspace = {
...canonicalWorkspaceFixture("bootstrap-slot"),
workspace: {
...canonicalWorkspaceFixture("bootstrap-slot").workspace,
name: "Bootstrap slot",
description: "Needs configuration",
},
};
const request = {
action: "create" as const,
workspace,
baseCommit: "a".repeat(40),
};
beforeEach(() => {
server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })));
});
test("validates a bootstrap draft and requires a separate confirmation before creating it", async () => {
const user = userEvent.setup();
const published = vi.fn();
let publishCalls = 0;
server.use(http.post("/api/workspaces/publish", () => {
publishCalls += 1;
return HttpResponse.json({ revision: workspaceRevisionFixture("bootstrap-slot") });
}));
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={published} />);
expect(screen.getByRole("button", { name: "Create workspace" })).toBeDisabled();
await user.click(screen.getByRole("button", { name: "Validate draft" }));
expect(await screen.findByText("Workspace definition is valid.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Create workspace" }));
expect(screen.getByRole("heading", { name: "Confirm workspace creation" })).toBeVisible();
expect(publishCalls).toBe(0);
await user.click(screen.getByRole("button", { name: "Confirm create" }));
await waitFor(() => expect(published).toHaveBeenCalledWith(workspaceRevisionFixture("bootstrap-slot")));
expect(publishCalls).toBe(1);
});
test("surfaces a safe curator-owned refusal without showing conflict resolution UI", async () => {
const user = userEvent.setup();
server.use(http.post("/api/workspaces/publish", () => HttpResponse.json({
code: "workspace_curator_owned",
message: "Existing descriptors are curator-owned.",
}, { status: 409 })));
render(<WorkspacePublishDialog open request={request} onOpenChange={vi.fn()} onPublished={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Validate draft" }));
await user.click(await screen.findByRole("button", { name: "Create workspace" }));
await user.click(screen.getByRole("button", { name: "Confirm create" }));
expect(await screen.findByText("workspace_curator_owned: Existing descriptors are curator-owned.")).toBeVisible();
expect(screen.queryByText(/save revised draft|use your draft|use registry value/i)).not.toBeInTheDocument();
});
@@ -1,92 +0,0 @@
import { useEffect, useState } from "react";
import {
asWorkspaceApiError,
publishWorkspace,
validateWorkspace,
type PublishWorkspaceRequest,
type WorkspaceRevision,
} from "../api/workspaces";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
export interface WorkspacePublishDialogProps {
open: boolean;
request: PublishWorkspaceRequest;
onOpenChange: (open: boolean) => void;
onPublished: (revision: WorkspaceRevision | undefined) => void;
}
function RevisionSummary({ request }: { request: PublishWorkspaceRequest }) {
return <p className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 font-mono text-xs text-muted-foreground">New workspace · base {request.baseCommit.slice(0, 12)}</p>;
}
export function WorkspacePublishDialog({ open, request, onOpenChange, onPublished }: WorkspacePublishDialogProps) {
const [validatedRequest, setValidatedRequest] = useState<PublishWorkspaceRequest>();
const [confirming, setConfirming] = useState(false);
const [message, setMessage] = useState<string>();
const [publishing, setPublishing] = useState(false);
useEffect(() => {
if (!open) return;
setValidatedRequest(undefined);
setConfirming(false);
setMessage(undefined);
setPublishing(false);
}, [open, request]);
async function validateDraft() {
setMessage(undefined);
try {
const result = await validateWorkspace(request.workspace);
setValidatedRequest({ ...request, workspace: result.workspace });
setMessage("Workspace definition is valid.");
} catch (error) {
const safe = asWorkspaceApiError(error);
setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed");
}
}
async function createWorkspace() {
if (!validatedRequest) return;
setPublishing(true);
setMessage(undefined);
try {
const result = await publishWorkspace(validatedRequest);
onPublished(result?.revision);
onOpenChange(false);
} catch (error) {
const safe = asWorkspaceApiError(error);
setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Publication could not be completed");
setConfirming(false);
} finally {
setPublishing(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="max-w-[min(94vw,46rem)] p-0">
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
<DialogTitle>{confirming ? "Confirm workspace creation" : "Create workspace"}</DialogTitle>
<DialogDescription>{confirming ? "This creates the validated workspace definition in the shared Git registry." : "Validation and an explicit confirmation are required before this shared definition is created."}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 px-5 py-5">
<RevisionSummary request={validatedRequest ?? request} />
{message && <p role="status" className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-sm">{message}</p>}
{confirming ? (
<DialogFooter>
<Button variant="outline" onClick={() => setConfirming(false)}>Cancel</Button>
<Button onClick={() => { void createWorkspace(); }} disabled={publishing}>{publishing ? "Creating…" : "Confirm create"}</Button>
</DialogFooter>
) : (
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={() => { void validateDraft(); }}>Validate draft</Button>
<Button disabled={!validatedRequest} onClick={() => setConfirming(true)}>Create workspace</Button>
</div>
)}
</div>
<button type="button" aria-label="Close publish workspace" onClick={() => onOpenChange(false)} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">×</button>
</DialogContent>
</Dialog>
);
}