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"
33import { useTranslation } from "react-i18next"
44
55import { Alert , AlertDescription , AlertTitle } from "@/components/ui/alert"
66import { Button } from "@/components/ui/button"
77import { Input } from "@/components/ui/input"
88import { Skeleton } from "@/components/ui/skeleton"
9+ import { cn } from "@/lib/utils"
910import { fetchGatewayTimeoutSettings , updateGatewayTimeoutSettings } from "@/features/dashboard/api"
1011import 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+
1922function 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+
94170export 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" ) }
0 commit comments