| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { Atom } from "effect/reactivity"; |
| 3 | import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; |
| 4 | import { useMemo, useRef, useState } from "react"; |
| 5 | |
| 6 | import { useEnvironments } from "~/state/environments"; |
| 7 | import { serverEnvironment } from "~/state/server"; |
| 8 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 9 | import { |
| 10 | canOneClickUpdateProviderCandidate, |
| 11 | collectProviderUpdateCandidates, |
| 12 | getProviderUpdateRunToastView, |
| 13 | type ProviderUpdateRun, |
| 14 | } from "./ProviderUpdateLaunchNotification.logic"; |
| 15 | import { Button } from "./ui/button"; |
| 16 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 17 | import { 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 | */ |
| 28 | export 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. */ |
| 88 | export 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 | } |