refactor: replace CADDY_MANAGER with DEPLOY_ENV for instance-role awareness

DEPLOY_ENV=production now marks the primary instance globally - used for
Caddy ownership, the Dev/Prod header badge, and Caddy UI gating. Removes
build-time VITE_DEPLOY_ENV/import.meta.env.DEV from the header in favour
of the runtime API response (isProduction field in /api/auth/config).
This commit is contained in:
Brückner
2026-06-10 14:43:31 +02:00
parent 49cd0ae4f6
commit 515052fbda
5 changed files with 34 additions and 30 deletions

View File

@ -190,8 +190,8 @@ msg_info "Creating .env file for each instance"
for d in "${APP_DIR}" "${DEV_DIR}"; do
SECRET="$(openssl rand -hex 32)"
run "printf 'JWT_SECRET=\"%s\"\n' '${SECRET}' > ${d}/.env && chown ghostgrid:ghostgrid ${d}/.env && chmod 600 ${d}/.env"
# Only the production instance owns the shared Caddy (one Caddy per container).
[[ "$d" == "${APP_DIR}" ]] && run "printf 'CADDY_MANAGER=true\n' >> ${d}/.env"
# Only the production instance owns Caddy and shows "Production" in the UI.
[[ "$d" == "${APP_DIR}" ]] && run "printf 'DEPLOY_ENV=production\n' >> ${d}/.env"
done
msg_ok ".env files created (main + dev)"

View File

