Skip to content

Commit 16b2139

Browse files
committed
feat(console): split settings page into upstream/records/cors sections with scroll nav
- Settings page now uses sticky section nav (upstream timeouts / records / CORS) with smooth scroll + active highlight. - Add read-only Records section showing effective DEBUG_DB_MAX_RECORDS. - Add read-only CORS section showing allow-origin and enabled state. - Backend timeouts API now returns runtime: { retentionMaxRecords, corsAllowOrigin, corsEnabled }. - Drop square 'L' logo from NavBar, keep only LRS wordmark.
1 parent 8d869b0 commit 16b2139

8 files changed

Lines changed: 275 additions & 52 deletions

File tree

changelog/2026-06-27.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,10 @@
2121
- 新增 `console_requests.cost_pricing_json` 列(迁移 `drizzle/0009_request_cost_pricing_snapshot.sql`),存请求响应时生效的 `ModelPricing` 快照。
2222
- 写入:`saveConsoleResponse``resolveRequestPricing` 解析一次有效定价(route/model override → catalog),写入快照列,并复用同一份定价做 Key 额度扣费(`syncApiKeyQuotaCharge` 不再二次查价)。
2323
- 读取:新增 `resolveRowPricing(cost_pricing_json, …)`,优先用行内快照、缺失时回退实时定价(兼容历史旧行)。日志列表(`mapListRow`)、详情(`mapRow`)、用量统计(`updateUsageAccumulator`)、时序(timeseries)四处成本计算统一走它,保证日志与用量页一致。
24+
- 配置页改造为可滚动主从布局:
25+
- 左侧 section nav 拆为三项(上游超时 / 记录 / 跨域),点击可平滑滚动并高亮当前 section;
26+
- 新增「记录」section:只读展示 `DEBUG_DB_MAX_RECORDS` 生效值(`getMaxDebugRecords()`),带「只读」徽标说明由环境变量控制;
27+
- 新增「跨域」section:只读展示 CORS 允许来源 (`*`) 与启用状态(开关样式视觉),说明由内置策略控制;
28+
- 后端 `GET/PATCH /__console/api/settings/timeouts` 响应附加 `runtime: { retentionMaxRecords, corsAllowOrigin, corsEnabled }`;前端 `GatewayTimeoutSettingsPayload` 增加可选 `runtime` 字段;
29+
- i18n:新增 `settings.navRecords` / `navCors` / `recordsTitle` / `maxRecordsLabel` / `maxRecordsDesc` / `corsTitle` / `corsOriginLabel` / `corsOriginDesc` / `corsEnabledLabel` / `corsEnabledDesc` / `readOnlyBadge` / `readOnlyHint` 等中英文 key。
30+
- NavBar 顶部 logo 去除方形「L」图标,只保留 `LRS` 文字,避免与左侧菜单图标视觉冲突。

