ai_api / src /app /(dashboard) /dashboard /settings /components /FeatureFlagsGrid.tsx
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
17.5 kB
"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<FlagData[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [category, setCategory] = useState<string>("all");
const [savingKeys, setSavingKeys] = useState<Set<string>>(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<Set<string>>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Feature Flags</h1>
{summary && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span className="font-medium text-emerald-700 dark:text-emerald-300">
{summary.active} active
</span>
<span className="text-text-muted/60">·</span>
<span className="text-text-muted">{summary.inactive} inactive</span>
<span className="text-text-muted/60">·</span>
<span className="font-medium text-sky-700 dark:text-sky-300">
{summary.overriddenByDb} DB overrides
</span>
</div>
)}
</div>
{/* Search + Filter */}
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
{/* Search input with search icon */}
<div className="relative">
<span className="material-symbols-outlined absolute left-2.5 top-2 text-sm text-text-muted">
search
</span>
<input
type="text"
placeholder="Search flags..."
value={search}
onChange={(e) => 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"
/>
</div>
{/* Category filter */}
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-1.5 text-sm text-text-primary focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15 sm:w-auto"
>
{CATEGORIES.map((cat) => (
<option key={cat.value} value={cat.value} className="bg-card text-text-primary">
{cat.label}
</option>
))}
</select>
</div>
</div>
{/* Pending-restart banner — shown when at least one requiresRestart flag
was toggled in this session. */}
{pendingRestartKeys.size > 0 && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-amber-600 dark:text-amber-300">
restart_alt
</span>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
{pendingRestartKeys.size} change{pendingRestartKeys.size === 1 ? "" : "s"} require
a server restart to take effect.
</p>
<p className="mt-0.5 text-xs text-amber-800/80 dark:text-amber-200/80">
These flags only apply after the process reloads. Restart now or continue editing
— pending flags stay queued until you confirm.
</p>
</div>
</div>
{!showRestartConfirm ? (
<button
onClick={() => setShowRestartConfirm(true)}
className="shrink-0 rounded-lg border border-amber-300 bg-white/70 px-4 py-2 text-sm font-medium text-amber-800 transition-colors hover:bg-amber-100 dark:border-amber-400/40 dark:bg-transparent dark:text-amber-300 dark:hover:bg-amber-500/20"
>
Restart Server
</button>
) : (
<div className="flex items-center gap-3">
<button
onClick={() => setShowRestartConfirm(false)}
className="text-sm text-amber-800/80 hover:text-amber-950 dark:text-amber-300/80 dark:hover:text-amber-200"
disabled={restarting}
>
Cancel
</button>
<button
onClick={handleRestart}
disabled={restarting}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-amber-700 disabled:opacity-50 dark:bg-amber-500/30 dark:text-amber-200 dark:hover:bg-amber-500/40"
>
{restarting ? "Restarting…" : "Confirm Restart"}
</button>
</div>
)}
</div>
</div>
)}
{/* Explanation banner for the synthetic "Requires Restart" view */}
{category === "__restart" && (
<div className="rounded-xl border border-sky-200 bg-sky-50 p-3 text-sm text-sky-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-sky-600 dark:text-blue-300">info</span>
<p>
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 <strong>Restart Server</strong> banner above to apply.
</p>
</div>
</div>
)}
{/* Loading skeleton */}
{loading && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 320px), 1fr))" }}
>
{Array.from({ length: 9 }).map((_, i) => (
<div
key={i}
className="h-36 animate-pulse rounded-xl bg-black/[0.04] dark:bg-white/5"
/>
))}
</div>
)}
{/* Error state */}
{!loading && error && (
<div className="flex items-center justify-between rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-500/30 dark:bg-red-500/10">
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
<button
onClick={loadFlags}
className="text-sm font-medium text-red-700 underline hover:no-underline dark:text-red-300"
>
Retry
</button>
</div>
)}
{/* Grid */}
{!loading && !error && (
<>
{filteredFlags.length === 0 ? (
<div className="py-16 text-center text-text-muted">
<span className="material-symbols-outlined text-4xl">search_off</span>
<p className="mt-2 text-sm">No flags match your search</p>
</div>
) : (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 320px), 1fr))" }}
>
{filteredFlags.map((flag) => (
<FeatureFlagCard
key={flag.key}
flag={flag}
onToggle={handleToggle}
onReset={handleReset}
saving={savingKeys.has(flag.key)}
/>
))}
</div>
)}
{/* Reset All button */}
{summary && summary.overriddenByDb > 0 && (
<div className="flex justify-end border-t border-border pt-4">
{!showResetConfirm ? (
<button
onClick={() => setShowResetConfirm(true)}
className="rounded-lg border border-red-200 bg-red-50/60 px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-100 dark:border-red-500/40 dark:bg-transparent dark:text-red-300 dark:hover:bg-red-500/10"
>
Reset All Overrides
</button>
) : (
<div className="flex items-center gap-3">
<p className="text-sm text-text-muted">
Reset all {summary.overriddenByDb} DB override(s)?
</p>
<button
onClick={() => setShowResetConfirm(false)}
className="text-sm text-text-muted hover:text-text-primary"
>
Cancel
</button>
<button
onClick={handleResetAll}
disabled={resettingAll}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50 dark:bg-red-500/20 dark:text-red-300 dark:hover:bg-red-500/30"
>
{resettingAll ? "Resetting..." : "Confirm Reset"}
</button>
</div>
)}
</div>
)}
</>
)}
</div>
);
}