feat: relationship model (requires/repos/predicates) + git sync
A derived, mostly-computed model of how programs, deployments, and repos relate,
plus the git-sync surfaces that motivated it. See docs/relationships.md.
Core:
- `requires: [{kind, ref, version?, bind?}]` on programs + deployments — one
precondition relation; `system_dependencies` is its `{kind: system}` alias.
kind fixes meaning + check (system=installed, deployment=exists).
- relations.py: derives repos (git toplevel / monorepo), fan-in, and the
predicates functional?/fresh?/deployed? — nothing stored.
- env is generated FROM a `{kind: deployment, bind}` requirement (target URL →
consumer env), never scraped back into one; explicit defaults.env still wins.
- git.py: working-copy status/pull, repo toplevel + remote url.
Surfaces:
- `castle graph` + GET /graph — the relationship diagnostic.
- GET /repos, /repos/{key}/git|sync — repo-scoped sync (a repo is the sync unit;
a monorepo backs several programs). GET /programs/{name}/git|sync + repo context.
- Dashboard: Graph screen, program-page git status + repo-aware Sync, and a
monorepo banner on Programs.
Governing principle: predicates are derived; encode only the non-derivable, as a
node or edge property. Pull-only sync — converge stays a separate step.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
Package,
|
||||
Server,
|
||||
Share2,
|
||||
Network,
|
||||
Wrench,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -38,6 +39,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
|
||||
],
|
||||
},
|
||||
{ to: "/programs", label: "Programs", icon: Package },
|
||||
{ to: "/graph", label: "Graph", icon: Network },
|
||||
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
||||
]
|
||||
|
||||
|
||||
68
app/src/components/MonorepoBanner.tsx
Normal file
68
app/src/components/MonorepoBanner.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react"
|
||||
import { GitFork, Loader2, RefreshCw } from "lucide-react"
|
||||
import { useRepos, useRepoSync } from "@/services/api/hooks"
|
||||
import type { RepoSummary } from "@/types"
|
||||
|
||||
// Repos that back more than one program (monorepos) get one row with a single
|
||||
// repo-scoped Sync — the honest place for it, since a pull moves the whole working
|
||||
// copy. Standalone programs (a repo of one) sync from their own program page.
|
||||
export function MonorepoBanner() {
|
||||
const { data: repos } = useRepos()
|
||||
const monorepos = (repos ?? []).filter((r) => r.programs.length > 1)
|
||||
if (monorepos.length === 0) return null
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
{monorepos.map((r) => (
|
||||
<MonorepoRow key={r.key} repo={r} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonorepoRow({ repo }: { repo: RepoSummary }) {
|
||||
const sync = useRepoSync()
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
|
||||
const behind = repo.behind ?? 0
|
||||
const onSync = () => {
|
||||
setMsg(null)
|
||||
sync.mutate(repo.key, {
|
||||
onSuccess: (d) =>
|
||||
setMsg(d.pulled ? `Pulled — ${d.deployments.join(", ")} may need restart/apply` : "Already up to date"),
|
||||
onError: (e) => {
|
||||
let t = String(e)
|
||||
try {
|
||||
t = JSON.parse((e as Error).message).detail ?? t
|
||||
} catch {
|
||||
t = (e as Error).message ?? t
|
||||
}
|
||||
setMsg(t)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<GitFork size={15} className="text-[var(--muted)]" />
|
||||
<span className="font-mono font-semibold">{repo.key}</span>
|
||||
<span className="text-xs text-[var(--muted)]">monorepo · {repo.programs.join(", ")}</span>
|
||||
{behind > 0 ? (
|
||||
<span className="text-xs text-amber-400">● {behind} behind</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--muted)]">✓ up to date</span>
|
||||
)}
|
||||
{repo.dirty && <span className="text-xs text-amber-400">· dirty</span>}
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={sync.isPending}
|
||||
className="ml-auto flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-[var(--border)] hover:bg-[var(--muted)]/10 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{sync.isPending ? <Loader2 size={12} className="animate-spin" /> : <RefreshCw size={12} />}
|
||||
Sync repo
|
||||
</button>
|
||||
</div>
|
||||
{msg && <div className="text-xs text-[var(--muted)] mt-1.5">{msg}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
app/src/components/detail/GitSyncRow.tsx
Normal file
78
app/src/components/detail/GitSyncRow.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { GitBranch, Loader2, RefreshCw } from "lucide-react"
|
||||
import type { GitStatus } from "@/types"
|
||||
|
||||
interface GitSyncRowProps {
|
||||
status: GitStatus
|
||||
program: string // the program whose page this is (excluded from "shared by")
|
||||
loading: boolean // a fetch/refresh of the status is in flight
|
||||
syncing: boolean // a git pull is in flight
|
||||
onSync: () => void
|
||||
}
|
||||
|
||||
// The "Git" value cell in the Program Info card: branch · behind/ahead · dirty,
|
||||
// plus a Sync (git pull) button. Pull-only — converge stays a separate step. For a
|
||||
// monorepo, sync operates on the whole repo and lists the sibling programs.
|
||||
export function GitSyncRow({ status, program, loading, syncing, onSync }: GitSyncRowProps) {
|
||||
const behind = status.behind ?? 0
|
||||
const ahead = status.ahead ?? 0
|
||||
|
||||
return (
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<GitBranch size={13} className="text-[var(--muted)]" />
|
||||
<span className="font-mono">{status.detached ? "detached" : status.branch ?? "—"}</span>
|
||||
</span>
|
||||
|
||||
{status.behind === null ? (
|
||||
<span className="text-xs text-[var(--muted)]">no upstream</span>
|
||||
) : behind > 0 ? (
|
||||
<span className="text-xs text-amber-400">
|
||||
● {behind} behind{ahead > 0 ? `, ${ahead} ahead` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--muted)]">
|
||||
✓ up to date{ahead > 0 ? ` (${ahead} ahead)` : ""}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className={`text-xs ${status.dirty ? "text-amber-400" : "text-[var(--muted)]"}`}>
|
||||
· {status.dirty ? "dirty" : "clean"}
|
||||
</span>
|
||||
|
||||
{status.error && (
|
||||
<span className="text-xs text-red-400" title={status.error}>
|
||||
· fetch error
|
||||
</span>
|
||||
)}
|
||||
|
||||
{status.repo?.multi && (
|
||||
<span
|
||||
className="text-xs text-[var(--muted)]"
|
||||
title={`Shared repo — syncing pulls the whole ${status.repo.key} working copy`}
|
||||
>
|
||||
· shared by {status.repo.programs.filter((p) => p !== program).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={syncing || loading}
|
||||
title={
|
||||
status.repo?.multi
|
||||
? `git pull the whole ${status.repo.key} repo (${status.repo.programs.join(", ")})`
|
||||
: status.dirty
|
||||
? "Working copy has local changes — a pull may be refused"
|
||||
: "git pull (fast-forward)"
|
||||
}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs rounded border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]/10 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||
)}
|
||||
{status.repo?.multi ? "Sync repo" : "Sync"}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
148
app/src/pages/GraphPage.tsx
Normal file
148
app/src/pages/GraphPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,17 +1,43 @@
|
||||
import { useState } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { useProgram } from "@/services/api/hooks"
|
||||
import { useProgram, useProgramGit, useProgramSync } from "@/services/api/hooks"
|
||||
import { subdomainUrl } from "@/lib/labels"
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||
import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
||||
import { DeploymentsSection } from "@/components/detail/DeploymentsSection"
|
||||
import { ProgramActions, ActionOutputPanel, type ActionOutput } from "@/components/ProgramActions"
|
||||
import { GitSyncRow } from "@/components/detail/GitSyncRow"
|
||||
|
||||
export function ProgramDetailPage() {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data: deployment, isLoading, error, refetch } = useProgram(name ?? "")
|
||||
const git = useProgramGit(name ?? "", !!deployment?.repo)
|
||||
const sync = useProgramSync()
|
||||
const [actionOutput, setActionOutput] = useState<ActionOutput | null>(null)
|
||||
|
||||
const handleSync = () => {
|
||||
if (!deployment) return
|
||||
setActionOutput(null)
|
||||
sync.mutate(deployment.id, {
|
||||
onSuccess: (data) => {
|
||||
const lines = [data.output || (data.pulled ? "Pulled." : "Already up to date.")]
|
||||
if (data.pulled && data.deployments.length) {
|
||||
lines.push("", `May need a restart/apply: ${data.deployments.join(", ")}`)
|
||||
}
|
||||
setActionOutput({ action: "sync", text: lines.join("\n"), ok: true })
|
||||
},
|
||||
onError: (err) => {
|
||||
let text = String(err)
|
||||
try {
|
||||
text = JSON.parse((err as Error).message).detail ?? text
|
||||
} catch {
|
||||
text = (err as Error).message ?? text
|
||||
}
|
||||
setActionOutput({ action: "sync", text, ok: false })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-8 text-[var(--muted)]">Loading...</div>
|
||||
@@ -85,6 +111,18 @@ export function ProgramDetailPage() {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{deployment.repo && git.data?.is_repo && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Git</span>
|
||||
<GitSyncRow
|
||||
status={git.data}
|
||||
program={deployment.id}
|
||||
loading={git.isFetching}
|
||||
syncing={sync.isPending}
|
||||
onSync={handleSync}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{deployment.version && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Version</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { usePrograms } from "@/services/api/hooks"
|
||||
import { ProgramList } from "@/components/ProgramList"
|
||||
import { MonorepoBanner } from "@/components/MonorepoBanner"
|
||||
import { PageHeader } from "@/components/PageHeader"
|
||||
|
||||
export function Programs() {
|
||||
@@ -9,6 +10,8 @@ export function Programs() {
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<PageHeader title="Programs" subtitle="Software catalog" />
|
||||
|
||||
<MonorepoBanner />
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[var(--muted)]">Loading...</p>
|
||||
) : programs && programs.length > 0 ? (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tools } from "@/pages/Tools"
|
||||
import { Programs } from "@/pages/Programs"
|
||||
import { GatewayPage } from "@/pages/GatewayPage"
|
||||
import { MeshPage } from "@/pages/MeshPage"
|
||||
import { GraphPage } from "@/pages/GraphPage"
|
||||
import { ServiceDetailPage } from "@/pages/ServiceDetail"
|
||||
import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
|
||||
import { ToolDetailPage } from "@/pages/ToolDetail"
|
||||
@@ -26,6 +27,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "programs", element: <Programs /> },
|
||||
{ path: "gateway", element: <GatewayPage /> },
|
||||
{ path: "mesh", element: <MeshPage /> },
|
||||
{ path: "graph", element: <GraphPage /> },
|
||||
{ path: "services/:name", element: <ServiceDetailPage /> },
|
||||
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
||||
{ path: "tools/:name", element: <ToolDetailPage /> },
|
||||
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
JobDetail,
|
||||
ProgramSummary,
|
||||
ProgramDetail,
|
||||
GitStatus,
|
||||
GraphModel,
|
||||
RepoSummary,
|
||||
ProgramSyncResponse,
|
||||
StatusResponse,
|
||||
GatewayInfo,
|
||||
GatewayConfigRequest,
|
||||
@@ -226,6 +230,67 @@ export function useProgramAction() {
|
||||
})
|
||||
}
|
||||
|
||||
// Git status of a program's working copy. The backend fetches from the remote,
|
||||
// so this call is comparatively slow — hence its own query (not part of the
|
||||
// program detail) with a short staleTime. `enabled` lets the caller skip it for
|
||||
// programs with no repo.
|
||||
export function useProgramGit(name: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["programs", name, "git"],
|
||||
queryFn: () => apiClient.get<GitStatus>(`/programs/${name}/git`),
|
||||
enabled: enabled && !!name,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
// Fast-forward a program's source (git pull). Pull-only — converge (restart/apply)
|
||||
// stays a separate, explicit step. Refreshes the program and its git status.
|
||||
export function useProgramSync() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
apiClient.post<ProgramSyncResponse>(`/programs/${name}/sync`),
|
||||
onSuccess: (_data, name) => {
|
||||
qc.invalidateQueries({ queryKey: ["programs", name, "git"] })
|
||||
qc.invalidateQueries({ queryKey: ["programs"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRepos() {
|
||||
return useQuery({
|
||||
queryKey: ["repos"],
|
||||
queryFn: () => apiClient.get<RepoSummary[]>("/repos"),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
// Fast-forward a whole repo (git pull the working copy). Pull-only — converge is
|
||||
// separate. Refreshes repos, programs, and their git status.
|
||||
export function useRepoSync() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (key: string) =>
|
||||
apiClient.post<ProgramSyncResponse>(`/repos/${key}/sync`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["repos"] })
|
||||
qc.invalidateQueries({ queryKey: ["programs"] })
|
||||
qc.invalidateQueries({ queryKey: ["graph"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The derived relationship model (repos, requires edges, functional/fresh status).
|
||||
// Fetches git status per repo server-side, so it's comparatively slow — its own
|
||||
// query with a modest staleTime.
|
||||
export function useGraph() {
|
||||
return useQuery({
|
||||
queryKey: ["graph"],
|
||||
queryFn: () => apiClient.get<GraphModel>("/graph"),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveGatewayConfig() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
@@ -312,6 +377,12 @@ export function useEventStream() {
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] })
|
||||
})
|
||||
|
||||
es.addEventListener("program-sync", () => {
|
||||
// A program's source was pulled (possibly by another client) — refresh
|
||||
// programs and their git status.
|
||||
qc.invalidateQueries({ queryKey: ["programs"] })
|
||||
})
|
||||
|
||||
es.addEventListener("mesh", () => {
|
||||
// A remote node updated or went offline — refresh mesh, nodes, and gateway
|
||||
qc.invalidateQueries({ queryKey: ["mesh"] })
|
||||
|
||||
@@ -74,6 +74,85 @@ export interface ProgramDetail extends ProgramSummary {
|
||||
manifest: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Git state of a program's source working copy (GET /programs/{name}/git).
|
||||
// ahead/behind are relative to the upstream tracking branch (null = no upstream);
|
||||
// behind reflects the fetch the status call performed.
|
||||
export interface GitStatus {
|
||||
is_repo: boolean
|
||||
branch: string | null
|
||||
upstream: string | null
|
||||
dirty: boolean
|
||||
ahead: number | null
|
||||
behind: number | null
|
||||
detached: boolean
|
||||
error: string | null
|
||||
// The repo (git working copy) this program's source lives in. `multi` marks a
|
||||
// monorepo shared by several programs — sync operates on the whole repo.
|
||||
repo?: {
|
||||
key: string
|
||||
programs: string[]
|
||||
multi: boolean
|
||||
deployments: string[]
|
||||
} | null
|
||||
}
|
||||
|
||||
// GET /repos — a repo (git working copy) with its members and last-known git state.
|
||||
export interface RepoSummary {
|
||||
key: string
|
||||
path: string
|
||||
url: string | null
|
||||
ref: string | null
|
||||
programs: string[]
|
||||
deployments: string[]
|
||||
branch: string | null
|
||||
behind: number | null
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
// GET /graph — the derived relationship model (docs/relationships.md).
|
||||
export interface GraphRepo {
|
||||
key: string
|
||||
path: string
|
||||
url: string | null
|
||||
ref: string | null
|
||||
programs: string[]
|
||||
deployments: string[]
|
||||
behind: number | null
|
||||
dirty: boolean
|
||||
fresh: boolean | null
|
||||
}
|
||||
export interface GraphNode {
|
||||
name: string
|
||||
program: string | null
|
||||
kind: string
|
||||
repo: string | null
|
||||
depended_on_by: number
|
||||
unmet: string[]
|
||||
functional: boolean
|
||||
fresh: boolean | null
|
||||
deployed: boolean | null
|
||||
}
|
||||
export interface GraphEdge {
|
||||
src: string
|
||||
dst: string
|
||||
kind: "system" | "deployment"
|
||||
bind: string | null
|
||||
}
|
||||
export interface GraphModel {
|
||||
repos: GraphRepo[]
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
}
|
||||
|
||||
// POST /programs/{name}/sync — a fast-forward pull (no build/apply/restart).
|
||||
export interface ProgramSyncResponse {
|
||||
program: string
|
||||
status: string
|
||||
output: string
|
||||
pulled: boolean
|
||||
deployments: string[] // affected deployments that may need restart/apply
|
||||
}
|
||||
|
||||
// Union for the shared ConfigPanel (ProgramFields / ServiceFields / JobFields)
|
||||
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
|
||||
|
||||
|
||||
Reference in New Issue
Block a user