"use client"; import { useState, useEffect, useMemo, useCallback } from "react"; import { matchesSearch } from "@/shared/utils/turkishText"; import FeatureFlagCard from "./FeatureFlagCard"; // Type for flag data from API interface FlagData { key: string; label: string; description: string; category: "security" | "network" | "policies" | "runtime" | "cli" | "health"; type: "boolean" | "enum"; enumValues: string[] | null; defaultValue: string; effectiveValue: string; source: "db" | "env" | "default"; requiresRestart: boolean; warningLevel?: "info" | "caution" | "danger"; } interface Summary { total: number; active: number; inactive: number; overriddenByDb: number; overriddenByEnv: number; } interface FlagUpdateResult { effectiveValue: string; source: FlagData["source"]; previousValue: string; previousSource: FlagData["source"]; requiresRestart: boolean; } const ACTIVE_VALUES = new Set(["true", "1", "yes"]); function isActiveFlagValue(value: string): boolean { return ACTIVE_VALUES.has(value); } const CATEGORIES = [ { value: "all", label: "All" }, { value: "security", label: "Security" }, { value: "network", label: "Network" }, { value: "policies", label: "Policies" }, { value: "runtime", label: "Runtime" }, { value: "cli", label: "CLI" }, { value: "health", label: "Health" }, // Synthetic "category" that filters by requiresRestart=true regardless of // real category — surfaces flags that need a process restart to take effect. { value: "__restart", label: "Requires Restart" }, ]; export default function FeatureFlagsGrid() { const [flags, setFlags] = useState([]); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [category, setCategory] = useState("all"); const [savingKeys, setSavingKeys] = useState>(new Set()); const [resettingAll, setResettingAll] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(""); // Set of flag keys whose DB override was changed in this session without // a subsequent restart. Used to surface the restart banner. const [pendingRestartKeys, setPendingRestartKeys] = useState>(new Set()); const [restarting, setRestarting] = useState(false); const [showRestartConfirm, setShowRestartConfirm] = useState(false); const loadFlags = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch("/api/settings/feature-flags"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setFlags(data.flags); setSummary(data.summary); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load feature flags"); } finally { setLoading(false); } }, []); useEffect(() => { loadFlags(); }, [loadFlags]); useEffect(() => { const timer = setTimeout(() => setDebouncedSearch(search), 300); return () => clearTimeout(timer); }, [search]); const applyFlagResult = useCallback((key: string, result: FlagUpdateResult) => { const wasActive = isActiveFlagValue(result.previousValue); const isNowActive = isActiveFlagValue(result.effectiveValue); const wasDb = result.previousSource === "db"; const isNowDb = result.source === "db"; const wasEnv = result.previousSource === "env"; const isNowEnv = result.source === "env"; setFlags((prev) => prev.map((f) => f.key === key ? { ...f, effectiveValue: result.effectiveValue, source: result.source } : f ) ); setSummary((s) => s ? { ...s, active: s.active + (isNowActive ? 1 : 0) - (wasActive ? 1 : 0), inactive: s.inactive + (isNowActive ? 0 : 1) - (wasActive ? 0 : 1), overriddenByDb: s.overriddenByDb + (isNowDb ? 1 : 0) - (wasDb ? 1 : 0), overriddenByEnv: s.overriddenByEnv + (isNowEnv ? 1 : 0) - (wasEnv ? 1 : 0), } : s ); }, []); const filteredFlags = useMemo(() => { return flags .filter((f) => { if (category === "all") return true; if (category === "__restart") return f.requiresRestart; return f.category === category; }) .filter( (f) => debouncedSearch === "" || matchesSearch(f.key, debouncedSearch) || matchesSearch(f.description, debouncedSearch) ); }, [flags, debouncedSearch, category]); const handleToggle = useCallback( async (key: string, newValue: string) => { setSavingKeys((prev) => new Set(prev).add(key)); try { const res = await fetch("/api/settings/feature-flags", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, value: newValue }), }); if (!res.ok) { setError(`Failed to update flag: HTTP ${res.status}`); return; } const result = (await res.json()) as FlagUpdateResult; applyFlagResult(key, result); if (result.requiresRestart) { setPendingRestartKeys((prev) => new Set(prev).add(key)); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to update flag"); } finally { setSavingKeys((prev) => { const next = new Set(prev); next.delete(key); return next; }); } }, [applyFlagResult] ); const handleReset = useCallback( async (key: string) => { setSavingKeys((prev) => new Set(prev).add(key)); try { const res = await fetch("/api/settings/feature-flags", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key }), // no value = remove override }); if (!res.ok) { setError(`Failed to update flag: HTTP ${res.status}`); return; } const result = (await res.json()) as FlagUpdateResult; applyFlagResult(key, result); if (result.requiresRestart) { setPendingRestartKeys((prev) => new Set(prev).add(key)); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to update flag"); } finally { setSavingKeys((prev) => { const next = new Set(prev); next.delete(key); return next; }); } }, [applyFlagResult] ); const handleRestart = useCallback(async () => { setRestarting(true); try { const res = await fetch("/api/restart", { method: "POST" }); if (!res.ok) { setError(`Restart failed: HTTP ${res.status}`); setShowRestartConfirm(false); setRestarting(false); return; } // Server is going down — wait for it to come back, then reload. const stillUp = async () => { try { const r = await fetch("/api/health/ping", { cache: "no-store" }); return r.ok; } catch { return false; } }; const waitDown = setInterval(async () => { if (!(await stillUp())) { clearInterval(waitDown); const waitUp = setInterval(async () => { if (await stillUp()) { clearInterval(waitUp); setPendingRestartKeys(new Set()); window.location.reload(); } }, 1000); } }, 500); } catch (err) { setError(err instanceof Error ? err.message : "Restart failed"); setShowRestartConfirm(false); setRestarting(false); } }, []); const handleResetAll = useCallback(async () => { setResettingAll(true); try { const res = await fetch("/api/settings/feature-flags", { method: "DELETE" }); if (!res.ok) { setError(`Failed to reset overrides: HTTP ${res.status}`); setShowResetConfirm(false); return; } await loadFlags(); setShowResetConfirm(false); } catch (err) { setError(err instanceof Error ? err.message : "Failed to reset overrides"); setShowResetConfirm(false); } finally { setResettingAll(false); } }, [loadFlags]); return (
{/* Header */}

