From 6fb39dc809c97c866518e27c8731900a2f55a36d Mon Sep 17 00:00:00 2001 From: fuomag9 <1580624+fuomag9@users.noreply.github.com> Date: Sun, 25 Jan 2026 01:39:36 +0100 Subject: [PATCH] Implement slave-master architecture --- app/(dashboard)/settings/SettingsClient.tsx | 367 +++++- app/(dashboard)/settings/actions.ts | 262 +++- app/(dashboard)/settings/page.tsx | 51 +- app/api/instances/sync/route.ts | 92 ++ drizzle/0003_instances.sql | 13 + drizzle/meta/0003_snapshot.json | 1203 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + proxy.ts | 7 +- src/instrumentation.ts | 24 + src/lib/caddy.ts | 3 + src/lib/db/schema.ts | 18 + src/lib/instance-sync.ts | 407 +++++++ src/lib/models/instances.ts | 117 ++ src/lib/settings.ts | 47 +- 14 files changed, 2598 insertions(+), 20 deletions(-) create mode 100644 app/api/instances/sync/route.ts create mode 100644 drizzle/0003_instances.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/lib/instance-sync.ts create mode 100644 src/lib/models/instances.ts diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx index 6d7b075a..e88db470 100644 --- a/app/(dashboard)/settings/SettingsClient.tsx +++ b/app/(dashboard)/settings/SettingsClient.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { useFormState } from "react-dom"; import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material"; import type { GeneralSettings, AuthentikSettings, MetricsSettings, LoggingSettings, DnsSettings } from "@/src/lib/settings"; @@ -9,7 +10,13 @@ import { updateAuthentikSettingsAction, updateMetricsSettingsAction, updateLoggingSettingsAction, - updateDnsSettingsAction + updateDnsSettingsAction, + updateInstanceModeAction, + updateSlaveMasterTokenAction, + createSlaveInstanceAction, + deleteSlaveInstanceAction, + toggleSlaveInstanceAction, + syncSlaveInstancesAction } from "./actions"; type Props = { @@ -23,15 +30,60 @@ type Props = { metrics: MetricsSettings | null; logging: LoggingSettings | null; dns: DnsSettings | null; + instanceSync: { + mode: "standalone" | "master" | "slave"; + modeFromEnv: boolean; + tokenFromEnv: boolean; + overrides: { + general: boolean; + cloudflare: boolean; + authentik: boolean; + metrics: boolean; + logging: boolean; + dns: boolean; + }; + slave: { + hasToken: boolean; + lastSyncAt: string | null; + lastSyncError: string | null; + } | null; + master: { + instances: Array<{ + id: number; + name: string; + base_url: string; + enabled: boolean; + last_sync_at: string | null; + last_sync_error: string | null; + }>; + envInstances: Array<{ + name: string; + url: string; + }>; + } | null; + }; }; -export default function SettingsClient({ general, cloudflare, authentik, metrics, logging, dns }: Props) { +export default function SettingsClient({ general, cloudflare, authentik, metrics, logging, dns, instanceSync }: Props) { const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null); const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null); const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null); const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null); const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null); const [dnsState, dnsFormAction] = useFormState(updateDnsSettingsAction, null); + const [instanceModeState, instanceModeFormAction] = useFormState(updateInstanceModeAction, null); + const [slaveTokenState, slaveTokenFormAction] = useFormState(updateSlaveMasterTokenAction, null); + const [slaveInstanceState, slaveInstanceFormAction] = useFormState(createSlaveInstanceAction, null); + const [syncState, syncFormAction] = useFormState(syncSlaveInstancesAction, null); + + const isSlave = instanceSync.mode === "slave"; + const isMaster = instanceSync.mode === "master"; + const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general); + const [cloudflareOverride, setCloudflareOverride] = useState(instanceSync.overrides.cloudflare); + const [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik); + const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics); + const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging); + const [dnsOverride, setDnsOverride] = useState(instanceSync.overrides.dns); return ( @@ -42,6 +94,222 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics Configure organization-wide defaults and DNS automation. + + + + Instance Sync + + + Choose whether this instance acts independently, pushes configuration to slave nodes, or pulls configuration from a master. + + + {instanceSync.modeFromEnv && ( + + Instance mode is configured via INSTANCE_MODE environment variable and cannot be changed at runtime. + + )} + {instanceModeState?.message && ( + + {instanceModeState.message} + + )} + + Standalone + Master + Slave + + + + + + + {isSlave && ( + + + Master Connection + + + {instanceSync.tokenFromEnv && ( + + Sync token is configured via INSTANCE_SYNC_TOKEN environment variable and cannot be changed at runtime. + + )} + {slaveTokenState?.message && ( + + {slaveTokenState.message} + + )} + {instanceSync.slave?.hasToken && !instanceSync.tokenFromEnv && ( + + A master sync token is configured. Leave the token field blank to keep it, or select "Remove existing token" to delete it. + + )} + + } + label="Remove existing token" + disabled={!instanceSync.slave?.hasToken || instanceSync.tokenFromEnv} + /> + + + + + + {instanceSync.slave?.lastSyncAt + ? `Last sync: ${instanceSync.slave.lastSyncAt}${instanceSync.slave.lastSyncError ? ` (${instanceSync.slave.lastSyncError})` : ""}` + : "No sync payload has been received yet."} + + + )} + + {isMaster && ( + + + Slave Instances + + + {slaveInstanceState?.message && ( + + {slaveInstanceState.message} + + )} + + + + + + + + + + {syncState?.message && ( + + {syncState.message} + + )} + + + + + + {instanceSync.master?.instances.length === 0 && instanceSync.master?.envInstances.length === 0 && ( + No slave instances configured yet. + )} + + {instanceSync.master?.envInstances && instanceSync.master.envInstances.length > 0 && ( + <> + + Environment-configured instances (via INSTANCE_SLAVES) + + {instanceSync.master.envInstances.map((instance, index) => ( + + + {instance.name} + + {instance.url} + + + Configured via environment variable + + + + ))} + + )} + + {instanceSync.master?.instances && instanceSync.master.instances.length > 0 && ( + + UI-configured instances + + )} + {instanceSync.master?.instances.map((instance) => ( + + + {instance.name} + + {instance.base_url} + + + {instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"} + + {instance.last_sync_error && ( + + {instance.last_sync_error} + + )} + + + + + + + + + + + + + + ))} + + )} + + + @@ -53,11 +321,24 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics {generalState.message} )} + {isSlave && ( + setGeneralOverride(event.target.checked)} + /> + } + label="Override master settings" + /> + )} @@ -95,21 +377,34 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics {cloudflareState.message} )} + {isSlave && ( + setCloudflareOverride(event.target.checked)} + /> + } + label="Override master settings" + /> + )} } label="Remove existing token" - disabled={!cloudflare.hasToken} + disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)} /> - - + +