mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
44
src/shared/components/requestLoggerPreferences.ts
Normal file
44
src/shared/components/requestLoggerPreferences.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
66
tests/unit/request-logger-preferences.test.ts
Normal file
66
tests/unit/request-logger-preferences.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user