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
@@ -39,12 +39,15 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
const [editHost, setEditHost] = useState<L4ProxyHost | null>(null);
const [deleteHost, setDeleteHost] = useState<L4ProxyHost | null>(null);
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [bannerRefresh, setBannerRefresh] = useState(0);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const signalBannerRefresh = () => setBannerRefresh(n => n + 1);
useEffect(() => {
setSearchTerm(initialSearch);
}, [initialSearch]);
@@ -66,6 +69,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
const handleToggleEnabled = async (id: number, enabled: boolean) => {
await toggleL4ProxyHostAction(id, enabled);
signalBannerRefresh();
};
const columns = [
@@ -215,7 +219,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
return (
<Stack spacing={4}>
<L4PortsApplyBanner />
<L4PortsApplyBanner refreshSignal={bannerRefresh} />
<PageHeader
title="L4 Proxy Hosts"
description="Define TCP/UDP stream proxies powered by caddy-l4. Port mappings are applied automatically by the L4 port manager."
@@ -245,6 +249,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
onClose={() => {
setCreateOpen(false);
setTimeout(() => setDuplicateHost(null), 200);
signalBannerRefresh();
}}
initialData={duplicateHost}
/>
@@ -253,7 +258,10 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
<EditL4HostDialog
open={!!editHost}
host={editHost}
onClose={() => setEditHost(null)}
onClose={() => {
setEditHost(null);
signalBannerRefresh();
}}
/>
)}
@@ -261,7 +269,10 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch }:
<DeleteL4HostDialog
open={!!deleteHost}
host={deleteHost}
onClose={() => setDeleteHost(null)}
onClose={() => {
setDeleteHost(null);
signalBannerRefresh();
}}
/>
)}
</Stack>