mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 11:33:50 +08:00
fix(backends): hide edit/remove row actions when locked to cloud (#1555)
The Manage Backends modal previously rendered pencil and trash icons on
every backend row even when the deployment was started with
--lock-to-cloud. In locked mode the user should not be able to mutate
the locked backend's host/key/name or remove it outright.
backend-row.tsx now consults getLockedCloudHost() and skips rendering
both action buttons when a locked cloud host is configured. The row
identity (name + host) still renders so the user can see which backend
is locked.
Adds three focused tests to describe("BackendRow", ...):
* renders edit and remove buttons when not locked to a cloud host
* hides edit and remove buttons when locked via VITE_LOCK_TO_CLOUD
* hides edit and remove buttons when locked via the
window.__AGENT_CANVAS_LOCK_TO_CLOUD__ runtime global
Also extends afterEach with vi.unstubAllEnvs + window-global cleanup
(consistent with backend-form-modal.test.tsx) and isolates three
pre-existing edit-form tests from a local .env that sets
VITE_LOCK_TO_CLOUD, so they keep exercising edit-form behavior rather
than lock behavior.
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
parent
74f06866ec
commit
cfa2ba5796
@@ -83,7 +83,6 @@ function TestSeed({
|
||||
const ctx = useActiveBackendContext();
|
||||
React.useEffect(() => {
|
||||
onMount(ctx);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return children as React.ReactElement;
|
||||
}
|
||||
@@ -128,6 +127,9 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
delete (window as unknown as Record<string, unknown>)
|
||||
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
|
||||
__resetActiveStoreForTests();
|
||||
__resetHealthStoreForTests();
|
||||
});
|
||||
@@ -229,6 +231,13 @@ describe("ManageBackendsModal", () => {
|
||||
});
|
||||
|
||||
it("opens an edit form pre-filled with the row's backend, and persists changes via updateBackend", async () => {
|
||||
// These tests exercise edit-form behavior, not lock behavior; isolate
|
||||
// them from a local .env that sets VITE_LOCK_TO_CLOUD and would hide the
|
||||
// pencil button this test depends on.
|
||||
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
|
||||
delete (window as unknown as Record<string, unknown>)
|
||||
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
let backendId = "";
|
||||
@@ -279,6 +288,10 @@ describe("ManageBackendsModal", () => {
|
||||
});
|
||||
|
||||
it("preserves kind:cloud when renaming a cloud backend on a custom domain", async () => {
|
||||
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
|
||||
delete (window as unknown as Record<string, unknown>)
|
||||
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
let backendId = "";
|
||||
@@ -328,6 +341,10 @@ describe("ManageBackendsModal", () => {
|
||||
});
|
||||
|
||||
it("closes the edit form when the header close button is clicked", async () => {
|
||||
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
|
||||
delete (window as unknown as Record<string, unknown>)
|
||||
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
@@ -612,4 +629,115 @@ describe("BackendRow", () => {
|
||||
cloudBackend.host,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders edit and remove buttons when the deployment is not locked to a cloud host", async () => {
|
||||
// The repo's .env may set VITE_LOCK_TO_CLOUD; clear it explicitly so this
|
||||
// test exercises the unlocked code path regardless of local env state.
|
||||
vi.stubEnv("VITE_LOCK_TO_CLOUD", "");
|
||||
delete (window as unknown as Record<string, unknown>)
|
||||
.__AGENT_CANVAS_LOCK_TO_CLOUD__;
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onEdit = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
|
||||
renderInQueryClient(
|
||||
<ul>
|
||||
<BackendRow
|
||||
backend={cloudBackend}
|
||||
health={{
|
||||
isConnected: true,
|
||||
consecutiveFailures: 0,
|
||||
lastError: null,
|
||||
disabled: false,
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
|
||||
const editButton = within(row).getByTestId(
|
||||
`manage-backends-edit-${cloudBackend.name}`,
|
||||
);
|
||||
const removeButton = within(row).getByTestId(
|
||||
`manage-backends-remove-${cloudBackend.name}`,
|
||||
);
|
||||
|
||||
expect(editButton).toHaveAccessibleName("BACKEND$EDIT");
|
||||
expect(removeButton).toHaveAccessibleName("BACKEND$REMOVE");
|
||||
|
||||
await user.click(editButton);
|
||||
await user.click(removeButton);
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides edit and remove buttons when locked to a cloud host via VITE_LOCK_TO_CLOUD", () => {
|
||||
vi.stubEnv("VITE_LOCK_TO_CLOUD", "https://cloud.example.com");
|
||||
const onEdit = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
|
||||
renderInQueryClient(
|
||||
<ul>
|
||||
<BackendRow
|
||||
backend={cloudBackend}
|
||||
health={{
|
||||
isConnected: true,
|
||||
consecutiveFailures: 0,
|
||||
lastError: null,
|
||||
disabled: false,
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onEdit={onEdit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
|
||||
expect(
|
||||
within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides edit and remove buttons when locked to a cloud host via window.__AGENT_CANVAS_LOCK_TO_CLOUD__", () => {
|
||||
(
|
||||
window as unknown as Record<string, unknown>
|
||||
).__AGENT_CANVAS_LOCK_TO_CLOUD__ = "https://cloud.example.com";
|
||||
|
||||
renderInQueryClient(
|
||||
<ul>
|
||||
<BackendRow
|
||||
backend={cloudBackend}
|
||||
health={{
|
||||
isConnected: true,
|
||||
consecutiveFailures: 0,
|
||||
lastError: null,
|
||||
disabled: false,
|
||||
}}
|
||||
onSelect={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId(`manage-backends-row-${cloudBackend.name}`);
|
||||
expect(
|
||||
within(row).queryByTestId(`manage-backends-edit-${cloudBackend.name}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(row).queryByTestId(`manage-backends-remove-${cloudBackend.name}`),
|
||||
).not.toBeInTheDocument();
|
||||
// Row identity (name + host) is still rendered so the user can see the
|
||||
// locked backend is selected, just not mutate it.
|
||||
expect(within(row).getByText(cloudBackend.name)).toBeInTheDocument();
|
||||
expect(within(row).getByText(cloudBackend.host)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -912,7 +912,7 @@ function CloudLoginColumn({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 flex-col items-center gap-3">
|
||||
<div className="flex flex-1 min-w-0 flex-col items-center gap-3 pb-7">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<OpenHandsLogoWhite width={56} height={56} aria-hidden />
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { BackendStatusDot } from "./backend-status-dot";
|
||||
import { BackendVersion } from "./backend-version";
|
||||
import { DeviceFlowAuth } from "./device-flow-auth";
|
||||
import { getBackendStatusLabel } from "./backend-status-label";
|
||||
import { getLockedCloudHost } from "#/api/agent-server-config";
|
||||
|
||||
const ROW_ACTION_BUTTON_CLASS =
|
||||
"inline-flex cursor-pointer items-center justify-center rounded-md p-1 text-muted transition-colors hover:bg-interactive-hover hover:text-white";
|
||||
@@ -59,6 +60,7 @@ export function BackendRow({
|
||||
: "text-[var(--oh-muted)]";
|
||||
const dotStatus = isInvalidApiKey ? false : (health?.isConnected ?? null);
|
||||
const canSelect = health?.isConnected === true && !isInvalidApiKey;
|
||||
const lockedCloudHost = getLockedCloudHost();
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -131,24 +133,28 @@ export function BackendRow({
|
||||
statusDisplay="modal"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={t(I18nKey.BACKEND$EDIT)}
|
||||
data-testid={`manage-backends-edit-${backend.name}`}
|
||||
className={ROW_ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<Pencil aria-hidden className="size-4" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={t(I18nKey.BACKEND$REMOVE)}
|
||||
data-testid={`manage-backends-remove-${backend.name}`}
|
||||
className={ROW_ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<Trash2 aria-hidden className="size-4" strokeWidth={2} />
|
||||
</button>
|
||||
{!lockedCloudHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={t(I18nKey.BACKEND$EDIT)}
|
||||
data-testid={`manage-backends-edit-${backend.name}`}
|
||||
className={ROW_ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<Pencil aria-hidden className="size-4" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{!lockedCloudHost && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={t(I18nKey.BACKEND$REMOVE)}
|
||||
data-testid={`manage-backends-remove-${backend.name}`}
|
||||
className={ROW_ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<Trash2 aria-hidden className="size-4" strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user