From 83df252a3d1d57e67c58b6e4e099642d40790c28 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 1 May 2026 11:35:57 -0500 Subject: [PATCH] feat: add control for experiments to the dashboard --- .../dashboard/settings/experiments/page.tsx | 96 +++++++++++++++++++ web/src/components/app-sidebar.tsx | 7 ++ web/src/lib/api.ts | 19 ++++ 3 files changed, 122 insertions(+) create mode 100644 web/src/app/dashboard/settings/experiments/page.tsx diff --git a/web/src/app/dashboard/settings/experiments/page.tsx b/web/src/app/dashboard/settings/experiments/page.tsx new file mode 100644 index 0000000..74d3e23 --- /dev/null +++ b/web/src/app/dashboard/settings/experiments/page.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { IconAlertTriangle } from "@tabler/icons-react" + +import { useCurrentUser } from "@/hooks/use-current-user" +import { getFeatureFlags, setFeatureFlag, type FeatureFlag } from "@/lib/api" +import { SiteHeader } from "@/components/site-header" +import { Switch } from "@/components/ui/switch" +import { Label } from "@/components/ui/label" + +export default function ExperimentsPage() { + const { hasPermission } = useCurrentUser() + const canManage = hasPermission("settings:manage") + + const [flags, setFlags] = useState([]) + const [error, setError] = useState(null) + const [toggling, setToggling] = useState(null) + + const load = useCallback(async () => { + try { + const data = await getFeatureFlags() + setFlags(data) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + async function handleToggle(flag: FeatureFlag) { + setError(null) + setToggling(flag.key) + try { + await setFeatureFlag(flag.key, !flag.enabled) + await load() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setToggling(null) + } + } + + return ( + <> + +
+
+ +
+

+ Experimental features +

+

+ These features are under active development. They may be + incomplete, change without notice, or be removed entirely. + Disabling a feature hides its routes and UI — no data is deleted. +

+
+
+ + {error &&

{error}

} + + {flags.map((flag) => ( +
+
+ +

+ {flag.description} +

+
+ handleToggle(flag)} + disabled={!canManage || toggling === flag.key} + /> +
+ ))} + + {flags.length === 0 && !error && ( +

+ No experimental features available. +

+ )} +
+ + ) +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index b7d55a0..cc54c77 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -19,6 +19,7 @@ import { IconArrowUpCircle, IconArrowsShuffle, IconSkull, + IconFlask, } from "@tabler/icons-react"; import Image from "next/image"; import Link from "next/link"; @@ -123,6 +124,12 @@ const systemItems: NavItem[] = [ icon: IconClipboardList, requiredPermissions: ["events:read"], }, + { + title: "Experiments", + url: "/dashboard/settings/experiments", + icon: IconFlask, + requiredPermissions: ["settings:manage"], + }, ]; export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b04b1b8..a6ce249 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -338,6 +338,25 @@ export function deleteLogo() { return apiFetch("/admin/settings/logo", { method: "DELETE" }) } +// Feature Flags +export type FeatureFlag = { + key: string + name: string + description: string + enabled: boolean +} + +export function getFeatureFlags() { + return apiFetch("/admin/feature-flags") +} + +export function setFeatureFlag(key: string, enabled: boolean) { + if (enabled) { + return upsertSetting(key, "true") + } + return deleteSetting(key) +} + // Proxy config export type ProxyConfig = { mode: "disabled" | "open" | "allowlist" | "blocklist" -- 2.51.2