diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index c55338cd5c46..5e27fa901f0e 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -21,6 +21,7 @@ import { getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, + getProviderUpdateRunToastView, getProviderUpdateSidebarPillView, hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, @@ -1058,3 +1059,90 @@ it("does not offer incompatible latest versions and restores suggestions after p expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected); } }); + +describe("getProviderUpdateRunToastView", () => { + const updateState = ( + status: "succeeded" | "failed", + message: string, + ): ServerProvider["updateState"] => ({ + status, + startedAt: checkedAt, + finishedAt: laterCheckedAt, + message, + output: null, + }); + const run = ( + machineLabel: string, + providerDriver: string, + result: Parameters[0][number]["result"], + ) => ({ + machineLabel, + driver: driver(providerDriver), + instanceId: instanceId(providerDriver), + result, + }); + + it("lists every failed update and ignores interrupted ones", () => { + const view = getProviderUpdateRunToastView([ + run( + "Mac Studio", + "codex", + AsyncResult.success({ + providers: [ + provider({ + driver: driver("codex"), + updateState: updateState("succeeded", "Provider updated."), + }), + ], + }), + ), + run( + "Mac Studio", + "claudeAgent", + AsyncResult.success({ + providers: [ + provider({ + driver: driver("claudeAgent"), + updateState: updateState("failed", "npm exited with code 1."), + }), + ], + }), + ), + run("Laptop", "codex", AsyncResult.failure(Cause.die(new Error("WebSocket closed")))), + run("Server", "codex", AsyncResult.failure(Cause.interrupt())), + ]); + + expect(view).toEqual({ + type: "error", + title: "2 of 3 provider updates failed", + description: "Mac Studio · Claude: npm exited with code 1.\nLaptop · Codex: WebSocket closed", + }); + }); + + it("reports success when every update succeeded", () => { + const succeeded = AsyncResult.success({ + providers: [ + provider({ + driver: driver("codex"), + updateState: updateState("succeeded", "Provider updated."), + }), + ], + }); + + expect( + getProviderUpdateRunToastView([ + run("Mac Studio", "codex", succeeded), + run("Laptop", "codex", succeeded), + ]), + ).toEqual({ + type: "success", + title: "2 providers updated", + description: "New sessions will use the updated providers.", + }); + expect( + getProviderUpdateRunToastView([ + run("Server", "codex", AsyncResult.failure(Cause.interrupt())), + ]), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index dd7b25a62cad..e245fcb7cf30 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -8,6 +8,7 @@ import { type ServerProvider, } from "@t3tools/contracts"; import { + isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; @@ -330,6 +331,61 @@ export function getProviderUpdateProgressToastView(input: { return getProviderUpdateRunningToastView(input.providerCount); } +/** One provider update sent by the cross-machine "Update all", with its result. */ +export interface ProviderUpdateRun { + readonly machineLabel: string; + readonly driver: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + readonly result: AtomCommandResult< + { readonly providers: ReadonlyArray }, + unknown + >; +} + +/** + * Summarize a cross-machine "Update all" as one toast, or null when every + * request was interrupted. Each update that did not succeed gets its own line, + * so a failure on one machine is not hidden by successes on the others. + */ +export function getProviderUpdateRunToastView( + runs: ReadonlyArray, +): Pick | null { + const settled = runs.filter((run) => !isAtomCommandInterrupted(run.result)); + if (settled.length === 0) { + return null; + } + const failureLines = settled.flatMap((run) => { + const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`; + if (run.result._tag === "Failure") { + const error = squashAtomCommandFailure(run.result); + return [`${label}: ${error instanceof Error ? error.message : "Provider update failed."}`]; + } + const updateState = run.result.value.providers.find( + (provider) => provider.instanceId === run.instanceId, + )?.updateState; + return updateState?.status === "succeeded" + ? [] + : [`${label}: ${updateState?.message ?? "Provider update did not finish."}`]; + }); + if (failureLines.length === 0) { + return { + type: "success", + title: settled.length === 1 ? "Provider updated" : `${settled.length} providers updated`, + description: getProviderUpdatedDescription(settled.length), + }; + } + return { + type: "error", + title: + failureLines.length < settled.length + ? `${failureLines.length} of ${settled.length} provider updates failed` + : settled.length === 1 + ? "Provider update failed" + : "Provider updates failed", + description: failureLines.join("\n"), + }; +} + export function collectUpdatedProviderSnapshots(input: { readonly results: ReadonlyArray< AtomCommandResult<{ readonly providers: ReadonlyArray }, unknown> diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx new file mode 100644 index 000000000000..fe435b5833ef --- /dev/null +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -0,0 +1,112 @@ +import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; +import { useMemo, useRef, useState } from "react"; + +import { useEnvironments } from "~/state/environments"; +import { serverEnvironment } from "~/state/server"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { + canOneClickUpdateProviderCandidate, + collectProviderUpdateCandidates, + getProviderUpdateRunToastView, + type ProviderUpdateRun, +} from "./ProviderUpdateLaunchNotification.logic"; +import { Button } from "./ui/button"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Updates every outdated provider on every connected machine at once, then + * reports the results in one toast. Each server queues updates that share an + * installer, so sending them all together is safe. The server still checks + * permissions: a session that cannot operate a machine gets a failure line for + * it. Renders nothing when no machine has a one-click update. + */ +export function ProviderUpdatesAction() { + const { environments } = useEnvironments(); + const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { + reportFailure: false, + }); + const pending = useRef(false); + const [isPending, setIsPending] = useState(false); + const machines = useMemo( + () => + environments.flatMap((environment) => { + const providers = environment.serverConfig?.providers ?? []; + if (environment.connection.phase !== "connected") { + return []; + } + const candidates = collectProviderUpdateCandidates(providers).filter((candidate) => + canOneClickUpdateProviderCandidate(candidate, providers), + ); + return candidates.length > 0 + ? [{ environmentId: environment.environmentId, label: environment.label, candidates }] + : []; + }), + [environments], + ); + // Candidates leave the list as soon as their servers report them queued, so + // keep the button while the run is in flight. + if (machines.length === 0 && !isPending) { + return null; + } + + const handleUpdate = async () => { + if (pending.current) return; + pending.current = true; + setIsPending(true); + try { + const runs = await Promise.all( + machines.flatMap(({ environmentId, label, candidates }) => + candidates.map(async (candidate): Promise => ({ + machineLabel: label, + driver: candidate.driver, + instanceId: candidate.instanceId, + result: await updateProvider({ + environmentId, + input: { provider: candidate.driver, instanceId: candidate.instanceId }, + }), + })), + ), + ); + const view = getProviderUpdateRunToastView(runs); + if (view) { + toastManager.add( + stackedThreadToast({ + ...view, + description: {view.description}, + }), + ); + } + } finally { + pending.current = false; + setIsPending(false); + } + }; + + return ( + + void handleUpdate()} + > + {isPending ? "Updating…" : "Update all"} + + } + /> + + {machines.map((machine) => ( +
+ {machine.label}:{" "} + {machine.candidates + .map((candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver) + .join(", ")} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index aa947d5d1de7..6b62b342c41b 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -59,6 +59,7 @@ import { isProviderUpdateActive, type ProviderSettingsUpdateCandidate, } from "../ProviderUpdateLaunchNotification.logic"; +import { ProviderUpdatesAction } from "../ProviderUpdatesAction"; import { Button } from "../ui/button"; import { Empty, @@ -1065,6 +1066,7 @@ export function EnvironmentProviderSettings({ variant="plain" headerAction={
+ {readOnly ? ( diff --git a/docs/user/updating.md b/docs/user/updating.md index 6909c7b6022d..79ceff4fdb50 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -56,6 +56,13 @@ update can roll back to the previous version. If the update still fails: 2. Check that you updated the server's machine, not only the device you are using. 3. For a command-line server, stop it and relaunch the exact version shown in the notice. +## Update providers + +**Settings → Providers** shows provider updates for the selected environment. +**Update all** updates every outdated provider on every connected environment +at once. Hover it to see which providers it will update. Providers that only +offer a manual update command are not included. + ## Mobile updates To update an environment from your phone, open **Settings → Environments** and