feat: Implement multi-node support with MQTT and mDNS for service discovery and coordination

This commit is contained in:
2026-02-23 02:30:12 -08:00
parent eeaa5045d0
commit 3343e955fd
29 changed files with 1878 additions and 35 deletions

View File

@@ -0,0 +1,136 @@
import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText } from "lucide-react"
import type { GatewayInfo, HealthStatus } from "@/types"
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
import { HealthBadge } from "./HealthBadge"
interface GatewayPanelProps {
gateway: GatewayInfo
statuses: HealthStatus[]
}
export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s]))
const { mutate: reload, isPending: reloading } = useGatewayReload()
const [showCaddyfile, setShowCaddyfile] = useState(false)
const { data: caddyfileData } = useCaddyfile(showCaddyfile)
const multiNode = useMemo(() => {
const nodes = new Set(gateway.routes.map((r) => r.node))
return nodes.size > 1
}, [gateway.routes])
return (
<section className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2">
<Globe size={16} className="text-[var(--primary)]" />
<h2 className="font-semibold">Gateway</h2>
<span className="text-sm text-[var(--muted)]">
{gateway.hostname} &middot; port {gateway.port} &middot; {gateway.routes.length} route{gateway.routes.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => reload()}
disabled={reloading}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] hover:bg-[var(--border)]/80 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors disabled:opacity-40"
title="Regenerate Caddyfile and reload Caddy"
>
<RefreshCw size={12} className={reloading ? "animate-spin" : ""} />
Reload
</button>
<button
onClick={() => setShowCaddyfile((v) => !v)}
className={`flex items-center gap-1 text-xs px-2.5 py-1 rounded transition-colors ${
showCaddyfile
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
title="View generated Caddyfile"
>
<FileText size={12} />
Caddyfile
</button>
</div>
</div>
{/* Route table */}
{gateway.routes.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-4 py-2 font-medium text-[var(--muted)]">Path</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Component</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Port</th>
{multiNode && (
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
)}
<th className="px-4 py-2 font-medium text-[var(--muted)]">Health</th>
</tr>
</thead>
<tbody>
{gateway.routes.map((route) => {
const health = statusMap.get(route.component)
return (
<tr
key={route.path}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-black/20 transition-colors"
>
<td className="px-4 py-2 font-mono text-[var(--primary)]">
{route.path}
</td>
<td className="px-4 py-2">
<Link
to={`/component/${route.component}`}
className="hover:text-[var(--primary)] transition-colors"
>
{route.component}
</Link>
</td>
<td className="px-4 py-2 font-mono text-[var(--muted)]">
{route.target_port}
</td>
{multiNode && (
<td className="px-4 py-2">
<Link
to={`/node/${route.node}`}
className="text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
{route.node}
</Link>
</td>
)}
<td className="px-4 py-2">
{health ? (
<HealthBadge status={health.status} latency={health.latency_ms} />
) : (
<HealthBadge status="unknown" />
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
{gateway.routes.length === 0 && (
<p className="px-4 py-6 text-center text-[var(--muted)] text-sm">
No proxy routes configured.
</p>
)}
{/* Caddyfile viewer */}
{showCaddyfile && caddyfileData && (
<div className="border-t border-[var(--border)]">
<pre className="px-4 py-3 text-xs font-mono text-[var(--muted)] overflow-x-auto max-h-64 overflow-y-auto">
{caddyfileData.content}
</pre>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,67 @@
import { Link } from "react-router-dom"
import { Network, Wifi, WifiOff } from "lucide-react"
import { cn } from "@/lib/utils"
import type { MeshStatus } from "@/types"
interface MeshPanelProps {
mesh: MeshStatus
}
export function MeshPanel({ mesh }: MeshPanelProps) {
if (!mesh.enabled) return null
return (
<section className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)]">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<Network size={16} className="text-[var(--primary)]" />
<h2 className="font-semibold text-sm">Mesh</h2>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full",
mesh.mqtt_connected
? "bg-green-800/50 text-green-300"
: "bg-red-800/50 text-red-300",
)}
>
{mesh.mqtt_connected ? (
<Wifi size={10} />
) : (
<WifiOff size={10} />
)}
{mesh.mqtt_connected ? "connected" : "disconnected"}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-[var(--muted)]">
{mesh.mqtt_broker_host && (
<span className="font-mono">
mqtt://{mesh.mqtt_broker_host}:{mesh.mqtt_broker_port}
</span>
)}
{mesh.mdns_enabled && (
<span>mDNS active</span>
)}
<span>
{mesh.peer_count} peer{mesh.peer_count !== 1 ? "s" : ""}
</span>
</div>
</div>
{mesh.peers.length > 0 && (
<div className="border-t border-[var(--border)] px-4 py-2 flex items-center gap-2">
<span className="text-xs text-[var(--muted)]">Peers:</span>
{mesh.peers.map((peer) => (
<Link
key={peer}
to={`/node/${peer}`}
className="text-xs font-mono px-2 py-0.5 rounded bg-[var(--border)] hover:text-[var(--foreground)] transition-colors"
>
{peer}
</Link>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,41 @@
import { Link } from "react-router-dom"
import { cn } from "@/lib/utils"
import type { NodeSummary } from "@/types"
interface NodeBarProps {
nodes: NodeSummary[]
}
export function NodeBar({ nodes }: NodeBarProps) {
// Only show when there are remote nodes
if (nodes.length <= 1) return null
return (
<div className="flex items-center gap-2 mb-6">
{nodes.map((node) => (
<Link
key={node.hostname}
to={`/node/${node.hostname}`}
className={cn(
"flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md border transition-colors",
node.is_local
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--foreground)]"
: "border-[var(--border)] bg-[var(--card)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)]/20",
node.is_stale && "opacity-50",
)}
>
<span
className={cn(
"w-2 h-2 rounded-full",
node.online && !node.is_stale ? "bg-green-400" : "bg-zinc-500",
)}
/>
<span className="font-medium">{node.hostname}</span>
{!node.is_local && node.deployed_count > 0 && (
<span className="text-xs text-[var(--muted)]">({node.deployed_count})</span>
)}
</Link>
))}
</div>
)
}