|
|
|
|
@@ -3,42 +3,84 @@ import { api } from '../lib/api'
|
|
|
|
|
|
|
|
|
|
const D = 'rgba(237,237,240,'
|
|
|
|
|
|
|
|
|
|
function Section({ title, badge, children }: { title: string; badge?: React.ReactNode; children: React.ReactNode }) {
|
|
|
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, overflow: 'hidden', marginBottom: 14 }}>
|
|
|
|
|
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
<div style={{ padding: '13px 20px', borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
|
|
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff' }}>{title}</span>
|
|
|
|
|
{badge}
|
|
|
|
|
</div>
|
|
|
|
|
<div style={{ padding: 20 }}>{children}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ReadonlyField({ label, value }: { label: string; value: string }) {
|
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, color: `${D}0.55)`, marginBottom: 6 }}>{label}</label>
|
|
|
|
|
<div style={{
|
|
|
|
|
background: 'rgba(0,0,0,0.18)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 6,
|
|
|
|
|
padding: '8px 12px', fontSize: 13, color: `${D}0.4)`, fontFamily: "'DM Mono',monospace",
|
|
|
|
|
userSelect: 'all',
|
|
|
|
|
}}>{value || '—'}</div>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AuthConfig { client_id: string; auth_url: string; token_url: string }
|
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
|
|
|
width: '100%', background: 'rgba(0,0,0,0.28)',
|
|
|
|
|
border: '1px solid rgba(255,255,255,0.09)', borderRadius: 6,
|
|
|
|
|
padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none',
|
|
|
|
|
boxSizing: 'border-box',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const RETENTION_OPTIONS = [
|
|
|
|
|
{ label: '7 days', value: 7 },
|
|
|
|
|
{ label: '30 days', value: 30 },
|
|
|
|
|
{ label: '90 days', value: 90 },
|
|
|
|
|
{ label: '180 days', value: 180 },
|
|
|
|
|
{ label: '1 year', value: 365 },
|
|
|
|
|
{ label: '2 years', value: 730 },
|
|
|
|
|
{ label: 'Indefinite',value: 0 },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
const LOG_LEVELS = ['debug', 'info', 'warn', 'error']
|
|
|
|
|
|
|
|
|
|
export default function Settings() {
|
|
|
|
|
const [name, setName] = useState('Local Model Routex')
|
|
|
|
|
const [logLevel, setLogLevel] = useState('info')
|
|
|
|
|
const [retentionDays, setRetentionDays] = useState(365)
|
|
|
|
|
const [dirty, setDirty] = useState(false)
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
const [saveMsg, setSaveMsg] = useState('')
|
|
|
|
|
|
|
|
|
|
const [syncLoading, setSyncLoading] = useState(false)
|
|
|
|
|
const [syncMsg, setSyncMsg] = useState('')
|
|
|
|
|
const [auth, setAuth] = useState<AuthConfig | null>(null)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetch('/api/auth/config').then(r => r.json()).then(setAuth).catch(() => {})
|
|
|
|
|
api.settings.get().then(s => {
|
|
|
|
|
if (s['app.name']) setName(s['app.name'])
|
|
|
|
|
if (s['app.log_level']) setLogLevel(s['app.log_level'])
|
|
|
|
|
if (s['log.retention_days'] !== undefined) setRetentionDays(Number(s['log.retention_days']))
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
function markDirty() { setDirty(true); setSaveMsg('') }
|
|
|
|
|
|
|
|
|
|
async function save() {
|
|
|
|
|
setSaving(true)
|
|
|
|
|
setSaveMsg('')
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all([
|
|
|
|
|
api.settings.put('app.name', name),
|
|
|
|
|
api.settings.put('app.log_level', logLevel),
|
|
|
|
|
api.settings.put('log.retention_days', retentionDays),
|
|
|
|
|
])
|
|
|
|
|
setDirty(false)
|
|
|
|
|
setSaveMsg('Saved')
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
setSaveMsg('Error: ' + e.message)
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function forceSync() {
|
|
|
|
|
setSyncLoading(true)
|
|
|
|
|
setSyncMsg('')
|
|
|
|
|
@@ -52,20 +94,25 @@ export default function Settings() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const issuer = auth?.auth_url
|
|
|
|
|
? new URL(auth.auth_url).origin + new URL(auth.auth_url).pathname.replace(/\/authorize$/, '')
|
|
|
|
|
: ''
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="animate-fade-up" style={{ maxWidth: 620 }}>
|
|
|
|
|
<Section title="Authentication"
|
|
|
|
|
badge={<span style={{ fontSize: 10, fontWeight: 600, padding: '2px 7px', borderRadius: 3, background: 'rgba(34,197,94,0.12)', color: '#4ade80', border: '1px solid rgba(34,197,94,0.2)' }}>OIDC</span>}>
|
|
|
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
|
|
|
<ReadonlyField label="OIDC Issuer URL" value={issuer} />
|
|
|
|
|
<ReadonlyField label="Client ID" value={auth?.client_id ?? ''} />
|
|
|
|
|
<p style={{ fontSize: 12, color: `${D}0.3)`, lineHeight: 1.5, margin: 0 }}>
|
|
|
|
|
Configured via environment variables (<code style={{ fontFamily: 'monospace' }}>GATEWAY_OIDC_*</code>).
|
|
|
|
|
</p>
|
|
|
|
|
<Section title="General">
|
|
|
|
|
<div style={{ display: 'grid', gap: 15 }}>
|
|
|
|
|
<Field label="Router name">
|
|
|
|
|
<input type="text" value={name} onChange={e => { setName(e.target.value); markDirty() }} style={inputStyle} />
|
|
|
|
|
</Field>
|
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
|
|
|
<Field label="Log level">
|
|
|
|
|
<select value={logLevel} onChange={e => { setLogLevel(e.target.value); markDirty() }} style={{ ...inputStyle, cursor: 'pointer' }}>
|
|
|
|
|
{LOG_LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label="Log retention">
|
|
|
|
|
<select value={retentionDays} onChange={e => { setRetentionDays(Number(e.target.value)); markDirty() }} style={{ ...inputStyle, cursor: 'pointer' }}>
|
|
|
|
|
{RETENTION_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</Field>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
@@ -86,6 +133,18 @@ export default function Settings() {
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 10 }}>
|
|
|
|
|
{saveMsg && <span style={{ fontSize: 12, color: saveMsg.startsWith('Error') ? '#f87171' : '#4ade80' }}>{saveMsg}</span>}
|
|
|
|
|
<button onClick={() => { setDirty(false); setSaveMsg('') }} disabled={!dirty || saving}
|
|
|
|
|
style={{ background: 'transparent', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 7, padding: '8px 16px', fontSize: 13, color: `${D}0.5)`, cursor: 'pointer', opacity: !dirty ? 0.4 : 1 }}>
|
|
|
|
|
Discard
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={save} disabled={!dirty || saving}
|
|
|
|
|
style={{ background: '#7c3aed', border: 'none', borderRadius: 7, padding: '8px 20px', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer', opacity: !dirty || saving ? 0.5 : 1 }}>
|
|
|
|
|
{saving ? 'Saving…' : 'Save changes'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|