1import { useAtomValue } from "@effect/atom-react";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import { Atom } from "effect/reactivity";
4import { useMemo, useState } from "react";
5
6import type { EnvironmentPresentation } from "~/state/environments";
7import { serverEnvironment } from "~/state/server";
8import {
9 buildVersionMismatchDismissalKey,
10 dismissServerUpdateFailure,
11 dismissVersionMismatch,
12 isServerUpdateFailureDismissed,
13 isVersionMismatchDismissed,
14 resolveServerConfigVersionMismatch,
15 resolveServerSelfUpdateCapability,
16 supportsDesktopAppUpdate,
17 supportsServerUpdateThreadContinuation,
18} from "~/versionSkew";
19import {
20 ServerUpdateAction,
21 ServerUpdateProgress,
22 ServerUpdatesAction,
23} from "../ServerUpdateAction";
24import { InlineButton } from "../ui/button";
25import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
26import type { ComposerBannerStackItem } from "./ComposerBannerStack";
27import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus";
28
29/** Keep every machine's update visible while auto balance has no single update target. */
30export function useAutoBalanceUpdateBanner(
31 environments: readonly EnvironmentPresentation[],
32): ComposerBannerStackItem | null {
33 const statesAtom = useMemo(
34 () =>
35 Atom.make((get) =>
36 environments.map((environment) => ({
37 environment,
38 state: get(serverEnvironment.updateStateAtom(environment.environmentId)),
39 })),
40 ),
41 [environments],
42 );
43 const states = useAtomValue(statesAtom);
44 const [dismissedNotices, setDismissedNotices] = useState<ReadonlySet<string | ServerUpdateState>>(
45 () => new Set(),
46 );
47 const machines = states.flatMap(({ environment, state }) => {
48 const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
49 const dismissKey = mismatch
50 ? buildVersionMismatchDismissalKey(environment.environmentId, mismatch)
51 : null;
52 if (
53 state.status === "idle"
54 ? !mismatch ||
55 (dismissKey !== null && dismissedNotices.has(dismissKey)) ||
56 isVersionMismatchDismissed(dismissKey)
57 : dismissedNotices.has(state) || isServerUpdateFailureDismissed(state)
58 )
59 return [];
60 const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig);
61 const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig);
62 return [
63 {
64 environmentId: environment.environmentId,
65 serverLabel: environment.label,
66 selfUpdate,
67 installation: environment.serverConfig?.environment.capabilities.serverInstallation,
68 desktopAppUpdate,
69 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
70 continueThreadsAfterServerUpdate:
71 environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false,
72 targetVersion: state.status === "idle" ? mismatch!.clientVersion : state.targetVersion,
73 connected: environment.connection.phase === "connected",
74 remoteUpdate: selfUpdate !== null && (selfUpdate !== "desktop-managed" || desktopAppUpdate),
75 state,
76 dismissKey,
77 },
78 ];
79 });
80 if (machines.length === 0) return null;
81
82 const running = machines.filter((machine) => machine.state.status === "running").length;
83 const failed = machines.filter((machine) => machine.state.status === "failed").length;
84 const manual = machines.filter((machine) => !machine.remoteUpdate).length;
85 const targets = machines.filter(
86 (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running",
87 );
88 const count = running || failed || machines.length;
89 const status = running ? "running" : failed ? "failed" : "idle";
90 const prefix = running ? "Updating" : failed ? "Could not update" : "Update available for";
91 const title = `${prefix} ${count} ${count === 1 ? "machine" : "machines"}`;
92 return {
93 id: `auto-balance-server-updates-${dismissedNotices.size}`,
94 variant: failed ? "error" : "default",
95 priority: running ? "urgent" : "notice",
96 icon: <ComposerServerUpdateIcon status={status} />,
97 title: (
98 <Popover>
99 <PopoverTrigger
100 render={<InlineButton />}
101 className="max-w-full"
102 aria-label={`${title}. View machines`}
103 >
104 <span className="min-w-0 truncate">{title}</span>
105 </PopoverTrigger>
106 <PopoverPopup side="top" align="start" width="md">
107 <div className="space-y-3 text-xs">
108 {machines.map((machine) => (
109 <div key={machine.environmentId} className="space-y-1">
110 <div className="font-medium">{machine.serverLabel}</div>
111 {machine.state.status !== "idle" ? (
112 <ServerUpdateProgress state={machine.state} />
113 ) : !machine.remoteUpdate ? (
114 <>
115 <div className="text-muted-foreground">Manual update required</div>
116 <ServerUpdateAction {...machine} />
117 </>
118 ) : (
119 <div className="text-muted-foreground">
120 {machine.connected
121 ? `Ready to update to ${machine.targetVersion}`
122 : "Reconnect this machine to update"}
123 </div>
124 )}
125 </div>
126 ))}
127 </div>
128 </PopoverPopup>
129 </Popover>
130 ),
131 description:
132 manual > 0 ? `${manual} ${manual === 1 ? "needs" : "need"} a manual update` : undefined,
133 actions:
134 running === 0 && targets.length > 0 ? (
135 <ServerUpdatesAction
136 targets={targets}
137 variant="ghost"
138 label={
139 failed > 0
140 ? "Retry"
141 : targets.length === machines.length
142 ? "Update all"
143 : `Update ${targets.length} ${targets.length === 1 ? "machine" : "machines"}`
144 }
145 />
146 ) : undefined,
147 dismissLabel: "Dismiss update notice",
148 ...(running
149 ? {}
150 : {
151 onDismiss: () => {
152 const next = new Set(dismissedNotices);
153 for (const { state, dismissKey } of machines) {
154 dismissServerUpdateFailure(state);
155 dismissVersionMismatch(dismissKey);
156 if (dismissKey) next.add(dismissKey);
157 if (state.status === "failed") next.add(state);
158 }
159 setDismissedNotices(next);
160 },
161 }),
162 };
163}