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}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {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)}
/>
-
-
+
+