1import { useAtomValue } from "@effect/atom-react";
2import { Atom } from "effect/reactivity";
3import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
4import { useMemo, useRef, useState } from "react";
5
6import { useEnvironments } from "~/state/environments";
7import { serverEnvironment } from "~/state/server";
8import { useAtomCommand } from "~/state/use-atom-command";
9import {
10 canOneClickUpdateProviderCandidate,
11 collectProviderUpdateCandidates,
12 getProviderUpdateRunToastView,
13 type ProviderUpdateRun,
14} from "./ProviderUpdateLaunchNotification.logic";
15import { Button } from "./ui/button";
16import { stackedThreadToast, toastManager } from "./ui/toast";
17import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
18
19/**
20 * Every outdated provider with a one-click update on every connected machine,
21 * plus a function that updates them all and reports the results in one toast.
22 * Each server queues updates that share an installer, so sending them all
23 * together is safe. Only permitted machines are offered; the server checks
24 * permission again at execution. The returned promise
25 * settles with every result when every update has finished. `quiet` skips the
26 * toast, for a caller that reports the results itself.
27 */
28export function useProviderUpdates() {
29 const { environments } = useEnvironments();
30 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
31 reportFailure: false,
32 });
33 const machinesAtom = useMemo(
34 () =>
35 Atom.make((get) =>
36 environments.flatMap((environment) => {
37 const providers = environment.serverConfig?.providers ?? [];
38 if (
39 environment.connection.phase !== "connected" ||
40 !get(serverEnvironment.updateProvider.permissionAtom(environment.environmentId))
41 ) {
42 return [];
43 }
44 const candidates = collectProviderUpdateCandidates(providers).filter((candidate) =>
45 canOneClickUpdateProviderCandidate(candidate, providers),
46 );
47 return candidates.length > 0
48 ? [{ environmentId: environment.environmentId, label: environment.label, candidates }]
49 : [];
50 }),
51 ),
52 [environments],
53 );
54 const machines = useAtomValue(machinesAtom);
55
56 const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
57 const runs = await Promise.all(
58 machines.flatMap(({ environmentId, label, candidates }) =>
59 candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
60 environmentId,
61 machineLabel: label,
62 driver: candidate.driver,
63 instanceId: candidate.instanceId,
64 result: await updateProvider({
65 environmentId,
66 input: { provider: candidate.driver, instanceId: candidate.instanceId },
67 }),
68 })),
69 ),
70 );
71 const view = quiet ? null : getProviderUpdateRunToastView(runs);
72 if (view) {
73 toastManager.add(
74 stackedThreadToast({
75 ...view,
76 description: <span className="whitespace-pre-line">{view.description}</span>,
77 }),
78 );
79 }
80 return runs;
81 };
82
83 return { machines, updateAll };
84}
85
86/** Provider settings button for {@link useProviderUpdates}. Renders nothing
87 when no machine has a one-click update. */
88export function ProviderUpdatesAction() {
89 const { machines, updateAll } = useProviderUpdates();
90 const pending = useRef(false);
91 const [isPending, setIsPending] = useState(false);
92 // Candidates leave the list as soon as their servers report them queued, so
93 // keep the button while the run is in flight.
94 if (machines.length === 0 && !isPending) {
95 return null;
96 }
97
98 const handleUpdate = async () => {
99 if (pending.current) return;
100 pending.current = true;
101 setIsPending(true);
102 try {
103 await updateAll();
104 } finally {
105 pending.current = false;
106 setIsPending(false);
107 }
108 };
109
110 return (
111 <Tooltip>
112 <TooltipTrigger
113 render={
114 <Button
115 size="xs"
116 variant="ghost-muted"
117 disabled={isPending}
118 onClick={() => void handleUpdate()}
119 >
120 {isPending ? "Updating…" : "Update all"}
121 </Button>
122 }
123 />
124 <TooltipPopup side="top">
125 {machines.map((machine) => (
126 <div key={machine.environmentId}>
127 {machine.label}:{" "}
128 {machine.candidates
129 .map((candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver)
130 .join(", ")}
131 </div>
132 ))}
133 </TooltipPopup>
134 </Tooltip>
135 );
136}