1import { resolveEnvironmentMachineKind } from "@t3tools/contracts";
2
3import {
4 useClientSettings,
5 useClientSettingsHydrated,
6 useUpdateClientSettings,
7} from "~/hooks/useSettings";
8import type { EnvironmentPresentation } from "~/state/environments";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { Switch } from "../ui/switch";
11import { EnvironmentRow, environmentTransportLabel } from "./EnvironmentRow";
12import { FoldedSettingsSection } from "./FoldedSettingsSection";
13import { searchableSetting } from "./settingsSearch";
14
15const preferences = [
16 { value: 100, label: "Prefer" },
17 { value: 50, label: "Normal" },
18 { value: 25, label: "Less often" },
19 { value: 0, label: "Manual only" },
20] as const;
21
22type LoadPreference = (typeof preferences)[number]["value"];
23
24/** Snaps a saved weight (older builds stored a slider value) onto the four preferences. */
25export function loadPreferenceForWeight(weight: number | undefined): LoadPreference {
26 if (weight === undefined || weight === 50) return 50;
27 if (weight === 0) return 0;
28 return weight < 50 ? 25 : 100;
29}
30
31function preferenceLabel(preference: LoadPreference): string {
32 return preferences.find((entry) => entry.value === preference)!.label;
33}
34
35/**
36 * Closed-header summary: the machines not at Normal, so the folded section
37 * still tells you what is set. Null when every machine is at the default.
38 */
39export function summarizeLoadPreferences(
40 environments: ReadonlyArray<Pick<EnvironmentPresentation, "environmentId" | "label">>,
41 weights: Readonly<Record<string, number>>,
42): string | null {
43 const parts = environments.flatMap((environment) => {
44 const preference = loadPreferenceForWeight(weights[environment.environmentId]);
45 return preference === 50
46 ? []
47 : [`${environment.label} ${preferenceLabel(preference).toLowerCase()}`];
48 });
49 return parts.length === 0 ? null : parts.join(" · ");
50}
51
52/**
53 * Folded section under the environments list. Its switch turns balancing on
54 * for this client, and the body holds one row per switched-on machine with
55 * how often that machine should receive new threads. Rendered only when two
56 * or more machines are on, since one machine has nothing to balance against.
57 */
58export function LoadBalancingSettings({
59 environments,
60}: {
61 environments: ReadonlyArray<EnvironmentPresentation>;
62}) {
63 const settings = useClientSettings();
64 const settingsHydrated = useClientSettingsHydrated();
65 const updateSettings = useUpdateClientSettings();
66
67 if (environments.length < 2) return null;
68
69 const { id, title } = searchableSetting("load-balancing");
70 return (
71 <FoldedSettingsSection
72 id={id}
73 title={title}
74 summary={
75 settings.loadBalancingEnabled
76 ? summarizeLoadPreferences(environments, settings.loadBalancingWeights)
77 : "Off"
78 }
79 control={
80 <Switch
81 aria-label="Automatically balance load"
82 checked={settings.loadBalancingEnabled}
83 disabled={!settingsHydrated}
84 onCheckedChange={(loadBalancingEnabled) => updateSettings({ loadBalancingEnabled })}
85 />
86 }
87 >
88 <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4">
89 New threads in shared projects start on the machine with the most free CPU and memory,
90 weighted by each machine's preference.
91 </p>
92 {environments.map((environment) => (
93 <EnvironmentRow
94 key={environment.environmentId}
95 kind={resolveEnvironmentMachineKind(environment.serverConfig)}
96 label={environment.label}
97 subtitle={environmentTransportLabel(environment)}
98 >
99 <Select
100 items={preferences}
101 value={loadPreferenceForWeight(
102 settings.loadBalancingWeights[environment.environmentId],
103 )}
104 disabled={!settingsHydrated || !settings.loadBalancingEnabled}
105 onValueChange={(value) => {
106 if (value === null) return;
107 updateSettings({
108 loadBalancingWeights: {
109 ...settings.loadBalancingWeights,
110 [environment.environmentId]: value,
111 },
112 });
113 }}
114 >
115 <SelectTrigger
116 size="xs"
117 className="w-32"
118 aria-label={`${environment.label} load preference`}
119 >
120 <SelectValue />
121 </SelectTrigger>
122 <SelectPopup align="end" alignItemWithTrigger={false}>
123 {preferences.map(({ value, label }) => (
124 <SelectItem key={value} value={value}>
125 {label}
126 </SelectItem>
127 ))}
128 </SelectPopup>
129 </Select>
130 </EnvironmentRow>
131 ))}
132 </FoldedSettingsSection>
133 );
134}