Feature Flags

{summary && (
{summary.active} active · {summary.inactive} inactive · {summary.overriddenByDb} DB overrides
)}
{/* Search + Filter */}
{/* Search input with search icon */}
search setSearch(e.target.value)} className="w-full rounded-lg border border-border bg-bg-subtle py-1.5 pl-8 pr-4 text-sm text-text-primary placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15 sm:w-64" />
{/* Category filter */}
{/* Pending-restart banner — shown when at least one requiresRestart flag was toggled in this session. */} {pendingRestartKeys.size > 0 && (
restart_alt

{pendingRestartKeys.size} change{pendingRestartKeys.size === 1 ? "" : "s"} require a server restart to take effect.

These flags only apply after the process reloads. Restart now or continue editing — pending flags stay queued until you confirm.

{!showRestartConfirm ? ( ) : (
)}
)} {/* Explanation banner for the synthetic "Requires Restart" view */} {category === "__restart" && (
info

These flags only take effect after the server restarts. Toggle them like any other flag — the change is persisted immediately, but the new value is only read at process startup. Use the Restart Server banner above to apply.

)} {/* Loading skeleton */} {loading && (
{Array.from({ length: 9 }).map((_, i) => (
))}
)} {/* Error state */} {!loading && error && (

{error}

)} {/* Grid */} {!loading && !error && ( <> {filteredFlags.length === 0 ? (
search_off

No flags match your search

) : (
{filteredFlags.map((flag) => ( ))}
)} {/* Reset All button */} {summary && summary.overriddenByDb > 0 && (
{!showResetConfirm ? ( ) : (

Reset all {summary.overriddenByDb} DB override(s)?

)}
)} )}
); }