@ -15,10 +15,10 @@ const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toStrin
const JWT_SECRET = process.env.JWT_SECRET || 'ghostgrid-dev-secret-change-in-production';
const JWT_EXPIRY = '24h';
// One Caddy serves the whole container; only the instance with CADDY_MANAGER=true
// owns it (pushes config, seeds routes, accepts route edits). The other instance
// must never push — POST /load replaces the entire config and would clobber it.
const IS_CADDY_MANAGER = process.env.CADDY_MANAGER === 'true';
// DEPLOY_ENV=production marks the primary instance: it owns Caddy (pushes config,
// seeds routes, accepts route edits) and shows "Production" in the UI. The dev
// instance must never push to Caddy — POST /load replaces the entire config.
const IS_PRODUCTION = process.env.DEPLOY_ENV === 'production';
interface JwtPayload {
userId: string;
@ -153,7 +153,7 @@ function importCaddyfileRoutes(userId?: string): void {
}
async function pushCaddyConfig(): Promise<void> {
if (!IS_CADDY_MANAGER) return;
if (!IS_PRODUCTION) return;
if (getSetting('caddy_enabled') !== 'true') return;
const adminUrl = getSetting('caddy_admin_url') || 'http://localhost:2019';
const body = buildCaddyfile();
@ -180,7 +180,7 @@ async function startServer() {
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
}
if (IS_CADDY_MANAGER && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
if (IS_PRODUCTION && getSetting('caddy_enabled') === 'true' && getCaddyRoutes().length === 0) {
importCaddyfileRoutes();
}
@ -277,7 +277,7 @@ async function startServer() {
effectiveRedirectUri,
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
caddyManaged: IS_CADDY_MANAGER,
isProduction: IS_PRODUCTION,
});
});
@ -1205,7 +1205,7 @@ async function startServer() {
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const { hostname, upstream, tls, compress, redirectPath } = req.body as {
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean; redirectPath?: string;
};
@ -1225,7 +1225,7 @@ async function startServer() {
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const { hostname, upstream, tls, compress, redirectPath } = req.body as {
@ -1245,7 +1245,7 @@ async function startServer() {
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
try {
if (!IS_CADDY_MANAGER) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id);
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
const existing = getCaddyRouteById(id);

View File

@ -55,6 +55,7 @@ export default function App() {
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
const [isProduction, setIsProduction] = useState(false);
useEffect(() => {
const root = document.documentElement;
@ -143,7 +144,7 @@ export default function App() {
if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.json());
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); }
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); }
} catch (err) {
console.error('[App] Failed to load data:', err);
} finally {
@ -484,6 +485,7 @@ export default function App() {
theme={theme}
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
onLogout={handleLogout}
isProduction={isProduction}
/>
<div className="flex-1 flex flex-col md:flex-row">

View File

@ -11,6 +11,7 @@ interface HeaderProps {
theme: 'dark' | 'light';
onThemeToggle: () => void;
onLogout: () => void;
isProduction: boolean;
}
export default function Header({
@ -22,6 +23,7 @@ export default function Header({
theme,
onThemeToggle,
onLogout,
isProduction,
}: HeaderProps) {
const [showMailInbox, setShowMailInbox] = useState(false);
const [showBellDropdown, setShowBellDropdown] = useState(false);
@ -63,8 +65,8 @@ export default function Header({
{/* System Indicator */}
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
<span className={`w-2 h-2 rounded-full animate-pulse ${(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'bg-amber-400' : 'bg-emerald-500'}`} />
<span>System: {(import.meta.env.VITE_DEPLOY_ENV === 'dev' || import.meta.env.DEV) ? 'Development' : 'Production'}</span>
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span>System: {isProduction ? 'Production' : 'Development'}</span>
</div>
{/* Mail Inbox */}

View File

@ -193,7 +193,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [caddyEnabled, setCaddyEnabled] = useState(false);
const [caddyManaged, setCaddyManaged] = useState(true);
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://localhost:2019');
const [caddyAdminUrl, setCaddyAdminUrl] = useState('http://127.0.0.1:2019');
const [caddyStatus, setCaddyStatus] = useState<'unknown' | 'available' | 'unavailable'>('unknown');
const [caddyRoutes, setCaddyRoutes] = useState<CaddyRoute[]>([]);
const [addingRoute, setAddingRoute] = useState(false);
@ -225,7 +225,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
.then(r => r.json())
.then(d => {
if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri);
setCaddyManaged(d.caddyManaged !== false);
setCaddyManaged(d.isProduction !== false);
})
.catch(() => {});
}, []);
@ -265,7 +265,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setSemaphoreApiToken('');
setSemaphoreProjectId(data.semaphore_project_id || '');
setCaddyEnabled(data.caddy_enabled === 'true');
setCaddyAdminUrl(data.caddy_admin_url || 'http://localhost:2019');
setCaddyAdminUrl(data.caddy_admin_url || 'http://127.0.0.1:2019');
} catch {
setError('Network error loading settings.');
} finally {
@ -350,7 +350,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
return;
}
const templates = await res.json() as any[];
setSemaphoreTestResult(`Connected ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
setSemaphoreTestResult(`Connected - ${templates.length} task template${templates.length !== 1 ? 's' : ''} found.`);
} catch {
setSemaphoreTestResult('Error: Network error connecting to Semaphore.');
} finally {
@ -371,7 +371,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const res = await authFetch('/api/caddy/routes');
if (res.ok) setCaddyRoutes(await res.json());
} catch {}
// Status check runs separately purely informational, never blocks the list
// Status check runs separately - purely informational, never blocks the list
authFetch('/api/caddy/status')
.then(res => res.ok ? res.json() : null)
.then(s => setCaddyStatus(s?.available ? 'available' : 'unavailable'))
@ -570,7 +570,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
)}
{/* Section tabs switch between Integrations and System to keep the page light */}
{/* Section tabs - switch between Integrations and System to keep the page light */}
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
{([
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
@ -748,7 +748,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
/>
</FieldRow>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow label="Automation User" hint="Setup > Users > Automation user (e.g. automation)">
<FieldRow label="Automation User" hint="Setup > Users > Automation user">
<Input
value={checkmkApiUser}
onChange={setCheckmkApiUser}
@ -944,19 +944,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{caddyManaged && (
<div className={`space-y-5 transition-opacity duration-200 ${!caddyEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Caddy Admin URL" hint="Default: http://localhost:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://localhost:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
<FieldRow label="Caddy Admin URL" hint="Default: http://127.0.0.1:2019">
<Input value={caddyAdminUrl} onChange={setCaddyAdminUrl} placeholder="http://172.0.0.1:2019" monospace icon={<Globe className="w-3.5 h-3.5" />} />
</FieldRow>
{/* Route list */}
{caddyEnabled && (
<div className="space-y-2">
<Label>Proxy Routes</Label>
<Hint>Prefix the upstream with https:// for TLS backends (e.g. Semaphore) — the certificate is not verified.</Hint>
<Hint>Prefix the upstream with https:// for TLS backends - the certificate is not verified.</Hint>
{caddyStatus === 'unavailable' && (
<p className="text-[11px] font-mono text-amber-400 mb-2">
Caddy Admin API not reachable routes will be applied when Caddy starts.
Caddy Admin API not reachable - routes will be applied when Caddy starts.
</p>
)}
@ -1003,7 +1003,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<X className="w-3.5 h-3.5" />
</button>
</div>
<Input value={editRedirect} onChange={setEditRedirect} placeholder="Root redirect (optional), e.g. /monitoring/check_mk/" monospace />
<Input value={editRedirect} onChange={setEditRedirect} placeholder="root redirect" monospace />
</div>
) : (
<div className="flex items-center justify-between">
@ -1071,7 +1071,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{addingRoute ? 'Adding…' : 'Add'}
</button>
</div>
<Input value={newRedirect} onChange={setNewRedirect} placeholder="Root redirect (optional), e.g. /monitoring/check_mk/" monospace />
<Input value={newRedirect} onChange={setNewRedirect} placeholder="Root redirect (optional), e.g. /site1/" monospace />
</div>
</div>
)}
@ -1095,7 +1095,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
<div className="text-right">
<p className="text-xl font-bold text-white font-mono leading-none">
{dbInfo ? formatBytes(dbInfo.sizeBytes) : ''}
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
</p>
<p className="text-[10px] text-slate-500 font-mono mt-1">
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
@ -1172,7 +1172,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-300 leading-relaxed">
<strong>Import overwrites the entire database</strong> this cannot be undone.
<strong>Import overwrites the entire database</strong> - this cannot be undone.
</p>
</div>
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">