Skip to content
Closed
73 changes: 43 additions & 30 deletions apps/web/src/components/ProviderUpdatesAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,18 @@ 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.
* Every outdated provider with a one-click update on every connected machine,
* plus a function that updates them all and 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. The returned promise
* settles when every update has finished.
*/
export function ProviderUpdatesAction() {
export function useProviderUpdates() {
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) => {
Expand All @@ -44,6 +43,41 @@ export function ProviderUpdatesAction() {
}),
[environments],
);

const updateAll = async () => {
const runs = await Promise.all(
machines.flatMap(({ environmentId, label, candidates }) =>
candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
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: <span className="whitespace-pre-line">{view.description}</span>,
}),
);
}
};

return { machines, updateAll };
}

/** Provider settings button for {@link useProviderUpdates}. Renders nothing
when no machine has a one-click update. */
export function ProviderUpdatesAction() {
const { machines, updateAll } = useProviderUpdates();
const pending = useRef(false);
const [isPending, setIsPending] = useState(false);
// 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) {
Expand All @@ -55,28 +89,7 @@ export function ProviderUpdatesAction() {
pending.current = true;
setIsPending(true);
try {
const runs = await Promise.all(
machines.flatMap(({ environmentId, label, candidates }) =>
candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
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: <span className="whitespace-pre-line">{view.description}</span>,
}),
);
}
await updateAll();
} finally {
pending.current = false;
setIsPending(false);
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/ServerUpdateAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"
readonly appearance?: "button" | "icon";
};

function useServerUpdate() {
/** Updates one server and waits until it is back on the target version.
Reports its own success or failure toast; never rejects. */
export function useServerUpdate() {
const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {
const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
Expand Down Expand Up @@ -164,7 +166,9 @@ export function ServerUpdatesAction({
);
}

function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean {
export function canUpdateServer(
result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
): boolean {
if (result._tag !== "Success" || !result.value.authenticated) return false;
return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
}
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RefreshIcon } from "~/components/ui/refresh-icon";
import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
import type { AnimationEventHandler } from "react";

const DOWNLOAD_PROGRESS_RADIUS = 14;
Expand All @@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState =
| "checking"
| "available"
| "downloading"
| "downloaded";
| "downloaded"
// Other machines or providers have updates. Matches the server update icon
// in Connections settings; the download arrow stays for this app's own file.
| "everything";

function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
if (percent === null || !Number.isFinite(percent)) return 0;
Expand Down Expand Up @@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({
return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;
}
if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;
if (status === "everything") return <CircleArrowUpIcon className="size-4" />;

return (
<RefreshIcon
Expand Down
Loading
Loading