diff --git a/admin/src/App.css b/admin/src/App.css index a064a56f9aa..c0e02cd0e8a 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -34,6 +34,37 @@ textarea.settings:focus { border-top: 1px solid #ddd; } +/* --- env-var banner --- */ +/* Shown only when settings.json contains ${VAR} placeholders. The + typical reader is a Docker/K8s operator who has just been surprised + by env-var substitution semantics, so the copy must explain rather + than warn — visual weight matches a note, not an error. */ +.settings-envvar-banner { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + margin-bottom: 16px; + background: #f5f7ff; + border: 1px solid #c7d2fe; + border-radius: 6px; + color: #1e293b; +} +.settings-envvar-banner svg { + flex-shrink: 0; + color: #4f46e5; + margin-top: 2px; +} +.settings-envvar-banner strong { + display: block; + margin-bottom: 4px; +} +.settings-envvar-banner p { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + /* --- mode toggle --- */ .settings-mode-toggle { display: inline-flex; diff --git a/admin/src/App.tsx b/admin/src/App.tsx index b182be02fa9..d7395251b4f 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -32,12 +32,19 @@ export const App = () => { const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']}); const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']}) + // When the server explicitly rejects us for not being admin, we must + // NOT reconnect on the subsequent `disconnect` event — otherwise the + // socket cycles forever (connect → admin_auth_error → server + // disconnect → SPA auto-reconnect → …). The flag is reset on each + // successful connect. + let authErrored = false; pluginsSocket.on('connect', () => { useStore.getState().setPluginsSocket(pluginsSocket); }); settingSocket.on('connect', () => { + authErrored = false; useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setShowLoading(false) settingSocket.emit('load'); @@ -46,7 +53,7 @@ export const App = () => { settingSocket.on('disconnect', (reason) => { useStore.getState().setShowLoading(true) - if (reason === 'io server disconnect') settingSocket.connect(); + if (reason === 'io server disconnect' && !authErrored) settingSocket.connect(); }); settingSocket.on('settings', (settings: any) => { @@ -75,6 +82,22 @@ export const App = () => { const detail = payload?.message ?? ''; setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false}); } + }); + + // Backend emits this when the connecting socket does not have an + // admin session. Previously the server just dropped silently, so the + // SPA would sit on a loading screen with no clue. Surface it AND + // suppress the automatic reconnect — without this flag the SPA would + // immediately reconnect to a socket that will reject it again. + settingSocket.on('admin_auth_error', (payload?: {message?: string}) => { + authErrored = true; + const {setToastState} = useStore.getState(); + setToastState({ + open: true, + title: payload?.message || t('admin_settings.toast.auth_error'), + success: false, + }); + useStore.getState().setShowLoading(false); }) return () => { diff --git a/admin/src/components/settings/ModeToggle.tsx b/admin/src/components/settings/ModeToggle.tsx index 0f87f62ce84..bce7ef81d34 100644 --- a/admin/src/components/settings/ModeToggle.tsx +++ b/admin/src/components/settings/ModeToggle.tsx @@ -1,13 +1,17 @@ import { Trans, useTranslation } from 'react-i18next'; -export type Mode = 'form' | 'raw'; +export type Mode = 'form' | 'raw' | 'effective'; type Props = { mode: Mode; onChange: (mode: Mode) => void; + // When false, the Effective tab is hidden. We hide it for installs that + // aren't using env-var substitution at all — there's no useful difference + // between the raw file and the effective in-memory config for them. + showEffective?: boolean; }; -export const ModeToggle = ({ mode, onChange }: Props) => { +export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) => { const { t } = useTranslation(); return (
@@ -31,6 +35,19 @@ export const ModeToggle = ({ mode, onChange }: Props) => { > + {showEffective && ( + + )}
); }; diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index 7511b8e399f..8c5529ea806 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,22 +1,42 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useStore } from '../store/store'; import { isJSONClean, cleanComments } from '../utils/utils'; import { Trans, useTranslation } from 'react-i18next'; import { IconButton } from '../components/IconButton'; -import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react'; +import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react'; import { FormView } from '../components/settings/FormView'; import { ModeToggle, type Mode } from '../components/settings/ModeToggle'; const TAB_INDENT = ' '; +// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is +// running with env-var substitution (overwhelmingly Docker / Kubernetes). +// We use this to gate the Docker-aware UX (the explanatory banner and the +// Effective-config tab) so non-container installs see the existing UI +// unchanged. Conservative on purpose — false negatives just keep the old +// behaviour. +const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/; + export const SettingsPage = () => { const { t } = useTranslation(); const settingsSocket = useStore(state => state.settingsSocket); const settings = useStore(state => state.settings) ?? ''; + const resolved = useStore(state => state.resolved); + + const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]); const [mode, setMode] = useState('form'); const [exposeExperimental] = useState(false); + // The Effective tab is only meaningful when there is a `resolved` + // payload AND the file uses substitution. Falling back to Raw on + // either condition keeps the toggle honest if the user opens this + // page against an older server. + const canShowEffective = usesEnvVars && resolved != null; + useEffect(() => { + if (mode === 'effective' && !canShowEffective) setMode('raw'); + }, [mode, canShowEffective]); + // Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders. const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Tab') return; @@ -57,49 +77,80 @@ export const SettingsPage = () => { settingsSocket.emit('saveSettings', settings); }; + const effectiveJson = useMemo(() => { + if (resolved == null) return ''; + try { return JSON.stringify(resolved, null, 2); } catch { return ''; } + }, [resolved]); + return (

- - - {mode === 'form' - ? setMode('raw')} /> - : ( -