diff --git a/app/src/components/settings/hosts/AdminBundleModal.tsx b/app/src/components/settings/hosts/AdminBundleModal.tsx
index 6630be5..2707669 100644
--- a/app/src/components/settings/hosts/AdminBundleModal.tsx
+++ b/app/src/components/settings/hosts/AdminBundleModal.tsx
@@ -29,6 +29,7 @@ export default function AdminBundleModal({
ownerId,
network,
reason,
+ onSaved,
onClose,
}: {
connectionId: string;
@@ -40,6 +41,8 @@ export default function AdminBundleModal({
network?: NetworkChoice;
/** Why the bundle is needed (e.g. the refusal that led here). */
reason?: string;
+ /** Called with the folder once a bundle was actually written. */
+ onSaved?: (folder: string) => void;
onClose: () => void;
}) {
const askEnvironment = kind === "environment" && !environment;
@@ -63,7 +66,10 @@ export default function AdminBundleModal({
ownerId,
network: picked && "network" in picked ? picked.network : network,
});
- if (folder) setSaved(folder);
+ if (folder) {
+ setSaved(folder);
+ onSaved?.(folder);
+ }
} catch (e) {
setError(toAwsError(e));
} finally {
diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx
index 6003815..307442b 100644
--- a/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx
+++ b/app/src/components/settings/hosts/DeployEnvironmentModal.test.tsx
@@ -45,7 +45,7 @@ describe("DeployEnvironmentModal", () => {
handlers.clear();
});
- it("the public network mode is the default and says it costs nothing", () => {
+ it("the public network mode is the default and says it costs nothing", async () => {
const props = renderModal();
expect(screen.getByRole("radio", { name: "Public" })).toHaveAttribute("aria-checked", "true");
expect(screen.getByText("$0 — tasks get a public IP, no inbound access")).toBeInTheDocument();
@@ -56,9 +56,10 @@ describe("DeployEnvironmentModal", () => {
expect(screen.getByLabelText("Name")).toHaveValue("main");
fireEvent.click(screen.getByRole("button", { name: "Deploy" }));
expect(props.onDeploy).toHaveBeenCalledWith("main", { mode: "public" });
+ await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
- it("validates the name with the backend's rule", () => {
+ it("validates the name with the backend's rule", async () => {
const props = renderModal({ scan: scan({ environments: [env({ name: "main" })] }) });
const deploy = screen.getByRole("button", { name: "Deploy" });
expect(screen.getByText(/already exists/)).toBeInTheDocument();
@@ -71,9 +72,10 @@ describe("DeployEnvironmentModal", () => {
expect(deploy).toBeEnabled();
fireEvent.click(deploy);
expect(props.onDeploy).toHaveBeenCalledWith("team-2", { mode: "public" });
+ await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
- it("an existing network needs a VPC and two distinct subnets", () => {
+ it("an existing network needs a VPC and two distinct subnets", async () => {
const props = renderModal();
fireEvent.click(screen.getByRole("radio", { name: "Existing VPC" }));
const deploy = screen.getByRole("button", { name: "Deploy" });
@@ -91,6 +93,7 @@ describe("DeployEnvironmentModal", () => {
subnet_ids: ["subnet-0123abcd", "subnet-4567abcd"],
assign_public_ip: true,
});
+ await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
it("keeps the dialog open and shows the server's refusal", async () => {
@@ -111,9 +114,9 @@ describe("DeployEnvironmentModal", () => {
expect(screen.queryByLabelText("Name")).toBeNull();
expect(screen.queryByRole("button", { name: "Deploy" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
- await waitFor(() =>
- expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" }),
- );
+ // Running (op id known) is the visible sign the invoke resolved.
+ expect(await screen.findByText("Running")).toBeInTheDocument();
+ expect(invoke).toHaveBeenCalledWith("deploy_aws_permissions_boundary", { connectionId: "h1", region: "eu-west-1" });
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, events: [tofuEvent("Creating boundary")] }));
expect(screen.getByText("Creating boundary")).toBeInTheDocument();
emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "Apply complete!" } }));
@@ -121,7 +124,29 @@ describe("DeployEnvironmentModal", () => {
expect(props.onDetect).toHaveBeenCalled();
});
- it("opens the boundary bundle when creating the boundary is denied", async () => {
+ it("offers the boundary bundle when the boundary run fails, with its summary as the reason", async () => {
+ invoke.mockImplementation(async (cmd: string) => {
+ if (cmd === "deploy_aws_permissions_boundary") return "b1";
+ if (cmd === "save_aws_admin_bundle") return "/tmp/b";
+ throw new Error(`unexpected ${cmd}`);
+ });
+ const props = renderModal({ scan: scan({ boundary: "no" }) });
+ fireEvent.click(screen.getByRole("button", { name: "Create the permissions boundary (needs an account administrator)" }));
+ expect(await screen.findByText("Running")).toBeInTheDocument();
+ const summary = "Error: creating IAM Policy (triple-c-permissions-boundary): AccessDenied: not authorized to perform iam:CreatePolicy";
+ emit(progress({ op_id: "b1", state_key: BOUNDARY_KEY, done: true, cancellable: false, result: { ok: false, exit_code: 1, summary } }));
+ expect(props.onDetect).toHaveBeenCalled();
+ // Offered, not opened.
+ expect(screen.queryByRole("dialog", { name: "Permissions boundary bundle" })).toBeNull();
+ expect(screen.queryByLabelText("Name")).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "Get the boundary bundle for an administrator" }));
+ const dialog = screen.getByRole("dialog", { name: "Permissions boundary bundle" });
+ expect(within(dialog).getByRole("alert")).toHaveTextContent(summary);
+ fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" }));
+ expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument();
+ });
+
+ it("opens the boundary bundle when the boundary command itself is denied", async () => {
invoke.mockImplementation(async (cmd: string) => {
if (cmd === "deploy_aws_permissions_boundary") throw { kind: "access_denied", action: "iam:CreatePolicy", message: "denied iam:CreatePolicy" };
if (cmd === "save_aws_admin_bundle") return "/tmp/b";
@@ -142,9 +167,10 @@ describe("DeployEnvironmentModal", () => {
network: null,
}),
);
+ expect(await within(dialog).findByText("/tmp/b")).toBeInTheDocument();
});
- it("repairs a named environment and warns that a different network replaces it", () => {
+ it("repairs a named environment and warns that a different network replaces it", async () => {
const props = renderModal({ repair: "dev" });
expect(screen.getByRole("dialog", { name: "Repair dev" })).toBeInTheDocument();
expect(screen.queryByLabelText("Name")).toBeNull();
@@ -152,5 +178,6 @@ describe("DeployEnvironmentModal", () => {
fireEvent.click(screen.getByRole("radio", { name: "Private + NAT" }));
fireEvent.click(screen.getByRole("button", { name: "Repair" }));
expect(props.onDeploy).toHaveBeenCalledWith("dev", { mode: "private-nat" });
+ await waitFor(() => expect(props.onClose).toHaveBeenCalled());
});
});
diff --git a/app/src/components/settings/hosts/DeployEnvironmentModal.tsx b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx
index 6fccbea..c061795 100644
--- a/app/src/components/settings/hosts/DeployEnvironmentModal.tsx
+++ b/app/src/components/settings/hosts/DeployEnvironmentModal.tsx
@@ -6,7 +6,7 @@ import Button from "../../ui/Button";
import Field, { inputClass } from "../../ui/Field";
import Modal from "../../ui/Modal";
import AdminBundleModal from "./AdminBundleModal";
-import DeployProgress, { useDeployRuns } from "./DeployProgress";
+import DeployProgress, { runKey, useDeployRuns } from "./DeployProgress";
import NetworkFields from "./NetworkFields";
import {
BOUNDARY_STATE_KEY,
@@ -51,7 +51,8 @@ export default function DeployEnvironmentModal({
if (result?.ok) setBoundaryDone(true);
onDetect();
});
- const boundaryRun = boundary.runs[BOUNDARY_STATE_KEY];
+ const boundaryRun = boundary.runs[runKey(region, BOUNDARY_STATE_KEY)];
+ const boundaryFailed = boundaryRun?.done && boundaryRun.result && !boundaryRun.result.ok ? boundaryRun.result : null;
if (bundle) {
return (
@@ -137,6 +138,24 @@ export default function DeployEnvironmentModal({
)}
{boundaryRun && }
+ {boundaryFailed && (
+
+
+ Creating the boundary failed. If it was refused, an account administrator can create it from a
+ bundle.
+
+
+ setBundle({
+ reason: boundaryFailed.summary ?? `OpenTofu exited with code ${boundaryFailed.exit_code}`,
+ })
+ }
+ >
+ Get the boundary bundle for an administrator
+
+
+ )}
) : (
diff --git a/app/src/components/settings/hosts/DeployProgress.test.tsx b/app/src/components/settings/hosts/DeployProgress.test.tsx
new file mode 100644
index 0000000..0e4e17c
--- /dev/null
+++ b/app/src/components/settings/hosts/DeployProgress.test.tsx
@@ -0,0 +1,77 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import { runKey, useDeployRuns } from "./DeployProgress";
+import type { DeployProgress } from "../../../lib/types";
+import { progress, tofuEvent } from "./testkit";
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
+
+type Handler = (e: { payload: DeployProgress }) => void;
+const handlers = new Set
();
+vi.mock("@tauri-apps/api/event", () => ({
+ listen: vi.fn(async (_name: string, handler: Handler) => {
+ handlers.add(handler);
+ return () => handlers.delete(handler);
+ }),
+}));
+function emit(p: DeployProgress) {
+ act(() => {
+ for (const h of [...handlers]) h({ payload: p });
+ });
+}
+
+const KEY = "environments/main/terraform.tfstate";
+
+describe("useDeployRuns", () => {
+ beforeEach(() => handlers.clear());
+
+ it("tracks runs for the same state key in two regions independently", async () => {
+ const onDone = vi.fn();
+ const { result } = renderHook(() => useDeployRuns(onDone));
+ await act(async () => {
+ await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "eu");
+ });
+ await act(async () => {
+ await result.current.start(KEY, "us-east-1", "Deploy of main", async () => "us");
+ });
+ expect(handlers.size).toBe(2);
+ emit(progress({ op_id: "eu", state_key: KEY, events: [tofuEvent("eu line")] }));
+ emit(progress({ op_id: "us", state_key: KEY, events: [tofuEvent("us line")] }));
+ emit(progress({ op_id: "us", state_key: KEY, done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "ok" } }));
+
+ const eu = result.current.runs[runKey("eu-west-1", KEY)];
+ const us = result.current.runs[runKey("us-east-1", KEY)];
+ expect(eu.events.map((e) => e.message)).toEqual(["eu line"]);
+ expect(eu.done).toBe(false);
+ expect(us.events.map((e) => e.message)).toEqual(["us line"]);
+ expect(us.done).toBe(true);
+ expect(onDone).toHaveBeenCalledTimes(1);
+ expect(onDone).toHaveBeenCalledWith({ stateKey: KEY, region: "us-east-1", result: { ok: true, exit_code: 0, summary: "ok" } });
+ // The finished run's listener is gone; the other region's is still there.
+ expect(handlers.size).toBe(1);
+ });
+
+ it("removes its progress listeners on unmount", async () => {
+ const { result, unmount } = renderHook(() => useDeployRuns());
+ await act(async () => {
+ await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => "a");
+ await result.current.start("boundary/terraform.tfstate", "eu-west-1", "Boundary", async () => "b");
+ });
+ expect(handlers.size).toBe(2);
+ unmount();
+ expect(handlers.size).toBe(0);
+ });
+
+ it("a rejected start removes its listener and returns the AWS error", async () => {
+ const { result } = renderHook(() => useDeployRuns());
+ let err: unknown;
+ await act(async () => {
+ err = await result.current.start(KEY, "eu-west-1", "Deploy of main", async () => {
+ throw { kind: "other", message: "A deployment is already running" };
+ });
+ });
+ expect(err).toEqual({ kind: "other", message: "A deployment is already running" });
+ expect(handlers.size).toBe(0);
+ expect(result.current.runs).toEqual({});
+ });
+});
diff --git a/app/src/components/settings/hosts/DeployProgress.tsx b/app/src/components/settings/hosts/DeployProgress.tsx
index 7f61ae6..bd51d1b 100644
--- a/app/src/components/settings/hosts/DeployProgress.tsx
+++ b/app/src/components/settings/hosts/DeployProgress.tsx
@@ -28,13 +28,18 @@ export interface DeployRun {
type Subscription = { unlisten: (() => void) | null };
+/** Runs are tracked per region and state key: the same key exists in every region. */
+export const runKey = (region: string, stateKey: string) => `${region}:${stateKey}`;
+
/**
- * Runs keyed by state key. `start` subscribes to `aws-deploy-progress` before
+ * Runs keyed by `runKey(region, stateKey)`. `start` subscribes to `aws-deploy-progress` before
* invoking, keeps every event for the state key it started until the op id
* is known (the backend may emit just before the invoke resolves), then keeps
* only that op's events. Resolves to the command's error, or null once started.
*/
-export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOutcome | null }) => void) {
+export function useDeployRuns(
+ onDone?: (run: { stateKey: string; region: string; result: TofuOutcome | null }) => void,
+) {
const [runs, setRuns] = useState>({});
const subs = useRef(new Map());
const mounted = useRef(true);
@@ -53,25 +58,26 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut
const start = useCallback(
async (stateKey: string, region: string, label: string, run: () => Promise): Promise => {
- subs.current.get(stateKey)?.unlisten?.();
+ const k = runKey(region, stateKey);
+ subs.current.get(k)?.unlisten?.();
const sub: Subscription = { unlisten: null };
- subs.current.set(stateKey, sub);
- const current = () => mounted.current && subs.current.get(stateKey) === sub;
+ subs.current.set(k, sub);
+ const current = () => mounted.current && subs.current.get(k) === sub;
const stop = () => {
sub.unlisten?.();
sub.unlisten = null;
- if (subs.current.get(stateKey) === sub) subs.current.delete(stateKey);
+ if (subs.current.get(k) === sub) subs.current.delete(k);
};
let opId: string | null = null;
const early: ProgressPayload[] = [];
const apply = (p: ProgressPayload) => {
setRuns((prev) => {
- const r = prev[stateKey];
+ const r = prev[k];
if (!r || r.opId !== p.op_id || r.done) return prev;
return {
...prev,
- [stateKey]: {
+ [k]: {
...r,
events: p.events.length ? [...r.events, ...p.events] : r.events,
done: p.done,
@@ -82,13 +88,13 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut
});
if (p.done) {
stop();
- onDoneRef.current?.({ stateKey, result: p.result ?? null });
+ onDoneRef.current?.({ stateKey, region, result: p.result ?? null });
}
};
setRuns((prev) => ({
...prev,
- [stateKey]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null },
+ [k]: { stateKey, region, label, opId: null, events: [], done: false, cancellable: false, result: null },
}));
const unlisten = await listen(AWS_DEPLOY_PROGRESS_EVENT, ({ payload }) => {
@@ -108,7 +114,7 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut
stop();
if (mounted.current) {
setRuns((prev) => {
- const { [stateKey]: _gone, ...rest } = prev;
+ const { [k]: _gone, ...rest } = prev;
return rest;
});
}
@@ -116,17 +122,18 @@ export function useDeployRuns(onDone?: (run: { stateKey: string; result: TofuOut
}
if (!current()) return null;
const id = opId;
- setRuns((prev) => (prev[stateKey] ? { ...prev, [stateKey]: { ...prev[stateKey], opId: id } } : prev));
+ setRuns((prev) => (prev[k] ? { ...prev, [k]: { ...prev[k], opId: id } } : prev));
for (const p of early.splice(0)) if (p.op_id === id) apply(p);
return null;
},
[],
);
- const dismiss = useCallback((stateKey: string) => {
+ /** `key` is a `runKey`. */
+ const dismiss = useCallback((key: string) => {
setRuns((prev) => {
- if (!prev[stateKey]?.done) return prev;
- const { [stateKey]: _gone, ...rest } = prev;
+ if (!prev[key]?.done) return prev;
+ const { [key]: _gone, ...rest } = prev;
return rest;
});
}, []);
diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx
index 937f8af..f6f37dc 100644
--- a/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx
+++ b/app/src/components/settings/hosts/EnvironmentsPanel.test.tsx
@@ -131,6 +131,7 @@ describe("EnvironmentsPanel", () => {
fireEvent.click(within(row).getByRole("button", { name: "Destroy dev" }));
const dialog = screen.getByRole("dialog", { name: "Destroy dev" });
+ expect(within(dialog).getByText(/deployment is incomplete \(no valid environment record\)/)).toBeInTheDocument();
const confirm = within(dialog).getByRole("button", { name: "Destroy" });
expect(confirm).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText(/Type dev to confirm/), { target: { value: "de" } });
@@ -248,7 +249,10 @@ describe("EnvironmentsPanel", () => {
await open();
fireEvent.click(await screen.findByRole("button", { name: "Join dev" }));
const dialog = screen.getByRole("dialog");
+ // Opening the bundle is not a request yet.
+ expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull();
fireEvent.click(within(dialog).getByRole("button", { name: "Save bundle to folder…" }));
+ expect(await within(dialog).findByText("/home/me/onboard-dev")).toBeInTheDocument();
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith("save_aws_admin_bundle", {
connectionId: "h1",
@@ -316,6 +320,95 @@ describe("EnvironmentsPanel", () => {
expect(screen.getByText((_, el) => el?.textContent === long && el.tagName === "PRE")).toBeInTheDocument();
});
+ it("a workspace user who closes the bundle without saving is not waiting", async () => {
+ backend(() => scan({ environments: [env()] }));
+ render( );
+ await open();
+ fireEvent.click(await screen.findByRole("button", { name: "Join dev" }));
+ fireEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Close" }));
+ expect(screen.queryByText("Waiting for an admin to onboard you")).toBeNull();
+ expect(within(screen.getByTestId("env-dev")).getByRole("button", { name: "Join dev" })).toHaveTextContent("Join");
+ });
+
+ it("never offers Destroy on a ready environment", async () => {
+ backend(() => scan({ environments: [env({ actions: ["join", "update"] })] }));
+ render( );
+ await open();
+ const row = await screen.findByTestId("env-dev");
+ expect(within(row).getByText("Ready")).toBeInTheDocument();
+ expect(within(row).queryByRole("button", { name: "Destroy dev" })).toBeNull();
+ expect(within(row).queryByRole("button", { name: "Unlock dev" })).toBeNull();
+ });
+
+ it("after a region change, the old region's rows can't be acted on, and actions use the new region", async () => {
+ let resolveUs: (s: EnvironmentScan) => void = () => {};
+ invoke.mockImplementation(async (cmd: string, args: { region?: string }) => {
+ if (cmd === "detect_aws_environments") {
+ if (args.region === "us-east-1") return new Promise((r) => (resolveUs = r));
+ return scan({ environments: [env({ state: { state: "incomplete", lock: null }, actions: ["repair", "destroy"], info: null, onboarding: null })] });
+ }
+ if (cmd === "update_aws_environment") return "u1";
+ throw new Error(`unexpected ${cmd}`);
+ });
+ render( );
+ await open();
+ expect(await screen.findByRole("button", { name: "Destroy dev" })).toBeEnabled();
+ fireEvent.change(screen.getByLabelText("Region"), { target: { value: "us-east-1" } });
+ // Before the new scan answers, nothing from eu-west-1 can be clicked.
+ expect(screen.queryByTestId("env-dev")).toBeNull();
+ expect(screen.queryByRole("button", { name: "Destroy dev" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Deploy a new environment" })).toBeNull();
+ await act(async () =>
+ resolveUs(scan({ region: "us-east-1", environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] })),
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "Update dev" }));
+ await waitFor(() =>
+ expect(invoke).toHaveBeenCalledWith("update_aws_environment", { connectionId: "h1", region: "us-east-1", name: "dev" }),
+ );
+ expect(await screen.findByText("Running")).toBeInTheDocument();
+ });
+
+ it("disables row actions while detection is in flight", async () => {
+ let calls = 0;
+ let release: () => void = () => {};
+ invoke.mockImplementation(async (cmd: string) => {
+ if (cmd !== "detect_aws_environments") throw new Error(`unexpected ${cmd}`);
+ calls += 1;
+ const found = scan({ environments: [env({ actions: ["join", "update"], onboarding: { state: "joined" } })] });
+ if (calls === 1) return found;
+ return new Promise((r) => (release = () => r(found)));
+ });
+ render( );
+ await open();
+ expect(await screen.findByRole("button", { name: "Update dev" })).toBeEnabled();
+ fireEvent.click(screen.getByRole("button", { name: "Detect again" }));
+ expect(screen.getByRole("button", { name: "Update dev" })).toBeDisabled();
+ await act(async () => release());
+ expect(screen.getByRole("button", { name: "Update dev" })).toBeEnabled();
+ });
+
+ it("a done that arrives before the invoke resolves is shown and re-detects", async () => {
+ backend(
+ () => scan({ environments: [env({ state: { state: "update_available" }, actions: ["update"], onboarding: { state: "joined" } })] }),
+ async (cmd) => {
+ if (cmd === "update_aws_environment") {
+ emit(progress({ op_id: "u1", events: [tofuEvent("Refreshing state")] }));
+ emit(progress({ op_id: "u1", events: [], done: true, cancellable: false, result: { ok: true, exit_code: 0, summary: "No changes." } }));
+ return "u1";
+ }
+ throw new Error(`unexpected ${cmd}`);
+ },
+ );
+ render( );
+ await open();
+ fireEvent.click(await screen.findByRole("button", { name: "Update dev" }));
+ expect(await screen.findByText(/Finished: No changes\./)).toBeInTheDocument();
+ expect(screen.getByText("Refreshing state")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Cancel deployment" })).toBeNull();
+ await waitFor(() => expect(detectCalls()).toBe(2));
+ expect(handlers.size).toBe(0);
+ });
+
it("routes a detection failure through the AWS error copy", async () => {
backend(() => {
throw { kind: "state_bucket_taken", bucket: "triple-c-tfstate-123456789012-eu-west-1" };
diff --git a/app/src/components/settings/hosts/EnvironmentsPanel.tsx b/app/src/components/settings/hosts/EnvironmentsPanel.tsx
index d2cb958..bec40ad 100644
--- a/app/src/components/settings/hosts/EnvironmentsPanel.tsx
+++ b/app/src/components/settings/hosts/EnvironmentsPanel.tsx
@@ -26,7 +26,7 @@ import Modal from "../../ui/Modal";
import StatusIndicator from "../../ui/StatusIndicator";
import AdminBundleModal from "./AdminBundleModal";
import DeployEnvironmentModal from "./DeployEnvironmentModal";
-import DeployProgress, { useDeployRuns, type DeployRun } from "./DeployProgress";
+import DeployProgress, { runKey, useDeployRuns, type DeployRun } from "./DeployProgress";
import PolicyFiles from "./PolicyFiles";
import {
COMMON_REGIONS,
@@ -98,7 +98,9 @@ function TypedConfirmModal({
);
}
-type Bundle = { kind: ModuleKind; environment?: string; ownerId?: string };
+type Bundle = { kind: ModuleKind; region: string; environment?: string; ownerId?: string };
+/** A row-level key: the same environment name can exist in every region. */
+const rowKey = (region: string, name: string) => `${region}:${name}`;
/** The environment a run's state key belongs to, if any. */
function runEnvironment(stateKey: string): string | null {
@@ -129,8 +131,8 @@ export default function EnvironmentsPanel({
const [waiting, setWaiting] = useState>(new Set());
const [deployOpen, setDeployOpen] = useState(false);
const [repairOf, setRepairOf] = useState(null);
- const [destroyOf, setDestroyOf] = useState(null);
- const [unlockOf, setUnlockOf] = useState(null);
+ const [destroyOf, setDestroyOf] = useState<{ name: string; region: string } | null>(null);
+ const [unlockOf, setUnlockOf] = useState<{ env: DetectedEnvironment; region: string } | null>(null);
const [bundle, setBundle] = useState(null);
const [copyNote, setCopyNote] = useState(null);
const request = useRef(0);
@@ -162,16 +164,18 @@ export default function EnvironmentsPanel({
if (open) void detect();
}, [open, detect]);
+ /** Start a run in `runRegion` (always the region of the scan the action came from). */
const launch = useCallback(
- async (envName: string, stateKey: string, label: string, run: () => Promise) => {
- setRowErrors(({ [envName]: _gone, ...rest }) => rest);
- setPending((p) => ({ ...p, [envName]: true }));
- const err = await start(stateKey, region, label, run);
- setPending(({ [envName]: _gone, ...rest }) => rest);
- if (err) setRowErrors((r) => ({ ...r, [envName]: err }));
+ async (runRegion: string, envName: string, stateKey: string, label: string, run: () => Promise) => {
+ const rk = rowKey(runRegion, envName);
+ setRowErrors(({ [rk]: _gone, ...rest }) => rest);
+ setPending((p) => ({ ...p, [rk]: true }));
+ const err = await start(stateKey, runRegion, label, run);
+ setPending(({ [rk]: _gone, ...rest }) => rest);
+ if (err) setRowErrors((r) => ({ ...r, [rk]: err }));
return err;
},
- [start, region],
+ [start],
);
// Reattach to a deploy running on this machine (a re-follow replays its whole log).
@@ -179,10 +183,12 @@ export default function EnvironmentsPanel({
if (!scan) return;
for (const e of scan.environments) {
const key = environmentStateKey(e.name);
- const seen = `${scan.region}:${key}`;
- if (e.actions.includes("follow") && !runs[key] && !followed.current.has(seen)) {
+ const seen = runKey(scan.region, key);
+ if (e.actions.includes("follow") && !runs[seen] && !followed.current.has(seen)) {
followed.current.add(seen);
- void launch(e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, scan.region, key));
+ void launch(scan.region, e.name, key, `Deploy of ${e.name}`, () =>
+ followAwsDeployment(connection.id, scan.region, key),
+ );
}
}
}, [scan, runs, launch, connection.id]);
@@ -200,49 +206,55 @@ export default function EnvironmentsPanel({
}
};
- const deploy = (name: string, network: NetworkChoice) =>
- launch(name, environmentStateKey(name), `Deploy of ${name}`, () =>
- deployAwsEnvironment(connection.id, region, name, network),
+ // Rows (and every action on them) belong to the scan they came from. After a
+ // region change the old scan's rows are hidden, and while any detection is in
+ // flight their actions are disabled; actions always target `scan.region`.
+ const current = scan && scan.region === region ? scan : null;
+ const stale = detecting || !current;
+
+ const deploy = (runRegion: string, name: string, network: NetworkChoice) =>
+ launch(runRegion, name, environmentStateKey(name), `Deploy of ${name}`, () =>
+ deployAwsEnvironment(connection.id, runRegion, name, network),
);
- const repair = (name: string, network?: NetworkChoice) =>
- launch(name, environmentStateKey(name), `Repair of ${name}`, () =>
- repairAwsEnvironment(connection.id, region, name, network),
+ const repair = (runRegion: string, name: string, network?: NetworkChoice) =>
+ launch(runRegion, name, environmentStateKey(name), `Repair of ${name}`, () =>
+ repairAwsEnvironment(connection.id, runRegion, name, network),
);
- const join = (e: DetectedEnvironment) => {
- if (!scan) return;
+ const join = (found: EnvironmentScan, e: DetectedEnvironment) => {
if (isAdmin) {
- void launch(e.name, onboardingStateKey(e.name, scan.owner_key), `Joining ${e.name}`, () =>
- onboardAwsUser(connection.id, region, e.name),
+ void launch(found.region, e.name, onboardingStateKey(e.name, found.owner_key), `Joining ${e.name}`, () =>
+ onboardAwsUser(connection.id, found.region, e.name),
);
} else {
- setWaiting((w) => new Set(w).add(e.name));
- setBundle({ kind: "user_onboard", environment: e.name, ownerId: scan.owner_id });
+ setBundle({ kind: "user_onboard", region: found.region, environment: e.name, ownerId: found.owner_id });
}
};
const rowRuns = (name: string) =>
- Object.values(runs).filter((r) => r.region === region && runEnvironment(r.stateKey) === name);
- const shown = new Set(scan?.environments.map((e) => e.name) ?? []);
+ Object.values(runs).filter((r) => r.region === current?.region && runEnvironment(r.stateKey) === name);
+ const shown = new Set(current?.environments.map((e) => e.name) ?? []);
const looseRuns = Object.values(runs).filter(
- (r) => r.region !== region || !shown.has(runEnvironment(r.stateKey) ?? ""),
+ (r) => r.region !== current?.region || !shown.has(runEnvironment(r.stateKey) ?? ""),
);
const progress = (r: DeployRun) => (
dismiss(r.stateKey)}
+ onDismiss={() => dismiss(runKey(r.region, r.stateKey))}
/>
);
- const envRow = (e: DetectedEnvironment) => {
+ const envRow = (found: EnvironmentScan, e: DetectedEnvironment) => {
+ const at = found.region;
const status = stateStatus(e.state);
const lock = stateLock(e.state);
const has = (a: DetectedEnvironment["actions"][number]) => e.actions.includes(a);
const onboarding = e.onboarding?.state;
- const busy = !!pending[e.name];
- const rowError = rowErrors[e.name];
+ const busy = stale || !!pending[rowKey(at, e.name)];
+ const rowError = rowErrors[rowKey(at, e.name)];
+ const isWaiting = waiting.has(rowKey(at, e.name));
return (
The environment's record can't be used: {e.state.reason}
)}
- {!isAdmin && waiting.has(e.name) && onboarding !== "joined" && (
+ {!isAdmin && isWaiting && onboarding !== "joined" && (
Waiting for an admin to onboard you
)}
{has("join") && onboarding !== "joined" && onboarding !== "in_progress" && (
-
join(e)}>
- {!isAdmin && waiting.has(e.name) ? "Show onboarding bundle" : "Join"}
+ join(found, e)}>
+ {!isAdmin && isWaiting ? "Show onboarding bundle" : "Join"}
)}
{has("follow") && (
@@ -288,7 +300,7 @@ export default function EnvironmentsPanel({
aria-label={`Follow ${e.name}`}
onClick={() => {
const key = environmentStateKey(e.name);
- void launch(e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, region, key));
+ void launch(at, e.name, key, `Deploy of ${e.name}`, () => followAwsDeployment(connection.id, at, key));
}}
>
Follow
@@ -300,8 +312,8 @@ export default function EnvironmentsPanel({
disabled={busy}
aria-label={`Update ${e.name}`}
onClick={() =>
- void launch(e.name, environmentStateKey(e.name), `Update of ${e.name}`, () =>
- updateAwsEnvironment(connection.id, region, e.name),
+ void launch(at, e.name, environmentStateKey(e.name), `Update of ${e.name}`, () =>
+ updateAwsEnvironment(connection.id, at, e.name),
)
}
>
@@ -313,18 +325,18 @@ export default function EnvironmentsPanel({
size="sm"
disabled={busy}
aria-label={`Repair ${e.name}`}
- onClick={() => (e.info ? void repair(e.name) : setRepairOf(e.name))}
+ onClick={() => (e.info ? void repair(at, e.name) : setRepairOf(e.name))}
>
Repair
)}
{has("unlock") && (
-
setUnlockOf(e)}>
+ setUnlockOf({ env: e, region: at })}>
Unlock
)}
{has("destroy") && (
- setDestroyOf(e.name)}>
+ setDestroyOf({ name: e.name, region: at })}>
Destroy
)}
@@ -340,7 +352,7 @@ export default function EnvironmentsPanel({
);
};
- const nothingFound = !!scan && scan.environments.length === 0;
+ const nothingFound = !!current && current.environments.length === 0;
return (
@@ -376,29 +388,36 @@ export default function EnvironmentsPanel({
{awsErrorMessage(detectError)}
)}
- {scan && isAdmin && scan.boundary === "no" && (
+ {current && isAdmin && current.boundary === "no" && (
This account has no Triple-C permissions boundary yet; deploying creates it first.
)}
- {scan && scan.environments.length > 0 &&
{scan.environments.map(envRow)} }
+ {current && current.environments.length > 0 && (
+
{current.environments.map((e) => envRow(current, e))}
+ )}
{nothingFound && (
- No Triple-C environment in {scan.region}.
+ No Triple-C environment in {current.region}.
{!isAdmin &&
" Deploying one needs an environment admin: attach the environment-admin policies to an identity, or send an admin a bundle to deploy."}
{!tier && " Run Check to find out whether you can deploy one yourself."}
)}
- {scan && isAdmin && (
-
setDeployOpen(true)}>
+ {current && isAdmin && (
+ setDeployOpen(true)}
+ >
Deploy a new environment
)}
{nothingFound && !isAdmin && (
-
setBundle({ kind: "environment" })}>
+ setBundle({ kind: "environment", region: current.region })}>
Request an environment from an admin…
{copyNote && (
@@ -411,47 +430,49 @@ export default function EnvironmentsPanel({
{looseRuns.map(progress)}
)}
- {scan && (deployOpen || repairOf) && (
+ {current && (deployOpen || repairOf) && (
{
setDeployOpen(false);
setRepairOf(null);
}}
- onDeploy={repairOf ? (name, network) => repair(name, network) : deploy}
+ onDeploy={(name, network) =>
+ repairOf ? repair(current.region, name, network) : deploy(current.region, name, network)
+ }
onDetect={() => void detect()}
/>
)}
{destroyOf && (
setDestroyOf(null)}
onConfirm={() =>
- launch(destroyOf, environmentStateKey(destroyOf), `Destroy of ${destroyOf}`, () =>
- destroyIncompleteAwsEnvironment(connection.id, region, destroyOf),
+ launch(destroyOf.region, destroyOf.name, environmentStateKey(destroyOf.name), `Destroy of ${destroyOf.name}`, () =>
+ destroyIncompleteAwsEnvironment(connection.id, destroyOf.region, destroyOf.name),
)
}
>
- {destroyOf} never finished deploying, so OpenTofu removes everything it created for it. This can't be
- undone.
+ This environment's deployment is incomplete (no valid environment record). OpenTofu removes everything
+ it created for {destroyOf.name} in {destroyOf.region}. This can't be undone.
)}
{unlockOf && (
setUnlockOf(null)}
onConfirm={async () => {
try {
- await unlockAwsState(connection.id, region, environmentStateKey(unlockOf.name));
+ await unlockAwsState(connection.id, unlockOf.region, environmentStateKey(unlockOf.env.name));
} catch (e) {
return toAwsError(e);
}
@@ -460,7 +481,7 @@ export default function EnvironmentsPanel({
}}
>
{(() => {
- const lock = stateLock(unlockOf.state);
+ const lock = stateLock(unlockOf.env.state);
return lock ? (
Held by {lock.who || "unknown"} since{" "}
@@ -478,10 +499,16 @@ export default function EnvironmentsPanel({
{bundle && (
{
+ if (bundle.kind === "user_onboard" && bundle.environment) {
+ const rk = rowKey(bundle.region, bundle.environment);
+ setWaiting((w) => new Set(w).add(rk));
+ }
+ }}
onClose={() => setBundle(null)}
/>
)}