From 38b538877dc6799140ceab325d0d3954af517178 Mon Sep 17 00:00:00 2001 From: Engineer <310755840+lightspeed-internal-1[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:08:07 +0000 Subject: [PATCH 1/7] Improve environment list consistency Signed-off-by: Engineer <310755840+lightspeed-internal-1[bot]@users.noreply.github.com> --- .../web/src/pages/EnvironmentsPage.test.tsx | 164 +++++++ platform/web/src/pages/EnvironmentsPage.tsx | 438 +++++++++--------- 2 files changed, 387 insertions(+), 215 deletions(-) create mode 100644 platform/web/src/pages/EnvironmentsPage.test.tsx diff --git a/platform/web/src/pages/EnvironmentsPage.test.tsx b/platform/web/src/pages/EnvironmentsPage.test.tsx new file mode 100644 index 000000000..7e5239249 --- /dev/null +++ b/platform/web/src/pages/EnvironmentsPage.test.tsx @@ -0,0 +1,164 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Environment } from "@/api"; +import { PermissionIdentityProvider } from "@/lib/permissions"; +import { EnvironmentsPage } from "./EnvironmentsPage"; + +const mocks = vi.hoisted(() => ({ api: vi.fn(), role: "viewer" })); +vi.mock("@/api", async (original) => ({ ...await original(), api: mocks.api })); +vi.mock("@/lib/universes", () => ({ + useActiveUniverse: () => ({ universe: { id: "universe", role: mocks.role }, slug: "test", isLoading: false }), +})); + +function environment(id: string, source: Environment["source"], status: Environment["status"] = "ready"): Environment { + return { + environmentId: id, displayName: id, source, status, desiredPower: "running", + createdAtMs: 1, updatedAtMs: 1, requestId: id, publicIngressEnabled: false, + incarnation: { incarnationId: id, createdAtMs: 1, updatedAtMs: 1, powerStates: ["running", "paused"] }, + }; +} +const provisioned = environment("Build box", { type: "provisioned", providerId: "incus", bindingId: "binding" }, "paused"); +const registered = environment("Runner", { type: "registered", daemonId: "daemon", registrationKeyId: "key-id", identityMode: "persistent" }, "offline"); +const external = environment("External box", { type: "external", connection: { endpoint: "https://example.test", transport: "http" } }); +let rows: Environment[]; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mocks.role = "viewer"; + rows = [registered, external, provisioned]; + mocks.api.mockReset().mockImplementation(async (_method: string, path: string) => { + if (path.endsWith("/environments")) return rows; + if (path.endsWith("/secrets")) return { grants: [], providers: [] }; + if (path.endsWith("/hints")) return { devEnvdEndpoint: null }; + if (path.endsWith("/environment-provider-bindings") || path.endsWith("/environment-templates") + || path.endsWith("/environment-registration-keys") || path.endsWith("/credentials")) return []; + throw new Error(`Unexpected request: ${path}`); + }); + client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +async function settle() { + for (let i = 0; i < 5; i++) await act(async () => { await vi.advanceTimersByTimeAsync(5); }); +} +async function show() { + await act(async () => root.render( + + + , + )); + await settle(); +} +async function details(name: string) { + const button = container.querySelector(`button[aria-label="Details for ${name}"]`)!; + await act(async () => button.click()); + await settle(); + return button; +} + +it("shows one sorted semantic inventory with source-specific signals and lazy details", async () => { + await show(); + const table = container.querySelector('table[aria-label="Environments"]')!; + expect([...table.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual(["Environment", "Source", "Status", "Details"]); + expect([...table.querySelectorAll("tbody tr:not([hidden])")].map((row) => row.querySelector("td")?.textContent)).toEqual([ + "Build boxBuild box", "External boxExternal box", "RunnerRunner", + ]); + expect(table.textContent).toContain("Desired: running"); + expect(table.textContent).toContain("Never seen"); + expect(table.textContent).toContain("key-id"); + expect(mocks.api.mock.calls.some(([, path]) => path.endsWith("/credentials"))).toBe(false); + expect(mocks.api.mock.calls.some(([, path]) => path.endsWith("/environment-registration-keys"))).toBe(false); + const button = await details("Runner"); + expect(button.getAttribute("aria-expanded")).toBe("true"); + expect(document.getElementById(button.getAttribute("aria-controls")!)?.textContent).toContain("daemon"); + expect(container.textContent).not.toContain("Closing spends"); + expect(container.textContent).not.toContain("Close environment"); + await details("Runner"); + expect(button.getAttribute("aria-expanded")).toBe("false"); + expect(document.getElementById(button.getAttribute("aria-controls")!)?.closest("tr")?.hidden).toBe(true); +}); + +it.each(["viewer", "operator"])("preserves source-specific controls for %s", async (role) => { + mocks.role = role; + await show(); + await details("Build box"); + expect(container.textContent?.includes("Close environment")).toBe(role === "operator"); + expect(container.textContent?.includes("Idle policy…")).toBe(role === "operator"); + expect(container.textContent?.includes("Enrollment")).toBe(role === "operator"); + await details("Build box"); + await details("Runner"); + await details("External box"); + expect(container.textContent).not.toContain("Close environment"); + expect(container.textContent).not.toContain("Idle policy…"); +}); + +it("distinguishes an empty inventory from all closed and preserves the closed toggle", async () => { + rows = [{ ...registered, status: "closed" }]; + await show(); + expect(container.textContent).toContain("Every environment here is closed."); + expect(container.textContent).not.toContain("No environments yet"); + await act(async () => container.querySelector('[role="switch"]')!.click()); + await settle(); + expect(container.querySelector("table")?.textContent).toContain("Runner"); +}); + +it("shows empty inventory independently of provider metadata failures", async () => { + rows = []; + const implementation = mocks.api.getMockImplementation()!; + mocks.api.mockImplementation(async (method, path) => { + if (path.endsWith("/environment-provider-bindings")) throw new Error("Provider offline"); + return implementation(method, path); + }); + await show(); + expect(container.textContent).toContain("No environments yet"); + expect(container.textContent).toContain("Provider bindings unavailable"); +}); + +it("retains usable inventory when enrollment metadata fails", async () => { + mocks.role = "operator"; + const implementation = mocks.api.getMockImplementation()!; + mocks.api.mockImplementation(async (method, path) => { + if (path.endsWith("/environment-registration-keys")) throw new Error("Keys unavailable"); + return implementation(method, path); + }); + await show(); + expect(container.querySelector("table")?.textContent).toContain("Runner"); + expect(container.querySelector("table")?.textContent).toContain("key-id"); + expect(container.textContent).toContain("Registration keys unavailable"); +}); + +it("shows loading and inventory failure without a false empty state", async () => { + const implementation = mocks.api.getMockImplementation()!; + let reject!: (error: Error) => void; + mocks.api.mockImplementation((method, path) => path.endsWith("/environments") + ? new Promise((_, fail) => { reject = fail; }) : implementation(method, path)); + await show(); + expect(container.textContent).toContain("Loading…"); + expect(container.textContent).not.toContain("No environments yet"); + await act(async () => reject(new Error("Inventory unavailable"))); + await settle(); + expect(container.textContent).toContain("Environments unavailable"); + expect(container.textContent).not.toContain("No environments yet"); +}); + +it("continues polling while power diverges", async () => { + await show(); + const reads = () => mocks.api.mock.calls.filter(([, path]) => path.endsWith("/environments")).length; + const before = reads(); + await act(async () => { await vi.advanceTimersByTimeAsync(10_000); }); + expect(reads()).toBeGreaterThan(before); +}); diff --git a/platform/web/src/pages/EnvironmentsPage.tsx b/platform/web/src/pages/EnvironmentsPage.tsx index 4fa59fae4..104145cd5 100644 --- a/platform/web/src/pages/EnvironmentsPage.tsx +++ b/platform/web/src/pages/EnvironmentsPage.tsx @@ -1,6 +1,6 @@ import { useActionPermissions } from "@/lib/permissions"; import { ReadError } from "@/components/read-error"; -import { useState } from "react"; +import { useId, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Boxes, Check, ChevronDown, Copy, KeyRound, Plus, Trash2 } from "lucide-react"; import { @@ -25,6 +25,10 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; +import { + Table, TableActionsCell, TableBody, TableCard, TableCell, + TableHead, TableHeader, TableRow, TableTitleCell, +} from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -49,7 +53,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { EmptyState, LoadingNote, PageHeader, ShowHiddenToggle, UniverseNotFound } from "@/components/page"; +import { EmptyState, LoadingNote, PageHeader, SectionHeader, ShowHiddenToggle, UniverseNotFound } from "@/components/page"; import { EnvironmentIdlePolicyDialog } from "@/components/environment/idle-policy-dialog"; import { IdlePolicyFields, @@ -152,14 +156,12 @@ function ProviderList({ universeId }: { universeId: string }) { const [showClosed, setShowClosed] = useState(false); const allEnvironmentRows = (environments.data ?? []) .slice() - .sort((a, b) => environmentName(a).localeCompare(environmentName(b))); + .sort((a, b) => environmentName(a).localeCompare(environmentName(b)) + || a.environmentId.localeCompare(b.environmentId)); const closedCount = allEnvironmentRows.filter((environment) => environment.status === "closed").length; const environmentRows = showClosed ? allEnvironmentRows : allEnvironmentRows.filter((environment) => environment.status !== "closed"); - // One lookup for every card: configuring an environment is decided per - // environment, not from the universe role alone. - const decisions = useActionPermissions(universeId); const enabledBindings = new Set( bindingRows.filter((binding) => binding.status === "enabled").map((binding) => binding.bindingId), ); @@ -170,20 +172,6 @@ function ProviderList({ universeId }: { universeId: string }) { .slice() .sort((a, b) => a.displayName.localeCompare(b.displayName)); const keysById = new Map(keyRows.map((key) => [key.registrationKeyId, key])); - // Registered environments are grouped under the key that admitted them; - // provisioned and external ones keep their flat list. - const groupedRows = keyRows - .map((key) => ({ - key, - environments: environmentRows.filter((environment) => - environment.source.type === "registered" - && environment.source.registrationKeyId === key.registrationKeyId), - })) - .filter((group) => group.environments.length > 0); - const orphanRegistered = environmentRows.filter((environment) => - environment.source.type === "registered" - && !keysById.has(environment.source.registrationKeyId)); - const ungroupedRows = environmentRows.filter((environment) => environment.source.type !== "registered"); return ( <> @@ -191,7 +179,7 @@ function ProviderList({ universeId }: { universeId: string }) { title="Environments" description="Computers and sandboxes agents can use in this universe." actions={writable && ( -
+
0 && ( )} -
)} /> @@ -222,57 +209,44 @@ function ProviderList({ universeId }: { universeId: string }) { {secrets.error && ( )} - {registrationKeys.error && ( - - )} - {bindings.data && environments.data && allEnvironmentRows.length === 0 && ( + {environments.data && allEnvironmentRows.length === 0 && ( Machines agents run commands on: created from a provider template, or registered by running lightspeed-envd on a machine of your own. )} - {groupedRows.map((group) => ( -
-
- -

{group.key.displayName}

- {group.key.identityMode} - - {group.environments.length} registered - {group.key.maxActiveEnvironments ? ` · limit ${group.key.maxActiveEnvironments}` : ""} - -
- {group.environments.map((environment) => ( - - ))} -
- ))} -
- {[...ungroupedRows, ...orphanRegistered].map((environment) => ( - - candidate.bindingId === provisionedBindingId(environment) - && candidate.templateId === environment.incarnation.templateId - )} - registrationKey={undefined} - secrets={secrets.data} - /> - ))} -
+ {environmentRows.length > 0 && ( + + + + + Environment + Source + Status + Details + + + + {environmentRows.map((environment) => ( + + candidate.bindingId === provisionedBindingId(environment) + && candidate.templateId === environment.incarnation.templateId + )} + registrationKey={environment.source.type === "registered" + ? keysById.get(environment.source.registrationKeyId) : undefined} + secrets={secrets.data} + /> + ))} + +
+
+ )} {environments.data && allEnvironmentRows.length > 0 && environmentRows.length === 0 && (

Every environment here is closed.

)} @@ -284,13 +258,29 @@ function ProviderList({ universeId }: { universeId: string }) { onCheckedChange={setShowClosed} />
- {writable && keyRows.length > 0 && } + {writable && ( +
+ } + /> + {registrationKeys.isLoading && } + {registrationKeys.error && ( + + )} + {registrationKeys.data && keyRows.length === 0 && ( +

No registration keys yet.

+ )} + {keyRows.length > 0 && } +
+ )} {bindingRows.length > 0 && } ); } -function EnvironmentCard({ +function EnvironmentRow({ universeId, environment, configurable, @@ -310,121 +300,131 @@ function EnvironmentCard({ secrets: SecretsInventory | undefined; }) { const [open, setOpen] = useState(false); + const detailsId = useId(); const [policyOpen, setPolicyOpen] = useState(false); const source = environment.source; const gone = ["closing", "closed", "failed"].includes(environment.status); const registered = source.type === "registered"; return ( - -
-
-
-

{environmentName(environment)}

-

+ <> + + + +

{source.type === "provisioned" ? "Provisioned" : registered ? "Registered" : "External"}
+ {source.type !== "external" && ( +
{source.type === "provisioned" - ? template?.displayName ?? environment.incarnation.templateId ?? "Provisioned environment" - : source.type === "registered" - ? `Registered daemon · ${registrationKey?.displayName ?? source.registrationKeyId}` - : "External environment"} -

+ ? `${source.providerId} · ${template?.displayName ?? environment.incarnation.templateId ?? "Unknown template"}` + : registrationKey?.displayName ?? source.registrationKeyId} +
+ )} + + + + {registered && (

- {registered - ? environment.lastSeenAtMs - ? `Last seen ${relativeTime(environment.lastSeenAtMs)}` - : "Never seen" - : environment.publicEndpoint ?? "Private environment"} + {environment.lastSeenAtMs ? `Last seen ${relativeTime(environment.lastSeenAtMs)}` : "Never seen"}

-
- - + )} + {source.type === "provisioned" && powerDiverges(environment) && ( +

Desired: {environment.desiredPower}

+ )} + + +
- -
-
- - - {source.type === "registered" && ( - - )} - {source.type === "registered" && } - {source.type === "registered" && ( - - )} - {source.type === "registered" && environment.lastSeenAtMs && ( - - )} - {source.type === "provisioned" && } - {source.type === "provisioned" && } - {environment.incarnation.templateId && } - {environment.incarnation.providerTargetId && } +
- - {configurable && source.type === "provisioned" && ( -
- - {!gone && ( - + {configurable && source.type === "provisioned" && ( +
+ + {!gone && ( + + )} + {template?.publicIngress && ( + + )} + +
)} - {template?.publicIngress && ( - + {configurable && source.type === "provisioned" && !gone && ( + )} - -
- )} - {configurable && registered && !gone && ( -
- - - Closing spends the daemon identity; the machine itself is untouched. -
)} - {configurable && source.type === "provisioned" && !gone && ( - - )}
-
-
-
+ + + ); } @@ -746,58 +746,66 @@ function RegistrationKeys({ universeId: string; keys: EnvironmentRegistrationKey[]; }) { + const [open, setOpen] = useState(false); const [showInactive, setShowInactive] = useState(false); const inactiveCount = keys.filter((key) => key.status !== "active").length; const rows = showInactive ? keys : keys.filter((key) => key.status === "active"); return ( -
-

Registration keys

-

- A key lets lightspeed-envd daemons dial in and register as - environments; each key is the group its environments belong to. Revoking stops new daemons - while registered ones keep reconnecting. -

-
- {rows.map((key) => ( -
-
-

{key.displayName}

-

- {key.keyPrefix}… · {key.registrationKeyId} -

-
- {key.identityMode} - - {key.activeEnvironmentCount} active / {key.registeredEnvironmentCount} total - {key.maxActiveEnvironments ? ` · limit ${key.maxActiveEnvironments}` : ""} - {key.identityMode === "ephemeral" - ? ` · grace ${Math.round(key.ephemeralDisconnectGraceMs / 60_000)} min` - : ""} - {key.expiresAtMs ? ` · expires ${relativeTime(key.expiresAtMs).replace(" ago", "")}` : ""} - - {key.status === "active" ? ( - active - ) : ( - {key.status} - )} - {key.status === "active" && ( - + +
+ + Registration keys ({keys.length}) + + +

+ A key lets lightspeed-envd daemons dial in and register as + environments. Revoking stops new daemons + while registered ones keep reconnecting. +

+
+ {rows.map((key) => ( +
+
+

{key.displayName}

+

+ {key.keyPrefix}… · {key.registrationKeyId} +

+
+ {key.identityMode} + + {key.activeEnvironmentCount} active / {key.registeredEnvironmentCount} total + {key.maxActiveEnvironments ? ` · limit ${key.maxActiveEnvironments}` : ""} + {key.identityMode === "ephemeral" + ? ` · grace ${Math.round(key.ephemeralDisconnectGraceMs / 60_000)} min` + : ""} + {key.expiresAtMs ? ` · expires ${relativeTime(key.expiresAtMs).replace(" ago", "")}` : ""} + + {key.status === "active" ? ( + active + ) : ( + {key.status} + )} + {key.status === "active" && ( + + )} +
+ ))} + {rows.length === 0 && ( +

Every registration key here is revoked or expired.

)}
- ))} - {rows.length === 0 && ( -

Every registration key here is revoked or expired.

- )} -
-
- -
-
+
+ +
+ + + ); } @@ -1050,7 +1058,7 @@ function ProviderBindings({ bindings }: { bindings: EnvironmentProviderBinding[]
- Provider bindings + Provider configuration {enabled} enabled · {bindings.length} total From 34ab13e262d57ba72da64df0862a3865f1ec0788 Mon Sep 17 00:00:00 2001 From: lb <542828+lukebuehler@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:37:04 +0200 Subject: [PATCH 2/7] setup tables --- .../web/src/components/app-shell.test.tsx | 4 +- .../src/components/confirm-danger-button.tsx | 2 +- .../components/environment/power-controls.tsx | 4 +- .../src/components/models/model-api-key.tsx | 79 +--- .../models/model-provider-details-dialog.tsx | 57 ++- .../components/models/model-provider-list.tsx | 109 +---- .../src/components/models/subscription.tsx | 39 +- .../models/use-model-providers.test.ts | 18 +- .../components/models/use-model-providers.ts | 60 ++- .../components/resource-details-dialog.tsx | 47 +++ .../web/src/components/resource-table.tsx | 105 +++++ platform/web/src/components/universe-nav.tsx | 2 +- platform/web/src/lib/resource-status.test.ts | 37 ++ platform/web/src/lib/resource-status.ts | 80 ++++ .../web/src/pages/EnvironmentsPage.test.tsx | 91 +++- platform/web/src/pages/EnvironmentsPage.tsx | 393 +++++++----------- .../src/pages/McpServersPage.dialog.test.tsx | 105 ++++- platform/web/src/pages/McpServersPage.tsx | 371 ++++++----------- platform/web/src/pages/ModelsPage.test.tsx | 51 ++- platform/web/src/pages/ModelsPage.tsx | 25 +- 20 files changed, 900 insertions(+), 779 deletions(-) create mode 100644 platform/web/src/components/resource-details-dialog.tsx create mode 100644 platform/web/src/components/resource-table.tsx create mode 100644 platform/web/src/lib/resource-status.test.ts create mode 100644 platform/web/src/lib/resource-status.ts diff --git a/platform/web/src/components/app-shell.test.tsx b/platform/web/src/components/app-shell.test.tsx index 4e69e0f04..f2767734d 100644 --- a/platform/web/src/components/app-shell.test.tsx +++ b/platform/web/src/components/app-shell.test.tsx @@ -19,7 +19,7 @@ vi.mock("@/components/universe-switcher", () => ({ UniverseSwitcher: () => null vi.mock("@/components/user-menu", () => ({ UserMenu: () => null })); const WORK = ["Bots", "Sessions"]; -const SETUP = ["Profiles", "Workspaces", "Models", "Environments", "MCP servers"]; +const SETUP = ["Profiles", "Workspaces", "Environments", "MCP servers", "Models"]; let root: Root; let container: HTMLDivElement; @@ -111,7 +111,7 @@ it("links each page at its flat route", async () => { await sidebarFor("admin"); expect([...container.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([ "/u/test/bots", "/u/test/sessions", - "/u/test/profiles", "/u/test/workspaces", "/u/test/models", "/u/test/environments", "/u/test/mcp-servers", + "/u/test/profiles", "/u/test/workspaces", "/u/test/environments", "/u/test/mcp-servers", "/u/test/models", "/u/test/credentials", "/u/test/api-keys", "/u/test/members", "/u/test/settings/general", "/u/test/settings/channels", "/u/test/settings/templates", ]); diff --git a/platform/web/src/components/confirm-danger-button.tsx b/platform/web/src/components/confirm-danger-button.tsx index 0bafcfda6..47376dce9 100644 --- a/platform/web/src/components/confirm-danger-button.tsx +++ b/platform/web/src/components/confirm-danger-button.tsx @@ -29,7 +29,7 @@ export function ConfirmDangerButton({ }) { return ( - }> + }> {label} diff --git a/platform/web/src/components/environment/power-controls.tsx b/platform/web/src/components/environment/power-controls.tsx index c8ed32165..a20bfd437 100644 --- a/platform/web/src/components/environment/power-controls.tsx +++ b/platform/web/src/components/environment/power-controls.tsx @@ -54,10 +54,12 @@ export function EnvironmentPowerControls({ universeId, environment, onChanged, + size = "xs", }: { universeId: string; environment: Environment; onChanged?: () => void; + size?: "xs" | "sm"; }) { const queryClient = useQueryClient(); const power = useMutation({ @@ -91,7 +93,7 @@ export function EnvironmentPowerControls({ - + + )} ); @@ -564,13 +551,11 @@ export function OpenAiCompatibleForm({ export function OpenAiCompatibleDetails({ universeId, provider, - oauthGrant, onChanged, onRemoved, }: { universeId: string; provider: SecretProvider; - oauthGrant?: SecretGrant; onChanged: () => void; onRemoved: () => void; }) { @@ -600,30 +585,6 @@ export function OpenAiCompatibleDetails({ ); return (
-
-
Provider
-
{provider.providerId}
-
Credential ID
-
- {provider.credentialId} -
-
Status
-
- -
-
Authentication
-
- {provider.config.type === "modelOAuth" ? ( - <> - OAuth · {provider.config.grantId} - - ) : provider.hasCredential ? ( - "API key" - ) : ( - "None" - )} -
-
{endpoint && } {remove.error.message}

)} {writable && ( - + remove.mutate()} /> - {endpoint && } - + {endpoint && } + )}
); } -/// Why a provider row is or is not used for model calls. -function ProviderStatusBadge({ - provider, - oauthGrant, -}: { - provider: SecretProvider; - oauthGrant?: SecretGrant; -}) { - const attention = (label: string) => ( - - {label} - - ); - if (!provider.usableForModels) return attention("legacy id — replace"); - if (provider.status === "disabled") return disabled; - if (provider.config.type === "modelOAuth") { - if (!oauthGrant || oauthGrant.status === "revoked") return attention("OAuth grant missing"); - if (oauthGrant.status !== "active") { - return attention(oauthGrant.status === "needsReauth" ? "needs reauth" : oauthGrant.status); - } - } else if (provider.config.type !== "modelEndpoint" && !provider.hasCredential) { - return attention("needs key"); - } - if (provider.status !== "active") return attention("needs configuration"); - return active; -} export interface ProviderModelCatalogEntry { model: string; diff --git a/platform/web/src/components/models/model-provider-details-dialog.tsx b/platform/web/src/components/models/model-provider-details-dialog.tsx index aa6eeb7a6..560ce1557 100644 --- a/platform/web/src/components/models/model-provider-details-dialog.tsx +++ b/platform/web/src/components/models/model-provider-details-dialog.tsx @@ -1,4 +1,9 @@ -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import type { RefObject } from "react"; +import { ResourceDetail, ResourceDetailsDialog, ResourceDetailsGrid } from "@/components/resource-details-dialog"; +import { ResourceStatus } from "@/components/resource-table"; +import { resourceTimestamp } from "@/lib/resource-status"; +import { formatExpiry } from "@/lib/integrations"; +import { subscriptionAccountLabel, subscriptionBinding } from "@/lib/subscriptions"; import { modelProviderDefinition } from "./catalog"; import { ModelApiKeyDetails, OpenAiCompatibleDetails } from "./model-api-key"; import { SubscriptionDetails } from "./subscription"; @@ -11,34 +16,43 @@ export function ModelProviderDetailsDialog({ provider, onOpenChange, onChanged, + finalFocus, }: { universeId: string; provider: ConnectedModelProvider | null; onOpenChange: (open: boolean) => void; onChanged: () => void; + finalFocus?: RefObject; }) { const definition = provider ? modelProviderDefinition(provider.kind) : null; return ( - - - {provider && definition && ( - <> - - - - {provider.title} - - - onOpenChange(false)} - /> - - )} - - + } finalFocus={finalFocus}> + {provider && ( + <> + + {"provider" in provider + ? {provider.provider.providerId} + : {subscriptionAccountLabel(provider.grant) || "—"}} + + {"provider" in provider ? provider.provider.credentialId : provider.grant.grantId} + {provider.connectionDescription ? `${provider.connection} · ${provider.connectionDescription}` : provider.connection} + {"provider" in provider && provider.provider.config.type === "modelOAuth" && ( + {provider.provider.config.grantId} + )} + {"grant" in provider && <> + {subscriptionBinding(provider.grant)?.envName ?? "—"} + {formatExpiry(provider.grant.expiresAtMs)} + } + {resourceTimestamp(provider.createdAtMs)} + {resourceTimestamp(provider.updatedAtMs)} + + onOpenChange(false)} /> + + )} + ); } @@ -73,7 +87,6 @@ function ModelProviderDetails({ diff --git a/platform/web/src/components/models/model-provider-list.tsx b/platform/web/src/components/models/model-provider-list.tsx index 959be3868..60c3f6da3 100644 --- a/platform/web/src/components/models/model-provider-list.tsx +++ b/platform/web/src/components/models/model-provider-list.tsx @@ -1,99 +1,34 @@ import { EmptyState } from "@/components/page"; -import { BrainCircuit, ChevronRight } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { - Table, - TableBody, - TableCard, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { BrainCircuit } from "lucide-react"; +import { TableCell } from "@/components/ui/table"; +import { ResourceIdentityCell, ResourceRow, ResourceStatus, ResourceTable, ResourceValue } from "@/components/resource-table"; import { modelProviderDefinition } from "./catalog"; -import type { ConnectedModelProvider, ModelProviderStatus } from "./use-model-providers"; +import type { ConnectedModelProvider } from "./use-model-providers"; -/// Connected model providers; a row opens the details dialog. -export function ModelProviderList({ - providers, - onSelect, -}: { +export function ModelProviderList({ providers, onSelect, showEmpty = true }: { providers: ConnectedModelProvider[]; - onSelect: (provider: ConnectedModelProvider) => void; + onSelect: (provider: ConnectedModelProvider, trigger: HTMLButtonElement | null) => void; + showEmpty?: boolean; }) { if (providers.length === 0) { - return ( + return showEmpty ? ( - Provider API keys, compatible endpoints and coding-agent subscriptions that sessions call - models through. + Provider API keys, compatible endpoints and coding-agent subscriptions that sessions call models through. - ); + ) : null; } return ( - - - - - Provider - Type - Status - Updated - - - - - {providers.map((provider) => { - const { Logo } = modelProviderDefinition(provider.kind); - return ( - onSelect(provider)} - > - -
- - - -
- {provider.title} - {provider.subtitle} -
-
-
- {provider.type} - - - - - {formatTimestamp(provider.updatedAtMs)} - - - - -
- ); - })} -
-
-
+ + {providers.map((provider) => { + const { Logo } = modelProviderDefinition(provider.kind); + return ( + onSelect(provider, trigger)}> + } compactDescription={[provider.connection, provider.connectionDescription, provider.subtitle.trim().toLocaleLowerCase() !== provider.title.trim().toLocaleLowerCase() ? provider.subtitle : null].filter(Boolean).join(" · ")} /> + {provider.connection} + + + ); + })} + ); } - -function StatusBadge({ status }: { status: ModelProviderStatus }) { - if (status === "active") return active; - if (status === "disabled") return disabled; - return ( - - needs attention - - ); -} - -function formatTimestamp(timestampMs: number): string { - if (!timestampMs) return "—"; - return new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(timestampMs)); -} diff --git a/platform/web/src/components/models/subscription.tsx b/platform/web/src/components/models/subscription.tsx index 660bdf20c..b1058d363 100644 --- a/platform/web/src/components/models/subscription.tsx +++ b/platform/web/src/components/models/subscription.tsx @@ -1,21 +1,17 @@ +import { ResourceActions } from "@/components/resource-details-dialog"; import { useActionPermissions } from "@/lib/permissions"; import { useState, type FormEvent } from "react"; import { useMutation } from "@tanstack/react-query"; import { Copy } from "lucide-react"; import { api, type SecretGrant, type SubscriptionImportResult } from "@/api"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DialogFooter } from "@/components/ui/dialog"; import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; -import { IdText } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; -import { formatExpiry } from "@/lib/integrations"; import { CODEX_AUTH_JSON_BOOTSTRAP, isCodexTokenSet, - subscriptionAccountLabel, - subscriptionBinding, type SubscriptionProvider, } from "@/lib/subscriptions"; import { ConfirmDangerButton } from "@/components/confirm-danger-button"; @@ -155,7 +151,6 @@ export function SubscriptionDetails({ onDisconnected: () => void; }) { const writable = useActionPermissions(universeId).can("configure_resource"); - const binding = subscriptionBinding(grant); const disconnect = useMutation({ mutationFn: () => api( @@ -167,22 +162,6 @@ export function SubscriptionDetails({ return (
-
-
Account
-
{subscriptionAccountLabel(grant) || "—"}
-
Bind as
-
{binding ? {binding.envName} : "—"}
-
Expires
-
{formatExpiry(grant.expiresAtMs)}
-
Status
-
- -
-
Credential ID
-
- {grant.grantId} -
-

Used only inside environments (Environments → Assign credential; the variable name above is suggested automatically). Lightspeed's own sessions do not use this credential; they use @@ -191,7 +170,7 @@ export function SubscriptionDetails({ {isCodexTokenSet(grant) && } {disconnect.error &&

{disconnect.error.message}

} {writable && ( - + disconnect.mutate()} /> - + )}
); @@ -240,15 +219,3 @@ export function CodexBootstrapNote() { ); } - -function SubscriptionStatusBadge({ status }: { status: SecretGrant["status"] }) { - if (status === "active") return connected; - if (status === "needsReauth" || status === "failed") { - return ( - - {status === "needsReauth" ? "reconnect" : status} - - ); - } - return revoked; -} diff --git a/platform/web/src/components/models/use-model-providers.test.ts b/platform/web/src/components/models/use-model-providers.test.ts index 8f1181be0..5ae5f79f0 100644 --- a/platform/web/src/components/models/use-model-providers.test.ts +++ b/platform/web/src/components/models/use-model-providers.test.ts @@ -35,14 +35,14 @@ it("lists every model provider row, including legacy and OAuth rows no add form grant("plain-token"), ], ); - expect(rows.map((row) => [row.kind, row.title, row.type, row.status])).toEqual([ - ["anthropicApiKey", "Anthropic (API key)", "Anthropic (API key)", "attention"], - ["openAiCompatible", "deepseek", "OpenAI-compatible provider", "active"], - ["openAiCompatible", "ollama", "OpenAI-compatible provider", "active"], - ["openAiApiKey", "OpenAI (API key)", "OpenAI (API key)", "active"], - ["openAiCompatible", "vendor", "OAuth connection", "attention"], - ["anthropicSubscription", "Lukas · Max", "Claude Code (subscription)", "active"], + expect(rows.map((row) => [row.kind, row.title, row.connection, row.status.label])).toEqual([ + ["anthropicApiKey", "Anthropic", "API key", "Needs attention"], + ["openAiCompatible", "deepseek", "API key", "Active"], + ["anthropicSubscription", "Lukas · Max", "Subscription", "Connected"], + ["openAiCompatible", "ollama", "No authentication", "Active"], + ["openAiApiKey", "Production", "API key", "Active"], + ["openAiCompatible", "vendor", "OAuth", "Needs sign-in"], ]); - expect(rows[3]).toMatchObject({ subtitle: "Production", updatedAtMs: 2 }); - expect(rows[4]).toMatchObject({ oauthGrant: { grantId: "vendor-login" } }); + expect(rows.find((row) => row.title === "Production")).toMatchObject({ subtitle: "openai", createdAtMs: 1, updatedAtMs: 2 }); + expect(rows.find((row) => row.title === "vendor")).toMatchObject({ oauthGrant: { grantId: "vendor-login" } }); }); diff --git a/platform/web/src/components/models/use-model-providers.ts b/platform/web/src/components/models/use-model-providers.ts index 2de7427f0..a4c6ca7e1 100644 --- a/platform/web/src/components/models/use-model-providers.ts +++ b/platform/web/src/components/models/use-model-providers.ts @@ -1,7 +1,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type SecretGrant, type SecretProvider, type SecretsInventory } from "@/api"; import { subscriptionAccountLabel, subscriptionProviderOf } from "@/lib/subscriptions"; -import { modelProviderDefinition, type ModelProviderKind } from "./catalog"; +import type { ModelProviderKind } from "./catalog"; +import { modelProviderStatus, subscriptionStatus, type ResourceStatusInfo } from "@/lib/resource-status"; /// One connected model provider as the list shows it. Each variant keeps its /// source record so the details dialog can render kind-specific content. @@ -9,9 +10,11 @@ export type ConnectedModelProvider = { id: string; title: string; subtitle: string; - /// The list's Type column. - type: string; - status: ModelProviderStatus; + subtitleMono: boolean; + connection: string; + connectionDescription?: string; + status: ResourceStatusInfo; + createdAtMs: number; updatedAtMs: number; } & ( | { @@ -26,11 +29,9 @@ export type ConnectedModelProvider = { } ); -export type ModelProviderStatus = "active" | "attention" | "disabled"; - /// Every model provider row in the universe inventory, including legacy and -/// OAuth-configured rows no add form creates, followed by coding-agent -/// subscriptions that are still connected. +/// OAuth-configured rows no add form creates, and connected coding-agent +/// subscriptions, sorted by their displayed name. export function connectedModelProviders( inventory: SecretsInventory | undefined, subscriptions: SecretGrant[] | undefined, @@ -38,7 +39,6 @@ export function connectedModelProviders( const grants = inventory?.grants ?? []; const providers = (inventory?.providers ?? []) .slice() - .sort((a, b) => a.providerId.localeCompare(b.providerId) || a.credentialId.localeCompare(b.credentialId)) .map((provider): ConnectedModelProvider => { const builtIn = provider.providerId === "openai" || provider.providerId === "anthropic"; const config = provider.config; @@ -47,26 +47,24 @@ export function connectedModelProviders( : undefined; const common = { id: `model-provider:${provider.credentialId}`, - status: providerStatus(provider, oauthGrant), + status: modelProviderStatus(provider, oauthGrant), + connection: config.type === "modelOAuth" ? "OAuth" : config.type === "modelEndpoint" ? "No authentication" : "API key", + subtitleMono: true, + createdAtMs: provider.createdAtMs, updatedAtMs: provider.updatedAtMs, provider, oauthGrant, }; if (builtIn && config.type === "modelApiKey") { const kind = provider.providerId === "openai" ? "openAiApiKey" : "anthropicApiKey"; - const name = modelProviderDefinition(kind).name; - return { ...common, kind, title: name, subtitle: provider.displayName ?? provider.credentialId, type: name }; + const name = provider.providerId === "openai" ? "OpenAI" : "Anthropic"; + return { ...common, kind, title: provider.displayName || name, subtitle: provider.providerId }; } return { ...common, kind: "openAiCompatible", title: provider.displayName ?? provider.providerId, subtitle: provider.providerId, - type: providerEndpoint(provider) - ? modelProviderDefinition("openAiCompatible").name - : config.type === "modelOAuth" - ? "OAuth connection" - : "API key", }; }); const connectedSubscriptions = (subscriptions ?? []) @@ -76,29 +74,23 @@ export function connectedModelProviders( if (!provider) return []; const kind: ModelProviderKind = provider === "anthropic" ? "anthropicSubscription" : "openAiSubscription"; - const name = modelProviderDefinition(kind).name; + const name = provider === "anthropic" ? "Claude Code" : "Codex"; + const account = subscriptionAccountLabel(grant); return [{ kind, id: `subscription:${grant.grantId}`, - title: grant.displayName ?? name, - subtitle: subscriptionAccountLabel(grant) || grant.grantId, - type: name, - status: grant.status === "active" ? "active" : "attention", + title: grant.displayName || name, + subtitle: account || grant.grantId, + subtitleMono: !account, + connection: "Subscription", + connectionDescription: name, + status: subscriptionStatus(grant), + createdAtMs: grant.createdAtMs, updatedAtMs: grant.updatedAtMs, grant, }]; }); - return [...providers, ...connectedSubscriptions]; -} - -/// Usable for model calls: an active row under the id the runtime reads, -/// with its credential when it needs one. -function providerStatus(provider: SecretProvider, oauthGrant: SecretGrant | undefined): ModelProviderStatus { - if (provider.status === "disabled") return "disabled"; - const credentialed = - provider.config.type === "modelEndpoint" - || (provider.config.type === "modelOAuth" ? oauthGrant?.status === "active" : provider.hasCredential); - return provider.status === "active" && provider.usableForModels && credentialed ? "active" : "attention"; + return [...providers, ...connectedSubscriptions].sort((a, b) => a.title.localeCompare(b.title) || a.id.localeCompare(b.id)); } export function providerEndpoint(provider: SecretProvider) { @@ -118,6 +110,8 @@ export function useModelProviders(universeId: string) { return { connected: connectedModelProviders(secrets.data, subscriptions.data), isLoading: subscriptions.isLoading || secrets.isLoading, + hasData: subscriptions.data !== undefined || secrets.data !== undefined, + isComplete: subscriptions.data !== undefined && secrets.data !== undefined, error: subscriptions.error ?? secrets.error ?? null, }; } diff --git a/platform/web/src/components/resource-details-dialog.tsx b/platform/web/src/components/resource-details-dialog.tsx new file mode 100644 index 000000000..2a84720e9 --- /dev/null +++ b/platform/web/src/components/resource-details-dialog.tsx @@ -0,0 +1,47 @@ +import { useRef, type ReactNode, type RefObject } from "react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export function ResourceDetailsDialog({ open, onOpenChange, title, description, icon, finalFocus, children }: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: ReactNode; + icon?: ReactNode; + finalFocus?: RefObject; + children: ReactNode; +}) { + const contentRef = useRef(null); + return ( + + + + + {icon && } + {title} + + {description} + +
{children}
+
+
+ ); +} + +export function ResourceDetailsGrid({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function ResourceDetail({ label, children, mono = false }: { label: string; children: ReactNode; mono?: boolean }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export function ResourceActions({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/platform/web/src/components/resource-table.tsx b/platform/web/src/components/resource-table.tsx new file mode 100644 index 000000000..7fd22d39c --- /dev/null +++ b/platform/web/src/components/resource-table.tsx @@ -0,0 +1,105 @@ +import { useRef, type ReactNode } from "react"; +import { ChevronRight, LoaderCircle } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Table, TableActionsCell, TableBody, TableCard, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import type { ResourceStatusInfo } from "@/lib/resource-status"; +import { cn } from "@/lib/utils"; + +/** Inventory rows share their hierarchy and interaction; columns keep their domain meaning. */ +export function ResourceTable({ label, columns, children }: { + label: string; + columns: string[]; + children: ReactNode; +}) { + return ( + + + + + {columns.map((column, index) => 0 && index < columns.length - 1 ? "hidden sm:table-cell" : undefined}>{column})} + Details + + + {children} +
+
+ ); +} + +export function ResourceRow({ name, onOpen, children }: { + name: string; + onOpen: (trigger: HTMLButtonElement | null) => void; + children: ReactNode; +}) { + const triggerRef = useRef(null); + return ( + onOpen(triggerRef.current)}> + {children} + + + + + ); +} + +export function ResourceIdentityCell({ name, subtitle, mono = false, icon, compactDescription }: { + name: string; + subtitle?: string; + mono?: boolean; + icon?: ReactNode; + compactDescription?: string; +}) { + const secondary = subtitle?.trim().toLocaleLowerCase() !== name.trim().toLocaleLowerCase() ? subtitle : undefined; + return ( + +
+ {icon && } +
+ {name} + {secondary && {secondary}} + {compactDescription && {compactDescription}} +
+
+
+ ); +} + +export function ResourceValue({ children, description, mono = false }: { + children: string; + description?: string; + mono?: boolean; +}) { + return ( +
+ {children} + {description && {description}} +
+ ); +} + +export function ResourceStatusBadge({ status }: { status: ResourceStatusInfo }) { + return ( + + {status.tone === "pending" && + ); +} + +export function ResourceStatus({ status }: { status: ResourceStatusInfo }) { + return ( +
+ + {status.description &&

{status.description}

} +
+ ); +} diff --git a/platform/web/src/components/universe-nav.tsx b/platform/web/src/components/universe-nav.tsx index da257ede9..3d0cafd79 100644 --- a/platform/web/src/components/universe-nav.tsx +++ b/platform/web/src/components/universe-nav.tsx @@ -68,9 +68,9 @@ export const UNIVERSE_NAV: UniverseNavGroup[] = [ items: [ { path: "profiles", label: "Profiles", icon: SlidersHorizontal, action: "read" }, { path: "workspaces", label: "Workspaces", icon: FolderGit2, action: "read" }, - { path: "models", label: "Models", icon: BrainCircuit, action: "read" }, { path: "environments", label: "Environments", icon: Boxes, action: "read" }, { path: "mcp-servers", label: "MCP servers", icon: Server, action: "read" }, + { path: "models", label: "Models", icon: BrainCircuit, action: "read" }, ], }, { diff --git a/platform/web/src/lib/resource-status.test.ts b/platform/web/src/lib/resource-status.test.ts new file mode 100644 index 000000000..da8407a03 --- /dev/null +++ b/platform/web/src/lib/resource-status.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "vitest"; +import type { Environment, McpServer, SecretProvider } from "@/api"; +import { environmentStatus, mcpServerStatus, modelProviderStatus, subscriptionStatus } from "./resource-status"; + +const provider: SecretProvider = { + providerId: "openai", credentialId: "model:openai", usableForModels: true, providerKind: "modelApiKey", + config: { type: "modelApiKey" }, status: "active", hasCredential: true, createdAtMs: 1, updatedAtMs: 1, +}; + +it("explains missing model credentials and gives disabled state precedence", () => { + expect(modelProviderStatus({ ...provider, hasCredential: false })).toMatchObject({ label: "Needs API key", tone: "warning" }); + expect(modelProviderStatus({ ...provider, hasCredential: false, status: "disabled" })).toEqual({ label: "Disabled", tone: "neutral" }); + expect(modelProviderStatus({ ...provider, config: { type: "modelEndpoint", endpoint: { baseUrl: "http://localhost:11434/v1", apiKinds: ["openai:completions"] } }, hasCredential: false })).toEqual({ label: "Active", tone: "positive" }); +}); + +it("keeps configuration readiness distinct from failed or revoked OAuth connections", () => { + const oauth: SecretProvider = { ...provider, config: { type: "modelOAuth", grantId: "login" } }; + expect(modelProviderStatus(oauth)).toMatchObject({ label: "Needs sign-in", tone: "warning" }); + expect(modelProviderStatus(oauth, { status: "needsReauth" })).toMatchObject({ label: "Needs sign-in", description: "Sign in again to reconnect" }); + expect(subscriptionStatus({ status: "failed" })).toEqual({ label: "Connection failed", tone: "danger" }); + expect(subscriptionStatus({ status: "revoked" })).toEqual({ label: "Revoked", tone: "neutral" }); +}); + +it("explains power convergence even when the current machine is still ready", () => { + const machine: Pick = { + source: { type: "provisioned", bindingId: "binding", providerId: "incus" }, status: "ready", desiredPower: "paused", + }; + expect(environmentStatus(machine)).toMatchObject({ label: "Changing power", tone: "pending", description: "Ready → paused" }); + expect(environmentStatus({ ...machine, status: "paused", desiredPower: "running" })).toMatchObject({ label: "Waking", tone: "pending" }); + expect(environmentStatus({ ...machine, status: "paused" })).toMatchObject({ label: "Paused", tone: "neutral" }); +}); + +it("distinguishes signing in from binding a bearer credential", () => { + const server: Pick = { status: "needsAuthConfig", authPolicy: { type: "requiredOAuth" } }; + expect(mcpServerStatus(server)).toEqual({ label: "Needs sign-in", tone: "warning" }); + expect(mcpServerStatus({ ...server, authPolicy: { type: "requiredBearer" } })).toEqual({ label: "Needs credential", tone: "warning" }); +}); diff --git a/platform/web/src/lib/resource-status.ts b/platform/web/src/lib/resource-status.ts new file mode 100644 index 000000000..5e02659d2 --- /dev/null +++ b/platform/web/src/lib/resource-status.ts @@ -0,0 +1,80 @@ +import type { Environment, McpServer, SecretGrant, SecretProvider } from "@/api"; + +export interface ResourceStatusInfo { + label: string; + tone: "positive" | "neutral" | "warning" | "danger" | "pending"; + description?: string; +} + +export function modelProviderStatus(provider: SecretProvider, grant?: Pick): ResourceStatusInfo { + if (provider.status === "disabled") return { label: "Disabled", tone: "neutral" }; + if (!provider.usableForModels) return { label: "Needs attention", tone: "warning", description: "Replace this legacy credential" }; + if (provider.config.type === "modelOAuth") { + if (!grant || grant.status === "revoked") return { label: "Needs sign-in", tone: "warning", description: "OAuth connection is missing" }; + if (grant.status !== "active") return subscriptionStatus(grant); + } else if (provider.config.type !== "modelEndpoint" && !provider.hasCredential) { + return { label: "Needs API key", tone: "warning" }; + } + if (provider.status !== "active") return { label: "Needs configuration", tone: "warning" }; + return { label: "Active", tone: "positive" }; +} + +export function subscriptionStatus(grant: Pick): ResourceStatusInfo { + switch (grant.status) { + case "active": return { label: "Connected", tone: "positive" }; + case "needsReauth": return { label: "Needs sign-in", tone: "warning", description: "Sign in again to reconnect" }; + case "failed": return { label: "Connection failed", tone: "danger" }; + case "revoked": return { label: "Revoked", tone: "neutral" }; + } +} + +export function mcpServerStatus(server: Pick): ResourceStatusInfo { + switch (server.status) { + case "active": return { label: "Active", tone: "positive" }; + case "disabled": return { label: "Disabled", tone: "neutral" }; + case "unverified": return { label: "Unverified", tone: "warning" }; + case "needsAuthConfig": return { + label: server.authPolicy.type.includes("OAuth") ? "Needs sign-in" : "Needs credential", + tone: "warning", + }; + } +} + +export function environmentStatus(environment: Pick): ResourceStatusInfo { + const { status, source, desiredPower } = environment; + const observed = status === "ready" ? "running" : status === "offline" ? "stopped" + : status === "paused" || status === "suspended" ? status : null; + if (source.type === "provisioned" && observed && observed !== desiredPower) { + return { + label: desiredPower === "running" ? "Waking" : "Changing power", + tone: "pending", + description: `${sentenceCase(status)} → ${desiredPower}`, + }; + } + const description = source.type === "registered" + ? environment.lastSeenAtMs ? `Last seen ${relativeResourceTime(environment.lastSeenAtMs)}` : "Never seen" + : undefined; + return { + label: status === "booting" ? "Starting" : sentenceCase(status), + tone: status === "ready" ? "positive" : status === "failed" ? "danger" + : status === "unknown" ? "warning" + : ["provisioning", "booting", "closing"].includes(status) ? "pending" : "neutral", + description, + }; +} + +function sentenceCase(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +export function relativeResourceTime(ms: number): string { + const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000)); + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +export function resourceTimestamp(ms: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(ms)); +} diff --git a/platform/web/src/pages/EnvironmentsPage.test.tsx b/platform/web/src/pages/EnvironmentsPage.test.tsx index 7e5239249..fe5b8a642 100644 --- a/platform/web/src/pages/EnvironmentsPage.test.tsx +++ b/platform/web/src/pages/EnvironmentsPage.test.tsx @@ -30,6 +30,7 @@ let client: QueryClient; beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("PointerEvent", MouseEvent); mocks.role = "viewer"; rows = [registered, external, provisioned]; mocks.api.mockReset().mockImplementation(async (_method: string, path: string) => { @@ -69,41 +70,99 @@ async function details(name: string) { await settle(); return button; } +const dialog = () => document.body.querySelector('[role="dialog"]')!; +async function closeDetails() { + await act(async () => dialog().querySelector('[data-slot="dialog-close"]')!.click()); + await settle(); +} -it("shows one sorted semantic inventory with source-specific signals and lazy details", async () => { +it("shows one sorted semantic inventory with source-specific signals and lazy modal details", async () => { await show(); const table = container.querySelector('table[aria-label="Environments"]')!; expect([...table.querySelectorAll("thead th")].map((cell) => cell.textContent)).toEqual(["Environment", "Source", "Status", "Details"]); - expect([...table.querySelectorAll("tbody tr:not([hidden])")].map((row) => row.querySelector("td")?.textContent)).toEqual([ - "Build boxBuild box", "External boxExternal box", "RunnerRunner", + expect([...table.querySelectorAll("tbody tr")].map((row) => row.querySelector("td [title]")?.getAttribute("title"))).toEqual([ + "Build box", "External box", "Runner", ]); - expect(table.textContent).toContain("Desired: running"); + expect(table.textContent).toContain("Paused → running"); expect(table.textContent).toContain("Never seen"); expect(table.textContent).toContain("key-id"); expect(mocks.api.mock.calls.some(([, path]) => path.endsWith("/credentials"))).toBe(false); expect(mocks.api.mock.calls.some(([, path]) => path.endsWith("/environment-registration-keys"))).toBe(false); + expect(dialog()).toBeNull(); const button = await details("Runner"); - expect(button.getAttribute("aria-expanded")).toBe("true"); - expect(document.getElementById(button.getAttribute("aria-controls")!)?.textContent).toContain("daemon"); - expect(container.textContent).not.toContain("Closing spends"); - expect(container.textContent).not.toContain("Close environment"); - await details("Runner"); - expect(button.getAttribute("aria-expanded")).toBe("false"); - expect(document.getElementById(button.getAttribute("aria-controls")!)?.closest("tr")?.hidden).toBe(true); + expect(button.getAttribute("aria-haspopup")).toBe("dialog"); + expect(document.getElementById(dialog().getAttribute("aria-labelledby")!)?.textContent).toBe("Runner"); + expect(dialog().textContent).toContain("daemon"); + expect(dialog().closest("table")).toBeNull(); + expect(table.querySelectorAll("tbody tr")).toHaveLength(3); + expect(table.textContent).not.toContain("Secret environment variables"); + expect(mocks.api.mock.calls.filter(([, path]) => path.endsWith("/credentials")).map(([, path]) => path)).toEqual([ + "/api/v1/universes/universe/environments/Runner/credentials", + ]); + expect(dialog().textContent).not.toContain("Closing spends"); + expect(dialog().textContent).not.toContain("Close environment"); + await closeDetails(); + expect(dialog()).toBeNull(); + expect(document.activeElement).toBe(button); + expect(table.querySelectorAll("tbody tr")).toHaveLength(3); +}); + +it("opens details from the row and dismisses them with Escape", async () => { + await show(); + const cell = container.querySelector("tbody td")!; + await act(async () => (cell as HTMLElement).click()); + await settle(); + expect(dialog().textContent).toContain("Build box"); + await act(async () => dialog().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); + await settle(); + expect(dialog()).toBeNull(); }); it.each(["viewer", "operator"])("preserves source-specific controls for %s", async (role) => { mocks.role = role; await show(); await details("Build box"); - expect(container.textContent?.includes("Close environment")).toBe(role === "operator"); - expect(container.textContent?.includes("Idle policy…")).toBe(role === "operator"); + expect(dialog().textContent?.includes("Close environment")).toBe(role === "operator"); + expect(dialog().textContent?.includes("Idle policy…")).toBe(role === "operator"); expect(container.textContent?.includes("Enrollment")).toBe(role === "operator"); - await details("Build box"); + await closeDetails(); await details("Runner"); + expect(dialog().textContent).not.toContain("Close environment"); + expect(dialog().textContent).not.toContain("Idle policy…"); + await closeDetails(); await details("External box"); - expect(container.textContent).not.toContain("Close environment"); - expect(container.textContent).not.toContain("Idle policy…"); + expect(dialog().textContent).not.toContain("Close environment"); + expect(dialog().textContent).not.toContain("Idle policy…"); +}); + +it("keeps open details current as the environment changes", async () => { + await show(); + await details("Build box"); + expect(dialog().textContent).toContain("Waking"); + await act(async () => client.setQueryData(["environments", "universe"], rows.map((row) => + row.environmentId === provisioned.environmentId ? { ...row, status: "ready" } : row, + ))); + await settle(); + expect(dialog().textContent).toContain("Ready"); + expect(dialog().textContent).not.toContain("Waking"); +}); + +it("opens idle policy as a nested dialog and returns to environment details", async () => { + mocks.role = "operator"; + await show(); + await details("Build box"); + const policyButton = [...dialog().querySelectorAll("button")].find((button) => button.textContent === "Idle policy…")!; + await act(async () => policyButton.click()); + await settle(); + const policy = [...document.body.querySelectorAll('[role="dialog"]')].find((element) => + document.getElementById(element.getAttribute("aria-labelledby")!)?.textContent === "Idle policy for Build box", + )!; + expect(policy).toBeTruthy(); + const cancel = [...policy.querySelectorAll("button")].find((button) => button.textContent === "Cancel")!; + await act(async () => cancel.click()); + await settle(); + expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(dialog().textContent).toContain("Build box"); }); it("distinguishes an empty inventory from all closed and preserves the closed toggle", async () => { diff --git a/platform/web/src/pages/EnvironmentsPage.tsx b/platform/web/src/pages/EnvironmentsPage.tsx index 104145cd5..ef1919d68 100644 --- a/platform/web/src/pages/EnvironmentsPage.tsx +++ b/platform/web/src/pages/EnvironmentsPage.tsx @@ -1,6 +1,10 @@ import { useActionPermissions } from "@/lib/permissions"; +import { ResourceIdentityCell, ResourceRow, ResourceStatus, ResourceTable, ResourceValue } from "@/components/resource-table"; +import { ResourceActions, ResourceDetail, ResourceDetailsDialog, ResourceDetailsGrid } from "@/components/resource-details-dialog"; +import { ConfirmDangerButton } from "@/components/confirm-danger-button"; +import { environmentStatus, resourceTimestamp } from "@/lib/resource-status"; import { ReadError } from "@/components/read-error"; -import { useId, useState } from "react"; +import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Boxes, Check, ChevronDown, Copy, KeyRound, Plus, Trash2 } from "lucide-react"; import { @@ -26,8 +30,8 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { - Table, TableActionsCell, TableBody, TableCard, TableCell, - TableHead, TableHeader, TableRow, TableTitleCell, + Table, TableBody, TableCard, TableCell, + TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -88,7 +92,7 @@ export function EnvironmentsPage({ admin: _admin }: { admin: boolean }) { return ; } - return ; + return ; } const REFRESH_MS = 10_000; @@ -154,6 +158,8 @@ function ProviderList({ universeId }: { universeId: string }) { .slice() .sort((a, b) => a.displayName.localeCompare(b.displayName)); const [showClosed, setShowClosed] = useState(false); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const detailsTriggerRef = useRef(null); const allEnvironmentRows = (environments.data ?? []) .slice() .sort((a, b) => environmentName(a).localeCompare(environmentName(b)) @@ -162,6 +168,7 @@ function ProviderList({ universeId }: { universeId: string }) { const environmentRows = showClosed ? allEnvironmentRows : allEnvironmentRows.filter((environment) => environment.status !== "closed"); + const selectedEnvironment = allEnvironmentRows.find((environment) => environment.environmentId === selectedEnvironmentId); const enabledBindings = new Set( bindingRows.filter((binding) => binding.status === "enabled").map((binding) => binding.bindingId), ); @@ -216,36 +223,24 @@ function ProviderList({ universeId }: { universeId: string }) { )} {environmentRows.length > 0 && ( - - - - - Environment - Source - Status - Details - - - - {environmentRows.map((environment) => ( - - candidate.bindingId === provisionedBindingId(environment) - && candidate.templateId === environment.incarnation.templateId - )} - registrationKey={environment.source.type === "registered" - ? keysById.get(environment.source.registrationKeyId) : undefined} - secrets={secrets.data} - /> - ))} - -
-
+ + {environmentRows.map((environment) => ( + { + detailsTriggerRef.current = trigger; + setSelectedEnvironmentId(environment.environmentId); + }} + template={templateRows.find((candidate) => + candidate.bindingId === provisionedBindingId(environment) + && candidate.templateId === environment.incarnation.templateId + )} + registrationKey={environment.source.type === "registered" + ? keysById.get(environment.source.registrationKeyId) : undefined} + /> + ))} + )} {environments.data && allEnvironmentRows.length > 0 && environmentRows.length === 0 && (

Every environment here is closed.

@@ -258,6 +253,26 @@ function ProviderList({ universeId }: { universeId: string }) { onCheckedChange={setShowClosed} /> + { if (!open) setSelectedEnvironmentId(null); }} + title={selectedEnvironment ? environmentName(selectedEnvironment) : "Environment"} + description="Machine details, access credentials, and power settings." finalFocus={detailsTriggerRef}> + {selectedEnvironment && ( + + candidate.bindingId === provisionedBindingId(selectedEnvironment) + && candidate.templateId === selectedEnvironment.incarnation.templateId + )} + registrationKey={selectedEnvironment.source.type === "registered" + ? keysById.get(selectedEnvironment.source.registrationKeyId) : undefined} + secrets={secrets.data} + /> + )} + {writable && (
void; + template: EnvironmentTemplate | undefined; + registrationKey: EnvironmentRegistrationKey | undefined; +}) { + const source = environment.source; + const sourceName = source.type === "provisioned" ? "Provisioned" : source.type === "registered" ? "Registered" : "External"; + const sourceDescription = source.type === "provisioned" + ? `${source.providerId} · ${template?.displayName ?? environment.incarnation.templateId ?? "Unknown template"}` + : source.type === "registered" ? registrationKey?.displayName ?? source.registrationKeyId : source.connection.endpoint; + return ( + + + {sourceName} + + + ); +} + +function EnvironmentDetails({ universeId, environment, configurable, - operator, template, registrationKey, secrets, @@ -293,138 +332,93 @@ function EnvironmentRow({ environment: Environment; /** Configuring this environment: power, idle policy, ingress, close. */ configurable: boolean; - /** Universe-wide configuration; publishing secrets into a machine also needs it. */ - operator: boolean; template: EnvironmentTemplate | undefined; registrationKey: EnvironmentRegistrationKey | undefined; secrets: SecretsInventory | undefined; }) { - const [open, setOpen] = useState(false); - const detailsId = useId(); const [policyOpen, setPolicyOpen] = useState(false); const source = environment.source; const gone = ["closing", "closed", "failed"].includes(environment.status); - const registered = source.type === "registered"; return ( - <> - - - -
{source.type === "provisioned" ? "Provisioned" : registered ? "Registered" : "External"}
- {source.type !== "external" && ( -
- {source.type === "provisioned" - ? `${source.providerId} · ${template?.displayName ?? environment.incarnation.templateId ?? "Unknown template"}` - : registrationKey?.displayName ?? source.registrationKeyId} -
- )} -
- - - {registered && ( -

- {environment.lastSeenAtMs ? `Last seen ${relativeTime(environment.lastSeenAtMs)}` : "Never seen"} -

- )} - {source.type === "provisioned" && powerDiverges(environment) && ( -

Desired: {environment.desiredPower}

- )} -
- - - -
-