Skip to content

Commit 482c64a

Browse files
feat(ui): Add feature flag system (#208)
1 parent 543ef21 commit 482c64a

11 files changed

+329
-207
lines changed

ui/package-lock.json

Lines changed: 4 additions & 25 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"react-simple-keyboard": "^3.7.112",
4242
"react-xtermjs": "^1.0.9",
4343
"recharts": "^2.15.0",
44+
"semver": "^7.7.1",
4445
"tailwind-merge": "^2.5.5",
4546
"usehooks-ts": "^3.1.0",
4647
"validator": "^13.12.0",

ui/src/components/FeatureFlag.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useEffect } from "react";
2+
import { useFeatureFlag } from "../hooks/useFeatureFlag";
3+
4+
export function FeatureFlag({
5+
minAppVersion,
6+
name = "unnamed",
7+
fallback = null,
8+
children,
9+
}: {
10+
minAppVersion: string;
11+
name?: string;
12+
fallback?: React.ReactNode;
13+
children: React.ReactNode;
14+
}) {
15+
const { isEnabled, appVersion } = useFeatureFlag(minAppVersion);
16+
17+
useEffect(() => {
18+
if (!appVersion) return;
19+
console.log(
20+
`Feature '${name}' ${isEnabled ? "ENABLED" : "DISABLED"}: ` +
21+
`Current version: ${appVersion}, ` +
22+
`Required min version: ${minAppVersion || "N/A"}`,
23+
);
24+
}, [isEnabled, name, minAppVersion, appVersion]);
25+
26+
return isEnabled ? children : fallback;
27+
}
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { useMemo } from "react";
2+
3+
import { useCallback } from "react";
4+
5+
import { useEffect, useState } from "react";
6+
import { UsbConfigState } from "../hooks/stores";
7+
import { useJsonRpc } from "../hooks/useJsonRpc";
8+
import notifications from "../notifications";
9+
import { SettingsItem } from "../routes/devices.$id.settings";
10+
import { SelectMenuBasic } from "./SelectMenuBasic";
11+
import USBConfigDialog from "./USBConfigDialog";
12+
13+
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
14+
15+
function generateNumber(min: number, max: number) {
16+
return Math.floor(Math.random() * (max - min + 1) + min);
17+
}
18+
19+
function generateHex(min: number, max: number) {
20+
const len = generateNumber(min, max);
21+
const n = (Math.random() * 0xfffff * 1000000).toString(16);
22+
return n.slice(0, len);
23+
}
24+
25+
export interface USBConfig {
26+
vendor_id: string;
27+
product_id: string;
28+
serial_number: string;
29+
manufacturer: string;
30+
product: string;
31+
}
32+
33+
const usbConfigs = [
34+
{
35+
label: "JetKVM Default",
36+
value: "USB Emulation Device",
37+
},
38+
{
39+
label: "Logitech Universal Adapter",
40+
value: "Logitech USB Input Device",
41+
},
42+
{
43+
label: "Microsoft Wireless MultiMedia Keyboard",
44+
value: "Wireless MultiMedia Keyboard",
45+
},
46+
{
47+
label: "Dell Multimedia Pro Keyboard",
48+
value: "Multimedia Pro Keyboard",
49+
},
50+
];
51+
52+
type UsbConfigMap = Record<string, USBConfig>;
53+
54+
export function UsbConfigSetting() {
55+
const [send] = useJsonRpc();
56+
57+
const [usbConfigProduct, setUsbConfigProduct] = useState("");
58+
const [deviceId, setDeviceId] = useState("");
59+
const usbConfigData: UsbConfigMap = useMemo(
60+
() => ({
61+
"USB Emulation Device": {
62+
vendor_id: "0x1d6b",
63+
product_id: "0x0104",
64+
serial_number: deviceId,
65+
manufacturer: "JetKVM",
66+
product: "USB Emulation Device",
67+
},
68+
"Logitech USB Input Device": {
69+
vendor_id: "0x046d",
70+
product_id: "0xc52b",
71+
serial_number: generatedSerialNumber,
72+
manufacturer: "Logitech (x64)",
73+
product: "Logitech USB Input Device",
74+
},
75+
"Wireless MultiMedia Keyboard": {
76+
vendor_id: "0x045e",
77+
product_id: "0x005f",
78+
serial_number: generatedSerialNumber,
79+
manufacturer: "Microsoft",
80+
product: "Wireless MultiMedia Keyboard",
81+
},
82+
"Multimedia Pro Keyboard": {
83+
vendor_id: "0x413c",
84+
product_id: "0x2011",
85+
serial_number: generatedSerialNumber,
86+
manufacturer: "Dell Inc.",
87+
product: "Multimedia Pro Keyboard",
88+
},
89+
}),
90+
[deviceId],
91+
);
92+
93+
const syncUsbConfigProduct = useCallback(() => {
94+
send("getUsbConfig", {}, resp => {
95+
if ("error" in resp) {
96+
console.error("Failed to load USB Config:", resp.error);
97+
notifications.error(
98+
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
99+
);
100+
} else {
101+
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
102+
const usbConfigState = resp.result as UsbConfigState;
103+
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
104+
? usbConfigState.product
105+
: "custom";
106+
setUsbConfigProduct(product);
107+
}
108+
});
109+
}, [send]);
110+
111+
const handleUsbConfigChange = useCallback(
112+
(usbConfig: USBConfig) => {
113+
send("setUsbConfig", { usbConfig }, resp => {
114+
if ("error" in resp) {
115+
notifications.error(
116+
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
117+
);
118+
return;
119+
}
120+
// setUsbConfigProduct(usbConfig.product);
121+
notifications.success(
122+
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
123+
);
124+
syncUsbConfigProduct();
125+
});
126+
},
127+
[send, syncUsbConfigProduct],
128+
);
129+
130+
useEffect(() => {
131+
send("getDeviceID", {}, async resp => {
132+
if ("error" in resp) {
133+
return notifications.error(
134+
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
135+
);
136+
}
137+
setDeviceId(resp.result as string);
138+
});
139+
140+
syncUsbConfigProduct();
141+
}, [send, syncUsbConfigProduct]);
142+
143+
return (
144+
<>
145+
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
146+
147+
<SettingsItem
148+
title="USB Device Emulation"
149+
description="Set a Preconfigured USB Device"
150+
>
151+
<SelectMenuBasic
152+
size="SM"
153+
label=""
154+
className="max-w-[192px]"
155+
value={usbConfigProduct}
156+
onChange={e => {
157+
if (e.target.value === "custom") {
158+
setUsbConfigProduct(e.target.value);
159+
} else {
160+
const usbConfig = usbConfigData[e.target.value];
161+
handleUsbConfigChange(usbConfig);
162+
}
163+
}}
164+
options={[...usbConfigs, { value: "custom", label: "Custom" }]}
165+
/>
166+
</SettingsItem>
167+
{usbConfigProduct === "custom" && (
168+
<USBConfigDialog
169+
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
170+
onRestoreToDefault={() =>
171+
handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
172+
}
173+
/>
174+
)}
175+
</>
176+
);
177+
}

