Skip to content
Source
Sign inSign up

ProviderUpdatesAction.tsx

166 lines5.6 KB

1import { AuthProvidersManageScope, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
2import {
3 isAtomCommandInterrupted,
4 settlePromise,
5 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";
7import { useRef, useState } from "react";
8​
9import { useEnvironmentProviders, useEnvironments } from "~/state/environments";
10import { serverEnvironment } from "~/state/server";
11import { useEnvironmentsWithScope } from "~/state/session";
12import { useAtomCommand } from "~/state/use-atom-command";
13import {
14 canOneClickUpdateProviderCandidate,
15 collectProviderUpdateCandidates,
16 getProviderUpdateRunToastView,
17 type ProviderUpdateRun,
18} from "./ProviderUpdateLaunchNotification.logic";
19import { Button } from "./ui/button";
20import { stackedThreadToast, toastManager } from "./ui/toast";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
22​
23// `refreshModels` always drops the server's hour-long latest-version cache. `modelCatalogOnly`
24// drops it only when the model manifest changed.
25export function useCheckProviderUpdates() {
26 const { environments } = useEnvironments();
27 const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope);
28 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
29 reportFailure: false,
30 });
31 const inFlight = useRef<Promise<void> | null>(null);
32​
33 const check = async (quiet: boolean) => {
34 const failures = await Promise.all(
35 environments
36 .filter(
37 (environment) =>
38 environment.connection.phase === "connected" &&
39 manageableIds.has(environment.environmentId),
40 )
41 .map(async ({ environmentId, label }) => {
42 const result = await refreshProviders({ environmentId, input: { refreshModels: true } });
43 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return [];
44 const error = squashAtomCommandFailure(result);
45 return [`${label}: ${error instanceof Error ? error.message : "Refresh failed."}`];
46 }),
47 );
48 const lines = failures.flat();
49 if (quiet || lines.length === 0) return;
50 toastManager.add(
51 stackedThreadToast({
52 type: "error",
53 title: "Could not check provider updates",
54 description: <span className="whitespace-pre-line">{lines.join("\n")}</span>,
55 }),
56 );
57 };
58​
59 // A repeat click must not start a second model rediscovery while one is running.
60 return ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
61 if (inFlight.current) return inFlight.current;
62 const running = check(quiet).finally(() => {
63 inFlight.current = null;
64 });
65 inFlight.current = running;
66 return running;
67 };
69​
70export function useProviderUpdates() {
71 const { environments } = useEnvironments();
72 const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope);
73 const providersByEnvironment = useEnvironmentProviders();
74 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
75 reportFailure: false,
76 });
77 const machines = environments.flatMap((environment) => {
78 const providers = providersByEnvironment.get(environment.environmentId) ?? [];
79 if (
80 environment.connection.phase !== "connected" ||
81 !manageableIds.has(environment.environmentId)
82 ) {
83 return [];
84 }
85 const candidates = collectProviderUpdateCandidates(providers).filter((candidate) =>
86 canOneClickUpdateProviderCandidate(candidate, providers),
87 );
88 return candidates.length > 0
89 ? [{ environmentId: environment.environmentId, label: environment.label, candidates }]
90 : [];
91 });
92​
93 const updateAll = async ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
94 const runs = await Promise.all(
95 machines.flatMap(({ environmentId, label, candidates }) =>
96 candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
97 environmentId,
98 machineLabel: label,
99 driver: candidate.driver,
100 instanceId: candidate.instanceId,
101 result: await updateProvider({
102 environmentId,
103 input: { provider: candidate.driver, instanceId: candidate.instanceId },
104 }),
105 })),
106 ),
107 );
108 const view = quiet ? null : getProviderUpdateRunToastView(runs);
109 if (view) {
110 toastManager.add(
111 stackedThreadToast({
112 ...view,
113 description: <span className="whitespace-pre-line">{view.description}</span>,
114 }),
115 );
116 }
117 return runs;
118 };
119​
120 return { machines, updateAll };
122​
123export function ProviderUpdatesAction() {
124 const { machines, updateAll } = useProviderUpdates();
125 const pending = useRef(false);
126 const [isPending, setIsPending] = useState(false);
127 if (machines.length === 0 && !isPending) {
128 return null;
129 }
130​
131 const handleUpdate = async () => {
132 if (pending.current) return;
133 pending.current = true;
134 setIsPending(true);
135 await settlePromise(() => updateAll());
136 pending.current = false;
137 setIsPending(false);
138 };
139​
140 return (
141 <Tooltip>
142 <TooltipTrigger
143 render={
144 <Button
145 size="xs"
146 variant="ghost-muted"
147 disabled={isPending}
148 onClick={() => void handleUpdate()}
149 >
150 {isPending ? "Updating…" : "Update all"}
151 </Button>
152 }
153 />
154 <TooltipPopup side="top">
155 {machines.map((machine) => (
156 <div key={machine.environmentId}>
157 {machine.label}:{" "}
158 {machine.candidates
159 .map((candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver)
160 .join(", ")}
161 </div>
162 ))}
163 </TooltipPopup>
164 </Tooltip>
165 );