diff --git a/components/web-console/locales/en.json b/components/web-console/locales/en.json index 5b37cb50..02740896 100644 --- a/components/web-console/locales/en.json +++ b/components/web-console/locales/en.json @@ -123,10 +123,22 @@ "defaultMessage": "Copy the create-sandbox command", "description": "Accessible label for copying the create-sandbox command." }, + "app.gateway.connection.copySandboxConnectCommand": { + "defaultMessage": "Copy the connect-sandbox command", + "description": "Accessible label for the button that copies the sandbox connect command." + }, "app.gateway.connection.copySetupCommand": { "defaultMessage": "Copy the one-time setup commands", "description": "Accessible label for copying the consolidated one-time setup script." }, + "app.gateway.connection.editEditor": { + "defaultMessage": "Editor", + "description": "Accessible label for the editor selector in the connect-sandbox command." + }, + "app.gateway.connection.editExistingSandboxName": { + "defaultMessage": "Existing sandbox name (editable)", + "description": "Accessible label for the inline-editable sandbox name in the connect-sandbox command." + }, "app.gateway.connection.editModel": { "defaultMessage": "Model (editable)", "description": "Accessible label for the inline-editable model name in the setup command." @@ -139,6 +151,22 @@ "defaultMessage": "Sandbox name (editable)", "description": "Accessible label for the inline-editable sandbox name in the create-sandbox command." }, + "app.gateway.connection.editorOptions": { + "defaultMessage": "Supported interactive editors are vscode and cursor.", + "description": "Info note listing the supported editor values for the connect-sandbox command. Editor names are wrapped in tags." + }, + "app.gateway.connection.editorOptionsLink": { + "defaultMessage": "OpenShell connect documentation", + "description": "Link text pointing to the OpenShell sandbox connect CLI documentation." + }, + "app.gateway.connection.editorOptionsLinkNewTab": { + "defaultMessage": "Sandbox connect reference (opens in a new tab)", + "description": "Accessible name for the sandbox connect docs link, including that it opens in a new tab." + }, + "app.gateway.connection.editorOptionsTitle": { + "defaultMessage": "Editor options", + "description": "Title for the editor options info alert." + }, "app.gateway.connection.installLink": { "defaultMessage": "Install the OpenShell CLI", "description": "Link text pointing to the NVIDIA OpenShell installation documentation." @@ -167,6 +195,14 @@ "defaultMessage": "Create a sandbox", "description": "Title for the create-sandbox connection step." }, + "app.gateway.connection.sandboxConnect.description": { + "defaultMessage": "Disconnecting does not stop the sandbox process. Connect attaches to the same process instance and replays recent output.", + "description": "Supporting text for the connect-to-sandbox connection step." + }, + "app.gateway.connection.sandboxConnect.title": { + "defaultMessage": "Connect to a sandbox", + "description": "Title for the connect-to-sandbox connection step." + }, "app.gateway.connection.setup.description": { "defaultMessage": "Run these once to log in, add the Claude on Vertex AI provider, and select the model.", "description": "Supporting text for the one-time setup connection step." diff --git a/packages/gateway-management-ui/src/gateways/editable-command.tsx b/packages/gateway-management-ui/src/gateways/editable-command.tsx index a8d44a15..bf1c96a7 100644 --- a/packages/gateway-management-ui/src/gateways/editable-command.tsx +++ b/packages/gateway-management-ui/src/gateways/editable-command.tsx @@ -105,22 +105,44 @@ function EditableField({ ); } -/** - * A copyable command block whose marked value slots are editable in place. - * - * `templateCommand` carries the edit markers and is highlighted once; `copyText` - * is the same command with the operator's current values resolved and drives - * both the copy button and (identically) a whole-block text selection. Editing a - * field calls `onFieldChange(marker, value)`; a marker used twice in the command - * (the mirrored provider name) is kept in lockstep because both slots read the - * same entry in `values`. - */ +function SelectField({ + colorClassName, + label, + onChange, + options, + value, +}: { + colorClassName: string; + label: string; + onChange: (value: string) => void; + options: readonly string[]; + value: string; +}) { + return ( + + ); +} + export function EditableCommand({ copyAriaLabel, copyText, labels, markers, onFieldChange, + selectOptions, templateCommand, values, }: { @@ -129,6 +151,7 @@ export function EditableCommand({ labels: Record; markers: readonly string[]; onFieldChange: (marker: string, value: string) => void; + selectOptions?: Record; templateCommand: string; values: Record; }) { @@ -183,12 +206,32 @@ export function EditableCommand({
             
-              {parts.map((part, index) =>
-                part.kind === "text" ? (
-                  
-                    {part.value}
-                  
-                ) : (
+              {parts.map((part, index) => {
+                if (part.kind === "text") {
+                  return (
+                    
+                      {part.value}
+                    
+                  );
+                }
+
+                const options = selectOptions?.[part.marker];
+                if (options) {
+                  return (
+                     {
+                        onFieldChange(part.marker, value);
+                      }}
+                      options={options}
+                      value={values[part.marker] ?? ""}
+                    />
+                  );
+                }
+
+                return (
                   
-                ),
-              )}
+                );
+              })}
             
           
diff --git a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css index 46675f7e..24bc5352 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css +++ b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.module.css @@ -153,6 +153,26 @@ outline-offset: 2px; } +.selectField { + appearance: auto; + background: transparent; + border: none; + border-radius: var(--pf-t--global--border--radius--small); + cursor: pointer; + font: inherit; + padding: 0; +} + +.selectField:hover { + background-color: var(--pf-t--global--background--color--secondary--default); +} + +.selectField:focus-visible { + background-color: var(--pf-t--global--background--color--secondary--default); + outline: 2px solid currentColor; + outline-offset: 2px; +} + .prereqAlert { margin-block: var(--pf-t--global--spacer--sm); } diff --git a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx index 173aa90e..66555d2b 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { GatewayConnectionSteps } from "./gateway-connection-steps"; import { + buildSandboxConnectCommand, buildSandboxCreateCommand, type GatewayConnection, installDocsUrl, @@ -32,7 +33,7 @@ function renderSteps(gateway: GatewayConnection) { } describe("GatewayConnectionSteps", () => { - it("renders the setup and sandbox steps", () => { + it("renders the setup, sandbox create, and sandbox connect steps", () => { renderSteps(readyGateway); expect( @@ -41,6 +42,9 @@ describe("GatewayConnectionSteps", () => { expect( screen.getByRole("heading", { name: "Create a sandbox" }), ).toBeTruthy(); + expect( + screen.getByRole("heading", { name: "Connect to a sandbox" }), + ).toBeTruthy(); }); it("renders a prerequisite alert with an install docs link", () => { @@ -62,11 +66,11 @@ describe("GatewayConnectionSteps", () => { expect(link.textContent).toContain("Install the OpenShell CLI"); }); - it("highlights both command blocks with Shiki once they resolve", async () => { + it("highlights all command blocks with Shiki once they resolve", async () => { const { container } = renderSteps(readyGateway); await waitFor(() => { - expect(container.querySelectorAll(".shiki").length).toBe(2); + expect(container.querySelectorAll(".shiki").length).toBe(3); }); }); @@ -88,7 +92,7 @@ describe("GatewayConnectionSteps", () => { const { container } = renderSteps(readyGateway); await waitFor(() => { - expect(container.querySelectorAll(".shiki").length).toBe(2); + expect(container.querySelectorAll(".shiki").length).toBe(3); }); const providerFields = screen.getAllByRole("textbox", { @@ -134,6 +138,48 @@ describe("GatewayConnectionSteps", () => { ); }); + it("copies the raw sandbox connect command", async () => { + const user = userEvent.setup(); + renderSteps(readyGateway); + + await user.click( + screen.getByRole("button", { + name: "Copy the connect-sandbox command", + }), + ); + + expect(await navigator.clipboard.readText()).toBe( + buildSandboxConnectCommand(), + ); + }); + + it("mirrors an edited sandbox name into both create and connect commands", async () => { + const user = userEvent.setup(); + renderSteps(readyGateway); + + const createField = await screen.findByRole("textbox", { + name: "Sandbox name (editable)", + }); + createField.textContent = "scratch"; + fireEvent.input(createField); + + await user.click( + screen.getByRole("button", { name: "Copy the create-sandbox command" }), + ); + expect(await navigator.clipboard.readText()).toBe( + buildSandboxCreateCommand("scratch"), + ); + + await user.click( + screen.getByRole("button", { + name: "Copy the connect-sandbox command", + }), + ); + expect(await navigator.clipboard.readText()).toBe( + buildSandboxConnectCommand("scratch"), + ); + }); + it("shows a pending placeholder until the gateway is ready", () => { renderSteps({ ...readyGateway, diff --git a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx index d8908330..fd6234e3 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx +++ b/packages/gateway-management-ui/src/gateways/gateway-connection-steps.tsx @@ -12,12 +12,16 @@ import { useIntl } from "react-intl"; import { messages } from "../messages"; import { EditableCommand } from "./editable-command"; import { + buildSandboxConnectCommand, buildSandboxCreateCommand, buildSetupScript, claudeModel, + defaultEditor, type GatewayConnection, installDocsUrl, + sandboxConnectDocsUrl, sandboxName as defaultSandboxName, + validEditors, vertexProviderName, } from "./gateway-connections"; import styles from "./gateway-connection-steps.module.css"; @@ -30,8 +34,10 @@ import styles from "./gateway-connection-steps.module.css"; const providerMarker = "OSPROVIDERNAMEZ"; const modelMarker = "OSMODELNAMEZ"; const sandboxMarker = "OSSANDBOXNAMEZ"; +const editorMarker = "OSEDITORNAMEZ"; const setupMarkers = [providerMarker, modelMarker]; const sandboxMarkers = [sandboxMarker, modelMarker]; +const sandboxConnectMarkers = [sandboxMarker, editorMarker]; function ConnectionStep({ children, @@ -62,6 +68,7 @@ export function GatewayConnectionSteps({ const [providerName, setProviderName] = useState(vertexProviderName); const [model, setModel] = useState(claudeModel); const [sandboxName, setSandboxName] = useState(defaultSandboxName); + const [editor, setEditor] = useState(defaultEditor); // Marker form drives the (stable) highlight; the resolved form drives copy and // matches a whole-block text selection exactly. @@ -161,6 +168,64 @@ export function GatewayConnectionSteps({ values={{ [modelMarker]: model, [sandboxMarker]: sandboxName }} /> + + + } + iconPosition="end" + rel="noopener noreferrer" + target="_blank" + > + {intl.formatMessage(messages.connectionEditorOptionsLink)} + + } + className={styles.prereqAlert} + component="h3" + isInline + title={intl.formatMessage(messages.connectionEditorOptionsTitle)} + variant="info" + > + {intl.formatMessage(messages.connectionEditorOptions, { + code: (chunks) => {chunks}, + })} + + { + if (marker === sandboxMarker) { + setSandboxName(value); + } else if (marker === editorMarker) { + setEditor(value); + } + }} + selectOptions={{ [editorMarker]: validEditors }} + templateCommand={buildSandboxConnectCommand( + sandboxMarker, + editorMarker, + )} + values={{ [editorMarker]: editor, [sandboxMarker]: sandboxName }} + /> + ); } diff --git a/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts b/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts index cefee386..fd8f1adf 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-connections.test.ts @@ -4,6 +4,7 @@ import { buildGatewayAddCommand, buildInferenceSetCommand, buildProviderCreateCommand, + buildSandboxConnectCommand, buildSandboxCreateCommand, buildSetupScript, claudeModel, @@ -196,6 +197,30 @@ describe("gateway connections", () => { ); }); + it("builds a sandbox connect command with defaults", () => { + expect(buildSandboxConnectCommand()).toBe( + `openshell sandbox connect ${sandboxName} \\\n --editor cursor`, + ); + }); + + it("substitutes a custom name into the sandbox connect command", () => { + expect(buildSandboxConnectCommand("demo")).toBe( + "openshell sandbox connect demo \\\n --editor cursor", + ); + }); + + it("substitutes a custom editor into the sandbox connect command", () => { + expect(buildSandboxConnectCommand("demo", "vscode")).toBe( + "openshell sandbox connect demo \\\n --editor vscode", + ); + }); + + it("quotes shell-unsafe names in the sandbox connect command", () => { + expect(buildSandboxConnectCommand("my $(sandbox)")).toBe( + `openshell sandbox connect 'my $(sandbox)' \\\n --editor cursor`, + ); + }); + it("combines login, provider, and inference into one setup script when ready", () => { const script = buildSetupScript(gateway); diff --git a/packages/gateway-management-ui/src/gateways/gateway-connections.ts b/packages/gateway-management-ui/src/gateways/gateway-connections.ts index 43ec8b0c..c2855571 100644 --- a/packages/gateway-management-ui/src/gateways/gateway-connections.ts +++ b/packages/gateway-management-ui/src/gateways/gateway-connections.ts @@ -77,6 +77,9 @@ export const vertexProviderName = "my-gcp"; export const installDocsUrl = "https://docs.nvidia.com/openshell/about/installation"; +export const sandboxConnectDocsUrl = + "https://docs.nvidia.com/openshell/sandboxes/manage-sandboxes#connect-to-a-sandbox"; + /** Default sandbox name shown in the copyable create-sandbox command. */ export const sandboxName = "mysand"; @@ -162,6 +165,19 @@ export function buildSandboxCreateCommand( return `${variable}\n\n${command}`; } +export const validEditors = ["cursor", "vscode"] as const; +export const defaultEditor = "cursor"; + +export function buildSandboxConnectCommand( + name: string = sandboxName, + editor: string = defaultEditor, +): string { + return [ + `openshell sandbox connect ${shellArgument(name)}`, + `--editor ${shellArgument(editor)}`, + ].join(" \\\n "); +} + /** * One-time setup script that logs in to the gateway, adds the Claude on Vertex AI * provider, and selects the model, combined into a single copyable block so diff --git a/packages/gateway-management-ui/src/messages.ts b/packages/gateway-management-ui/src/messages.ts index 01ef91e4..f6a4a9fc 100644 --- a/packages/gateway-management-ui/src/messages.ts +++ b/packages/gateway-management-ui/src/messages.ts @@ -82,6 +82,30 @@ export const messages = defineMessages({ defaultMessage: "Region: {region}", description: "Region context that distinguishes a managed cluster option.", }, + connectionEditorOptions: { + id: "app.gateway.connection.editorOptions", + defaultMessage: + "Supported interactive editors are vscode and cursor.", + description: + "Info note listing the supported editor values for the connect-sandbox command. Editor names are wrapped in tags.", + }, + connectionEditorOptionsLink: { + id: "app.gateway.connection.editorOptionsLink", + defaultMessage: "OpenShell connect documentation", + description: + "Link text pointing to the OpenShell sandbox connect CLI documentation.", + }, + connectionEditorOptionsLinkNewTab: { + id: "app.gateway.connection.editorOptionsLinkNewTab", + defaultMessage: "Sandbox connect reference (opens in a new tab)", + description: + "Accessible name for the sandbox connect docs link, including that it opens in a new tab.", + }, + connectionEditorOptionsTitle: { + id: "app.gateway.connection.editorOptionsTitle", + defaultMessage: "Editor options", + description: "Title for the editor options info alert.", + }, connectionInstallLink: { id: "app.gateway.connection.installLink", defaultMessage: "Install the OpenShell CLI", @@ -113,6 +137,17 @@ export const messages = defineMessages({ description: "Shown in the login step while the gateway has not yet reached a running, ready-to-connect phase.", }, + connectionSandboxConnectDescription: { + id: "app.gateway.connection.sandboxConnect.description", + defaultMessage: + "Disconnecting does not stop the sandbox process. Connect attaches to the same process instance and replays recent output.", + description: "Supporting text for the connect-to-sandbox connection step.", + }, + connectionSandboxConnectTitle: { + id: "app.gateway.connection.sandboxConnect.title", + defaultMessage: "Connect to a sandbox", + description: "Title for the connect-to-sandbox connection step.", + }, connectionSandboxDescription: { id: "app.gateway.connection.sandbox.description", defaultMessage: @@ -177,6 +212,12 @@ export const messages = defineMessages({ defaultMessage: "Copy the create-sandbox command", description: "Accessible label for copying the create-sandbox command.", }, + copySandboxConnectCommand: { + id: "app.gateway.connection.copySandboxConnectCommand", + defaultMessage: "Copy the connect-sandbox command", + description: + "Accessible label for the button that copies the sandbox connect command.", + }, copySetupCommand: { id: "app.gateway.connection.copySetupCommand", defaultMessage: "Copy the one-time setup commands", @@ -221,6 +262,18 @@ export const messages = defineMessages({ defaultMessage: "Details", description: "Label for the gateway detail Details tab.", }, + editEditor: { + id: "app.gateway.connection.editEditor", + defaultMessage: "Editor", + description: + "Accessible label for the editor selector in the connect-sandbox command.", + }, + editExistingSandboxName: { + id: "app.gateway.connection.editExistingSandboxName", + defaultMessage: "Existing sandbox name (editable)", + description: + "Accessible label for the inline-editable sandbox name in the connect-sandbox command.", + }, editModel: { id: "app.gateway.connection.editModel", defaultMessage: "Model (editable)", diff --git a/specs/web-console/architecture.spec.md b/specs/web-console/architecture.spec.md index ca27728a..2bfb6dfa 100644 --- a/specs/web-console/architecture.spec.md +++ b/specs/web-console/architecture.spec.md @@ -494,7 +494,7 @@ The selected tab SHALL use the validated `tab` search parameter per `WEB-DATA-02 The `Details` tab SHALL contain the existing gateway description list (status, cluster, endpoint, CLI connection command, namespace, release identifier, and managed-database identifier), using the same values, loading, unavailable, and placement-resolution behavior required elsewhere in this specification. -The `Connection` tab SHALL guide the user through three ordered steps using an accessible ordered structure with a visible label and description for each step: +The `Connection` tab SHALL guide the user through four ordered steps using an accessible ordered structure with a visible label and description for each step: 1. **Log in to the gateway.** The step SHALL present the `openshell gateway add` command produced from the authorized gateway response as a read-only PatternFly Clipboard Copy value, using the same command construction, shell-argument encoding, and missing-value handling required by `WEB-UI-03`. When required connection values are absent, the step SHALL explain that login is unavailable rather than present an incomplete command. @@ -502,6 +502,8 @@ The `Connection` tab SHALL guide the user through three ordered steps using an a 3. **Create a sandbox.** The step SHALL present an `openshell sandbox create` command that names a sandbox, specifies CPU and memory resource requests and limits via a `--driver-config-json` flag, attaches the provider from step 2, and launches the agent, as a read-only Clipboard Copy value. The default resource values SHALL be CPU requests `100m`, CPU limits `500m`, memory requests `512Mi`, and memory limits `512Mi`. +4. **Connect to a sandbox.** The step SHALL present an `openshell sandbox connect` command that references the sandbox name from step 3 and an `--editor` flag. The sandbox name field SHALL share state with the create step so both commands stay in sync. The editor field SHALL be a constrained inline selector defaulting to `cursor`, offering only the supported values (`cursor`, `vscode`). An adjacent info alert SHALL document the supported values and link to the sandbox connect documentation on the canonical docs host. + The `Connection` tab SHALL also contain a secondary `Create or manage service accounts` link with its introductory content, before the walkthrough. The link SHALL set `tab=service-accounts`. It SHALL not start a create operation. The `Service accounts` tab SHALL own the gateway-scoped automation identity workflow. It SHALL include the heading, create action, responsive collection, one-time credential handoff, command groups, and lifecycle actions defined in [`openshell-gateway-service-accounts.spec.md`](../platform/openshell-gateway-service-accounts.spec.md). @@ -521,7 +523,7 @@ Every Clipboard Copy control SHALL have a localized accessible name and visible - GIVEN an authenticated user opens a gateway detail page - WHEN the page renders - THEN the `Connection` tab SHALL be selected -- AND the three ordered connection steps SHALL be visible +- AND the four ordered connection steps SHALL be visible - AND the `Service accounts` tab SHALL be available - AND the operational description list SHALL be available under the `Details` tab