ui/src/hooks/stores.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,3 +553,19 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
553553
modalView: "createPassword",
554554
setModalView: view => set({ modalView: view }),
555555
}));
556+
557+
export interface DeviceState {
558+
appVersion: string | null;
559+
systemVersion: string | null;
560+
561+
setAppVersion: (version: string) => void;
562+
setSystemVersion: (version: string) => void;
563+
}
564+
565+
export const useDeviceStore = create<DeviceState>(set => ({
566+
appVersion: null,
567+
systemVersion: null,
568+
569+
setAppVersion: version => set({ appVersion: version }),
570+
setSystemVersion: version => set({ systemVersion: version }),
571+
}));

ui/src/hooks/useFeatureFlag.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useContext } from "react";
2+
import { FeatureFlagContext } from "../providers/FeatureFlagProvider";
3+
4+
export const useFeatureFlag = (minAppVersion: string) => {
5+
const context = useContext(FeatureFlagContext);
6+
7+
if (!context) {
8+
throw new Error("useFeatureFlag must be used within a FeatureFlagProvider");
9+
}
10+
11+
const { isFeatureEnabled, appVersion } = context;
12+
13+
const isEnabled = isFeatureEnabled(minAppVersion);
14+
return { isEnabled, appVersion };
15+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { createContext } from "react";
2+
import semver from "semver";
3+
4+
interface FeatureFlagContextType {
5+
appVersion: string | null;
6+
isFeatureEnabled: (minVersion: string) => boolean;
7+
}
8+
9+
// Create the context
10+
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
11+
appVersion: null,
12+
isFeatureEnabled: () => false,
13+
});
14+
15+
// Provider component that fetches version and provides context
16+
export const FeatureFlagProvider = ({
17+
children,
18+
appVersion,
19+
}: {
20+
children: React.ReactNode;
21+
appVersion: string | null;
22+
}) => {
23+
const isFeatureEnabled = (minAppVersion: string) => {
24+
// If no version is set, feature is disabled.
25+
// The feature flag component can deside what to display as a fallback - either omit the component or like a "please upgrade to enable".
26+
if (!appVersion) return false;
27+
28+
// Extract the base versions without prerelease identifier
29+
const baseCurrentVersion = semver.coerce(appVersion)?.version;
30+
const baseMinVersion = semver.coerce(minAppVersion)?.version;
31+
32+
if (!baseCurrentVersion || !baseMinVersion) return false;
33+
34+
return semver.gte(baseCurrentVersion, baseMinVersion);
35+
};
36+
37+
const value = { appVersion, isFeatureEnabled };
38+
39+
return (
40+
<FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
41+
);
42+
};

0 commit comments

Comments
 (0)