feat: instant banner refresh on L4 mutations + master-slave L4 sync

Banner (L4PortsApplyBanner):
- Accept refreshSignal prop; re-fetch /api/l4-ports when it changes
- Signal fires immediately after create/edit/delete/toggle in L4ProxyHostsClient
  without waiting for a page reload

Master-slave replication (instance-sync):
- Add l4ProxyHosts to SyncPayload.data (optional for backward compat
  with older master instances that don't include it)
- buildSyncPayload: query and include l4ProxyHosts, sanitize ownerUserId
- applySyncPayload: clear and re-insert l4ProxyHosts in transaction;
  call applyL4Ports() if port diff requires it so the slave's sidecar
  recreates caddy with the correct ports
- Sync route: add isL4ProxyHost validator; backfill missing field from
  old masters; validate array when present

Tests (25 new tests):
- instance-sync.test.ts: buildSyncPayload includes L4 data, sanitizes ownerUserId;
  applySyncPayload replaces L4 hosts, handles missing field, writes trigger
  when ports differ, skips trigger when ports already match
- l4-ports-apply-banner.test.ts: banner refreshSignal contract + client
  increments counter on all mutation paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-03-22 00:22:44 +01:00
parent 3a4a4d51cf
commit 00c9bff8b4
6 changed files with 378 additions and 11 deletions

View File

@@ -25,7 +25,7 @@ type PortsResponse = {
error?: string;
};
export function L4PortsApplyBanner() {
export function L4PortsApplyBanner({ refreshSignal }: { refreshSignal?: number }) {
const [data, setData] = useState<PortsResponse | null>(null);
const [applying, setApplying] = useState(false);
const [polling, setPolling] = useState(false);
@@ -41,11 +41,17 @@ export function L4PortsApplyBanner() {
}
}, []);
// Initial fetch and poll when pending/applying
// Initial fetch on mount
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Re-fetch when the parent signals a mutation (create/edit/delete/toggle)
useEffect(() => {
if (!refreshSignal) return;
fetchStatus();
}, [refreshSignal, fetchStatus]);
useEffect(() => {
if (!data) return;
const shouldPoll = data.status.state === "pending" || data.status.state === "applying";