|
1 | | -import React, { useState } from 'react'; |
| 1 | +import React, { useEffect, useMemo, useState } from 'react'; |
2 | 2 | import { useStore } from '../store/store'; |
3 | 3 | import { isJSONClean, cleanComments } from '../utils/utils'; |
4 | 4 | import { Trans, useTranslation } from 'react-i18next'; |
5 | 5 | import { IconButton } from '../components/IconButton'; |
6 | | -import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react'; |
| 6 | +import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react'; |
7 | 7 | import { FormView } from '../components/settings/FormView'; |
8 | 8 | import { ModeToggle, type Mode } from '../components/settings/ModeToggle'; |
9 | 9 |
|
10 | 10 | const TAB_INDENT = ' '; |
11 | 11 |
|
| 12 | +// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is |
| 13 | +// running with env-var substitution (overwhelmingly Docker / Kubernetes). |
| 14 | +// We use this to gate the Docker-aware UX (the explanatory banner and the |
| 15 | +// Effective-config tab) so non-container installs see the existing UI |
| 16 | +// unchanged. Conservative on purpose — false negatives just keep the old |
| 17 | +// behaviour. |
| 18 | +const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/; |
| 19 | + |
12 | 20 | export const SettingsPage = () => { |
13 | 21 | const { t } = useTranslation(); |
14 | 22 | const settingsSocket = useStore(state => state.settingsSocket); |
15 | 23 | const settings = useStore(state => state.settings) ?? ''; |
| 24 | + const resolved = useStore(state => state.resolved); |
| 25 | + |
| 26 | + const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]); |
16 | 27 |
|
17 | 28 | const [mode, setMode] = useState<Mode>('form'); |
18 | 29 | const [exposeExperimental] = useState(false); |
19 | 30 |
|
| 31 | + // The Effective tab is only meaningful when there is a `resolved` |
| 32 | + // payload AND the file uses substitution. Falling back to Raw on |
| 33 | + // either condition keeps the toggle honest if the user opens this |
| 34 | + // page against an older server. |
| 35 | + const canShowEffective = usesEnvVars && resolved != null; |
| 36 | + useEffect(() => { |
| 37 | + if (mode === 'effective' && !canShowEffective) setMode('raw'); |
| 38 | + }, [mode, canShowEffective]); |
| 39 | + |
20 | 40 | // Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders. |
21 | 41 | const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
22 | 42 | if (e.key !== 'Tab') return; |
@@ -57,49 +77,80 @@ export const SettingsPage = () => { |
57 | 77 | settingsSocket.emit('saveSettings', settings); |
58 | 78 | }; |
59 | 79 |
|
| 80 | + const effectiveJson = useMemo(() => { |
| 81 | + if (resolved == null) return ''; |
| 82 | + try { return JSON.stringify(resolved, null, 2); } catch { return ''; } |
| 83 | + }, [resolved]); |
| 84 | + |
60 | 85 | return ( |
61 | 86 | <div className="settings-page"> |
62 | 87 | <h1><Trans i18nKey="admin_settings.current" /></h1> |
63 | 88 |
|
64 | | - <ModeToggle mode={mode} onChange={setMode} /> |
65 | | - |
66 | | - {mode === 'form' |
67 | | - ? <FormView onSwitchToRaw={() => setMode('raw')} /> |
68 | | - : ( |
69 | | - <textarea |
70 | | - value={settings} |
71 | | - className="settings" |
72 | | - data-testid="settings-raw-textarea" |
73 | | - spellCheck={false} |
74 | | - onKeyDown={handleKeyDown} |
75 | | - onChange={v => useStore.getState().setSettings(v.target.value)} |
76 | | - /> |
77 | | - ) |
78 | | - } |
| 89 | + {usesEnvVars && ( |
| 90 | + <div |
| 91 | + className="settings-envvar-banner" |
| 92 | + role="note" |
| 93 | + data-testid="settings-envvar-banner" |
| 94 | + > |
| 95 | + <Info size={18} aria-hidden="true" /> |
| 96 | + <div> |
| 97 | + <strong><Trans i18nKey="admin_settings.envvar_banner.title" /></strong> |
| 98 | + <p><Trans i18nKey="admin_settings.envvar_banner.body" /></p> |
| 99 | + </div> |
| 100 | + </div> |
| 101 | + )} |
79 | 102 |
|
80 | | - <div className="settings-button-bar"> |
81 | | - <IconButton |
82 | | - className="settingsButton" |
83 | | - data-testid="save-settings-button" |
84 | | - icon={<Save />} |
85 | | - title={<Trans i18nKey="admin_settings.current_save.value" />} |
86 | | - onClick={handleSave} |
| 103 | + <ModeToggle mode={mode} onChange={setMode} showEffective={canShowEffective} /> |
| 104 | + |
| 105 | + {mode === 'form' && <FormView onSwitchToRaw={() => setMode('raw')} />} |
| 106 | + {mode === 'raw' && ( |
| 107 | + <textarea |
| 108 | + value={settings} |
| 109 | + className="settings" |
| 110 | + data-testid="settings-raw-textarea" |
| 111 | + spellCheck={false} |
| 112 | + onKeyDown={handleKeyDown} |
| 113 | + onChange={v => useStore.getState().setSettings(v.target.value)} |
87 | 114 | /> |
88 | | - <IconButton |
89 | | - className="settingsButton" |
90 | | - data-testid="test-settings-button" |
91 | | - icon={<ShieldCheck />} |
92 | | - title={<Trans i18nKey="admin_settings.current_test.value" />} |
93 | | - onClick={testJSON} |
| 115 | + )} |
| 116 | + {mode === 'effective' && ( |
| 117 | + <textarea |
| 118 | + value={effectiveJson} |
| 119 | + className="settings" |
| 120 | + data-testid="settings-effective-textarea" |
| 121 | + spellCheck={false} |
| 122 | + readOnly |
| 123 | + aria-readonly="true" |
94 | 124 | /> |
95 | | - {exposeExperimental && ( |
96 | | - <IconButton |
97 | | - className="settingsButton" |
98 | | - data-testid="prettify-settings-button" |
99 | | - icon={<AlignLeft />} |
100 | | - title={<Trans i18nKey="admin_settings.current_prettify.value" />} |
101 | | - onClick={prettifyJSON} |
102 | | - /> |
| 125 | + )} |
| 126 | + |
| 127 | + <div className="settings-button-bar"> |
| 128 | + {mode !== 'effective' && ( |
| 129 | + <> |
| 130 | + <IconButton |
| 131 | + className="settingsButton" |
| 132 | + data-testid="save-settings-button" |
| 133 | + icon={<Save />} |
| 134 | + title={<Trans i18nKey="admin_settings.current_save.value" />} |
| 135 | + onClick={handleSave} |
| 136 | + /> |
| 137 | + <IconButton |
| 138 | + className="settingsButton" |
| 139 | + data-testid="test-settings-button" |
| 140 | + icon={<ShieldCheck />} |
| 141 | + title={<Trans i18nKey="admin_settings.current_test.value" />} |
| 142 | + onClick={testJSON} |
| 143 | + /> |
| 144 | + {exposeExperimental && ( |
| 145 | + <IconButton |
| 146 | + className="settingsButton" |
| 147 | + data-testid="prettify-settings-button" |
| 148 | + icon={<AlignLeft />} |
| 149 | + title={<Trans i18nKey="admin_settings.current_prettify.value" />} |
| 150 | + onClick={prettifyJSON} |
| 151 | + /> |
| 152 | + )} |
| 153 | + </> |
103 | 154 | )} |
104 | 155 | <IconButton |
105 | 156 | className="settingsButton" |
|
0 commit comments