ui: drop Graph page, add host switcher to left nav
The System Map subsumes the read-only Graph diagnostic, so remove the page, its route, and nav entry (the /graph API + useGraph stay — the map and palette consume them). Add a host switcher under the sidebar brand: it shows which castle host you're driving (the is_local node) and, when peers are present, jumps to a peer's dashboard at castle.<domain>, preserving the current view. Node summaries now carry gateway_domain so the switcher can build each host's URL.
This commit is contained in:
101
app/src/components/HostSwitcher.tsx
Normal file
101
app/src/components/HostSwitcher.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Check, ChevronsUpDown, ExternalLink, Hexagon } from "lucide-react"
|
||||||
|
import { useNodes } from "@/services/api/hooks"
|
||||||
|
import type { NodeSummary } from "@/types"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Each machine's castle is served at its own origin (castle.<domain>), so hopping
|
||||||
|
// between hosts is a cross-origin navigation. This shows which host you're driving
|
||||||
|
// (the is_local node) and lets you jump to a peer's dashboard, preserving the view.
|
||||||
|
function dashboardUrl(n: NodeSummary): string {
|
||||||
|
const base = n.gateway_domain
|
||||||
|
? `https://castle.${n.gateway_domain}`
|
||||||
|
: `http://${n.hostname}:${n.gateway_port}`
|
||||||
|
return `${base}${window.location.pathname}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HostSwitcher({ collapsed }: { collapsed: boolean }) {
|
||||||
|
const { data: nodes } = useNodes()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener("mousedown", onDown)
|
||||||
|
return () => window.removeEventListener("mousedown", onDown)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const list = nodes ?? []
|
||||||
|
const current = list.find((n) => n.is_local)
|
||||||
|
const others = list.filter((n) => !n.is_local)
|
||||||
|
const label = current?.hostname ?? "this node"
|
||||||
|
const canSwitch = others.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative px-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => canSwitch && setOpen((o) => !o)}
|
||||||
|
title={collapsed ? `Host: ${label}` : canSwitch ? "Switch host" : `Host: ${label}`}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md border border-[var(--border)] px-2 py-1.5 text-sm",
|
||||||
|
canSwitch ? "hover:bg-[var(--card)]" : "cursor-default",
|
||||||
|
collapsed && "justify-center px-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Hexagon size={16} className="shrink-0 text-[var(--primary)]" />
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left font-medium text-[var(--foreground)]">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{canSwitch && <ChevronsUpDown size={14} className="shrink-0 text-[var(--muted)]" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-2 z-50 mt-1 min-w-[190px] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--card)] shadow-xl">
|
||||||
|
<div className="border-b border-[var(--border)] px-2.5 py-1 text-[10px] uppercase tracking-wide text-[var(--muted)]">
|
||||||
|
Castle hosts
|
||||||
|
</div>
|
||||||
|
{list.map((n) => {
|
||||||
|
const isCur = n.is_local
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<Hexagon
|
||||||
|
size={14}
|
||||||
|
className={cn("shrink-0", isCur ? "text-[var(--primary)]" : "text-[var(--muted)]")}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{n.hostname}</span>
|
||||||
|
{isCur ? (
|
||||||
|
<Check size={13} className="shrink-0 text-[var(--primary)]" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink size={12} className="shrink-0 text-[var(--muted)]" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
const cls = "flex items-center gap-2 px-2.5 py-2 text-sm"
|
||||||
|
if (isCur)
|
||||||
|
return (
|
||||||
|
<div key={n.hostname} className={cn(cls, "bg-[var(--primary)]/10")}>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={n.hostname}
|
||||||
|
href={dashboardUrl(n)}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className={cn(cls, "hover:bg-white/5", !n.online && "opacity-40")}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Share2,
|
Share2,
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
Network,
|
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -23,6 +22,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useEventStream } from "@/services/api/hooks"
|
import { useEventStream } from "@/services/api/hooks"
|
||||||
import { AssistantDock } from "@/components/AssistantDock"
|
import { AssistantDock } from "@/components/AssistantDock"
|
||||||
import { CommandPalette } from "@/components/CommandPalette"
|
import { CommandPalette } from "@/components/CommandPalette"
|
||||||
|
import { HostSwitcher } from "@/components/HostSwitcher"
|
||||||
|
|
||||||
type NavLeaf = { to: string; label: string; icon: LucideIcon; end?: boolean }
|
type NavLeaf = { to: string; label: string; icon: LucideIcon; end?: boolean }
|
||||||
type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
|
type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
|
||||||
@@ -42,7 +42,6 @@ const NAV: (NavLeaf | NavGroup)[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/programs", label: "Programs", icon: Package },
|
{ to: "/programs", label: "Programs", icon: Package },
|
||||||
{ to: "/graph", label: "Graph", icon: Network },
|
|
||||||
{ to: "/map", label: "System Map", icon: MapIcon },
|
{ to: "/map", label: "System Map", icon: MapIcon },
|
||||||
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
||||||
]
|
]
|
||||||
@@ -202,6 +201,7 @@ export function Layout() {
|
|||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<HostSwitcher collapsed={false} />
|
||||||
<NavItems collapsed={false} onNavigate={() => setMobileOpen(false)} />
|
<NavItems collapsed={false} onNavigate={() => setMobileOpen(false)} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,6 +222,7 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
<Brand collapsed={collapsed} />
|
<Brand collapsed={collapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
<HostSwitcher collapsed={collapsed} />
|
||||||
<NavItems collapsed={collapsed} />
|
<NavItems collapsed={collapsed} />
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed((c) => !c)}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
import { AlertTriangle, CheckCircle2, GitFork, Layers, Link2, Package } from "lucide-react"
|
|
||||||
import { useGraph } from "@/services/api/hooks"
|
|
||||||
import type { GraphNode } from "@/types"
|
|
||||||
|
|
||||||
// A read-only diagnostic of how programs/deployments relate — repos (provenance),
|
|
||||||
// `requires` edges (dependency), and the derived predicates functional?/fresh?.
|
|
||||||
// Everything is computed server-side from git + config; nothing is stored.
|
|
||||||
export function GraphPage() {
|
|
||||||
const { data, isLoading, error } = useGraph()
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div className="max-w-4xl mx-auto px-6 py-8 text-[var(--muted)]">Loading…</div>
|
|
||||||
}
|
|
||||||
if (error || !data) {
|
|
||||||
return <div className="max-w-4xl mx-auto px-6 py-8 text-red-400">Failed to load graph.</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const monorepos = data.repos.filter((r) => r.programs.length > 1)
|
|
||||||
const staleRepos = data.repos.filter((r) => r.fresh === false)
|
|
||||||
const depEdges = data.edges.filter((e) => e.kind === "deployment")
|
|
||||||
const unhealthy = data.nodes.filter((n) => !n.functional)
|
|
||||||
const depended = data.nodes
|
|
||||||
.filter((n) => n.depended_on_by > 0)
|
|
||||||
.sort((a, b) => b.depended_on_by - a.depended_on_by)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-6 py-8 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
|
||||||
<GitFork size={20} /> Relationship Graph
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-[var(--muted)] mt-1">
|
|
||||||
Derived, never stored — repos from git, <code>requires</code> edges, and the{" "}
|
|
||||||
<code>functional?</code> / <code>fresh?</code> predicates, computed on the fly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
||||||
<Stat label="Repos" value={data.repos.length} sub={`${monorepos.length} monorepo`} />
|
|
||||||
<Stat label="Deployments" value={data.nodes.length} />
|
|
||||||
<Stat label="requires edges" value={depEdges.length} />
|
|
||||||
<Stat
|
|
||||||
label="Unmet"
|
|
||||||
value={unhealthy.length}
|
|
||||||
tone={unhealthy.length ? "warn" : "ok"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card title="Repos" icon={Layers} note="A monorepo is one working copy shared by several programs.">
|
|
||||||
{monorepos.length === 0 && (
|
|
||||||
<p className="text-sm text-[var(--muted)]">No monorepos — every program has its own repo.</p>
|
|
||||||
)}
|
|
||||||
{monorepos.map((r) => (
|
|
||||||
<div key={r.key} className="flex items-center gap-2 text-sm py-0.5">
|
|
||||||
<Package size={13} className="text-[var(--muted)]" />
|
|
||||||
<span className="font-mono">{r.key}</span>
|
|
||||||
<FreshBadge fresh={r.fresh} behind={r.behind} dirty={r.dirty} />
|
|
||||||
<span className="text-[var(--muted)]">→ {r.programs.join(", ")}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{staleRepos.length > 0 && (
|
|
||||||
<p className="text-xs text-amber-400 mt-2">
|
|
||||||
{staleRepos.length} repo(s) not fresh: {staleRepos.map((r) => r.key).join(", ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="requires (deployment → deployment)" icon={Link2}
|
|
||||||
note="Encoded dependency edges. Env for a bound dep is generated from this — never scraped back.">
|
|
||||||
{depEdges.length === 0 ? (
|
|
||||||
<p className="text-sm text-[var(--muted)]">
|
|
||||||
None declared yet — front-end/back-end deps have no encoded edge.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
depEdges.map((e, i) => (
|
|
||||||
<div key={i} className="text-sm font-mono py-0.5">
|
|
||||||
{e.src} <span className="text-[var(--muted)]">requires</span> {e.dst}
|
|
||||||
{e.bind && <span className="text-[var(--muted)]"> → ${e.bind}</span>}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="functional?" icon={unhealthy.length ? AlertTriangle : CheckCircle2}
|
|
||||||
note="A deployment is functional when every requirement is satisfied (system installed, deployment exists).">
|
|
||||||
{unhealthy.length === 0 ? (
|
|
||||||
<p className="text-sm text-green-400 flex items-center gap-1.5">
|
|
||||||
<CheckCircle2 size={14} /> All deployments functional.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
unhealthy.map((n) => (
|
|
||||||
<div key={n.name} className="text-sm py-0.5">
|
|
||||||
<span className="text-red-400">✗</span> <span className="font-mono">{n.name}</span>{" "}
|
|
||||||
<span className="text-[var(--muted)]">unmet: {n.unmet.join(", ")}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{depended.length > 0 && (
|
|
||||||
<Card title="Widely depended-on" icon={Link2} note="Fan-in — a property, not a category.">
|
|
||||||
{depended.map((n: GraphNode) => (
|
|
||||||
<div key={n.name} className="text-sm font-mono py-0.5">
|
|
||||||
{n.name} <span className="text-[var(--muted)]">← {n.depended_on_by} dependent(s)</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value, sub, tone }: { label: string; value: number; sub?: string; tone?: "ok" | "warn" }) {
|
|
||||||
const color = tone === "warn" ? "text-amber-400" : tone === "ok" ? "text-green-400" : ""
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3">
|
|
||||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
|
||||||
<div className="text-xs text-[var(--muted)]">{label}</div>
|
|
||||||
{sub && <div className="text-xs text-[var(--muted)]">{sub}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({
|
|
||||||
title, icon: Icon, note, children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
icon: typeof Layers
|
|
||||||
note?: string
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5">
|
|
||||||
<h2 className="text-sm font-semibold flex items-center gap-1.5 mb-1">
|
|
||||||
<Icon size={15} /> {title}
|
|
||||||
</h2>
|
|
||||||
{note && <p className="text-xs text-[var(--muted)] mb-3">{note}</p>}
|
|
||||||
<div className="space-y-0.5">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FreshBadge({ fresh, behind, dirty }: { fresh: boolean | null; behind: number | null; dirty: boolean }) {
|
|
||||||
if (fresh === null) return null
|
|
||||||
if (fresh) return <span className="text-xs text-green-400">fresh</span>
|
|
||||||
const bits = [behind ? `${behind} behind` : null, dirty ? "dirty" : null].filter(Boolean)
|
|
||||||
return <span className="text-xs text-amber-400">{bits.join(" · ") || "stale"}</span>
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { Tools } from "@/pages/Tools"
|
|||||||
import { Programs } from "@/pages/Programs"
|
import { Programs } from "@/pages/Programs"
|
||||||
import { GatewayPage } from "@/pages/GatewayPage"
|
import { GatewayPage } from "@/pages/GatewayPage"
|
||||||
import { MeshPage } from "@/pages/MeshPage"
|
import { MeshPage } from "@/pages/MeshPage"
|
||||||
import { GraphPage } from "@/pages/GraphPage"
|
|
||||||
import { SystemMapPage } from "@/pages/SystemMap"
|
import { SystemMapPage } from "@/pages/SystemMap"
|
||||||
import { ServiceDetailPage } from "@/pages/ServiceDetail"
|
import { ServiceDetailPage } from "@/pages/ServiceDetail"
|
||||||
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
|
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
|
||||||
@@ -28,7 +27,6 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "programs", element: <Programs /> },
|
{ path: "programs", element: <Programs /> },
|
||||||
{ path: "gateway", element: <GatewayPage /> },
|
{ path: "gateway", element: <GatewayPage /> },
|
||||||
{ path: "mesh", element: <MeshPage /> },
|
{ path: "mesh", element: <MeshPage /> },
|
||||||
{ path: "graph", element: <GraphPage /> },
|
|
||||||
{ path: "map", element: <SystemMapPage /> },
|
{ path: "map", element: <SystemMapPage /> },
|
||||||
{ path: "services/:name", element: <ServiceDetailPage /> },
|
{ path: "services/:name", element: <ServiceDetailPage /> },
|
||||||
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export interface SSEServiceActionEvent {
|
|||||||
export interface NodeSummary {
|
export interface NodeSummary {
|
||||||
hostname: string
|
hostname: string
|
||||||
gateway_port: number
|
gateway_port: number
|
||||||
|
gateway_domain: string | null // acme domain → dashboard at castle.<domain>
|
||||||
deployed_count: number
|
deployed_count: number
|
||||||
service_count: number
|
service_count: number
|
||||||
is_local: boolean
|
is_local: boolean
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ class NodeSummary(BaseModel):
|
|||||||
|
|
||||||
hostname: str
|
hostname: str
|
||||||
gateway_port: int
|
gateway_port: int
|
||||||
|
gateway_domain: str | None = None # acme domain → dashboard at castle.<domain>
|
||||||
deployed_count: int
|
deployed_count: int
|
||||||
service_count: int
|
service_count: int
|
||||||
is_local: bool = False
|
is_local: bool = False
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ def _local_node_summary(registry: object) -> NodeSummary:
|
|||||||
return NodeSummary(
|
return NodeSummary(
|
||||||
hostname=registry.node.hostname,
|
hostname=registry.node.hostname,
|
||||||
gateway_port=registry.node.gateway_port,
|
gateway_port=registry.node.gateway_port,
|
||||||
|
gateway_domain=registry.node.gateway_domain,
|
||||||
deployed_count=len(registry.deployed),
|
deployed_count=len(registry.deployed),
|
||||||
service_count=sum(1 for d in registry.deployed.values() if d.port is not None),
|
service_count=sum(1 for d in registry.deployed.values() if d.port is not None),
|
||||||
is_local=True,
|
is_local=True,
|
||||||
@@ -67,6 +68,7 @@ def _remote_node_summary(hostname: str, remote: object) -> NodeSummary:
|
|||||||
return NodeSummary(
|
return NodeSummary(
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
gateway_port=reg.node.gateway_port,
|
gateway_port=reg.node.gateway_port,
|
||||||
|
gateway_domain=getattr(reg.node, "gateway_domain", None),
|
||||||
deployed_count=len(reg.deployed),
|
deployed_count=len(reg.deployed),
|
||||||
service_count=sum(1 for d in reg.deployed.values() if d.port is not None),
|
service_count=sum(1 for d in reg.deployed.values() if d.port is not None),
|
||||||
is_local=False,
|
is_local=False,
|
||||||
|
|||||||
Reference in New Issue
Block a user