fix logs light mode controls (#3760)

Integrated into release/v3.8.24
This commit is contained in:
Randi
2026-06-13 07:01:47 -04:00
committed by GitHub
parent c5924a7639
commit da58330845
7 changed files with 451 additions and 209 deletions

View File

@@ -131,8 +131,8 @@ export default function LogsPage() {
onClick={() => setShowCleanHistory(true)}
disabled={cleaningHistory}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
border border-red-500/30 bg-red-500/10 text-red-200 hover:bg-red-500/20
hover:border-red-400/50 transition-all duration-200
border border-red-500/30 bg-red-500/10 text-red-700 hover:bg-red-500/15
hover:border-red-500/50 dark:text-red-300 dark:hover:bg-red-500/20 transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg

View File

@@ -20,23 +20,68 @@ interface FeatureFlagCardProps {
const CATEGORY_STYLES: Record<
FeatureFlagCardProps["flag"]["category"],
{ bg: string; text: string; label: string }
{ bg: string; border: string; text: string; label: string }
> = {
security: { bg: "bg-red-500/15", text: "text-red-400", label: "Security" },
network: { bg: "bg-blue-500/15", text: "text-blue-400", label: "Network" },
policies: { bg: "bg-amber-500/15", text: "text-amber-400", label: "Policies" },
runtime: { bg: "bg-purple-500/15", text: "text-purple-400", label: "Runtime" },
cli: { bg: "bg-green-500/15", text: "text-green-400", label: "CLI" },
health: { bg: "bg-cyan-500/15", text: "text-cyan-400", label: "Health" },
security: {
bg: "bg-red-50 dark:bg-red-500/15",
border: "border-red-200 dark:border-red-500/20",
text: "text-red-700 dark:text-red-300",
label: "Security",
},
network: {
bg: "bg-sky-50 dark:bg-blue-500/15",
border: "border-sky-200 dark:border-blue-500/20",
text: "text-sky-700 dark:text-blue-300",
label: "Network",
},
policies: {
bg: "bg-amber-50 dark:bg-amber-500/15",
border: "border-amber-200 dark:border-amber-500/20",
text: "text-amber-700 dark:text-amber-300",
label: "Policies",
},
runtime: {
bg: "bg-violet-50 dark:bg-purple-500/15",
border: "border-violet-200 dark:border-purple-500/20",
text: "text-violet-700 dark:text-purple-300",
label: "Runtime",
},
cli: {
bg: "bg-emerald-50 dark:bg-green-500/15",
border: "border-emerald-200 dark:border-green-500/20",
text: "text-emerald-700 dark:text-green-300",
label: "CLI",
},
health: {
bg: "bg-cyan-50 dark:bg-cyan-500/15",
border: "border-cyan-200 dark:border-cyan-500/20",
text: "text-cyan-700 dark:text-cyan-300",
label: "Health",
},
};
const SOURCE_STYLES: Record<
FeatureFlagCardProps["flag"]["source"],
{ bg: string; text: string; label: string }
{ bg: string; border: string; text: string; label: string }
> = {
db: { bg: "bg-blue-500/20", text: "text-blue-300", label: "DB" },
env: { bg: "bg-amber-500/20", text: "text-amber-300", label: "ENV" },
default: { bg: "bg-slate-500/20", text: "text-slate-400", label: "DEF" },
db: {
bg: "bg-sky-50 dark:bg-blue-500/20",
border: "border-sky-200 dark:border-blue-500/30",
text: "text-sky-700 dark:text-blue-300",
label: "DB",
},
env: {
bg: "bg-amber-50 dark:bg-amber-500/20",
border: "border-amber-200 dark:border-amber-500/30",
text: "text-amber-700 dark:text-amber-300",
label: "ENV",
},
default: {
bg: "bg-slate-100 dark:bg-slate-500/20",
border: "border-slate-200 dark:border-slate-500/30",
text: "text-slate-600 dark:text-slate-300",
label: "DEF",
},
};
function isEnabled(value: string): boolean {
@@ -46,7 +91,7 @@ function isEnabled(value: string): boolean {
function Spinner() {
return (
<span
className="inline-block w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"
className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-border border-t-text-primary"
aria-hidden="true"
/>
);
@@ -64,22 +109,20 @@ export default function FeatureFlagCard({
const cardBorder =
flag.type === "boolean" && enabled
? "border-green-500/30 shadow-green-500/10"
: "border-white/10";
const cardOpacity = flag.type === "boolean" && !enabled ? "opacity-80" : "";
? "border-emerald-300 shadow-emerald-500/10 dark:border-green-500/30"
: "border-border";
return (
<div
role="group"
aria-label={flag.label}
className={`backdrop-blur-xl bg-black/60 border rounded-xl p-4 transition-all duration-200 hover:-translate-y-px hover:shadow-xl hover:border-white/15 ${cardBorder} ${cardOpacity}`}
className={`rounded-xl border bg-card p-4 shadow-soft transition-all duration-200 hover:-translate-y-px hover:border-black/15 hover:bg-bg-subtle/60 hover:shadow-elevated dark:hover:border-white/15 dark:hover:bg-surface ${cardBorder}`}
>
{/* Top row: category badge + toggle/select */}
<div className="flex items-center justify-between mb-3">
<span
aria-label={`Category: ${flag.category}`}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${category.bg} ${category.text}`}
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${category.bg} ${category.border} ${category.text}`}
>
{category.label}
</span>
@@ -94,8 +137,8 @@ export default function FeatureFlagCard({
aria-label={flag.label}
disabled={saving}
onClick={() => onToggle(flag.key, enabled ? "false" : "true")}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/50 disabled:cursor-not-allowed disabled:opacity-50 ${
enabled ? "bg-green-500" : "bg-white/20"
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50 ${
enabled ? "bg-emerald-500" : "bg-slate-300 dark:bg-white/20"
}`}
>
<span
@@ -111,10 +154,10 @@ export default function FeatureFlagCard({
disabled={saving}
value={flag.effectiveValue}
onChange={(e) => onToggle(flag.key, e.target.value)}
className="text-xs bg-white/10 border border-white/20 rounded-md px-2 py-0.5 text-white focus:outline-none focus:ring-1 focus:ring-white/30 disabled:cursor-not-allowed disabled:opacity-50"
className="rounded-md border border-border bg-bg-subtle px-2 py-0.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{(flag.enumValues ?? []).map((val) => (
<option key={val} value={val} className="bg-neutral-900">
<option key={val} value={val} className="bg-card text-text-primary">
{val}
</option>
))}
@@ -125,12 +168,12 @@ export default function FeatureFlagCard({
{/* Flag key + warning icon */}
<div className="flex items-center gap-2 mb-1.5">
<span className="font-mono text-xs font-semibold text-white/90 truncate flex-1">
<span className="flex-1 truncate font-mono text-xs font-semibold text-text-primary">
{flag.key}
</span>
{flag.warningLevel === "caution" && (
<span className="text-amber-400 text-sm" aria-label="Caution">
<span className="text-sm text-amber-500 dark:text-amber-300" aria-label="Caution">
</span>
)}
@@ -141,7 +184,7 @@ export default function FeatureFlagCard({
)}
{flag.requiresRestart && (
<span
className="text-[10px] text-slate-400 border border-slate-400/30 rounded px-1"
className="rounded border border-slate-300 bg-slate-50 px-1 text-[10px] text-slate-600 dark:border-slate-400/30 dark:bg-transparent dark:text-slate-300"
title="Requires restart"
aria-label="Requires restart"
>
@@ -151,14 +194,14 @@ export default function FeatureFlagCard({
</div>
{/* Description */}
<p className="text-xs text-white/50 line-clamp-2 mb-3">{flag.description}</p>
<p className="mb-3 line-clamp-2 text-xs text-text-muted">{flag.description}</p>
{/* Bottom row: source badge + reset button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xs text-white/30">Source:</span>
<span className="text-xs text-text-muted">Source:</span>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono font-medium ${source.bg} ${source.text}`}
className={`inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-xs font-medium ${source.bg} ${source.border} ${source.text}`}
>
{source.label}
</span>
@@ -169,7 +212,7 @@ export default function FeatureFlagCard({
aria-label={`Reset ${flag.label} to default`}
disabled={saving}
onClick={() => onReset(flag.key)}
className="inline-flex items-center gap-1 text-xs text-white/40 hover:text-white/70 transition-colors disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white/50 rounded"
className="inline-flex items-center gap-1 rounded text-xs text-text-muted transition-colors hover:text-text-primary disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
refresh

View File

@@ -1,6 +1,7 @@
"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
@@ -26,6 +27,20 @@ interface Summary {
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" },
@@ -81,6 +96,32 @@ export default function FeatureFlagsGrid() {
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) => {
@@ -91,86 +132,72 @@ export default function FeatureFlagsGrid() {
.filter(
(f) =>
debouncedSearch === "" ||
f.key.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
f.description.toLowerCase().includes(debouncedSearch.toLowerCase())
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 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;
});
}
const result = await res.json();
setFlags((prev) => {
const oldFlag = prev.find((f) => f.key === key);
const wasDb = oldFlag?.source === "db";
const isNowDb = result.source === "db";
setSummary((s) =>
s ? { ...s, overriddenByDb: s.overriddenByDb + (isNowDb ? 1 : 0) - (wasDb ? 1 : 0) } : s
);
return prev.map((f) =>
f.key === key ? { ...f, effectiveValue: result.effectiveValue, source: result.source } : f
);
});
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 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;
});
}
const result = await res.json();
setFlags((prev) => {
const oldFlag = prev.find((f) => f.key === key);
const wasDb = oldFlag?.source === "db";
const isNowDb = result.source === "db";
setSummary((s) =>
s ? { ...s, overriddenByDb: s.overriddenByDb + (isNowDb ? 1 : 0) - (wasDb ? 1 : 0) } : s
);
return prev.map((f) =>
f.key === key ? { ...f, effectiveValue: result.effectiveValue, source: result.source } : f
);
});
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);
@@ -234,23 +261,27 @@ export default function FeatureFlagsGrid() {
{/* 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-white">Feature Flags</h1>
<h1 className="text-2xl font-semibold text-text-primary">Feature Flags</h1>
{summary && (
<div className="mt-1 flex gap-3 text-sm">
<span className="text-green-400">{summary.active} active</span>
<span className="text-slate-500">·</span>
<span className="text-slate-400">{summary.inactive} inactive</span>
<span className="text-slate-500">·</span>
<span className="text-blue-400">{summary.overriddenByDb} DB overrides</span>
<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 gap-2">
<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-slate-400">
<span className="material-symbols-outlined absolute left-2.5 top-2 text-sm text-text-muted">
search
</span>
<input
@@ -258,7 +289,7 @@ export default function FeatureFlagsGrid() {
placeholder="Search flags..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-4 py-1.5 rounded-lg bg-white/5 border border-white/10 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-white/20"
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>
@@ -266,10 +297,10 @@ export default function FeatureFlagsGrid() {
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/10 text-sm text-white focus:outline-none focus:border-white/20"
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-slate-900">
<option key={cat.value} value={cat.value} className="bg-card text-text-primary">
{cat.label}
</option>
))}
@@ -280,16 +311,18 @@ export default function FeatureFlagsGrid() {
{/* 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-500/40 bg-amber-500/10 p-4">
<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-400">restart_alt</span>
<span className="material-symbols-outlined text-amber-600 dark:text-amber-300">
restart_alt
</span>
<div>
<p className="text-sm font-medium text-amber-300">
<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-200/80">
<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>
@@ -298,7 +331,7 @@ export default function FeatureFlagsGrid() {
{!showRestartConfirm ? (
<button
onClick={() => setShowRestartConfirm(true)}
className="shrink-0 rounded-lg border border-amber-400/40 px-4 py-2 text-sm text-amber-300 hover:bg-amber-500/20 transition-colors"
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>
@@ -306,7 +339,7 @@ export default function FeatureFlagsGrid() {
<div className="flex items-center gap-3">
<button
onClick={() => setShowRestartConfirm(false)}
className="text-sm text-amber-300/80 hover:text-amber-200"
className="text-sm text-amber-800/80 hover:text-amber-950 dark:text-amber-300/80 dark:hover:text-amber-200"
disabled={restarting}
>
Cancel
@@ -314,7 +347,7 @@ export default function FeatureFlagsGrid() {
<button
onClick={handleRestart}
disabled={restarting}
className="rounded-lg bg-amber-500/30 px-4 py-2 text-sm text-amber-200 hover:bg-amber-500/40 disabled:opacity-50 transition-colors"
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>
@@ -326,9 +359,9 @@ export default function FeatureFlagsGrid() {
{/* Explanation banner for the synthetic "Requires Restart" view */}
{category === "__restart" && (
<div className="rounded-xl border border-blue-500/30 bg-blue-500/10 p-3 text-sm text-blue-200">
<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-blue-300">info</span>
<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
@@ -342,19 +375,25 @@ export default function FeatureFlagsGrid() {
{loading && (
<div
className="grid gap-4"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))" }}
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 320px), 1fr))" }}
>
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="h-36 rounded-xl bg-white/5 animate-pulse" />
<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-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
<button onClick={loadFlags} className="text-sm text-red-400 underline hover:no-underline">
<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>
@@ -364,14 +403,14 @@ export default function FeatureFlagsGrid() {
{!loading && !error && (
<>
{filteredFlags.length === 0 ? (
<div className="py-16 text-center text-slate-400">
<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(320px, 1fr))" }}
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 320px), 1fr))" }}
>
{filteredFlags.map((flag) => (
<FeatureFlagCard
@@ -387,29 +426,29 @@ export default function FeatureFlagsGrid() {
{/* Reset All button */}
{summary && summary.overriddenByDb > 0 && (
<div className="flex justify-end pt-4 border-t border-white/10">
<div className="flex justify-end border-t border-border pt-4">
{!showResetConfirm ? (
<button
onClick={() => setShowResetConfirm(true)}
className="rounded-lg border border-red-500/40 px-4 py-2 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
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-slate-400">
<p className="text-sm text-text-muted">
Reset all {summary.overriddenByDb} DB override(s)?
</p>
<button
onClick={() => setShowResetConfirm(false)}
className="text-sm text-slate-400 hover:text-white"
className="text-sm text-text-muted hover:text-text-primary"
>
Cancel
</button>
<button
onClick={handleResetAll}
disabled={resettingAll}
className="rounded-lg bg-red-500/20 px-4 py-2 text-sm text-red-400 hover:bg-red-500/30 disabled:opacity-50 transition-colors"
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>

View File

@@ -33,6 +33,12 @@ import {
resolveInitialVisibility,
shouldAutoRefresh,
} from "./requestLoggerSignature";
import {
DEFAULT_REFRESH_INTERVAL_SEC,
clampRefreshIntervalSec,
readSavedRefreshIntervalSec,
writeSavedRefreshIntervalSec,
} from "./requestLoggerPreferences";
// Number of call-log rows fetched per page. The viewer grows its window by this
// amount on "Load more" / infinite scroll so users can browse past the first
@@ -131,8 +137,9 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
const [detailLoggingLoading, setDetailLoggingLoading] = useState(false);
const [limit, setLimit] = useState(PAGE_SIZE);
const [hasMore, setHasMore] = useState(false);
const [refreshIntervalSec, setRefreshIntervalSec] = useState(10);
const [refreshIntervalSec, setRefreshIntervalSec] = useState(DEFAULT_REFRESH_INTERVAL_SEC);
const intervalRef = useRef(null);
const refreshIntervalSecRef = useRef(DEFAULT_REFRESH_INTERVAL_SEC);
const hasLoadedRef = useRef(false);
const logsSignatureRef = useRef("");
const scrollContainerRef = useRef(null);
@@ -162,6 +169,29 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
});
}, []);
useEffect(() => {
const saved = readSavedRefreshIntervalSec();
refreshIntervalSecRef.current = saved;
setRefreshIntervalSec(saved);
}, []);
useEffect(() => {
refreshIntervalSecRef.current = refreshIntervalSec;
}, [refreshIntervalSec]);
const updateRefreshIntervalSec = useCallback(
(valueOrUpdater: number | ((current: number) => number)) => {
const current = refreshIntervalSecRef.current;
const rawValue =
typeof valueOrUpdater === "function" ? valueOrUpdater(current) : valueOrUpdater;
const next = clampRefreshIntervalSec(rawValue);
refreshIntervalSecRef.current = next;
writeSavedRefreshIntervalSec(next);
setRefreshIntervalSec(next);
},
[]
);
const fetchLogs = useCallback(
async (showLoading = false) => {
if (showLoading) setLoading(true);
@@ -364,23 +394,23 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
setDetailData(null);
try {
const res = await fetch(`/api/logs/${logEntry.id}`, { cache: "no-store" });
if (res.ok) {
const data = await res.json();
const dataHasPipeline =
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
setDetailData((prev: { pipelinePayloads: any }) => ({
if (res.ok) {
const data = await res.json();
const dataHasPipeline =
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
setDetailData((prev: { pipelinePayloads: any }) => ({
...prev,
...data,
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
}));
// ensure the modal summary reflects the fetched call log summary
if (data && typeof data === "object") {
setSelectedLog((prev: any) => ({
...prev,
...data,
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
active: data.active === true,
}));
// ensure the modal summary reflects the fetched call log summary
if (data && typeof data === "object") {
setSelectedLog((prev: any) => ({
...prev,
...data,
active: data.active === true,
}));
}
}
} else {
// A deep-linked id can legitimately 404 while the request is still
// finalizing. Keep the modal open and poll /api/logs/[id] instead of
@@ -448,9 +478,11 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
useEffect(() => {
if (initialSelectedId && !initialOpenedRef.current) {
initialOpenedRef.current = true;
openDetail({ id: initialSelectedId, pendingLookup: true }).then(r => r).catch((error_) => {
console.error("Failed to open initial log id:", error_);
});
openDetail({ id: initialSelectedId, pendingLookup: true })
.then((r) => r)
.catch((error_) => {
console.error("Failed to open initial log id:", error_);
});
}
}, [initialSelectedId]);
@@ -506,9 +538,11 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
const idx = currentLogIndex;
const target = idx > 0 ? sortedLogsForNav[idx - 1] : null;
if (target?.id) {
openDetail(target).then(r => r).catch((error_) => {
console.error("Failed to open previous log id:", error_);
});
openDetail(target)
.then((r) => r)
.catch((error_) => {
console.error("Failed to open previous log id:", error_);
});
} else {
closeDetail();
}
@@ -760,7 +794,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
{/* Refresh interval */}
<div className="flex items-center gap-1">
<button
onClick={() => setRefreshIntervalSec((v) => Math.max(1, v - 1))}
onClick={() => updateRefreshIntervalSec((v) => v - 1)}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors text-sm font-bold"
title="Decrease interval"
>
@@ -773,13 +807,13 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
value={refreshIntervalSec}
onChange={(e) => {
const v = Number.parseInt(e.target.value, 10);
if (!Number.isNaN(v) && v >= 1 && v <= 300) setRefreshIntervalSec(v);
if (!Number.isNaN(v)) updateRefreshIntervalSec(v);
}}
className="w-12 text-center text-[11px] bg-transparent border border-border rounded px-1 py-0.5 text-text-primary [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
title="Auto-refresh interval in seconds"
/>
<button
onClick={() => setRefreshIntervalSec((v) => Math.min(300, v + 1))}
onClick={() => updateRefreshIntervalSec((v) => v + 1)}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors text-sm font-bold"
title="Increase interval"
>
@@ -970,8 +1004,10 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
{sortedLogs.map((log) => {
const isActive = log.active === true;
const statusStyle = isActive ? null : getStatusStyle(log.status);
const protocolKey = isActive ? null : (log.sourceFormat || log.provider);
const protocol = protocolKey ? getProtocolColor(protocolKey, log.provider) : null;
const protocolKey = isActive ? null : log.sourceFormat || log.provider;
const protocol = protocolKey
? getProtocolColor(protocolKey, log.provider)
: null;
const compatLabel = getProviderDisplayLabel(log.provider, providerNodes);
const providerColor = PROVIDER_COLORS[log.provider] || {
bg: "#374151",
@@ -988,7 +1024,7 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
<tr
key={log.id}
onClick={() => openDetail(log)}
className={`cursor-pointer hover:bg-primary/5 transition-colors ${isError ? "bg-red-500/5" : ""}`}
className={`cursor-pointer hover:bg-sky-500/10 dark:hover:bg-sky-400/10 transition-colors ${isError ? "bg-red-500/5" : ""}`}
>
{visibleColumns.status && (
<td className="px-3 py-2">
@@ -1016,7 +1052,9 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
) : (
<span
className={`inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase ${cacheSourceMeta?.className || ""}`}
title={isSemanticCache ? t("semanticCacheHit") : t("upstreamResponse")}
title={
isSemanticCache ? t("semanticCacheHit") : t("upstreamResponse")
}
>
{isSemanticCache ? t("semantic") : t("upstream")}
</span>
@@ -1073,7 +1111,11 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
) : (
<span
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
style={protocol ? { backgroundColor: protocol.bg, color: protocol.text } : {}}
style={
protocol
? { backgroundColor: protocol.bg, color: protocol.text }
: {}
}
>
{protocol?.label || "—"}
</span>
@@ -1118,28 +1160,28 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
{isActive ? (
<span className="text-text-muted text-[10px]"></span>
) : (
<>
<span className="text-text-muted">TI:</span>{" "}
<span className="text-primary">
{log.tokens?.in?.toLocaleString() || 0}
</span>
<span className="mx-1 text-border">|</span>
<span className="text-text-muted">TO:</span>{" "}
<span className="text-emerald-700 dark:text-emerald-400">
{log.tokens?.out?.toLocaleString() || 0}
</span>
{log.tokens?.compressed != null && log.tokens.compressed > 0 && (
<>
<span className="mx-1 text-border">|</span>
<span
className="text-purple-500 dark:text-purple-400 font-semibold"
title={`${log.tokens.compressed.toLocaleString()} tokens compressed`}
>
{log.tokens.compressed.toLocaleString()}
</span>
</>
)}
</>
<>
<span className="text-text-muted">TI:</span>{" "}
<span className="text-primary">
{log.tokens?.in?.toLocaleString() || 0}
</span>
<span className="mx-1 text-border">|</span>
<span className="text-text-muted">TO:</span>{" "}
<span className="text-emerald-700 dark:text-emerald-400">
{log.tokens?.out?.toLocaleString() || 0}
</span>
{log.tokens?.compressed != null && log.tokens.compressed > 0 && (
<>
<span className="mx-1 text-border">|</span>
<span
className="text-purple-500 dark:text-purple-400 font-semibold"
title={`${log.tokens.compressed.toLocaleString()} tokens compressed`}
>
{log.tokens.compressed.toLocaleString()}
</span>
</>
)}
</>
)}
</td>
)}
@@ -1147,22 +1189,24 @@ const RequestLoggerV2 = forwardRef<RequestLoggerV2Handle, { initialSelectedId?:
<td className="px-3 py-2 text-right whitespace-nowrap font-mono">
{isActive ? (
<span className="text-text-muted text-[10px]"></span>
) : (() => {
const tps = getLogTps(log);
const color =
tps <= 0
? "text-text-muted"
: tps >= 80
? "text-emerald-600 dark:text-emerald-400"
: tps >= 30
? "text-sky-600 dark:text-sky-400"
: "text-amber-600 dark:text-amber-400";
return (
<span className={color} title={`${tps.toFixed(2)} tokens/sec`}>
{formatTps(tps)}
</span>
);
})()}
) : (
(() => {
const tps = getLogTps(log);
const color =
tps <= 0
? "text-text-muted"
: tps >= 80
? "text-emerald-600 dark:text-emerald-400"
: tps >= 30
? "text-sky-600 dark:text-sky-400"
: "text-amber-600 dark:text-amber-400";
return (
<span className={color} title={`${tps.toFixed(2)} tokens/sec`}>
{formatTps(tps)}
</span>
);
})()
)}
</td>
)}
{visibleColumns.duration && (

View File

@@ -0,0 +1,44 @@
export const DEFAULT_REFRESH_INTERVAL_SEC = 10;
export const MIN_REFRESH_INTERVAL_SEC = 1;
export const MAX_REFRESH_INTERVAL_SEC = 300;
export const REFRESH_INTERVAL_STORAGE_KEY = "loggerRefreshIntervalSec";
type RefreshIntervalStorage = Pick<Storage, "getItem" | "setItem">;
function getBrowserStorage(): RefreshIntervalStorage | null {
if (globalThis.window === undefined) return null;
return window.localStorage;
}
export function clampRefreshIntervalSec(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_REFRESH_INTERVAL_SEC;
return Math.min(MAX_REFRESH_INTERVAL_SEC, Math.max(MIN_REFRESH_INTERVAL_SEC, Math.round(value)));
}
export function readSavedRefreshIntervalSec(
storage: RefreshIntervalStorage | null = getBrowserStorage()
): number {
if (!storage) return DEFAULT_REFRESH_INTERVAL_SEC;
try {
const saved = storage.getItem(REFRESH_INTERVAL_STORAGE_KEY);
if (!saved) return DEFAULT_REFRESH_INTERVAL_SEC;
return clampRefreshIntervalSec(Number.parseInt(saved, 10));
} catch {
return DEFAULT_REFRESH_INTERVAL_SEC;
}
}
export function writeSavedRefreshIntervalSec(
value: number,
storage: RefreshIntervalStorage | null = getBrowserStorage()
): boolean {
if (!storage) return false;
try {
storage.setItem(REFRESH_INTERVAL_STORAGE_KEY, String(clampRefreshIntervalSec(value)));
return true;
} catch {
// Storage may be unavailable in private browsing or locked-down embeds.
// The in-memory control still updates, so persistence failure is non-fatal.
return false;
}
}

View File

@@ -77,14 +77,14 @@ test("updatePendingRequestStreamChunks stores stream chunks in the detail", () =
usageHistory.clearPendingRequests();
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true);
const chunks = { provider: ["data: {\"a\":1}"], openai: [], client: [] };
const chunks = { provider: ['data: {"a":1}'], openai: [], client: [] };
usageHistory.updatePendingRequestStreamChunks("gpt-4", "openai", "conn-1", chunks);
const pending = usageHistory.getPendingRequests();
const detail = pending.details["conn-1"]?.["gpt-4 (openai)"]?.[0];
assert.ok(detail.streamChunks, "streamChunks should be set");
assert.equal(detail.streamChunks.provider.length, 1);
assert.equal(detail.streamChunks.provider[0], "data: {\"a\":1}");
assert.equal(detail.streamChunks.provider[0], 'data: {"a":1}');
});
test("updatePendingRequestStreamChunks stores empty streamChunks object (not null)", () => {
@@ -403,7 +403,9 @@ test("createRequestLogger without connectionId does not populate streamChunks",
const detail = pending.details["test-conn-2"]?.["gpt-4 (openai)"]?.[0];
assert.ok(detail, "pending request detail should exist");
assert.equal(detail.streamChunks, undefined,
assert.equal(
detail.streamChunks,
undefined,
"streamChunks should be undefined when connectionId not provided to createRequestLogger"
);
});
@@ -462,7 +464,11 @@ test("createRequestLogger captures stream chunks even when enabled: false", asyn
assert.equal(detail.streamChunks.provider[0], 'data: {"content":"hello"}');
// But getPipelinePayloads should return null when disabled
assert.equal(logger.getPipelinePayloads(), null, "pipeline payloads should be null when disabled");
assert.equal(
logger.getPipelinePayloads(),
null,
"pipeline payloads should be null when disabled"
);
});
test("createRequestLogger disabled logger other methods are no-ops", async () => {

View File

@@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_REFRESH_INTERVAL_SEC,
MAX_REFRESH_INTERVAL_SEC,
MIN_REFRESH_INTERVAL_SEC,
REFRESH_INTERVAL_STORAGE_KEY,
clampRefreshIntervalSec,
readSavedRefreshIntervalSec,
writeSavedRefreshIntervalSec,
} from "../../src/shared/components/requestLoggerPreferences.ts";
function createStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
values,
};
}
test("clampRefreshIntervalSec keeps refresh intervals within supported bounds", () => {
assert.equal(clampRefreshIntervalSec(Number.NaN), DEFAULT_REFRESH_INTERVAL_SEC);
assert.equal(clampRefreshIntervalSec(0), MIN_REFRESH_INTERVAL_SEC);
assert.equal(clampRefreshIntervalSec(17.4), 17);
assert.equal(clampRefreshIntervalSec(999), MAX_REFRESH_INTERVAL_SEC);
});
test("readSavedRefreshIntervalSec reads and clamps persisted values", () => {
const storage = createStorage({ [REFRESH_INTERVAL_STORAGE_KEY]: "17" });
assert.equal(readSavedRefreshIntervalSec(storage), 17);
storage.values.set(REFRESH_INTERVAL_STORAGE_KEY, "999");
assert.equal(readSavedRefreshIntervalSec(storage), MAX_REFRESH_INTERVAL_SEC);
storage.values.set(REFRESH_INTERVAL_STORAGE_KEY, "not-a-number");
assert.equal(readSavedRefreshIntervalSec(storage), DEFAULT_REFRESH_INTERVAL_SEC);
});
test("writeSavedRefreshIntervalSec persists clamped values outside React state updaters", () => {
const storage = createStorage();
assert.equal(writeSavedRefreshIntervalSec(0, storage), true);
assert.equal(storage.values.get(REFRESH_INTERVAL_STORAGE_KEY), String(MIN_REFRESH_INTERVAL_SEC));
assert.equal(writeSavedRefreshIntervalSec(17, storage), true);
assert.equal(storage.values.get(REFRESH_INTERVAL_STORAGE_KEY), "17");
});
test("refresh interval persistence degrades gracefully when storage is unavailable", () => {
const throwingStorage = {
getItem: () => {
throw new Error("storage blocked");
},
setItem: () => {
throw new Error("storage blocked");
},
};
assert.equal(readSavedRefreshIntervalSec(null), DEFAULT_REFRESH_INTERVAL_SEC);
assert.equal(readSavedRefreshIntervalSec(throwingStorage), DEFAULT_REFRESH_INTERVAL_SEC);
assert.equal(writeSavedRefreshIntervalSec(17, null), false);
assert.equal(writeSavedRefreshIntervalSec(17, throwingStorage), false);
});