feat: Implement multi-node support with MQTT and mDNS for service discovery and coordination
This commit is contained in:
136
app/src/components/GatewayPanel.tsx
Normal file
136
app/src/components/GatewayPanel.tsx
Normal 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} · port {gateway.port} · {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>
|
||||
)
|
||||
}
|
||||
67
app/src/components/MeshPanel.tsx
Normal file
67
app/src/components/MeshPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
app/src/components/NodeBar.tsx
Normal file
41
app/src/components/NodeBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks"
|
||||
import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
|
||||
import { GatewayPanel } from "@/components/GatewayPanel"
|
||||
import { MeshPanel } from "@/components/MeshPanel"
|
||||
import { NodeBar } from "@/components/NodeBar"
|
||||
import { ServiceSection } from "@/components/ServiceSection"
|
||||
import { JobSection } from "@/components/JobSection"
|
||||
import { ToolSection } from "@/components/ToolSection"
|
||||
@@ -11,6 +14,8 @@ export function Dashboard() {
|
||||
const { data: components, isLoading } = useComponents()
|
||||
const { data: statusResp } = useStatus()
|
||||
const { data: gateway } = useGateway()
|
||||
const { data: nodes } = useNodes()
|
||||
const { data: mesh } = useMeshStatus()
|
||||
|
||||
const { services, jobs, tools, frontends, other } = useMemo(() => {
|
||||
const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components }
|
||||
@@ -30,17 +35,23 @@ export function Dashboard() {
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Castle</h1>
|
||||
<p className="text-[var(--muted)] mt-1">
|
||||
Personal software platform
|
||||
{gateway && (
|
||||
<span className="ml-2 text-sm">
|
||||
· {services.length} services · {jobs.length} jobs
|
||||
· {tools.length} tools · port {gateway.port}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[var(--muted)] mt-1">Personal software platform</p>
|
||||
</div>
|
||||
|
||||
{nodes && <NodeBar nodes={nodes} />}
|
||||
|
||||
{gateway && (
|
||||
<div className="mb-6">
|
||||
<GatewayPanel gateway={gateway} statuses={statuses} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mesh && (
|
||||
<div className="mb-10">
|
||||
<MeshPanel mesh={mesh} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[var(--muted)]">Loading...</p>
|
||||
) : (
|
||||
|
||||
106
app/src/pages/NodeDetail.tsx
Normal file
106
app/src/pages/NodeDetail.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useParams, Link } from "react-router-dom"
|
||||
import { ArrowLeft, Server } from "lucide-react"
|
||||
import { useNode } from "@/services/api/hooks"
|
||||
import { RoleBadge } from "@/components/RoleBadge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function NodeDetailPage() {
|
||||
const { hostname } = useParams<{ hostname: string }>()
|
||||
const { data: node, isLoading, error } = useNode(hostname ?? "")
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<p className="text-[var(--muted)]">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !node) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<Link to="/" className="text-[var(--muted)] hover:text-[var(--foreground)] flex items-center gap-1 mb-4">
|
||||
<ArrowLeft size={14} /> Back
|
||||
</Link>
|
||||
<p className="text-red-400">Node "{hostname}" not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||
<Link to="/" className="text-[var(--muted)] hover:text-[var(--foreground)] flex items-center gap-1 mb-4">
|
||||
<ArrowLeft size={14} /> Back
|
||||
</Link>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server size={20} className="text-[var(--primary)]" />
|
||||
<h1 className="text-2xl font-bold">{node.hostname}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full",
|
||||
node.online ? "bg-green-800/50 text-green-300" : "bg-zinc-700/50 text-zinc-400",
|
||||
)}
|
||||
>
|
||||
<span className={cn("w-1.5 h-1.5 rounded-full", node.online ? "bg-green-400" : "bg-zinc-500")} />
|
||||
{node.online ? "online" : "offline"}
|
||||
</span>
|
||||
{node.is_local && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-800/50 text-blue-300">local</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--muted)] mt-1">
|
||||
Gateway port {node.gateway_port} · {node.deployed_count} deployed · {node.service_count} services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Deployed components table */}
|
||||
{node.deployed.length > 0 ? (
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Component</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Category</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{node.deployed.map((comp) => (
|
||||
<tr
|
||||
key={comp.id}
|
||||
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2.5">
|
||||
<Link
|
||||
to={`/component/${comp.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{comp.id}
|
||||
</Link>
|
||||
{comp.description && (
|
||||
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">{comp.description}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<RoleBadge role={comp.category} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{comp.runner ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||
{comp.port ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[var(--muted)]">No deployed components on this node.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createBrowserRouter } from "react-router-dom"
|
||||
import { Dashboard } from "@/pages/Dashboard"
|
||||
import { ComponentDetailPage } from "@/pages/ComponentDetail"
|
||||
import { NodeDetailPage } from "@/pages/NodeDetail"
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -11,4 +12,8 @@ export const router = createBrowserRouter([
|
||||
path: "/component/:name",
|
||||
element: <ComponentDetailPage />,
|
||||
},
|
||||
{
|
||||
path: "/node/:hostname",
|
||||
element: <NodeDetailPage />,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
GatewayInfo,
|
||||
ServiceActionResponse,
|
||||
SSEHealthEvent,
|
||||
MeshStatus,
|
||||
NodeSummary,
|
||||
NodeDetail,
|
||||
ToolSummary,
|
||||
ToolDetail,
|
||||
} from "@/types"
|
||||
@@ -107,6 +110,39 @@ export function useToolAction() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useGatewayReload() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post<{ status: string }>("/gateway/reload"),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["gateway"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useNodes() {
|
||||
return useQuery({
|
||||
queryKey: ["nodes"],
|
||||
queryFn: () => apiClient.get<NodeSummary[]>("/nodes"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useNode(hostname: string) {
|
||||
return useQuery({
|
||||
queryKey: ["nodes", hostname],
|
||||
queryFn: () => apiClient.get<NodeDetail>(`/nodes/${hostname}`),
|
||||
enabled: !!hostname,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMeshStatus() {
|
||||
return useQuery({
|
||||
queryKey: ["mesh"],
|
||||
queryFn: () => apiClient.get<MeshStatus>("/mesh/status"),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useTools() {
|
||||
return useQuery({
|
||||
queryKey: ["tools"],
|
||||
@@ -140,6 +176,13 @@ export function useEventStream() {
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
})
|
||||
|
||||
es.addEventListener("mesh", () => {
|
||||
// A remote node updated or went offline — refresh mesh, nodes, and gateway
|
||||
qc.invalidateQueries({ queryKey: ["mesh"] })
|
||||
qc.invalidateQueries({ queryKey: ["nodes"] })
|
||||
qc.invalidateQueries({ queryKey: ["gateway"] })
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
// EventSource auto-reconnects; nothing to do
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ComponentSummary {
|
||||
system_dependencies: string[]
|
||||
schedule: string | null
|
||||
installed: boolean | null
|
||||
node: string | null
|
||||
}
|
||||
|
||||
export interface ComponentDetail extends ComponentSummary {
|
||||
@@ -35,11 +36,20 @@ export interface StatusResponse {
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
export interface GatewayRoute {
|
||||
path: string
|
||||
target_port: number
|
||||
component: string
|
||||
node: string
|
||||
}
|
||||
|
||||
export interface GatewayInfo {
|
||||
port: number
|
||||
hostname: string
|
||||
component_count: number
|
||||
service_count: number
|
||||
managed_count: number
|
||||
routes: GatewayRoute[]
|
||||
}
|
||||
|
||||
export interface ServiceActionResponse {
|
||||
@@ -59,6 +69,31 @@ export interface SSEServiceActionEvent {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface NodeSummary {
|
||||
hostname: string
|
||||
gateway_port: number
|
||||
deployed_count: number
|
||||
service_count: number
|
||||
is_local: boolean
|
||||
online: boolean
|
||||
is_stale: boolean
|
||||
last_seen: number | null
|
||||
}
|
||||
|
||||
export interface NodeDetail extends NodeSummary {
|
||||
deployed: ComponentSummary[]
|
||||
}
|
||||
|
||||
export interface MeshStatus {
|
||||
enabled: boolean
|
||||
mqtt_connected: boolean
|
||||
mqtt_broker_host: string | null
|
||||
mqtt_broker_port: number | null
|
||||
mdns_enabled: boolean
|
||||
peer_count: number
|
||||
peers: string[]
|
||||
}
|
||||
|
||||
export interface ToolSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
|
||||
Reference in New Issue
Block a user