This commit is contained in:
@@ -30,6 +30,8 @@ export interface AppConfig {
|
|||||||
dataRoot?: string;
|
dataRoot?: string;
|
||||||
ollamaEnsureTimeoutMs: number;
|
ollamaEnsureTimeoutMs: number;
|
||||||
piManagementTimeoutMs: number;
|
piManagementTimeoutMs: number;
|
||||||
|
/** Host CLI platform projected into Docker; not the browser or container OS. */
|
||||||
|
hostPlatform?: string;
|
||||||
secretsFile?: string;
|
secretsFile?: string;
|
||||||
installationConfigFile?: string;
|
installationConfigFile?: string;
|
||||||
evidenceHostRegistryRoot?: string;
|
evidenceHostRegistryRoot?: string;
|
||||||
@@ -475,6 +477,7 @@ export function loadConfig(
|
|||||||
dataRoot: env.THT_DATA_ROOT,
|
dataRoot: env.THT_DATA_ROOT,
|
||||||
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
||||||
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
|
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
|
||||||
|
hostPlatform: env.THT_HOST_PLATFORM,
|
||||||
secretsFile,
|
secretsFile,
|
||||||
installationConfigFile,
|
installationConfigFile,
|
||||||
evidenceHostRegistryRoot: env.THT_EVIDENCE_HOST_REGISTRY_ROOT || undefined,
|
evidenceHostRegistryRoot: env.THT_EVIDENCE_HOST_REGISTRY_ROOT || undefined,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface PiInstallationConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PiStatus {
|
export interface PiStatus {
|
||||||
|
hostPlatform: "linux" | "macos" | "windows";
|
||||||
version?: string;
|
version?: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
credentials: PiCredentialStatus;
|
credentials: PiCredentialStatus;
|
||||||
@@ -92,6 +93,9 @@ interface PiManagementDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
|
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
|
||||||
|
const platform = config.hostPlatform ?? process.platform;
|
||||||
|
const hostPlatform = platform === "darwin" ? "macos"
|
||||||
|
: platform === "windows" || platform === "win32" ? "windows" : "linux";
|
||||||
const now = deps.now ?? (() => new Date());
|
const now = deps.now ?? (() => new Date());
|
||||||
const diagnostics: string[] = [];
|
const diagnostics: string[] = [];
|
||||||
const addDiagnostic = (message: string): void => {
|
const addDiagnostic = (message: string): void => {
|
||||||
@@ -169,11 +173,11 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
|||||||
try {
|
try {
|
||||||
const currentVersion = await version();
|
const currentVersion = await version();
|
||||||
addDiagnostic("Pi version probe succeeded");
|
addDiagnostic("Pi version probe succeeded");
|
||||||
return { version: currentVersion, ready: true, credentials, config: current, checkedAt };
|
return { hostPlatform, version: currentVersion, ready: true, credentials, config: current, checkedAt };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = stableMessage(error, "Pi runtime is unavailable");
|
const message = stableMessage(error, "Pi runtime is unavailable");
|
||||||
addDiagnostic(message);
|
addDiagnostic(message);
|
||||||
return { ready: false, credentials, config: current, checkedAt, message };
|
return { hostPlatform, ready: false, credentials, config: current, checkedAt, message };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-manage
|
|||||||
SETTINGS_FILE: settingsFile,
|
SETTINGS_FILE: settingsFile,
|
||||||
PI_BIN: "/usr/local/bin/pi",
|
PI_BIN: "/usr/local/bin/pi",
|
||||||
PI_MANAGEMENT_TIMEOUT_MS: "750",
|
PI_MANAGEMENT_TIMEOUT_MS: "750",
|
||||||
|
THT_HOST_PLATFORM: "linux",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,16 @@ function successfulExec(calls: Array<{ command: string; args: string[]; timeout:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["linux", "linux"], ["darwin", "macos"], ["windows", "windows"],
|
||||||
|
])("reports installation host %s independently of the backend container OS", async (host, expected) => {
|
||||||
|
const service = createPiManagement(loadConfig({ THT_HOST_PLATFORM: host }), {
|
||||||
|
modelCatalog, execute: successfulExec([]), readSettings: () => ({ thinking: "medium" }),
|
||||||
|
credentialStatus: () => "missing",
|
||||||
|
});
|
||||||
|
expect((await service.status()).hostPlatform).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
// Catches a Pi executable that emits unexpected text or is invoked through a shell, which could
|
// Catches a Pi executable that emits unexpected text or is invoked through a shell, which could
|
||||||
// turn a version display into a command-injection or information-disclosure surface.
|
// turn a version display into a command-injection or information-disclosure surface.
|
||||||
test("status parses only a Pi version from a fixed execFile argument array", async () => {
|
test("status parses only a Pi version from a fixed execFile argument array", async () => {
|
||||||
@@ -73,6 +84,7 @@ test("status parses only a Pi version from a fixed execFile argument array", asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.status()).resolves.toEqual({
|
await expect(service.status()).resolves.toEqual({
|
||||||
|
hostPlatform: "linux",
|
||||||
version: "0.80.3",
|
version: "0.80.3",
|
||||||
ready: true,
|
ready: true,
|
||||||
credentials: "missing",
|
credentials: "missing",
|
||||||
@@ -104,6 +116,7 @@ test.each(["present", "missing"] as const)(
|
|||||||
|
|
||||||
const status = await service.status();
|
const status = await service.status();
|
||||||
expect(status).toEqual({
|
expect(status).toEqual({
|
||||||
|
hostPlatform: "linux",
|
||||||
version: "0.80.3",
|
version: "0.80.3",
|
||||||
ready: true,
|
ready: true,
|
||||||
credentials,
|
credentials,
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ declare providers, model allowlists, defaults, embeddings, dimensions, or vector
|
|||||||
Do not edit `deploy/pi/models.json`, `deploy/pi/settings.json`, files under `generated/`, or
|
Do not edit `deploy/pi/models.json`, `deploy/pi/settings.json`, files under `generated/`, or
|
||||||
provider/model environment defaults. Those former sources are retired.
|
provider/model environment defaults. Those former sources are retired.
|
||||||
|
|
||||||
|
## Host instructions in Administration
|
||||||
|
|
||||||
|
The **Pi configuration** navigation button opens **Pi management**. Its Host maintenance
|
||||||
|
section selects the installation host's Linux, macOS, or Windows tab automatically; the
|
||||||
|
browser's operating system does not affect it. Selecting another tab is still possible.
|
||||||
|
|
||||||
|
The host CLI includes `THT_HOST_PLATFORM` in the generated Compose projection using the OS
|
||||||
|
on which it runs. Generate projections on the destination host, not on another computer,
|
||||||
|
and do not edit generated files. Without this projection (older deployments or native
|
||||||
|
development), the backend reports its own OS; a Linux Docker container cannot discover
|
||||||
|
whether the physical host is macOS or Windows. Run the updated host CLI's normal
|
||||||
|
configuration reload on the destination installation to regenerate this information.
|
||||||
|
|
||||||
## Minimal catalog
|
## Minimal catalog
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Administration follow-up: Gitea #28–#31
|
||||||
|
|
||||||
|
Baseline approved by the user: `f52bf22e` on `codex/prototype-administration-pages`.
|
||||||
|
Existing Admin, Core, sessions, and unified-model work is outside this review diff.
|
||||||
|
|
||||||
|
## Ticket acceptance
|
||||||
|
|
||||||
|
- [#28](https://git.tylconsulting.it/mptyl/ThothII/issues/28): Workspace page has four
|
||||||
|
tabs, in order: Preparation, Specific actions, About, Authentication. Every area's
|
||||||
|
content remains vertically accessible, with scrolling where required.
|
||||||
|
- [#29](https://git.tylconsulting.it/mptyl/ThothII/issues/29): Database opens at the
|
||||||
|
existing database list instead of automatically editing a connection. Owner comment:
|
||||||
|
remove the “Workspace preparation” footer and restore the table's original available
|
||||||
|
space and margins. Tables and columns remain accessible through the original grid actions.
|
||||||
|
- [#30](https://git.tylconsulting.it/mptyl/ThothII/issues/30): Pi fills the available
|
||||||
|
height minus its margins, scrolls overflowing content, and initially opens the tab
|
||||||
|
for the installation OS (Linux on a Linux server, macOS or Windows otherwise).
|
||||||
|
- [#31](https://git.tylconsulting.it/mptyl/ThothII/issues/31): Keep “management” in page
|
||||||
|
titles, remove it from Admin buttons; use “Pi configuration” for Pi.
|
||||||
|
|
||||||
|
## Scope and compatibility
|
||||||
|
|
||||||
|
The global workspace/model gate, operation locks, original Core, session navigation,
|
||||||
|
and unsaved-change guards are retained. The list currently retains the existing global
|
||||||
|
workspace scope; an asynchronous question asks whether the user wants to broaden this
|
||||||
|
to all workspaces. No cross-workspace operations are introduced without that decision.
|
||||||
|
|
||||||
|
Workspace tab panels stay mounted, preserving secret drafts and diagnostics. Pi platform
|
||||||
|
selection stays mounted across the two Pi sections and does not replace a manual choice
|
||||||
|
when status refreshes. OS information is projected by the host CLI, not inferred from
|
||||||
|
the browser. Older unprojected installations fall back to the backend runtime OS; see
|
||||||
|
the [operator guide](../general/pi-configuration.md#host-instructions-in-administration).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
TDD seams: AppShell navigation and public management-page behavior with MSW API fixtures;
|
||||||
|
the existing public Pi service and host projection contracts cover OS transport.
|
||||||
|
Red tests were observed before adding tabs, restoring list entry, selecting OS tabs,
|
||||||
|
projecting the host platform, and shortening navigation labels.
|
||||||
|
|
||||||
|
Verification results and independent Standards/Spec reviews are recorded below when complete.
|
||||||
|
Docker has not been rebuilt for this follow-up; the read-only QA preview uses the changed source.
|
||||||
@@ -439,12 +439,12 @@ test("context panels stay inside the manager and the Tables grid sits in a sideb
|
|||||||
|
|
||||||
await administration.click();
|
await administration.click();
|
||||||
await expect(administration).toHaveAttribute("aria-expanded", "false");
|
await expect(administration).toHaveAttribute("aria-expanded", "false");
|
||||||
await expect(adminNavigationRail.getByRole("button", { name: "Database management", exact: true })).toHaveCount(0);
|
await expect(adminNavigationRail.getByRole("button", { name: "Database", exact: true })).toHaveCount(0);
|
||||||
await administration.click();
|
await administration.click();
|
||||||
await expect(administration).toHaveAttribute("aria-expanded", "true");
|
await expect(administration).toHaveAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
await adminNavigationRail
|
await adminNavigationRail
|
||||||
.getByRole("button", { name: "Database management", exact: true })
|
.getByRole("button", { name: "Database", exact: true })
|
||||||
.click();
|
.click();
|
||||||
await expectCurrentNavigation(page, "Database management");
|
await expectCurrentNavigation(page, "Database management");
|
||||||
|
|
||||||
@@ -795,7 +795,7 @@ test("Workspace and Pi management share the centered work-area panel without cov
|
|||||||
await expectWorkAreaPanelGeometry(page, "Workspace management");
|
await expectWorkAreaPanelGeometry(page, "Workspace management");
|
||||||
await expectWorkAreaPanelHeaderUsesPrimary(page, "Workspace management");
|
await expectWorkAreaPanelHeaderUsesPrimary(page, "Workspace management");
|
||||||
|
|
||||||
const piTrigger = page.getByRole("button", { name: "Pi management", exact: true });
|
const piTrigger = page.getByRole("button", { name: "Pi configuration", exact: true });
|
||||||
await piTrigger.click();
|
await piTrigger.click();
|
||||||
await expectCurrentNavigation(page, "Pi management");
|
await expectCurrentNavigation(page, "Pi management");
|
||||||
await expect(
|
await expect(
|
||||||
@@ -812,7 +812,7 @@ test("Workspace and Pi management share the centered work-area panel without cov
|
|||||||
}
|
}
|
||||||
|
|
||||||
await page.setViewportSize({ width: 720, height: 800 });
|
await page.setViewportSize({ width: 720, height: 800 });
|
||||||
await page.getByRole("button", { name: "Workspace management", exact: true }).click();
|
await page.getByRole("button", { name: "Workspace", exact: true }).click();
|
||||||
await expectCurrentNavigation(page, "Workspace management");
|
await expectCurrentNavigation(page, "Workspace management");
|
||||||
const compactWorkArea = page.getByTestId("conversation-column");
|
const compactWorkArea = page.getByTestId("conversation-column");
|
||||||
const compactPanel = compactWorkArea.getByRole("dialog", { name: "Workspace management" });
|
const compactPanel = compactWorkArea.getByRole("dialog", { name: "Workspace management" });
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ LocalEvidenceArchive(root).initialize()
|
|||||||
}).allTextContents()).map(text => text.trim())).toEqual([
|
}).allTextContents()).map(text => text.trim())).toEqual([
|
||||||
"Database management", "Memory management", "Evidence management",
|
"Database management", "Memory management", "Evidence management",
|
||||||
]);
|
]);
|
||||||
await page.getByRole("button", { name: "Memory management", exact: true }).click();
|
await page.getByRole("button", { name: "Memory", exact: true }).click();
|
||||||
await page.screenshot({ path: "/private/tmp/thothii-m1-memory-opening.png", fullPage: true });
|
await page.screenshot({ path: "/private/tmp/thothii-m1-memory-opening.png", fullPage: true });
|
||||||
const memory = page.getByRole("main", { name: "Memory management" });
|
const memory = page.getByRole("main", { name: "Memory management" });
|
||||||
await expect(memory).toBeVisible();
|
await expect(memory).toBeVisible();
|
||||||
@@ -157,9 +157,9 @@ finally: service.close()
|
|||||||
THT_PRINCIPAL_ISSUER: "test", THT_PRINCIPAL_SUBJECT: "admin", THT_PRINCIPAL_IS_ADMIN: "true" });
|
THT_PRINCIPAL_ISSUER: "test", THT_PRINCIPAL_SUBJECT: "admin", THT_PRINCIPAL_IS_ADMIN: "true" });
|
||||||
expect(JSON.parse(recalled)).toEqual([]);
|
expect(JSON.parse(recalled)).toEqual([]);
|
||||||
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeEnabled();
|
await expect(page.getByRole("button", { name: "New card", exact: true })).toBeEnabled();
|
||||||
if (!await page.getByRole("button", { name: "Evidence management", exact: true }).isVisible())
|
if (!await page.getByRole("button", { name: "Evidence", exact: true }).isVisible())
|
||||||
await page.getByRole("button", { name: "Administration", exact: true }).click();
|
await page.getByRole("button", { name: "Administration", exact: true }).click();
|
||||||
await page.getByRole("button", { name: "Evidence management", exact: true }).click();
|
await page.getByRole("button", { name: "Evidence", exact: true }).click();
|
||||||
const evidence = page.getByRole("main", { name: "Evidence management" });
|
const evidence = page.getByRole("main", { name: "Evidence management" });
|
||||||
await expect(evidence).toBeVisible();
|
await expect(evidence).toBeVisible();
|
||||||
await evidence.getByRole("combobox", { name: /Workspace/ }).selectOption("fixture-workspace");
|
await evidence.getByRole("combobox", { name: /Workspace/ }).selectOption("fixture-workspace");
|
||||||
@@ -179,12 +179,12 @@ finally: service.close()
|
|||||||
await page.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-mobile.png", fullPage: true });
|
await page.screenshot({ path: "/private/tmp/thothii-acceptance-evidence-mobile.png", fullPage: true });
|
||||||
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
||||||
const navigation = page.getByRole("dialog", { name: "Navigation", exact: true });
|
const navigation = page.getByRole("dialog", { name: "Navigation", exact: true });
|
||||||
await expect(navigation.getByRole("button", { name: "Memory management", exact: true })).toBeVisible();
|
await expect(navigation.getByRole("button", { name: "Memory", exact: true })).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(navigation).toBeHidden();
|
await expect(navigation).toBeHidden();
|
||||||
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeFocused();
|
await expect(page.getByRole("button", { name: "Navigation", exact: true })).toBeFocused();
|
||||||
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
await page.getByRole("button", { name: "Navigation", exact: true }).click();
|
||||||
await navigation.getByRole("button", { name: "Memory management", exact: true }).click();
|
await navigation.getByRole("button", { name: "Memory", exact: true }).click();
|
||||||
await expect(navigation).toBeHidden();
|
await expect(navigation).toBeHidden();
|
||||||
const mobileMemory = page.getByRole("main", { name: "Memory management" });
|
const mobileMemory = page.getByRole("main", { name: "Memory management" });
|
||||||
await expect(mobileMemory).toBeVisible();
|
await expect(mobileMemory).toBeVisible();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface PiInstallationConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PiManagementStatus {
|
export interface PiManagementStatus {
|
||||||
|
hostPlatform?: "linux" | "macos" | "windows";
|
||||||
version?: string;
|
version?: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
credentials: "present" | "missing";
|
credentials: "present" | "missing";
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
test("uses short Admin buttons while keeping management in page titles", async () => {
|
||||||
|
renderShell();
|
||||||
|
await screen.findByRole("textbox", { name: /new question/i });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||||
|
for (const [label, title] of [["Workspace", "Workspace"], ["Evidence", "Evidence"], ["Memory", "Memory"], ["Database", "Database"], ["Pi configuration", "Pi"]]) {
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: label }));
|
||||||
|
expect(await screen.findByRole("main", { name: `${title} management` })).toBeVisible();
|
||||||
|
expect(screen.queryByRole("button", { name: `${title} management` })).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test.each(["Workspace", "Evidence", "Memory", "Database", "Pi"])("reloads the %s route as a full page", async name => {
|
test.each(["Workspace", "Evidence", "Memory", "Database", "Pi"])("reloads the %s route as a full page", async name => {
|
||||||
window.history.replaceState({ portal: "kept" }, "", `/portal?lang=it&thoth_route=administration/${name.toLowerCase()}#host`);
|
window.history.replaceState({ portal: "kept" }, "", `/portal?lang=it&thoth_route=administration/${name.toLowerCase()}#host`);
|
||||||
renderShell();
|
renderShell();
|
||||||
@@ -52,8 +63,8 @@ test("preserves host state and query parameters through Back and Forward", async
|
|||||||
window.history.replaceState({ portal: { view: "thoth" } }, "", "/portal?lang=it#host");
|
window.history.replaceState({ portal: { view: "thoth" } }, "", "/portal?lang=it#host");
|
||||||
renderShell();
|
renderShell();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Workspace" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Pi management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Pi configuration" }));
|
||||||
act(() => window.history.back());
|
act(() => window.history.back());
|
||||||
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
|
expect(await screen.findByRole("main", { name: "Workspace management" })).toBeVisible();
|
||||||
act(() => window.history.forward());
|
act(() => window.history.forward());
|
||||||
@@ -75,8 +86,8 @@ test.each(["workspace", "database", "memory", "evidence", "pi"])("denies a direc
|
|||||||
test("Back and Core navigation preserve a Memory draft until the editor cancels it", async () => {
|
test("Back and Core navigation preserve a Memory draft until the editor cancels it", async () => {
|
||||||
renderShell();
|
renderShell();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Workspace" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Memory" }));
|
||||||
const page = await screen.findByRole("main", { name: "Memory management" });
|
const page = await screen.findByRole("main", { name: "Memory management" });
|
||||||
await userEvent.click(await within(page).findByRole("button", { name: "New card" }));
|
await userEvent.click(await within(page).findByRole("button", { name: "New card" }));
|
||||||
await userEvent.type(within(page).getByLabelText("Title"), "Keep this draft");
|
await userEvent.type(within(page).getByLabelText("Title"), "Keep this draft");
|
||||||
@@ -122,7 +133,7 @@ test("option A has one collapsible global context and preserves the Core draft a
|
|||||||
await userEvent.click(screen.getByRole("button", { name: "Done" }));
|
await userEvent.click(screen.getByRole("button", { name: "Done" }));
|
||||||
expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("combobox", { name: "Model" })).not.toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Memory" }));
|
||||||
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
|
expect(await screen.findByRole("main", { name: "Memory management" })).toBeVisible();
|
||||||
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
await userEvent.click(screen.getByRole("button", { name: "Return to session" }));
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe("authenticated shell permissions", () => {
|
|||||||
renderShell({ subject: "admin", isAdmin: true, roles: ["admin"],
|
renderShell({ subject: "admin", isAdmin: true, roles: ["admin"],
|
||||||
permissions: ["session.use", "memory.manage", "database.manage"] });
|
permissions: ["session.use", "memory.manage", "database.manage"] });
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Administration" }));
|
await userEvent.click(await screen.findByRole("button", { name: "Administration" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Memory management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Memory" }));
|
||||||
const memory = screen.getByRole("main", { name: "Memory management" });
|
const memory = screen.getByRole("main", { name: "Memory management" });
|
||||||
expect(memory).toBeVisible();
|
expect(memory).toBeVisible();
|
||||||
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument();
|
expect(within(memory).queryByRole("combobox", { name: "Workspace" })).not.toBeInTheDocument();
|
||||||
@@ -111,9 +111,9 @@ describe("authenticated shell permissions", () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
|
expect(await screen.findByRole("button", { name: "New session" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,12 +137,12 @@ describe("authenticated shell permissions", () => {
|
|||||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
const panel = within(sessionNavigation).getByRole("region", { name: "Administration" });
|
const panel = within(sessionNavigation).getByRole("region", { name: "Administration" });
|
||||||
const database = within(panel).getByRole("button", { name: "Database management" });
|
const database = within(panel).getByRole("button", { name: "Database" });
|
||||||
const memory = within(panel).getByRole("button", { name: "Memory management" });
|
const memory = within(panel).getByRole("button", { name: "Memory" });
|
||||||
const evidence = within(panel).getByRole("button", { name: "Evidence management" });
|
const evidence = within(panel).getByRole("button", { name: "Evidence" });
|
||||||
const separator = within(panel).getByRole("separator");
|
const separator = within(panel).getByRole("separator");
|
||||||
const workspace = within(panel).getByRole("button", { name: "Workspace management" });
|
const workspace = within(panel).getByRole("button", { name: "Workspace" });
|
||||||
const pi = within(panel).getByRole("button", { name: "Pi management" });
|
const pi = within(panel).getByRole("button", { name: "Pi configuration" });
|
||||||
expect(panel.children[0]).toBe(database);
|
expect(panel.children[0]).toBe(database);
|
||||||
expect(panel.children[1]).toBe(memory);
|
expect(panel.children[1]).toBe(memory);
|
||||||
expect(panel.children[2]).toBe(evidence);
|
expect(panel.children[2]).toBe(evidence);
|
||||||
@@ -156,7 +156,7 @@ describe("authenticated shell permissions", () => {
|
|||||||
await user.keyboard(" ");
|
await user.keyboard(" ");
|
||||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||||
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
|
expect(within(sessionNavigation).queryByRole("region", { name: "Administration" })).not.toBeInTheDocument();
|
||||||
expect(within(sessionNavigation).queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
|
expect(within(sessionNavigation).queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps identity but hides logout outside local authentication", async () => {
|
test("keeps identity but hides logout outside local authentication", async () => {
|
||||||
@@ -277,8 +277,8 @@ describe("authenticated shell permissions", () => {
|
|||||||
|
|
||||||
act(() => clearAuthState());
|
act(() => clearAuthState());
|
||||||
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("tab", { name: "All sessions" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ test("starts on the core activity after a hard refresh even if metadata manageme
|
|||||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||||
expect(within(sessionNavigation).getByRole("button", { name: "New session" }))
|
expect(within(sessionNavigation).getByRole("button", { name: "New session" }))
|
||||||
.toHaveAttribute("aria-current", "page");
|
.toHaveAttribute("aria-current", "page");
|
||||||
expect(within(sessionNavigation).getByRole("button", { name: "Database management" }))
|
expect(within(sessionNavigation).getByRole("button", { name: "Database" }))
|
||||||
.not.toHaveAttribute("aria-current");
|
.not.toHaveAttribute("aria-current");
|
||||||
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -119,7 +119,7 @@ test("replaces the core conversation while keeping the session navigation", asyn
|
|||||||
const conversationColumn = screen.getByTestId("conversation-column");
|
const conversationColumn = screen.getByTestId("conversation-column");
|
||||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||||
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
|
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
|
||||||
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database management" });
|
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database" });
|
||||||
|
|
||||||
expect(newSession).toHaveAttribute("aria-current", "page");
|
expect(newSession).toHaveAttribute("aria-current", "page");
|
||||||
expect(newSession).toHaveAttribute("data-navigation-state", "current");
|
expect(newSession).toHaveAttribute("data-navigation-state", "current");
|
||||||
@@ -178,7 +178,7 @@ test("keeps a live core session connected while returning from database manageme
|
|||||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||||
const source = FakeEventSource.instances[0];
|
const source = FakeEventSource.instances[0];
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Database" }));
|
||||||
|
|
||||||
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
|
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
|
||||||
expect(source.closed).toBe(false);
|
expect(source.closed).toBe(false);
|
||||||
@@ -210,9 +210,9 @@ test("hides the complete administrative navigation from a regular user", () => {
|
|||||||
|
|
||||||
expect(screen.getByRole("button", { name: "New session" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "New session" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Administration" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Workspace management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Workspace" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Database" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Pi management" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Pi configuration" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("requires saving or cancelling a dirty database form before leaving", async () => {
|
test("requires saving or cancelling a dirty database form before leaving", async () => {
|
||||||
@@ -250,7 +250,8 @@ test("requires saving or cancelling a dirty database form before leaving", async
|
|||||||
renderShell();
|
renderShell();
|
||||||
await expandAdministration();
|
await expandAdministration();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Database" }));
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||||
const schema = await screen.findByLabelText("Schema");
|
const schema = await screen.findByLabelText("Schema");
|
||||||
await userEvent.clear(schema);
|
await userEvent.clear(schema);
|
||||||
await userEvent.type(schema, "reporting");
|
await userEvent.type(schema, "reporting");
|
||||||
|
|||||||
@@ -204,13 +204,13 @@ test("opens Workspace management from the right sidebar without interrupting the
|
|||||||
renderShell();
|
renderShell();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
await userEvent.click(screen.getByRole("button", { name: "Administration" }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
|
await userEvent.click(screen.getByRole("button", { name: "Workspace" }));
|
||||||
|
|
||||||
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
|
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
|
||||||
const dialog = screen.getByRole("main", { name: "Workspace management" });
|
const dialog = screen.getByRole("main", { name: "Workspace management" });
|
||||||
const workArea = screen.getByTestId("conversation-column");
|
const workArea = screen.getByTestId("conversation-column");
|
||||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||||
const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace management" });
|
const workspaceManagement = within(sessionNavigation).getByRole("button", { name: "Workspace" });
|
||||||
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
|
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
|
||||||
expect(workArea).toContainElement(dialog);
|
expect(workArea).toContainElement(dialog);
|
||||||
expect(sessionNavigation).not.toContainElement(dialog);
|
expect(sessionNavigation).not.toContainElement(dialog);
|
||||||
|
|||||||
@@ -122,12 +122,12 @@ test("Pi management preserves the open session summary and the model activity ti
|
|||||||
await user.click(await screen.findByText("Attiva uno"));
|
await user.click(await screen.findByText("Attiva uno"));
|
||||||
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
|
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
|
||||||
await user.click(screen.getByRole("button", { name: "Administration" }));
|
await user.click(screen.getByRole("button", { name: "Administration" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Pi management" }));
|
await user.click(screen.getByRole("button", { name: "Pi configuration" }));
|
||||||
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
||||||
const dialog = screen.getByRole("main", { name: "Pi management" });
|
const dialog = screen.getByRole("main", { name: "Pi management" });
|
||||||
const workArea = screen.getByTestId("conversation-column");
|
const workArea = screen.getByTestId("conversation-column");
|
||||||
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
|
||||||
const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi management" });
|
const piManagement = within(sessionNavigation).getByRole("button", { name: "Pi configuration" });
|
||||||
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
|
const newSession = within(sessionNavigation).getByRole("button", { name: "New session" });
|
||||||
expect(workArea).toContainElement(dialog);
|
expect(workArea).toContainElement(dialog);
|
||||||
expect(sessionNavigation).not.toContainElement(dialog);
|
expect(sessionNavigation).not.toContainElement(dialog);
|
||||||
@@ -151,7 +151,7 @@ test("Pi management preserves the open session summary and the model activity ti
|
|||||||
await user.click(screen.getByRole("button", { name: "Resume" }));
|
await user.click(screen.getByRole("button", { name: "Resume" }));
|
||||||
await screen.findByRole("button", { name: "Show model activity" });
|
await screen.findByRole("button", { name: "Show model activity" });
|
||||||
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" }));
|
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "Preserved activity" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Pi management" }));
|
await user.click(screen.getByRole("button", { name: "Pi configuration" }));
|
||||||
await user.click(await screen.findByRole("button", { name: "Return to session" }));
|
await user.click(await screen.findByRole("button", { name: "Return to session" }));
|
||||||
await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByRole("heading", { name: "Pi management" })).not.toBeInTheDocument());
|
||||||
await user.click(screen.getByRole("button", { name: "Show model activity" }));
|
await user.click(screen.getByRole("button", { name: "Show model activity" }));
|
||||||
|
|||||||
@@ -999,7 +999,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
navigate({ surface: "database-management" }, true);
|
navigate({ surface: "database-management" }, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Database management
|
Database
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
|
||||||
@@ -1013,13 +1013,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
if (!canLeaveDatabaseManagement()) return;
|
if (!canLeaveDatabaseManagement()) return;
|
||||||
navigate({ surface: "memory-management" }, true);
|
navigate({ surface: "memory-management" }, true);
|
||||||
}}
|
}}
|
||||||
>Memory management</Button>
|
>Memory</Button>
|
||||||
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
|
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
|
||||||
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||||
aria-current={currentNavigation === "evidence" ? "page" : undefined}
|
aria-current={currentNavigation === "evidence" ? "page" : undefined}
|
||||||
disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"}
|
disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"}
|
||||||
onClick={() => navigate({ surface: "evidence-management" })}
|
onClick={() => navigate({ surface: "evidence-management" })}
|
||||||
>Evidence management</Button>
|
>Evidence</Button>
|
||||||
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
|
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
|
||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
||||||
@@ -1032,7 +1032,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
navigate({ surface: "workspace-management" }, true);
|
navigate({ surface: "workspace-management" }, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Workspace management
|
Workspace
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
||||||
@@ -1047,7 +1047,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
|||||||
navigate({ surface: "pi-management" }, true);
|
navigate({ surface: "pi-management" }, true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pi management
|
Pi configuration
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>
|
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>
|
||||||
Workspace readiness: {preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"}
|
Workspace readiness: {preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"}
|
||||||
|
|||||||
@@ -90,12 +90,14 @@ function renderPage({
|
|||||||
canManageSecrets = true,
|
canManageSecrets = true,
|
||||||
onNavigationStateChange,
|
onNavigationStateChange,
|
||||||
presentation = "legacy",
|
presentation = "legacy",
|
||||||
|
initialWorkspaceId,
|
||||||
}: {
|
}: {
|
||||||
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
|
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canManageSecrets?: boolean;
|
canManageSecrets?: boolean;
|
||||||
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
|
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
|
||||||
presentation?: "legacy" | "fleet";
|
presentation?: "legacy" | "fleet";
|
||||||
|
initialWorkspaceId?: string;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
|
server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
|
||||||
typeof rows === "function" ? rows() : rows,
|
typeof rows === "function" ? rows() : rows,
|
||||||
@@ -104,6 +106,7 @@ function renderPage({
|
|||||||
const view = render(
|
const view = render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<DatabaseManagementPage
|
<DatabaseManagementPage
|
||||||
|
initialWorkspaceId={initialWorkspaceId}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
canManageSecrets={canManageSecrets}
|
canManageSecrets={canManageSecrets}
|
||||||
onNavigationStateChange={onNavigationStateChange}
|
onNavigationStateChange={onNavigationStateChange}
|
||||||
@@ -115,6 +118,15 @@ function renderPage({
|
|||||||
return { ...view, client };
|
return { ...view, client };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("fleet entry opens the database list without workspace preparation or an automatic editor", async () => {
|
||||||
|
renderPage({ presentation: "fleet", initialWorkspaceId: "psd-clinical" });
|
||||||
|
expect(await screen.findByRole("button", { name: "View tables for Policlinico San Donato" })).toBeVisible();
|
||||||
|
expect(screen.getByText("Research laboratory")).toBeVisible();
|
||||||
|
expect(screen.queryByRole("button", { name: "Back to list" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Workspace preparation/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("link", { name: "Open workspace preprocessing" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
function makeSyncRun(
|
function makeSyncRun(
|
||||||
scope: CatalogSyncRun["scope"],
|
scope: CatalogSyncRun["scope"],
|
||||||
tableIds: string[] = [],
|
tableIds: string[] = [],
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { CheckCircle2, CircleAlert, History, RefreshCw } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { workspacePreferences } from "../workspaces/preferences";
|
import { workspacePreferences } from "../workspaces/preferences";
|
||||||
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
|
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
|
||||||
import { WorkspacePreparation } from "./administration/WorkspacePreparation";
|
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -161,7 +160,6 @@ function isDescriptionGenerationActive(run?: DescriptionGenerationRun | null): b
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DatabaseManagementPage({
|
export function DatabaseManagementPage({
|
||||||
initialWorkspaceId,
|
|
||||||
canManage,
|
canManage,
|
||||||
canManageSecrets,
|
canManageSecrets,
|
||||||
onNavigationStateChange,
|
onNavigationStateChange,
|
||||||
@@ -411,15 +409,6 @@ export function DatabaseManagementPage({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const initialWorkspaceOpened = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialWorkspaceId || initialWorkspaceOpened.current || !data) return;
|
|
||||||
initialWorkspaceOpened.current = true;
|
|
||||||
const row = data.find(item => item.workspaceId === initialWorkspaceId);
|
|
||||||
if (row) openForm(row.configured ? "edit" : "configure", row, document.body);
|
|
||||||
else toast.info("This workspace's database configuration is unavailable");
|
|
||||||
}, [initialWorkspaceId, data, openForm]);
|
|
||||||
|
|
||||||
const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
|
const viewRow = useCallback((row: CatalogDatabase, origin: HTMLButtonElement) => {
|
||||||
openForm("view", row, origin);
|
openForm("view", row, origin);
|
||||||
}, [openForm]);
|
}, [openForm]);
|
||||||
@@ -1224,7 +1213,6 @@ export function DatabaseManagementPage({
|
|||||||
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
|
onOpenSensitiveHistory={openSensitivityReviewItemHistory}
|
||||||
activeSyncRun={currentActiveRun}
|
activeSyncRun={currentActiveRun}
|
||||||
/>
|
/>
|
||||||
<WorkspacePreparation workspaceId={activeRow.workspaceId} canManage={canManage} databaseSummary />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
@@ -1403,8 +1391,6 @@ export function DatabaseManagementPage({
|
|||||||
)}
|
)}
|
||||||
children={<>
|
children={<>
|
||||||
{fleetDrawer ?? <FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
|
{fleetDrawer ?? <FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
|
||||||
{!fleetDrawer && (activeRow ?? rows.find(row => row.id === selectedDatabaseId)) &&
|
|
||||||
<WorkspacePreparation workspaceId={(activeRow ?? rows.find(row => row.id === selectedDatabaseId))!.workspaceId} canManage={canManage} databaseSummary />}
|
|
||||||
</>}
|
</>}
|
||||||
drawer={(
|
drawer={(
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ const readyStatus = {
|
|||||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderManagement() {
|
function renderManagement(page = false) {
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<PiManagement open onClose={() => undefined} />
|
<PiManagement open page={page} onClose={() => undefined} />
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,20 @@ test("shows the catalog default as read-only installation state", async () => {
|
|||||||
expect(document.querySelector('input[type="password"]')).toBeNull();
|
expect(document.querySelector('input[type="password"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(["linux", "macos", "windows"])("opens host instructions for the installation platform %s", async hostPlatform => {
|
||||||
|
server.use(http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, hostPlatform })));
|
||||||
|
renderManagement(true);
|
||||||
|
await screen.findByText("Pi 0.80.3");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Host maintenance" }));
|
||||||
|
const label = hostPlatform === "macos" ? "macOS" : hostPlatform === "windows" ? "Windows" : "Linux";
|
||||||
|
expect(screen.getByRole("tab", { name: label })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByRole("tabpanel", { name: label })).toBeVisible();
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: "Windows" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Runtime and models" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Host maintenance" }));
|
||||||
|
expect(screen.getByRole("tabpanel", { name: "Windows" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("shows every session model exposed by Core", async () => {
|
test("shows every session model exposed by Core", async () => {
|
||||||
server.use(http.get("/api/models", () => HttpResponse.json({ models: [
|
server.use(http.get("/api/models", () => HttpResponse.json({ models: [
|
||||||
{ provider: "zai", id: "glm-5.3", name: "GLM 5.3", reasoning: true },
|
{ provider: "zai", id: "glm-5.3", name: "GLM 5.3", reasoning: true },
|
||||||
|
|||||||
@@ -202,13 +202,12 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
|
|||||||
</ol>;
|
</ol>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PiPlatformInstructions() {
|
function PiPlatformInstructions({ hostPlatform }: { hostPlatform?: PiPlatform }) {
|
||||||
const [activePlatform, setActivePlatform] = useState<PiPlatform>();
|
const [chosenPlatform, setActivePlatform] = useState<PiPlatform>();
|
||||||
const [focusedPlatform, setFocusedPlatform] = useState<PiPlatform>("linux");
|
const activePlatform = chosenPlatform ?? hostPlatform ?? "linux";
|
||||||
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
|
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
|
||||||
|
|
||||||
function activateAndFocus(platform: PiPlatform) {
|
function activateAndFocus(platform: PiPlatform) {
|
||||||
setFocusedPlatform(platform);
|
|
||||||
setActivePlatform(platform);
|
setActivePlatform(platform);
|
||||||
tabRefs.current[platform]?.focus();
|
tabRefs.current[platform]?.focus();
|
||||||
}
|
}
|
||||||
@@ -242,13 +241,11 @@ function PiPlatformInstructions() {
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activePlatform === platform.id}
|
aria-selected={activePlatform === platform.id}
|
||||||
aria-controls={`pi-${platform.id}-panel`}
|
aria-controls={`pi-${platform.id}-panel`}
|
||||||
tabIndex={focusedPlatform === platform.id ? 0 : -1}
|
tabIndex={activePlatform === platform.id ? 0 : -1}
|
||||||
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
|
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
|
||||||
onFocus={() => setFocusedPlatform(platform.id)}
|
|
||||||
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
|
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFocusedPlatform(platform.id);
|
setActivePlatform(platform.id);
|
||||||
setActivePlatform((current) => current === platform.id ? undefined : platform.id);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{platform.label}
|
{platform.label}
|
||||||
@@ -348,7 +345,9 @@ export function PiManagement({ open, onClose, page = false }: { open: boolean; o
|
|||||||
<Button variant={section === "instructions" ? "navigationActive" : "ghost"} className="w-full justify-start whitespace-normal" aria-current={section === "instructions" ? "page" : undefined} onClick={() => setSection("instructions")}>Host maintenance</Button>
|
<Button variant={section === "instructions" ? "navigationActive" : "ghost"} className="w-full justify-start whitespace-normal" aria-current={section === "instructions" ? "page" : undefined} onClick={() => setSection("instructions")}>Host maintenance</Button>
|
||||||
</nav>}
|
</nav>}
|
||||||
<div className={page ? "thot-pi-detail" : undefined}>
|
<div className={page ? "thot-pi-detail" : undefined}>
|
||||||
{(!page || section === "instructions") && <PiPlatformInstructions />}
|
<div hidden={page && section !== "instructions"}>
|
||||||
|
<PiPlatformInstructions hostPlatform={statusQuery.data?.hostPlatform} />
|
||||||
|
</div>
|
||||||
<div className={page ? undefined : "mt-5"} hidden={page && section !== "runtime"}>
|
<div className={page ? undefined : "mt-5"} hidden={page && section !== "runtime"}>
|
||||||
{forbidden ? (
|
{forbidden ? (
|
||||||
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||||
|
|||||||
@@ -10,6 +10,30 @@
|
|||||||
border-inline-end: 1px solid oklch(var(--border));
|
border-inline-end: 1px solid oklch(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thot-workspace-area-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 1px solid oklch(var(--border));
|
||||||
|
}
|
||||||
|
.thot-workspace-area-tabs [role="tab"] {
|
||||||
|
flex: none;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: oklch(var(--muted-foreground));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.thot-workspace-area-tabs [aria-selected="true"] {
|
||||||
|
border-bottom-color: oklch(var(--primary));
|
||||||
|
color: oklch(var(--primary));
|
||||||
|
background: oklch(var(--muted) / 0.4);
|
||||||
|
}
|
||||||
|
.thot-workspace-area-tabs [role="tab"]:focus-visible {
|
||||||
|
outline: 2px solid oklch(var(--ring));
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
@container work-area-panel (max-width: 45rem) {
|
@container work-area-panel (max-width: 45rem) {
|
||||||
.thot-workspace-manager__layout {
|
.thot-workspace-manager__layout {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -136,6 +136,28 @@ test("defaults workspace mutations and secrets to denied", async () => {
|
|||||||
expect(screen.queryByRole("heading", { name: "Runtime secrets" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("heading", { name: "Runtime secrets" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("page tabs group workspace areas and preserve unsaved secrets across tabs", async () => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(<QueryClientProvider client={client}>
|
||||||
|
<WorkspaceManager open page initialWorkspaceId="psd-clinical" onClose={vi.fn()} canManageWorkspace canManageSecrets />
|
||||||
|
</QueryClientProvider>);
|
||||||
|
const tabs = await screen.findByRole("tablist", { name: "Workspace areas" });
|
||||||
|
expect(within(tabs).getAllByRole("tab").map(tab => tab.textContent)).toEqual([
|
||||||
|
"Preparation", "Specific actions", "About", "Authentication",
|
||||||
|
]);
|
||||||
|
expect(screen.getByRole("tab", { name: "Preparation" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getByRole("tabpanel", { name: "Preparation" })).toBeVisible();
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: "Specific actions" }));
|
||||||
|
expect(await screen.findByRole("button", { name: "Validate workspace source" })).toBeVisible();
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: "Authentication" }));
|
||||||
|
await userEvent.type(await screen.findByLabelText("Data warehouse password"), "unsaved-value");
|
||||||
|
await userEvent.click(screen.getByRole("tab", { name: "About" }));
|
||||||
|
expect(screen.getByRole("heading", { name: "About this workspace" })).toBeVisible();
|
||||||
|
await userEvent.keyboard("{ArrowRight}");
|
||||||
|
expect(screen.getByRole("tab", { name: "Authentication" })).toHaveFocus();
|
||||||
|
expect(screen.getByLabelText("Data warehouse password")).toHaveValue("unsaved-value");
|
||||||
|
});
|
||||||
|
|
||||||
test("uses the shared modeless work-area panel and keeps workspace content scrollable", () => {
|
test("uses the shared modeless work-area panel and keeps workspace content scrollable", () => {
|
||||||
renderManager();
|
renderManager();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -76,6 +76,8 @@ type WorkspaceOperationGuard = Readonly<{
|
|||||||
|
|
||||||
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
import { useWorkingContextScope } from "../workspaces/WorkingContext";
|
||||||
|
|
||||||
|
const workspaceAreas = ["Preparation", "Specific actions", "About", "Authentication"] as const;
|
||||||
|
|
||||||
export function WorkspaceManager({
|
export function WorkspaceManager({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -96,6 +98,18 @@ export function WorkspaceManager({
|
|||||||
canManageSecrets?: boolean;
|
canManageSecrets?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const workingContext = useWorkingContextScope();
|
const workingContext = useWorkingContextScope();
|
||||||
|
const tabsId = useId();
|
||||||
|
const [activeArea, setActiveArea] = useState<typeof workspaceAreas[number]>("Preparation");
|
||||||
|
const areaTabs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
const areaId = (area: typeof workspaceAreas[number]) => `${tabsId}-${workspaceAreas.indexOf(area)}`;
|
||||||
|
const areaPanel = (area: typeof workspaceAreas[number]) => ({
|
||||||
|
role: page ? "tabpanel" : undefined,
|
||||||
|
id: `${areaId(area)}-panel`,
|
||||||
|
"aria-labelledby": page ? `${areaId(area)}-tab` : undefined,
|
||||||
|
hidden: page && activeArea !== area,
|
||||||
|
tabIndex: page ? 0 : undefined,
|
||||||
|
className: "space-y-5",
|
||||||
|
});
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>(initialWorkspaceId);
|
const [selectedId, setSelectedId] = useState<string | undefined>(initialWorkspaceId);
|
||||||
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
|
||||||
@@ -530,7 +544,31 @@ export function WorkspaceManager({
|
|||||||
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
|
<p className="font-mono text-xs text-muted-foreground">{selectedSummary.id}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{page && selectedId && <WorkspacePreparation workspaceId={selectedId} canManage={canPreprocess} />}
|
{page && <div role="tablist" aria-label="Workspace areas" className="thot-workspace-area-tabs">
|
||||||
|
{workspaceAreas.map((area, index) => <button
|
||||||
|
key={area}
|
||||||
|
ref={element => { areaTabs.current[index] = element; }}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`${areaId(area)}-tab`}
|
||||||
|
aria-controls={`${areaId(area)}-panel`}
|
||||||
|
aria-selected={activeArea === area}
|
||||||
|
tabIndex={activeArea === area ? 0 : -1}
|
||||||
|
onClick={() => setActiveArea(area)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
const next = event.key === "ArrowRight" ? (index + 1) % workspaceAreas.length
|
||||||
|
: event.key === "ArrowLeft" ? (index + workspaceAreas.length - 1) % workspaceAreas.length
|
||||||
|
: event.key === "Home" ? 0 : event.key === "End" ? workspaceAreas.length - 1 : undefined;
|
||||||
|
if (next === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveArea(workspaceAreas[next]);
|
||||||
|
areaTabs.current[next]?.focus();
|
||||||
|
}}
|
||||||
|
>{area}</button>)}
|
||||||
|
</div>}
|
||||||
|
{page && selectedId && <div {...areaPanel("Preparation")}>
|
||||||
|
<WorkspacePreparation workspaceId={selectedId} canManage={canPreprocess} />
|
||||||
|
</div>}
|
||||||
|
|
||||||
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
|
{(detailQuery.isLoading || runtimeQuery.isLoading) && <p className="text-sm text-muted-foreground">Loading workspace configuration…</p>}
|
||||||
{(detailQuery.isError || runtimeQuery.isError) && (
|
{(detailQuery.isError || runtimeQuery.isError) && (
|
||||||
@@ -539,6 +577,7 @@ export function WorkspaceManager({
|
|||||||
|
|
||||||
{detailQuery.data && runtime && (
|
{detailQuery.data && runtime && (
|
||||||
<>
|
<>
|
||||||
|
<div {...areaPanel("About")}>
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<h3 className="font-heading text-lg font-semibold">About this workspace</h3>
|
<h3 className="font-heading text-lg font-semibold">About this workspace</h3>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">
|
||||||
@@ -555,7 +594,9 @@ export function WorkspaceManager({
|
|||||||
<div><dt className="text-xs text-muted-foreground">Workspace language</dt><dd className="font-mono text-xs">{detailQuery.data.workspace.workspace.language}</dd></div>
|
<div><dt className="text-xs text-muted-foreground">Workspace language</dt><dd className="font-mono text-xs">{detailQuery.data.workspace.workspace.language}</dd></div>
|
||||||
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
|
<div><dt className="text-xs text-muted-foreground">Runtime status</dt><dd>{stateLabel(runtime.configurationState)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div {...areaPanel("Specific actions")}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3>
|
<h3 className="font-heading text-lg font-semibold">Workspace-specific actions</h3>
|
||||||
<p className="mt-1 text-sm leading-6 text-muted-foreground">The validation and connection checks below apply only to <span className="font-medium text-foreground">{selectedSummary.displayName}</span> and read its active revision without changing or publishing it.</p>
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">The validation and connection checks below apply only to <span className="font-medium text-foreground">{selectedSummary.displayName}</span> and read its active revision without changing or publishing it.</p>
|
||||||
@@ -606,6 +647,10 @@ export function WorkspaceManager({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div {...areaPanel("Authentication")}>
|
||||||
|
{!authentication && <p className="text-sm text-muted-foreground">Run a connection test in Specific actions to inspect authentication diagnostics.</p>}
|
||||||
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
|
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h4 className="font-heading font-semibold">Authentication</h4>
|
<h4 className="font-heading font-semibold">Authentication</h4>
|
||||||
@@ -693,6 +738,7 @@ export function WorkspaceManager({
|
|||||||
Runtime secret controls are available only to authorized workspace operators.
|
Runtime secret controls are available only to authorized workspace operators.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -82,36 +82,41 @@
|
|||||||
.thot-administration-dependency p { max-width: 72ch; margin-bottom: 0.5rem; }
|
.thot-administration-dependency p { max-width: 72ch; margin-bottom: 0.5rem; }
|
||||||
.thot-administration-mobile-nav { position: absolute; inset: 0 0 auto; z-index: 30; display: flex; height: 3rem; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0 1rem; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--background)); }
|
.thot-administration-mobile-nav { position: absolute; inset: 0 0 auto; z-index: 30; display: flex; height: 3rem; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0 1rem; border-bottom: 1px solid oklch(var(--border)); background: oklch(var(--background)); }
|
||||||
.thot-app-shell[data-administration-narrow="true"] { padding-top: 3rem; }
|
.thot-app-shell[data-administration-narrow="true"] { padding-top: 3rem; }
|
||||||
.thot-work-area-page .thot-workspace-manager__layout { flex: 1; min-height: 22rem; }
|
.thot-work-area-page .thot-work-area-panel__body--flush { display: flex; overflow: hidden; }
|
||||||
|
.thot-work-area-page .thot-workspace-manager__layout { flex: 1; min-height: 0; }
|
||||||
.thot-work-area-page .thot-workspace-manager__content { padding: 1.4rem; }
|
.thot-work-area-page .thot-workspace-manager__content { padding: 1.4rem; }
|
||||||
.thot-work-area-page__body--pi { padding: 1.5rem 1.75rem; }
|
.thot-work-area-page__body--pi { display: flex; padding: 1.5rem 1.75rem; overflow: hidden; }
|
||||||
.thot-pi-workbench { display: grid; grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.5fr); border: 1px solid oklch(var(--border)); background: oklch(var(--card)); }
|
.thot-pi-workbench { display: grid; flex: 1; min-width: 0; min-height: 0; grid-template-columns: minmax(12rem, 0.55fr) minmax(0, 1.5fr); border: 1px solid oklch(var(--border)); background: oklch(var(--card)); }
|
||||||
.thot-pi-index { background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); padding: 1.25rem; }
|
.thot-pi-index { background: oklch(var(--muted) / 0.45); border-inline-end: 1px solid oklch(var(--border)); padding: 1.25rem; }
|
||||||
.thot-pi-index a { display: block; padding: 0.65rem 0; font-size: 0.875rem; }
|
.thot-pi-index a { display: block; padding: 0.65rem 0; font-size: 0.875rem; }
|
||||||
.thot-pi-detail { min-width: 0; padding: 1.35rem; }
|
.thot-pi-index { min-height: 0; overflow-y: auto; }
|
||||||
|
.thot-pi-detail { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 1.35rem; }
|
||||||
|
.thot-pi-detail :where(section, ol, li, div, pre, dl, dd) { min-width: 0; max-width: 100%; }
|
||||||
|
.thot-pi-detail :where(p, dt, dd) { overflow-wrap: anywhere; }
|
||||||
|
.thot-pi-detail [data-testid="pi-platform-instructions-scroll"] { max-height: none; overflow: visible; }
|
||||||
.thot-workspace-preparation { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
|
.thot-workspace-preparation { border-block-start: 1px solid oklch(var(--border)); padding-top: 1.25rem; }
|
||||||
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; }
|
.thot-workspace-preparation .thot-preprocessing-control { max-width: 40rem; }
|
||||||
.thot-preprocessing-control[data-presentation="page"] { padding: 1rem; margin-top: 0.75rem; }
|
.thot-preprocessing-control[data-presentation="page"] { padding: 1rem; margin-top: 0.75rem; }
|
||||||
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: 0.8125rem; line-height: 1.5; }
|
.thot-preprocessing-control[data-presentation="page"] :where(strong, span, small, button) { font-size: 0.8125rem; line-height: 1.5; }
|
||||||
.thot-preprocessing-control[data-presentation="page"] button { height: auto; min-height: 2.25rem; white-space: normal; }
|
.thot-preprocessing-control[data-presentation="page"] button { height: auto; min-height: 2.25rem; white-space: normal; }
|
||||||
.thot-administration-database .thot-fleet-ledger__workspace { overflow-y: auto; }
|
|
||||||
.thot-administration-database .thot-fleet-ledger__content { flex: 1 0 26rem; min-height: 26rem; }
|
|
||||||
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: 1.125rem; }
|
.thot-administration-database .thot-fleet-ledger__kpi-value { font-size: 1.125rem; }
|
||||||
.thot-administration-database .thot-administration-dependency { flex: none; }
|
.thot-administration-database .thot-administration-dependency { flex: none; }
|
||||||
.thot-database-workbench { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
|
.thot-database-workbench { grid-template-columns: minmax(10rem, 0.55fr) minmax(0, 1.5fr); }
|
||||||
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.thot-database-workbench form > .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@container administration (max-width: 48rem) {
|
@container administration (max-width: 48rem) {
|
||||||
.thot-administration-split, .thot-pi-workbench { grid-template-columns: minmax(0, 1fr); }
|
.thot-administration-split, .thot-pi-workbench { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.thot-pi-workbench { grid-template-rows: auto minmax(0, 1fr); }
|
||||||
.thot-database-workbench form > .grid { grid-template-columns: minmax(0, 1fr); }
|
.thot-database-workbench form > .grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
.thot-administration-index, .thot-pi-index { border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
.thot-administration-index, .thot-pi-index { border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
||||||
.thot-administration-index { max-height: 22rem; overflow-y: auto; }
|
.thot-administration-index { max-height: 22rem; overflow-y: auto; }
|
||||||
.thot-administration-header { padding: 1.25rem; }
|
.thot-administration-header { padding: 1.25rem; }
|
||||||
.thot-administration-split { margin: 1rem; }
|
.thot-administration-split { margin: 1rem; }
|
||||||
.thot-work-area-page .thot-workspace-manager__layout { display: flex; flex-direction: column; overflow: visible; }
|
.thot-work-area-page .thot-workspace-manager__layout { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, auto) minmax(0, 1fr); }
|
||||||
.thot-work-area-page .thot-workspace-manager__navigation { max-height: 18rem; flex: none; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
.thot-work-area-page .thot-workspace-manager__navigation { max-height: 10rem; border-inline-end: 0; border-block-end: 1px solid oklch(var(--border)); }
|
||||||
.thot-work-area-page .thot-workspace-manager__content { overflow: visible; }
|
|
||||||
}
|
}
|
||||||
@container administration (max-width: 30rem) {
|
@container administration (max-width: 30rem) {
|
||||||
|
.thot-pi-detail dl { display: block; }
|
||||||
|
.thot-pi-detail dt { margin-top: 0.75rem; }
|
||||||
.thot-administration-header h1 { font-size: 1.5rem; }
|
.thot-administration-header h1 { font-size: 1.5rem; }
|
||||||
.thot-administration-header, .thot-administration-toolbar, .thot-work-area-page__body--pi { padding: 1rem; }
|
.thot-administration-header, .thot-administration-toolbar, .thot-work-area-page__body--pi { padding: 1rem; }
|
||||||
.thot-administration-detail, .thot-pi-detail { padding: 1rem; }
|
.thot-administration-detail, .thot-pi-detail { padding: 1rem; }
|
||||||
|
|||||||
@@ -3,18 +3,12 @@ import { getWorkspacePreprocessingStatus } from "../../api/workspace-preprocessi
|
|||||||
import { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
|
import { WorkspacePreprocessingControl } from "../WorkspacePreprocessingControl";
|
||||||
import { AdministrationLink } from "./AdministrationNavigation";
|
import { AdministrationLink } from "./AdministrationNavigation";
|
||||||
|
|
||||||
export function WorkspacePreparation({ workspaceId, canManage, databaseSummary = false }: {
|
export function WorkspacePreparation({ workspaceId, canManage }: {
|
||||||
workspaceId: string; canManage: boolean; databaseSummary?: boolean;
|
workspaceId: string; canManage: boolean;
|
||||||
}) {
|
}) {
|
||||||
const query = useQuery({ queryKey: ["workspace-preprocessing", workspaceId],
|
const query = useQuery({ queryKey: ["workspace-preprocessing", workspaceId],
|
||||||
queryFn: () => getWorkspacePreprocessingStatus(workspaceId), enabled: canManage, retry: false, refetchInterval: 5000 });
|
queryFn: () => getWorkspacePreprocessingStatus(workspaceId), enabled: canManage, retry: false, refetchInterval: 5000 });
|
||||||
if (!canManage) return <p className="text-sm text-muted-foreground">Database management permission is required to inspect or run preprocessing.</p>;
|
if (!canManage) return <p className="text-sm text-muted-foreground">Database management permission is required to inspect or run preprocessing.</p>;
|
||||||
if (databaseSummary) return <section aria-label="Workspace preparation dependency" className="thot-administration-dependency">
|
|
||||||
<p><strong>Workspace preparation</strong> · {workspaceId}</p>
|
|
||||||
<p role="status">{query.isLoading ? "Checking workspace readiness…" : query.isError ? "Workspace readiness is unavailable." : query.data?.detail}</p>
|
|
||||||
{query.isError && <button className="thot-administration-link" onClick={() => void query.refetch()}>Retry readiness</button>}
|
|
||||||
<AdministrationLink surface="workspace-management" workspace={workspaceId} className="thot-administration-link">Open workspace preprocessing</AdministrationLink>
|
|
||||||
</section>;
|
|
||||||
return <section aria-label="Workspace readiness" className="thot-workspace-preparation">
|
return <section aria-label="Workspace readiness" className="thot-workspace-preparation">
|
||||||
<p className="thot-administration-eyebrow">Preparation</p>
|
<p className="thot-administration-eyebrow">Preparation</p>
|
||||||
<h3 className="font-heading text-lg font-semibold mt-1">Ready for analysis</h3>
|
<h3 className="font-heading text-lg font-semibold mt-1">Ready for analysis</h3>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -279,6 +280,7 @@ func renderCompose(installation config.Installation, fingerprint string) []byte
|
|||||||
return []byte(fmt.Sprintf(`services:
|
return []byte(fmt.Sprintf(`services:
|
||||||
core:
|
core:
|
||||||
environment:
|
environment:
|
||||||
|
THT_HOST_PLATFORM: %s
|
||||||
THT_MODEL_CATALOG_FILE: /run/thothii-model-catalog/catalog.json
|
THT_MODEL_CATALOG_FILE: /run/thothii-model-catalog/catalog.json
|
||||||
THT_MODEL_CATALOG_REVISION: %s
|
THT_MODEL_CATALOG_REVISION: %s
|
||||||
THT_DEFAULT_SESSION_MODEL: %s
|
THT_DEFAULT_SESSION_MODEL: %s
|
||||||
@@ -306,7 +308,7 @@ func renderCompose(installation config.Installation, fingerprint string) []byte
|
|||||||
embedding-model-init:
|
embedding-model-init:
|
||||||
environment:
|
environment:
|
||||||
OLLAMA_MODEL: %s
|
OLLAMA_MODEL: %s
|
||||||
`, strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Interaction),
|
`, strconv.Quote(runtime.GOOS), strconv.Quote(fingerprint), strconv.Quote(installation.ModelCatalog.Defaults.Interaction),
|
||||||
strconv.Quote(embedding.ID), strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)),
|
strconv.Quote(embedding.ID), strconv.Quote(embeddingModelName(embedding.ID)), strconv.Quote(strconv.Itoa(embedding.Dimensions)),
|
||||||
strconv.Quote(installation.GeneratedModelCatalogPath()), strconv.Quote(installation.GeneratedPiModelsPath()),
|
strconv.Quote(installation.GeneratedModelCatalogPath()), strconv.Quote(installation.GeneratedPiModelsPath()),
|
||||||
strconv.Quote(installation.GeneratedPiSettingsPath()), strconv.Quote(embedding.ID),
|
strconv.Quote(installation.GeneratedPiSettingsPath()), strconv.Quote(embedding.ID),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -50,6 +51,9 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
|
|||||||
t.Fatalf("Pi projection must map canonical catalog keys to upstream model IDs")
|
t.Fatalf("Pi projection must map canonical catalog keys to upstream model IDs")
|
||||||
}
|
}
|
||||||
compose := string(first[ComposeFile])
|
compose := string(first[ComposeFile])
|
||||||
|
if !strings.Contains(compose, `THT_HOST_PLATFORM: "`+runtime.GOOS+`"`) {
|
||||||
|
t.Fatal("Compose must project the CLI host OS, not the Docker container OS")
|
||||||
|
}
|
||||||
for _, expected := range []string{"THT_MODEL_CATALOG_FILE", "THT_DEFAULT_SESSION_MODEL", "zai/glm-5.3", "THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "OLLAMA_MODEL", installation.GeneratedModelCatalogPath()} {
|
for _, expected := range []string{"THT_MODEL_CATALOG_FILE", "THT_DEFAULT_SESSION_MODEL", "zai/glm-5.3", "THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "OLLAMA_MODEL", installation.GeneratedModelCatalogPath()} {
|
||||||
if !strings.Contains(compose, expected) {
|
if !strings.Contains(compose, expected) {
|
||||||
t.Fatalf("Compose projection missing %q:\n%s", expected, compose)
|
t.Fatalf("Compose projection missing %q:\n%s", expected, compose)
|
||||||
|
|||||||
Reference in New Issue
Block a user