console/ai-proxy-dashboard/src/features/dashboard/components/nav-bar.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,8 @@ export function NavBar({
5151
<button
5252
type="button"
5353
onClick={() => onNavigate("monitor")}
54-
className="flex items-center gap-2.5"
54+
className="flex items-center"
5555
>
56-
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-primary text-base font-extrabold text-primary-foreground">
57-
L
58-
</span>
5956
<span className="text-[17px] font-extrabold tracking-[0.04em] text-foreground">LRS</span>
6057
</button>
6158
<span className="hidden text-[13px] text-muted-foreground sm:inline">

console/ai-proxy-dashboard/src/features/dashboard/components/settings-page.tsx

Lines changed: 219 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useEffect, useState } from "react"
2-
import { Clock, Loader2, RefreshCw, Save } from "lucide-react"
1+
import { useEffect, useRef, useState } from "react"
2+
import { Clock, Database, Globe, Loader2, RefreshCw, Save } from "lucide-react"
33
import { useTranslation } from "react-i18next"
44

55
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
66
import { Button } from "@/components/ui/button"
77
import { Input } from "@/components/ui/input"
88
import { Skeleton } from "@/components/ui/skeleton"
9+
import { cn } from "@/lib/utils"
910
import { fetchGatewayTimeoutSettings, updateGatewayTimeoutSettings } from "@/features/dashboard/api"
1011
import type { GatewayTimeoutSettingsPayload, TimeoutLimit } from "@/features/dashboard/types"
1112

@@ -16,6 +17,8 @@ type TimeoutFormState = {
1617
responseIdleTimeoutSeconds: string
1718
}
1819

20+
type SectionId = "upstream" | "records" | "cors"
21+
1922
function secondsText(ms: number): string {
2023
const seconds = ms / 1000
2124
return Number.isInteger(seconds) ? String(seconds) : String(Number(seconds.toFixed(3)))
@@ -91,6 +94,79 @@ function SettingRow({
9194
)
9295
}
9396

97+
function ReadOnlyRow({
98+
label,
99+
desc,
100+
children,
101+
}: {
102+
label: string
103+
desc: string
104+
children: React.ReactNode
105+
}) {
106+
return (
107+
<div className="flex items-center justify-between gap-4 rounded-xl border border-border p-4">
108+
<div className="min-w-0">
109+
<div className="text-[13.5px] font-semibold text-foreground">{label}</div>
110+
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{desc}</div>
111+
</div>
112+
<div className="shrink-0">{children}</div>
113+
</div>
114+
)
115+
}
116+
117+
function SectionTitle({ children }: { children: React.ReactNode }) {
118+
return (
119+
<div className="text-[12px] font-bold uppercase tracking-[0.04em] text-primary">{children}</div>
120+
)
121+
}
122+
123+
function SectionNavItem({
124+
icon,
125+
label,
126+
count,
127+
active,
128+
onClick,
129+
}: {
130+
icon: React.ReactNode
131+
label: string
132+
count: string
133+
active: boolean
134+
onClick: () => void
135+
}) {
136+
return (
137+
<button
138+
type="button"
139+
onClick={onClick}
140+
className={cn(
141+
"flex items-center gap-3 rounded-xl px-3.5 py-3 text-left transition-colors",
142+
active
143+
? "border border-[color:var(--accent-foreground)]/20 bg-accent"
144+
: "border border-transparent hover:bg-muted/50",
145+
)}
146+
>
147+
<span
148+
className={cn(
149+
"flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg",
150+
active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
151+
)}
152+
>
153+
{icon}
154+
</span>
155+
<div>
156+
<div
157+
className={cn(
158+
"text-[13.5px]",
159+
active ? "font-bold text-accent-foreground" : "font-semibold text-foreground",
160+
)}
161+
>
162+
{label}
163+
</div>
164+
<div className="text-[11px] text-muted-foreground">{count}</div>
165+
</div>
166+
</button>
167+
)
168+
}
169+
94170
export function SettingsPage({ onUnauthorized }: { onUnauthorized: () => void }) {
95171
const { t } = useTranslation()
96172
const [settings, setSettings] = useState<GatewayTimeoutSettingsPayload | null>(null)
@@ -104,6 +180,19 @@ export function SettingsPage({ onUnauthorized }: { onUnauthorized: () => void })
104180
const [feedback, setFeedback] = useState("")
105181
const [loading, setLoading] = useState(false)
106182
const [saving, setSaving] = useState(false)
183+
const [activeSection, setActiveSection] = useState<SectionId>("upstream")
184+
185+
const scrollRef = useRef<HTMLDivElement>(null)
186+
const sectionRefs = useRef<Record<SectionId, HTMLDivElement | null>>({
187+
upstream: null,
188+
records: null,
189+
cors: null,
190+
})
191+
192+
const goToSection = (id: SectionId) => {
193+
setActiveSection(id)
194+
sectionRefs.current[id]?.scrollIntoView({ behavior: "smooth", block: "start" })
195+
}
107196

108197
const handleUnauthorized = (message: string) => {
109198
if (message === "unauthorized") {
@@ -173,6 +262,8 @@ export function SettingsPage({ onUnauthorized }: { onUnauthorized: () => void })
173262
const fieldDesc = (defaultMs: number, limit: TimeoutLimit) =>
174263
`${t("settings.defaultValue", { value: secondsText(defaultMs) })} · ${t("settings.rangeHint", { min: secondsText(limit.minMs), max: secondsText(limit.maxMs) })}`
175264

265+
const runtime = settings?.runtime
266+
176267
return (
177268
<div className="flex flex-col gap-6">
178269
{/* Toolbar — actions */}
@@ -208,16 +299,28 @@ export function SettingsPage({ onUnauthorized }: { onUnauthorized: () => void })
208299
) : (
209300
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr]">
210301
{/* Section nav */}
211-
<div className="flex flex-col gap-3">
212-
<div className="flex items-center gap-3 rounded-xl border border-[color:var(--accent-foreground)]/20 bg-accent px-3.5 py-3">
213-
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
214-
<Clock className="h-4 w-4" />
215-
</span>
216-
<div>
217-
<div className="text-[13.5px] font-bold text-accent-foreground">{t("settings.navUpstream")}</div>
218-
<div className="text-[11px] text-muted-foreground">{t("settings.itemCount", { count: 4 })}</div>
219-
</div>
220-
</div>
302+
<div className="flex flex-col gap-1.5">
303+
<SectionNavItem
304+
icon={<Clock className="h-4 w-4" />}
305+
label={t("settings.navUpstream")}
306+
count={t("settings.itemCount", { count: 4 })}
307+
active={activeSection === "upstream"}
308+
onClick={() => goToSection("upstream")}
309+
/>
310+
<SectionNavItem
311+
icon={<Database className="h-4 w-4" />}
312+
label={t("settings.navRecords")}
313+
count={t("settings.itemCount", { count: 1 })}
314+
active={activeSection === "records"}
315+
onClick={() => goToSection("records")}
316+
/>
317+
<SectionNavItem
318+
icon={<Globe className="h-4 w-4" />}
319+
label={t("settings.navCors")}
320+
count={t("settings.itemCount", { count: 2 })}
321+
active={activeSection === "cors"}
322+
onClick={() => goToSection("cors")}
323+
/>
221324
<div className="mt-auto rounded-xl border border-border bg-muted/40 p-4">
222325
<div className="text-[11.5px] font-bold text-foreground">{t("settings.applyTitle")}</div>
223326
<p className="mt-1.5 text-[11.5px] leading-relaxed text-muted-foreground">{t("settings.applyNote")}</p>
@@ -226,41 +329,112 @@ export function SettingsPage({ onUnauthorized }: { onUnauthorized: () => void })
226329

227330
{/* Content */}
228331
<div className="flex flex-col">
229-
<div className="text-[12px] font-bold uppercase tracking-[0.04em] text-primary">
230-
{t("settings.firstByteTitle")}
231-
</div>
232-
<div className="mt-4 grid gap-3.5 md:grid-cols-2">
233-
<SettingRow
234-
label={t("settings.defaultFirstByteLabel")}
235-
desc={fieldDesc(settings.defaults.defaultFirstByteTimeoutMs, settings.limits.firstByte)}
236-
value={form.defaultFirstByteTimeoutSeconds}
237-
onChange={(value) => setForm((c) => ({ ...c, defaultFirstByteTimeoutSeconds: value }))}
238-
limit={settings.limits.firstByte}
239-
/>
240-
<SettingRow
241-
label={t("settings.streamFirstByteLabel")}
242-
desc={fieldDesc(settings.defaults.streamFirstByteTimeoutMs, settings.limits.firstByte)}
243-
value={form.streamFirstByteTimeoutSeconds}
244-
onChange={(value) => setForm((c) => ({ ...c, streamFirstByteTimeoutSeconds: value }))}
245-
limit={settings.limits.firstByte}
246-
/>
247-
<SettingRow
248-
label={t("settings.imageFirstByteLabel")}
249-
desc={fieldDesc(settings.defaults.imageFirstByteTimeoutMs, settings.limits.firstByte)}
250-
value={form.imageFirstByteTimeoutSeconds}
251-
onChange={(value) => setForm((c) => ({ ...c, imageFirstByteTimeoutSeconds: value }))}
252-
limit={settings.limits.firstByte}
253-
/>
254-
<SettingRow
255-
label={t("settings.responseIdleLabel")}
256-
desc={fieldDesc(settings.defaults.responseIdleTimeoutMs, settings.limits.responseIdle)}
257-
value={form.responseIdleTimeoutSeconds}
258-
onChange={(value) => setForm((c) => ({ ...c, responseIdleTimeoutSeconds: value }))}
259-
limit={settings.limits.responseIdle}
260-
/>
332+
<div ref={scrollRef} className="flex flex-col gap-7">
333+
{/* Upstream timeouts */}
334+
<div
335+
ref={(el) => {
336+
sectionRefs.current.upstream = el
337+
}}
338+
className="scroll-mt-4"
339+
>
340+
<SectionTitle>{t("settings.firstByteTitle")}</SectionTitle>
341+
<div className="mt-4 grid gap-3.5 md:grid-cols-2">
342+
<SettingRow
343+
label={t("settings.defaultFirstByteLabel")}
344+
desc={fieldDesc(settings.defaults.defaultFirstByteTimeoutMs, settings.limits.firstByte)}
345+
value={form.defaultFirstByteTimeoutSeconds}
346+
onChange={(value) => setForm((c) => ({ ...c, defaultFirstByteTimeoutSeconds: value }))}
347+
limit={settings.limits.firstByte}
348+
/>
349+
<SettingRow
350+
label={t("settings.streamFirstByteLabel")}
351+
desc={fieldDesc(settings.defaults.streamFirstByteTimeoutMs, settings.limits.firstByte)}
352+
value={form.streamFirstByteTimeoutSeconds}
353+
onChange={(value) => setForm((c) => ({ ...c, streamFirstByteTimeoutSeconds: value }))}
354+
limit={settings.limits.firstByte}
355+
/>
356+
<SettingRow
357+
label={t("settings.imageFirstByteLabel")}
358+
desc={fieldDesc(settings.defaults.imageFirstByteTimeoutMs, settings.limits.firstByte)}
359+
value={form.imageFirstByteTimeoutSeconds}
360+
onChange={(value) => setForm((c) => ({ ...c, imageFirstByteTimeoutSeconds: value }))}
361+
limit={settings.limits.firstByte}
362+
/>
363+
<SettingRow
364+
label={t("settings.responseIdleLabel")}
365+
desc={fieldDesc(settings.defaults.responseIdleTimeoutMs, settings.limits.responseIdle)}
366+
value={form.responseIdleTimeoutSeconds}
367+
onChange={(value) => setForm((c) => ({ ...c, responseIdleTimeoutSeconds: value }))}
368+
limit={settings.limits.responseIdle}
369+
/>
370+
</div>
371+
</div>
372+
373+
{/* Records (read-only) */}
374+
<div
375+
ref={(el) => {
376+
sectionRefs.current.records = el
377+
}}
378+
className="scroll-mt-4"
379+
>
380+
<div className="flex items-center gap-2">
381+
<SectionTitle>{t("settings.recordsTitle")}</SectionTitle>
382+
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
383+
{t("settings.readOnlyBadge")}
384+
</span>
385+
</div>
386+
<div className="mt-4">
387+
<ReadOnlyRow label={t("settings.maxRecordsLabel")} desc={t("settings.maxRecordsDesc")}>
388+
<span className="rounded-lg border border-border px-3.5 py-2 font-mono text-[13px] tabular-nums text-foreground">
389+
{runtime ? String(runtime.retentionMaxRecords) : "—"}
390+
</span>
391+
</ReadOnlyRow>
392+
</div>
393+
</div>
394+
395+
{/* CORS (read-only) */}
396+
<div
397+
ref={(el) => {
398+
sectionRefs.current.cors = el
399+
}}
400+
className="scroll-mt-4"
401+
>
402+
<div className="flex items-center gap-2">
403+
<SectionTitle>{t("settings.corsTitle")}</SectionTitle>
404+
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
405+
{t("settings.readOnlyBadge")}
406+
</span>
407+
</div>
408+
<div className="mt-4 grid gap-3.5 md:grid-cols-2">
409+
<ReadOnlyRow label={t("settings.corsOriginLabel")} desc={t("settings.corsOriginDesc")}>
410+
<span className="rounded-lg border border-border px-3.5 py-2 font-mono text-[13px] text-foreground">
411+
{runtime?.corsAllowOrigin ?? "*"}
412+
</span>
413+
</ReadOnlyRow>
414+
<ReadOnlyRow label={t("settings.corsEnabledLabel")} desc={t("settings.corsEnabledDesc")}>
415+
<span
416+
role="img"
417+
aria-label={runtime?.corsEnabled !== false ? "on" : "off"}
418+
className={cn(
419+
"relative inline-block h-6 w-[42px] rounded-full",
420+
runtime?.corsEnabled !== false ? "bg-primary" : "bg-muted",
421+
)}
422+
>
423+
<span
424+
className={cn(
425+
"absolute top-[3px] h-[18px] w-[18px] rounded-full bg-white transition-all",
426+
runtime?.corsEnabled !== false ? "right-[3px]" : "left-[3px]",
427+
)}
428+
/>
429+
</span>
430+
</ReadOnlyRow>
431+
</div>
432+
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("settings.readOnlyHint")}</p>
433+
</div>
261434
</div>
262435

263-
<div className="mt-6 flex items-center gap-3 border-t border-border pt-5">
436+
{/* Footer save bar */}
437+
<div className="mt-7 flex items-center gap-3 border-t border-border pt-5">
264438
<span className="text-[11.5px] text-muted-foreground">{t("settings.applyNote")}</span>
265439
<Button type="button" variant="outline" size="sm" className="ml-auto" onClick={restoreDefaults}>
266440
{t("settings.restoreDefaults")}

console/ai-proxy-dashboard/src/features/dashboard/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,12 @@ export type GatewayTimeoutSettings = {
357357
responseIdleTimeoutMs: number
358358
}
359359

360+
export type GatewaySettingsRuntimeInfo = {
361+
retentionMaxRecords: number
362+
corsAllowOrigin: string
363+
corsEnabled: boolean
364+
}
365+
360366
export type GatewayTimeoutSettingsPayload = GatewayTimeoutSettings & {
361367
ok: boolean
362368
defaults: GatewayTimeoutSettings
@@ -365,6 +371,7 @@ export type GatewayTimeoutSettingsPayload = GatewayTimeoutSettings & {
365371
responseIdle: TimeoutLimit
366372
}
367373
updatedAt: number | null
374+
runtime?: GatewaySettingsRuntimeInfo
368375
}
369376

370377
export type ModelFallbackMode = "disabled" | "same_model" | "any_model"

console/ai-proxy-dashboard/src/i18n/locales/en.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,19 @@ export default {
185185
responseIdleLabel: "Body idle timeout",
186186
secondsSuffix: "seconds",
187187
navUpstream: "Upstream timeouts",
188+
navRecords: "Records",
189+
navCors: "CORS",
188190
itemCount: "{{count}} items",
191+
recordsTitle: "Records",
192+
maxRecordsLabel: "Max retained request records",
193+
maxRecordsDesc: "Oldest records are pruned once exceeded",
194+
corsTitle: "CORS",
195+
corsOriginLabel: "CORS allowed origin",
196+
corsOriginDesc: "Built-in cross-origin handling",
197+
corsEnabledLabel: "Enable CORS",
198+
corsEnabledDesc: "Allow browsers to call the gateway cross-origin",
199+
readOnlyBadge: "Read-only",
200+
readOnlyHint: "Controlled by env vars / built-in policy — not editable in the console",
189201
applyTitle: "Takes effect",
190202
applyNote: "Persisted immediately when changed in the console, overriding env-var defaults — no gateway restart needed.",
191203
restoreDefaults: "Restore defaults",

0 commit comments

Comments
 (0)