diff --git a/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/settings-pools-screen-desktop.png b/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/settings-pools-screen-desktop.png
index addd1567c5..fc32f0495b 100644
Binary files a/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/settings-pools-screen-desktop.png and b/client/e2eTests/protoFleet/spec/onboardingVisual.spec.ts-snapshots/visual/settings-pools-screen-desktop.png differ
diff --git a/client/src/protoFleet/api/clients.ts b/client/src/protoFleet/api/clients.ts
index 6da8b36c12..8f39bf1144 100644
--- a/client/src/protoFleet/api/clients.ts
+++ b/client/src/protoFleet/api/clients.ts
@@ -18,6 +18,7 @@ import { FleetManagementService } from "@/protoFleet/api/generated/fleetmanageme
import { FleetNodeAdminService } from "@/protoFleet/api/generated/fleetnodeadmin/v1/fleetnodeadmin_pb";
import { ForemanImportService } from "@/protoFleet/api/generated/foremanimport/v1/foremanimport_pb";
import { InfrastructureService } from "@/protoFleet/api/generated/infrastructure/v1/infrastructure_pb";
+import { InstanceUpdateService } from "@/protoFleet/api/generated/instance/v1/updates_pb";
import { MinerCommandService } from "@/protoFleet/api/generated/minercommand/v1/command_pb";
import { NetworkInfoService } from "@/protoFleet/api/generated/networkinfo/v1/networkinfo_pb";
import { OnboardingService } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb";
@@ -55,6 +56,7 @@ const alertChannelClient = createClient(AlertChannelService, transport);
const alertRuleClient = createClient(AlertRuleService, transport);
const alertMaintenanceWindowClient = createClient(AlertMaintenanceWindowService, transport);
const alertHistoryClient = createClient(AlertHistoryService, transport);
+const instanceUpdateClient = createClient(InstanceUpdateService, transport);
export {
alertChannelClient,
@@ -82,5 +84,6 @@ export {
siteMapClient,
sitesClient,
telemetryClient,
+ instanceUpdateClient,
foremanImportClient,
};
diff --git a/client/src/protoFleet/config/navItems.test.ts b/client/src/protoFleet/config/navItems.test.ts
index 2ccf3f4da3..2c6b3b86be 100644
--- a/client/src/protoFleet/config/navItems.test.ts
+++ b/client/src/protoFleet/config/navItems.test.ts
@@ -100,6 +100,18 @@ describe("secondaryNavItems", () => {
);
});
+ it("gates the Updates settings page on instance:update", () => {
+ expect(secondaryNavItems).toContainEqual(
+ expect.objectContaining({
+ path: "/settings/updates",
+ label: "Updates",
+ parent: "/settings",
+ section: "Admin",
+ requiredPermission: "instance:update",
+ }),
+ );
+ });
+
it("folds role management into the Team destination", () => {
expect(secondaryNavItems).toContainEqual(
expect.objectContaining({
diff --git a/client/src/protoFleet/config/navItems.ts b/client/src/protoFleet/config/navItems.ts
index fb2ad915ec..3efba92c8e 100644
--- a/client/src/protoFleet/config/navItems.ts
+++ b/client/src/protoFleet/config/navItems.ts
@@ -205,6 +205,15 @@ export const secondaryNavItems: SecondaryNavItem[] = [
section: "Admin",
requiredPermission: "serverlog:read",
},
+ {
+ path: "/settings/updates",
+ label: "Updates",
+ parent: "/settings",
+ section: "Admin",
+ // The page's backing RPCs (GetUpdateStatus, SetReleaseChannel) are
+ // server-gated on instance:update, so gate the nav entry to match.
+ requiredPermission: "instance:update",
+ },
{
path: "/settings/preferences",
label: "Preferences",
diff --git a/client/src/protoFleet/features/settings/components/Updates.test.tsx b/client/src/protoFleet/features/settings/components/Updates.test.tsx
new file mode 100644
index 0000000000..fd607f93af
--- /dev/null
+++ b/client/src/protoFleet/features/settings/components/Updates.test.tsx
@@ -0,0 +1,738 @@
+import { act, fireEvent, render, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { create } from "@bufbuild/protobuf";
+import { Code, ConnectError } from "@connectrpc/connect";
+
+import Updates from "./Updates";
+import { instanceUpdateClient } from "@/protoFleet/api/clients";
+import type {
+ GetUpdateStatusResponse,
+ ReleaseInfo,
+ SetReleaseChannelResponse,
+} from "@/protoFleet/api/generated/instance/v1/updates_pb";
+import {
+ GetUpdateStatusResponseSchema,
+ ReleaseChannel,
+ ReleaseInfoSchema,
+ SetReleaseChannelResponseSchema,
+} from "@/protoFleet/api/generated/instance/v1/updates_pb";
+import { useHasPermission } from "@/protoFleet/store";
+import { pushToast } from "@/shared/features/toaster";
+import { copyToClipboard } from "@/shared/utils/utility";
+
+const permissionsMock = vi.hoisted(() => ({
+ current: ["instance:update", "fleet:read"],
+ isAuthenticated: true,
+ sessionExpiry: new Date(1_000),
+ setPermissions: vi.fn<(permissions: string[]) => void>(),
+}));
+const authErrorsMock = vi.hoisted(() => ({
+ handleAuthErrors: vi.fn(),
+}));
+
+vi.mock("react-router-dom", () => ({
+ Navigate: ({ to }: { to: string }) =>
,
+}));
+
+vi.mock("@/protoFleet/store", () => {
+ // Stable identity, mirroring the real hook's memoization: the page's fetch
+ // effect depends on handleAuthErrors.
+ authErrorsMock.handleAuthErrors.mockImplementation(
+ ({ error, onError }: { error: unknown; onError?: (error: unknown) => void }) => onError?.(error),
+ );
+ return {
+ useHasPermission: vi.fn((permission: string) => permissionsMock.current.includes(permission)),
+ usePermissions: () => permissionsMock.current,
+ useSetPermissions: () => permissionsMock.setPermissions,
+ useAuthErrors: () => authErrorsMock,
+ useFleetStore: {
+ getState: () => ({
+ auth: {
+ isAuthenticated: permissionsMock.isAuthenticated,
+ permissions: permissionsMock.current,
+ sessionExpiry: permissionsMock.sessionExpiry,
+ },
+ }),
+ },
+ };
+});
+
+vi.mock("@/protoFleet/api/clients", () => ({
+ instanceUpdateClient: {
+ getUpdateStatus: vi.fn(),
+ setReleaseChannel: vi.fn(),
+ },
+}));
+
+vi.mock("@/shared/utils/utility", () => ({
+ copyToClipboard: vi.fn(),
+}));
+
+vi.mock("@/shared/features/toaster", () => ({
+ pushToast: vi.fn(),
+ STATUSES: {
+ success: "success",
+ error: "error",
+ },
+}));
+
+const INSTALL_COMMAND = "curl -fsSL https://fleet.example.com/install.sh | sh -s -- v1.3.0";
+const RELEASE_NOTES_URL = "https://github.com/block/proto-fleet/releases/tag/v1.3.0";
+const DISMISSED_UPDATE_TAG_KEY = "dismissedUpdateTag";
+const SET_CHANNEL_RESPONSE = create(SetReleaseChannelResponseSchema);
+
+const buildReleaseInfo = (overrides?: Partial): ReleaseInfo =>
+ create(ReleaseInfoSchema, {
+ version: "v1.3.0",
+ releaseNotesUrl: RELEASE_NOTES_URL,
+ prerelease: false,
+ ...overrides,
+ });
+
+const buildStatus = (overrides?: Partial): GetUpdateStatusResponse =>
+ create(GetUpdateStatusResponseSchema, {
+ currentVersion: "v1.2.0",
+ channel: ReleaseChannel.STABLE,
+ statusAvailable: true,
+ updateAvailable: true,
+ installCommand: INSTALL_COMMAND,
+ latestEligible: buildReleaseInfo(),
+ ...overrides,
+ });
+
+const mockUseHasPermission = vi.mocked(useHasPermission);
+const mockGetUpdateStatus = vi.mocked(instanceUpdateClient.getUpdateStatus);
+const mockSetReleaseChannel = vi.mocked(instanceUpdateClient.setReleaseChannel);
+const mockCopyToClipboard = vi.mocked(copyToClipboard);
+const mockPushToast = vi.mocked(pushToast);
+
+const RC_CHECKBOX_NAME = "Include release candidates";
+const RELEASE_CHANNEL_SAVE_TIMEOUT_MS = 30_000;
+const PERMISSION_REVOKED_MESSAGE = "You no longer have permission to update this instance";
+
+const createDeferred = () => {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ localStorage.clear();
+ permissionsMock.current = ["instance:update", "fleet:read"];
+ permissionsMock.isAuthenticated = true;
+ permissionsMock.sessionExpiry = new Date(1_000);
+ permissionsMock.setPermissions.mockImplementation((permissions) => {
+ permissionsMock.current = permissions;
+ });
+ mockUseHasPermission.mockImplementation((permission) => permissionsMock.current.includes(permission));
+});
+
+describe("Updates", () => {
+ it("renders the current version, latest release, notes link, and copy control regardless of callout dismissal", async () => {
+ // The nav callout's dismissal must not hide the release on this page.
+ localStorage.setItem(DISMISSED_UPDATE_TAG_KEY, "v1.3.0");
+ mockGetUpdateStatus.mockResolvedValue(buildStatus());
+
+ const { findByText, getByText, getByRole } = render();
+
+ expect(await findByText("v1.2.0")).toBeInTheDocument();
+ expect(getByText("v1.3.0")).toBeInTheDocument();
+ const link = getByRole("link", { name: "Release notes" });
+ expect(link).toHaveAttribute("href", RELEASE_NOTES_URL);
+ expect(link).toHaveAttribute("target", "_blank");
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
+ expect(getByText(INSTALL_COMMAND)).toBeInTheDocument();
+ expect(getByRole("button", { name: "Copy install command" })).toBeInTheDocument();
+ });
+
+ it("omits the release notes link when the server provides no URL", async () => {
+ // The server blanks non-https notes URLs; the release still renders.
+ mockGetUpdateStatus.mockResolvedValue(
+ buildStatus({
+ latestEligible: buildReleaseInfo({
+ version: "v1.3.0",
+ releaseNotesUrl: "",
+ prerelease: false,
+ }),
+ }),
+ );
+
+ const { findByText, queryByRole } = render();
+
+ expect(await findByText("v1.3.0")).toBeInTheDocument();
+ expect(queryByRole("link", { name: "Release notes" })).not.toBeInTheDocument();
+ });
+
+ it("copies the install command and shows a success toast", async () => {
+ mockGetUpdateStatus.mockResolvedValue(buildStatus());
+ mockCopyToClipboard.mockResolvedValue(undefined);
+
+ const { findByRole } = render();
+ fireEvent.click(await findByRole("button", { name: "Copy install command" }));
+
+ expect(mockCopyToClipboard).toHaveBeenCalledWith(INSTALL_COMMAND);
+ await waitFor(() =>
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: "Install command copied to clipboard",
+ status: "success",
+ }),
+ );
+ });
+
+ it("shows an error toast when copying the install command fails", async () => {
+ mockGetUpdateStatus.mockResolvedValue(buildStatus());
+ mockCopyToClipboard.mockRejectedValue(new Error("copy failed"));
+
+ const { findByRole } = render();
+ fireEvent.click(await findByRole("button", { name: "Copy install command" }));
+
+ await waitFor(() =>
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: "Failed to copy install command",
+ status: "error",
+ }),
+ );
+ });
+
+ it("renders the up-to-date state when no update is available", async () => {
+ mockGetUpdateStatus.mockResolvedValue(
+ buildStatus({ updateAvailable: false, installCommand: "", latestEligible: undefined }),
+ );
+
+ const { findByText, queryByRole } = render();
+
+ expect(await findByText("You're on the latest version")).toBeInTheDocument();
+ expect(queryByRole("button", { name: "Copy install command" })).not.toBeInTheDocument();
+ });
+
+ it("renders an unavailable state when release discovery has not succeeded", async () => {
+ mockGetUpdateStatus.mockResolvedValue(
+ buildStatus({
+ statusAvailable: false,
+ updateAvailable: false,
+ latestEligible: undefined,
+ installCommand: "",
+ }),
+ );
+
+ const { findByText, queryByText } = render();
+
+ expect(await findByText("Update status unavailable")).toBeInTheDocument();
+ expect(queryByText("You're on the latest version")).not.toBeInTheDocument();
+ });
+
+ it("renders the error state when the status RPC fails on load", async () => {
+ mockGetUpdateStatus.mockRejectedValue(new Error("release registry unreachable"));
+
+ const { findByText, getByText } = render();
+
+ expect(await findByText("Unable to load update status")).toBeInTheDocument();
+ expect(getByText("release registry unreachable")).toBeInTheDocument();
+ });
+
+ it("saves a channel change and toasts success", async () => {
+ // The success path refetches; the second response carries the persisted
+ // new channel.
+ mockGetUpdateStatus
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }))
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE_AND_RC }));
+ mockSetReleaseChannel.mockResolvedValue(SET_CHANNEL_RESPONSE);
+
+ const { findByRole, getByRole } = render();
+ fireEvent.click(await findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+
+ expect(mockSetReleaseChannel).toHaveBeenCalledWith(
+ { channel: ReleaseChannel.STABLE_AND_RC },
+ { timeoutMs: RELEASE_CHANNEL_SAVE_TIMEOUT_MS },
+ );
+ await waitFor(() =>
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: "Release channel updated",
+ status: "success",
+ }),
+ );
+ await waitFor(() => expect(getByRole("checkbox", { name: RC_CHECKBOX_NAME })).toBeChecked());
+ });
+
+ it("saves a switch back to stable when the checkbox is unchecked", async () => {
+ mockGetUpdateStatus
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE_AND_RC }))
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }));
+ mockSetReleaseChannel.mockResolvedValue(SET_CHANNEL_RESPONSE);
+
+ const { findByRole, getByRole } = render();
+ const checkbox = await findByRole("checkbox", { name: RC_CHECKBOX_NAME });
+ expect(checkbox).toBeChecked();
+ fireEvent.click(checkbox);
+
+ expect(mockSetReleaseChannel).toHaveBeenCalledWith(
+ { channel: ReleaseChannel.STABLE },
+ { timeoutMs: RELEASE_CHANNEL_SAVE_TIMEOUT_MS },
+ );
+ await waitFor(() => expect(getByRole("checkbox", { name: RC_CHECKBOX_NAME })).not.toBeChecked());
+ });
+
+ it("refetches the update status after a successful channel change", async () => {
+ // Each channel offers a different eligible release; the page must not
+ // keep showing the old channel's offer after the switch.
+ const rcStatus = buildStatus({
+ channel: ReleaseChannel.STABLE_AND_RC,
+ installCommand: "curl -fsSL https://fleet.example.com/install.sh | sh -s -- v1.4.0-rc.1",
+ latestEligible: buildReleaseInfo({
+ version: "v1.4.0-rc.1",
+ releaseNotesUrl: "https://github.com/block/proto-fleet/releases/tag/v1.4.0-rc.1",
+ prerelease: true,
+ }),
+ });
+ mockGetUpdateStatus
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }))
+ .mockResolvedValueOnce(rcStatus);
+ mockSetReleaseChannel.mockResolvedValue(SET_CHANNEL_RESPONSE);
+
+ const { findByText, findByRole } = render();
+ expect(await findByText("v1.3.0")).toBeInTheDocument();
+
+ fireEvent.click(await findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+
+ expect(await findByText("v1.4.0-rc.1")).toBeInTheDocument();
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(2);
+ });
+
+ it("ignores an older status request that resolves after the latest request", async () => {
+ const staleRequest = createDeferred();
+ const latestRequest = createDeferred();
+ mockGetUpdateStatus.mockReturnValueOnce(staleRequest.promise).mockReturnValueOnce(latestRequest.promise);
+
+ const { rerender, findByText, getByRole, queryByText } = render();
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1));
+
+ // Losing and regaining permission replaces the in-flight request just as
+ // another status refresh would; the older response must not win later.
+ mockUseHasPermission.mockReturnValue(false);
+ rerender();
+ mockUseHasPermission.mockReturnValue(true);
+ rerender();
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(2));
+
+ await act(async () => {
+ latestRequest.resolve(buildStatus({ channel: ReleaseChannel.STABLE }));
+ await latestRequest.promise;
+ });
+ expect(await findByText("v1.3.0")).toBeInTheDocument();
+ expect(getByRole("checkbox", { name: RC_CHECKBOX_NAME })).not.toBeChecked();
+
+ await act(async () => {
+ staleRequest.resolve(
+ buildStatus({
+ channel: ReleaseChannel.STABLE_AND_RC,
+ latestEligible: buildReleaseInfo({
+ version: "v1.4.0-rc.1",
+ releaseNotesUrl: "https://github.com/block/proto-fleet/releases/tag/v1.4.0-rc.1",
+ prerelease: true,
+ }),
+ }),
+ );
+ await staleRequest.promise;
+ });
+ expect(queryByText("v1.4.0-rc.1")).not.toBeInTheDocument();
+ expect(getByRole("checkbox", { name: RC_CHECKBOX_NAME })).not.toBeChecked();
+ });
+
+ it("ignores an older status request that rejects after the latest request succeeds", async () => {
+ const staleRequest = createDeferred();
+ const latestRequest = createDeferred();
+ mockGetUpdateStatus.mockReturnValueOnce(staleRequest.promise).mockReturnValueOnce(latestRequest.promise);
+
+ const { rerender, findByText, getByRole, queryByText } = render();
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1));
+
+ mockUseHasPermission.mockReturnValue(false);
+ rerender();
+ mockUseHasPermission.mockReturnValue(true);
+ rerender();
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(2));
+
+ await act(async () => {
+ latestRequest.resolve(buildStatus({ channel: ReleaseChannel.STABLE }));
+ await latestRequest.promise;
+ });
+ expect(await findByText("v1.3.0")).toBeInTheDocument();
+
+ await act(async () => {
+ staleRequest.reject(new Error("stale registry failure"));
+ await staleRequest.promise.catch(() => undefined);
+ });
+ expect(queryByText("Unable to load update status")).not.toBeInTheDocument();
+ expect(queryByText("stale registry failure")).not.toBeInTheDocument();
+ expect(getByRole("checkbox", { name: RC_CHECKBOX_NAME })).not.toBeChecked();
+ });
+
+ it("preserves global auth handling when a status request rejects after unmount", async () => {
+ const request = createDeferred();
+ const sessionError = new ConnectError("session expired", Code.Unauthenticated);
+ mockGetUpdateStatus.mockReturnValue(request.promise);
+
+ const page = render();
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1));
+ page.unmount();
+
+ await act(async () => {
+ request.reject(sessionError);
+ await request.promise.catch(() => undefined);
+ });
+
+ expect(authErrorsMock.handleAuthErrors).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: sessionError,
+ }),
+ );
+ expect(permissionsMock.setPermissions).not.toHaveBeenCalled();
+ expect(mockPushToast).not.toHaveBeenCalled();
+ });
+
+ it("invalidates revoked permission without toasting when a status request outlives the page", async () => {
+ const request = createDeferred();
+ const permissionError = new ConnectError("permission revoked", Code.PermissionDenied);
+ mockGetUpdateStatus.mockReturnValue(request.promise);
+
+ const page = render();
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1));
+ page.unmount();
+
+ await act(async () => {
+ request.reject(permissionError);
+ await request.promise.catch(() => undefined);
+ });
+
+ expect(authErrorsMock.handleAuthErrors).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: permissionError,
+ }),
+ );
+ expect(permissionsMock.setPermissions).toHaveBeenCalledWith(["fleet:read"]);
+ expect(mockPushToast).not.toHaveBeenCalled();
+ });
+
+ it("does not apply a delayed status auth failure to a replacement session", async () => {
+ const request = createDeferred();
+ const sessionError = new ConnectError("old session expired", Code.Unauthenticated);
+ mockGetUpdateStatus.mockReturnValue(request.promise);
+
+ const page = render();
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1));
+ page.unmount();
+ permissionsMock.sessionExpiry = new Date(2_000);
+
+ await act(async () => {
+ request.reject(sessionError);
+ await request.promise.catch(() => undefined);
+ });
+
+ expect(authErrorsMock.handleAuthErrors).not.toHaveBeenCalled();
+ expect(permissionsMock.setPermissions).not.toHaveBeenCalled();
+ expect(mockPushToast).not.toHaveBeenCalled();
+ });
+
+ it("disables channel and copy controls throughout the save and refetch", async () => {
+ const save = createDeferred();
+ const refetch = createDeferred();
+ mockGetUpdateStatus.mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }));
+ mockGetUpdateStatus.mockReturnValueOnce(refetch.promise);
+ mockSetReleaseChannel.mockReturnValue(save.promise);
+
+ const { findByRole, getByRole } = render();
+ const checkbox = await findByRole("checkbox", { name: RC_CHECKBOX_NAME });
+ const copyButton = getByRole("button", { name: "Copy install command" });
+ fireEvent.click(checkbox);
+
+ await waitFor(() => expect(mockSetReleaseChannel).toHaveBeenCalledTimes(1));
+ expect(checkbox).toBeDisabled();
+ expect(copyButton).toBeDisabled();
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ save.resolve(SET_CHANNEL_RESPONSE);
+ await save.promise;
+ });
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(2));
+ expect(checkbox).toBeDisabled();
+ expect(copyButton).toBeDisabled();
+
+ await act(async () => {
+ refetch.resolve(buildStatus({ channel: ReleaseChannel.STABLE_AND_RC }));
+ await refetch.promise;
+ });
+ await waitFor(() => expect(checkbox).not.toBeDisabled());
+ expect(copyButton).not.toBeDisabled();
+ expect(checkbox).toBeChecked();
+ });
+
+ it("waits for an in-flight save before a remounted page loads status", async () => {
+ const save = createDeferred();
+ const rcStatus = buildStatus({
+ channel: ReleaseChannel.STABLE_AND_RC,
+ installCommand: "curl -fsSL https://fleet.example.com/install.sh | sh -s -- v1.4.0-rc.1",
+ latestEligible: buildReleaseInfo({
+ version: "v1.4.0-rc.1",
+ releaseNotesUrl: "https://github.com/block/proto-fleet/releases/tag/v1.4.0-rc.1",
+ prerelease: true,
+ }),
+ });
+ mockGetUpdateStatus
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }))
+ .mockResolvedValueOnce(rcStatus);
+ mockSetReleaseChannel.mockReturnValue(save.promise);
+
+ const firstPage = render();
+ fireEvent.click(await firstPage.findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+ await waitFor(() => expect(mockSetReleaseChannel).toHaveBeenCalledTimes(1));
+ firstPage.unmount();
+
+ const remountedPage = render();
+ await act(async () => Promise.resolve());
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ save.resolve(SET_CHANNEL_RESPONSE);
+ await save.promise;
+ });
+ expect(await remountedPage.findByText("v1.4.0-rc.1")).toBeInTheDocument();
+ expect(remountedPage.getByRole("checkbox", { name: RC_CHECKBOX_NAME })).toBeChecked();
+ expect(mockPushToast).not.toHaveBeenCalledWith({
+ message: "Release channel updated",
+ status: "success",
+ });
+ });
+
+ it("loads status after a timed-out save releases the remount barrier", async () => {
+ const save = createDeferred();
+ const rcStatus = buildStatus({ channel: ReleaseChannel.STABLE_AND_RC });
+ mockGetUpdateStatus
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }))
+ .mockResolvedValueOnce(rcStatus);
+ mockSetReleaseChannel.mockReturnValue(save.promise);
+
+ const firstPage = render();
+ fireEvent.click(await firstPage.findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+ await waitFor(() =>
+ expect(mockSetReleaseChannel).toHaveBeenCalledWith(
+ { channel: ReleaseChannel.STABLE_AND_RC },
+ { timeoutMs: RELEASE_CHANNEL_SAVE_TIMEOUT_MS },
+ ),
+ );
+ firstPage.unmount();
+
+ const remountedPage = render();
+ await act(async () => Promise.resolve());
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ save.reject(new ConnectError("release channel save timed out", Code.DeadlineExceeded));
+ await save.promise.catch(() => undefined);
+ });
+
+ expect(await remountedPage.findByText("v1.3.0")).toBeInTheDocument();
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(2);
+ expect(remountedPage.getByRole("checkbox", { name: RC_CHECKBOX_NAME })).toBeChecked();
+ });
+
+ it("reconciles status after an ambiguous non-auth save failure", async () => {
+ mockGetUpdateStatus.mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE })).mockResolvedValueOnce(
+ buildStatus({
+ channel: ReleaseChannel.STABLE_AND_RC,
+ latestEligible: buildReleaseInfo({
+ version: "v1.4.0-rc.1",
+ releaseNotesUrl: "https://github.com/block/proto-fleet/releases/tag/v1.4.0-rc.1",
+ prerelease: true,
+ }),
+ }),
+ );
+ mockSetReleaseChannel.mockRejectedValue(new Error("response lost after save"));
+
+ const { findByRole, findByText, getByRole } = render();
+ fireEvent.click(await findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+
+ await waitFor(() =>
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: "response lost after save",
+ status: "error",
+ }),
+ );
+ expect(await findByText("v1.4.0-rc.1")).toBeInTheDocument();
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(2);
+ expect(getByRole("checkbox", { name: RC_CHECKBOX_NAME })).toBeChecked();
+ });
+
+ it("reports a refresh failure separately after a successful save", async () => {
+ mockGetUpdateStatus
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }))
+ .mockRejectedValueOnce(new Error("refresh failed after save"));
+ mockSetReleaseChannel.mockResolvedValue(SET_CHANNEL_RESPONSE);
+
+ const { findByRole, findByText } = render();
+ fireEvent.click(await findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+
+ expect(await findByText("Unable to load update status")).toBeInTheDocument();
+ expect(await findByText("refresh failed after save")).toBeInTheDocument();
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: "Release channel updated",
+ status: "success",
+ });
+ expect(mockPushToast).not.toHaveBeenCalledWith({
+ message: "Failed to update release channel",
+ status: "error",
+ });
+ });
+
+ it("does not refetch after an auth-related save failure", async () => {
+ mockGetUpdateStatus.mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }));
+ mockSetReleaseChannel.mockRejectedValue(new ConnectError("session expired", Code.Unauthenticated));
+
+ const { findByRole } = render();
+ const checkbox = await findByRole("checkbox", { name: RC_CHECKBOX_NAME });
+ fireEvent.click(checkbox);
+
+ await waitFor(() => expect(mockSetReleaseChannel).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(checkbox).not.toBeDisabled());
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1);
+ expect(mockPushToast).not.toHaveBeenCalled();
+ });
+
+ it("reports a permission-denied save and redirects away from stale controls", async () => {
+ mockGetUpdateStatus.mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }));
+ mockSetReleaseChannel.mockRejectedValue(new ConnectError("permission revoked", Code.PermissionDenied));
+
+ const page = render();
+ fireEvent.click(await page.findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+
+ await waitFor(() =>
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: PERMISSION_REVOKED_MESSAGE,
+ status: "error",
+ }),
+ );
+ expect(permissionsMock.setPermissions).toHaveBeenCalledWith(["fleet:read"]);
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1);
+
+ page.rerender();
+ expect(page.getByTestId("navigate")).toHaveAttribute("data-to", "/settings/network");
+ });
+
+ it("invalidates revoked permission without toasting after a save outlives the page", async () => {
+ const save = createDeferred();
+ const permissionError = new ConnectError("permission revoked", Code.PermissionDenied);
+ mockGetUpdateStatus.mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }));
+ mockSetReleaseChannel.mockReturnValue(save.promise);
+
+ const page = render();
+ fireEvent.click(await page.findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+ await waitFor(() => expect(mockSetReleaseChannel).toHaveBeenCalledTimes(1));
+ page.unmount();
+
+ await act(async () => {
+ save.reject(permissionError);
+ await save.promise.catch(() => undefined);
+ });
+
+ expect(authErrorsMock.handleAuthErrors).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: permissionError,
+ }),
+ );
+ expect(permissionsMock.setPermissions).toHaveBeenCalledWith(["fleet:read"]);
+ expect(mockPushToast).not.toHaveBeenCalled();
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not apply a delayed save permission failure to a replacement session", async () => {
+ const save = createDeferred();
+ const permissionError = new ConnectError("old permission revoked", Code.PermissionDenied);
+ mockGetUpdateStatus.mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }));
+ mockSetReleaseChannel.mockReturnValue(save.promise);
+
+ const page = render();
+ fireEvent.click(await page.findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+ await waitFor(() => expect(mockSetReleaseChannel).toHaveBeenCalledTimes(1));
+ page.unmount();
+ permissionsMock.sessionExpiry = new Date(2_000);
+
+ await act(async () => {
+ save.reject(permissionError);
+ await save.promise.catch(() => undefined);
+ });
+
+ expect(authErrorsMock.handleAuthErrors).not.toHaveBeenCalled();
+ expect(permissionsMock.setPermissions).not.toHaveBeenCalled();
+ expect(mockPushToast).not.toHaveBeenCalled();
+ expect(mockGetUpdateStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it("invalidates stale client permission when the status load is denied", async () => {
+ mockGetUpdateStatus.mockRejectedValueOnce(new ConnectError("permission revoked", Code.PermissionDenied));
+
+ const page = render();
+
+ await waitFor(() =>
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: PERMISSION_REVOKED_MESSAGE,
+ status: "error",
+ }),
+ );
+ expect(permissionsMock.setPermissions).toHaveBeenCalledWith(["fleet:read"]);
+ expect(page.queryByText("Unable to load update status")).not.toBeInTheDocument();
+
+ page.rerender();
+ expect(page.getByTestId("navigate")).toHaveAttribute("data-to", "/settings/network");
+ });
+
+ it("toasts an error and leaves the checkbox unchecked when saving the channel fails", async () => {
+ mockGetUpdateStatus
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }))
+ .mockResolvedValueOnce(buildStatus({ channel: ReleaseChannel.STABLE }));
+ mockSetReleaseChannel.mockRejectedValue(new Error("registry rejected the channel"));
+
+ const { findByRole, getByRole } = render();
+ fireEvent.click(await findByRole("checkbox", { name: RC_CHECKBOX_NAME }));
+
+ expect(mockSetReleaseChannel).toHaveBeenCalledWith(
+ { channel: ReleaseChannel.STABLE_AND_RC },
+ { timeoutMs: RELEASE_CHANNEL_SAVE_TIMEOUT_MS },
+ );
+ await waitFor(() =>
+ expect(mockPushToast).toHaveBeenCalledWith({
+ message: "registry rejected the channel",
+ status: "error",
+ }),
+ );
+ await waitFor(() => expect(mockGetUpdateStatus).toHaveBeenCalledTimes(2));
+ // The checkbox is controlled by the persisted channel, which never moved.
+ expect(getByRole("checkbox", { name: RC_CHECKBOX_NAME })).not.toBeChecked();
+ });
+
+ it("redirects and does not fire the status RPC without the instance:update permission", async () => {
+ permissionsMock.current = ["fleet:read"];
+
+ const { getByTestId } = render();
+
+ expect(mockUseHasPermission).toHaveBeenCalledWith("instance:update");
+ expect(getByTestId("navigate")).toHaveAttribute("data-to", "/settings/network");
+ // Flush a microtask turn so an incorrectly-enabled fetch would have had a chance to fire.
+ await Promise.resolve();
+ expect(mockGetUpdateStatus).not.toHaveBeenCalled();
+ });
+
+ it("redirects to preferences when neither instance:update nor fleet:read is allowed", async () => {
+ permissionsMock.current = [];
+
+ const { getByTestId } = render();
+
+ expect(getByTestId("navigate")).toHaveAttribute("data-to", "/settings/preferences");
+ await Promise.resolve();
+ expect(mockGetUpdateStatus).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/protoFleet/features/settings/components/Updates.tsx b/client/src/protoFleet/features/settings/components/Updates.tsx
new file mode 100644
index 0000000000..7119e49a05
--- /dev/null
+++ b/client/src/protoFleet/features/settings/components/Updates.tsx
@@ -0,0 +1,324 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Navigate } from "react-router-dom";
+import { instanceUpdateClient } from "@/protoFleet/api/clients";
+import { ReleaseChannel } from "@/protoFleet/api/generated/instance/v1/updates_pb";
+import type { GetUpdateStatusResponse } from "@/protoFleet/api/generated/instance/v1/updates_pb";
+import { getErrorMessage } from "@/protoFleet/api/getErrorMessage";
+import { isAuthOrPermissionError, isPermissionDeniedError } from "@/protoFleet/api/requestErrors";
+import { getSettingsLandingPath } from "@/protoFleet/config/navItems";
+import SettingsEmptyState from "@/protoFleet/features/settings/components/SettingsEmptyState";
+import SettingsPageHeader from "@/protoFleet/features/settings/components/SettingsPageHeader";
+import { copyInstallCommand } from "@/protoFleet/features/updates/copyInstallCommand";
+import { useAuthErrors, useFleetStore, useHasPermission, usePermissions, useSetPermissions } from "@/protoFleet/store";
+import { Copy } from "@/shared/assets/icons";
+import Checkbox from "@/shared/components/Checkbox";
+import Header from "@/shared/components/Header";
+import Row from "@/shared/components/Row";
+import SkeletonBar from "@/shared/components/SkeletonBar";
+import { pushToast, STATUSES } from "@/shared/features/toaster";
+
+const SkeletonLoader = ;
+const INSTANCE_UPDATE_PERMISSION = "instance:update";
+const RELEASE_CHANNEL_SAVE_TIMEOUT_MS = 30_000;
+const PERMISSION_REVOKED_MESSAGE = "You no longer have permission to update this instance";
+const UPDATES_PAGE_DESCRIPTION = "View the server version and choose which releases this instance installs.";
+
+interface AuthSessionSnapshot {
+ isAuthenticated: boolean;
+ sessionExpiry: Date | null;
+}
+
+const captureAuthSession = (): AuthSessionSnapshot => {
+ const { isAuthenticated, sessionExpiry } = useFleetStore.getState().auth;
+ return { isAuthenticated, sessionExpiry };
+};
+
+const isSameAuthSession = (snapshot: AuthSessionSnapshot) => {
+ const { isAuthenticated, sessionExpiry } = useFleetStore.getState().auth;
+ // Login installs a new Date object and logout clears it. Compare identity so
+ // even a replacement session for the same user cannot inherit old failures.
+ return isAuthenticated === snapshot.isAuthenticated && sessionExpiry === snapshot.sessionExpiry;
+};
+
+// A route remount must not load status while the previous page instance is
+// still saving a channel. Otherwise it can briefly expose the old channel's
+// install command after navigation away and back.
+let inFlightReleaseChannelSave: Promise | null = null;
+
+const saveReleaseChannel = (channel: ReleaseChannel) => {
+ // This promise is shared across route instances, so it must have a bounded
+ // lifetime. A timed-out write is reconciled by the authoritative status
+ // fetch that follows on the current or next page instance.
+ const save = instanceUpdateClient.setReleaseChannel({ channel }, { timeoutMs: RELEASE_CHANNEL_SAVE_TIMEOUT_MS });
+ inFlightReleaseChannelSave = save;
+ void save.then(
+ () => {
+ if (inFlightReleaseChannelSave === save) {
+ inFlightReleaseChannelSave = null;
+ }
+ },
+ () => {
+ if (inFlightReleaseChannelSave === save) {
+ inFlightReleaseChannelSave = null;
+ }
+ },
+ );
+ return save;
+};
+
+const waitForReleaseChannelSave = async () => {
+ const save = inFlightReleaseChannelSave;
+ if (!save) {
+ return;
+ }
+ try {
+ await save;
+ } catch {
+ // A failed save can still be ambiguous (for example, a lost response
+ // after the server committed), so the caller must fetch authoritative
+ // status after the mutation settles either way.
+ }
+};
+
+const Updates = () => {
+ const canUpdateInstance = useHasPermission(INSTANCE_UPDATE_PERMISSION);
+ const permissions = usePermissions();
+ const setPermissions = useSetPermissions();
+ const { handleAuthErrors } = useAuthErrors();
+ const [status, setStatus] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+ const [isChannelChangePending, setIsChannelChangePending] = useState(false);
+ const latestStatusRequest = useRef(0);
+ const isMounted = useRef(false);
+ // The channel the server has persisted; the checkbox is controlled by it,
+ // so a failed save never moves the control.
+ const [channel, setChannel] = useState(ReleaseChannel.UNSPECIFIED);
+
+ const handlePermissionRevoked = useCallback(
+ (notify: boolean) => {
+ if (notify) {
+ pushToast({
+ message: PERMISSION_REVOKED_MESSAGE,
+ status: STATUSES.error,
+ });
+ }
+ // A delayed response can arrive after this component unmounts. Read the
+ // current store value so it cannot overwrite newer permission changes.
+ const currentPermissions = useFleetStore.getState().auth.permissions;
+ setPermissions(currentPermissions.filter((permission) => permission !== INSTANCE_UPDATE_PERMISSION));
+ },
+ [setPermissions],
+ );
+
+ const fetchStatus = useCallback(async () => {
+ const requestId = ++latestStatusRequest.current;
+ const authSession = captureAuthSession();
+ await waitForReleaseChannelSave();
+ if (requestId !== latestStatusRequest.current || !isSameAuthSession(authSession)) {
+ return;
+ }
+ try {
+ const response = await instanceUpdateClient.getUpdateStatus({});
+ if (requestId !== latestStatusRequest.current || !isSameAuthSession(authSession)) {
+ return;
+ }
+ setStatus(response);
+ setChannel(response.channel);
+ setLoadError(null);
+ } catch (err) {
+ if (!isSameAuthSession(authSession)) {
+ return;
+ }
+ const shouldUpdatePage = requestId === latestStatusRequest.current && isMounted.current;
+ handleAuthErrors({
+ error: err,
+ onError: () => {
+ if (isPermissionDeniedError(err)) {
+ handlePermissionRevoked(shouldUpdatePage);
+ return;
+ }
+ if (!shouldUpdatePage || isAuthOrPermissionError(err)) {
+ return;
+ }
+ setLoadError(getErrorMessage(err, "Failed to load update status"));
+ },
+ });
+ }
+ }, [handleAuthErrors, handlePermissionRevoked]);
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ // The RPC is server-gated on instance:update; non-holders are redirected
+ // below and must not fire it.
+ if (!canUpdateInstance) {
+ return;
+ }
+ void fetchStatus();
+ return () => {
+ // Ignore a response that arrives after permission loss, unmount, or a
+ // dependency-driven replacement request.
+ latestStatusRequest.current += 1;
+ };
+ }, [canUpdateInstance, fetchStatus]);
+
+ const handleIncludeRCChange = async (includeRC: boolean) => {
+ const nextChannel = includeRC ? ReleaseChannel.STABLE_AND_RC : ReleaseChannel.STABLE;
+ if (nextChannel === channel || isChannelChangePending) {
+ return;
+ }
+ const authSession = captureAuthSession();
+ setIsChannelChangePending(true);
+ try {
+ let saveSucceeded = false;
+ try {
+ await saveReleaseChannel(nextChannel);
+ saveSucceeded = true;
+ } catch (err) {
+ if (!isSameAuthSession(authSession)) {
+ return;
+ }
+ const shouldUpdatePage = isMounted.current;
+ handleAuthErrors({
+ error: err,
+ onError: () => {
+ if (isPermissionDeniedError(err)) {
+ handlePermissionRevoked(shouldUpdatePage);
+ return;
+ }
+ if (!shouldUpdatePage || isAuthOrPermissionError(err)) {
+ return;
+ }
+ pushToast({
+ message: getErrorMessage(err, "Failed to update release channel"),
+ status: STATUSES.error,
+ });
+ },
+ });
+ if (!shouldUpdatePage || isAuthOrPermissionError(err)) {
+ return;
+ }
+ }
+
+ if (!isMounted.current || !isSameAuthSession(authSession)) {
+ return;
+ }
+ if (saveSucceeded) {
+ setChannel(nextChannel);
+ pushToast({
+ message: "Release channel updated",
+ status: STATUSES.success,
+ });
+ }
+ // The eligible release differs per channel. Refresh after both success
+ // and an ambiguous non-auth save failure so the server remains the
+ // authority for the checkbox, offered version, and install command.
+ await fetchStatus();
+ } finally {
+ if (isMounted.current && isSameAuthSession(authSession)) {
+ setIsChannelChangePending(false);
+ }
+ }
+ };
+
+ // Redirect callers without instance:update away — placed after all
+ // hooks to satisfy rules-of-hooks.
+ if (!canUpdateInstance) {
+ return ;
+ }
+
+ const release = status?.statusAvailable && status.updateAvailable ? status.latestEligible : undefined;
+
+ return (
+
+
+ {loadError ? (
+
+ ) : (
+
+
+
+
+
+
Current version
+
{status?.currentVersion ?? SkeletonLoader}
+
+ {release ? (
+ <>
+
+
Latest available
+
+ {release.version}
+ {/* The server blanks non-https notes URLs, so an empty
+ string means "no link to offer". */}
+ {release.releaseNotesUrl ? (
+
+ Release notes
+
+ ) : null}
+
+ {status
+ ? status.statusAvailable
+ ? "You're on the latest version"
+ : "Update status unavailable"
+ : SkeletonLoader}
+
+
+ )}
+
+
+
+
+ {status ? (
+
+ ) : (
+
+ )}
+
+ An RC install cannot downgrade until the next stable release.
+
+
+
+ )}
+
+ );
+};
+
+export default Updates;
diff --git a/client/src/protoFleet/features/updates/copyInstallCommand.ts b/client/src/protoFleet/features/updates/copyInstallCommand.ts
new file mode 100644
index 0000000000..362bffc2bd
--- /dev/null
+++ b/client/src/protoFleet/features/updates/copyInstallCommand.ts
@@ -0,0 +1,20 @@
+import { pushToast, STATUSES } from "@/shared/features/toaster";
+import { copyToClipboard } from "@/shared/utils/utility";
+
+// Shared by the update notification modal and the settings Updates page so
+// both surfaces give identical feedback for the same action.
+export const copyInstallCommand = (installCommand: string) => {
+ copyToClipboard(installCommand)
+ .then(() => {
+ pushToast({
+ message: "Install command copied to clipboard",
+ status: STATUSES.success,
+ });
+ })
+ .catch(() => {
+ pushToast({
+ message: "Failed to copy install command",
+ status: STATUSES.error,
+ });
+ });
+};
diff --git a/client/src/protoFleet/routePrefetch.ts b/client/src/protoFleet/routePrefetch.ts
index 9c3a3b11ee..73408a41da 100644
--- a/client/src/protoFleet/routePrefetch.ts
+++ b/client/src/protoFleet/routePrefetch.ts
@@ -42,6 +42,7 @@ export const importSettingsSchedules = () =>
export const importSettingsCurtailment = () => import("@/protoFleet/features/settings/components/Curtailment");
export const importSettingsAlerts = () => import("@/protoFleet/features/alerts/pages/Alerts");
export const importSettingsIntegrations = () => import("@/protoFleet/features/settings/components/ApiKeys");
+export const importSettingsUpdates = () => import("@/protoFleet/features/settings/components/Updates");
export const importSiteDetailPage = () => import("@/protoFleet/features/sites/pages/SiteDetailPage");
export const importBuildingPage = () => import("@/protoFleet/features/buildings/pages/BuildingPage");
export const importFleetLayout = () => import("@/protoFleet/features/fleetManagement/components/FleetLayout");
@@ -82,4 +83,5 @@ export const settingsRoutePrefetch: readonly RouteImporter[] = [
importSettingsIntegrations,
importSettingsPreferences,
importServerLogsPage,
+ importSettingsUpdates,
];
diff --git a/client/src/protoFleet/router.tsx b/client/src/protoFleet/router.tsx
index 65e47dff99..17f457604c 100644
--- a/client/src/protoFleet/router.tsx
+++ b/client/src/protoFleet/router.tsx
@@ -36,6 +36,7 @@ import {
importSettingsPreferences,
importSettingsSchedules,
importSettingsTeam,
+ importSettingsUpdates,
importSiteDetailPage,
importUpdatePassword,
importWelcomePage,
@@ -90,6 +91,7 @@ const SettingsSchedules = lazy(importSettingsSchedules);
const SettingsCurtailment = lazy(importSettingsCurtailment);
const SettingsAlerts = lazy(importSettingsAlerts);
const SettingsIntegrations = lazy(importSettingsIntegrations);
+const SettingsUpdates = lazy(importSettingsUpdates);
const SiteDetailPage = lazy(importSiteDetailPage);
const BuildingPage = lazy(importBuildingPage);
const FleetLayout = lazy(importFleetLayout);
@@ -356,6 +358,12 @@ const router = createBrowserRouter([
,
),
+ createRoute(
+ "/settings/updates",
+
+
+ ,
+ ),
// Auth routes (fullscreen)
createRoute("/auth", , { fullscreen: true, loader: authLoader }),
createRoute("/update-password", , { fullscreen: true }),
diff --git a/docs/plans/2026-07-27-001-feat-release-update-notifications-plan.md b/docs/plans/2026-07-27-001-feat-release-update-notifications-plan.md
index 2c80ce1635..447f3abfec 100644
--- a/docs/plans/2026-07-27-001-feat-release-update-notifications-plan.md
+++ b/docs/plans/2026-07-27-001-feat-release-update-notifications-plan.md
@@ -312,12 +312,14 @@ U1, U2, U3 are independent and can proceed in any order. U4 depends on all three
- **Requirements:** R2 (display), R9.
- **Dependencies:** U1, U4.
- **Files:** `client/src/protoFleet/features/settings/components/Updates.tsx` (+ `Updates.test.tsx`); `client/src/protoFleet/config/navItems.ts`; `client/src/protoFleet/router.tsx`; `client/src/protoFleet/routePrefetch.ts`.
-- **Approach:** Mirror `Network.tsx` for structure (`SettingsPageHeader`, bordered card, skeleton loading) and the `CreateApiKeyModal.tsx` save pattern (RPC call → `pushToast` success/error). Content: current server version; “Update status unavailable” when `status_available` is false; when status is available and a newer eligible release exists, its version, release-notes link, and install-command copy control; otherwise an explicit up-to-date state. RPC transport failures keep the existing error state. A Stable / Stable + RC control calls `SetReleaseChannel`; RC helper copy notes that RC installs cannot downgrade until the next stable. Register the route across `routePrefetch.ts`, `router.tsx`, and `navItems.ts` with `requiredPermission: 'instance:update'`.
+- **Approach:** Mirror `Network.tsx` for structure (`SettingsPageHeader`, bordered card, skeleton loading) and the `CreateApiKeyModal.tsx` save pattern (RPC call → `pushToast` success/error). Content: current server version; “Update status unavailable” when `status_available` is false; when status is available and a newer eligible release exists, its version, release-notes link, and install-command copy control; otherwise an explicit up-to-date state. RPC transport failures keep the existing error state. A Stable / Stable + RC control calls `SetReleaseChannel`; the checkbox and command-copy control remain disabled through the save and corresponding refetch. Status requests carry a monotonically increasing request ID so an older response cannot overwrite a newer persisted channel and offer. A remounted page waits for any save started by its predecessor before loading status; the save has a 30-second RPC deadline so the cross-remount barrier always settles, and the unmounted page does not toast or refetch. Stale and unmounted failures from the same session still run global logout or permission invalidation while suppressing obsolete page state and toast work; each request captures the session-expiry object installed by login so delayed failures from a replaced session cannot mutate its successor. Successful saves and ambiguous non-auth save failures both refresh authoritative status. `Unauthenticated` defers to logout without another status call; `PermissionDenied` reports the revoked access while mounted, removes stale `instance:update` client authority in every lifecycle state of the originating session, and redirects through the permission-aware settings landing helper. RC helper copy notes that RC installs cannot downgrade until the next stable. Register the route across `routePrefetch.ts`, `router.tsx`, and `navItems.ts` with `requiredPermission: 'instance:update'`.
- **Test scenarios (Vitest):**
- Renders current and latest versions, release-notes link, and install-command copy control from a mocked status response (regardless of any dismissed nav callout).
- `status_available = false` → renders “Update status unavailable”; available status with `update_available = false` → renders the up-to-date state; status-RPC transport failure → renders the error state.
- Changing the channel calls `SetReleaseChannel` with the new value and toasts success.
- - RPC failure on save toasts error and leaves the control on the persisted value.
+ - Older status requests that resolve or reject after a newer success cannot replace the fresh UI; channel and copy controls remain disabled throughout both save and refetch.
+ - Remount during a save waits for the mutation before loading status; late ordinary failures suppress lifecycle work from the unmounted page, late authentication and permission failures still run same-session global cleanup without toasting, and failures from a replaced session cannot mutate its successor.
+ - Ambiguous non-auth save failure toasts error and refetches the persisted value; a successful save followed by refresh failure remains distinguishable from a save failure; authentication failures do not refetch; permission denial on load or save reports the revocation and redirects via the permission guard.
- Nav entry carries `requiredPermission: 'instance:update'` (follow existing navItems test precedent if one exists; otherwise assert the component guard).
- **Verification:** `npm run test` in `client/` green; the page appears under Settings only for permission holders.