diff --git a/app/src/components/detail/RelatedDeployments.tsx b/app/src/components/detail/RelatedDeployments.tsx new file mode 100644 index 0000000..d5f51e5 --- /dev/null +++ b/app/src/components/detail/RelatedDeployments.tsx @@ -0,0 +1,115 @@ +import { Link } from "react-router-dom" +import { ArrowUpRight, ArrowDownLeft } from "lucide-react" +import { useGraph } from "@/services/api/hooks" +import { KindBadge } from "@/components/KindBadge" +import { detailPath } from "@/lib/labels" + +/** One end of a `requires` edge, resolved to a navigable deployment. */ +interface Related { + name: string + kind: string | null // null → not a local node (e.g. a remote reference) + bind: string | null +} + +// Kinds with a local detail page. A `reference` (external service on another node) +// has none, so it's shown inert rather than linking to a route that 404s. +const NAVIGABLE = new Set(["service", "static", "tool", "job"]) + +/** + * The dependency edges of a deployment, both directions, as links. "Depends on" = + * outgoing `requires` edges (this → target); "Required by" = incoming (peer → this). + * Only deployment-kind edges are navigable — system (package) requirements aren't + * entities. Renders nothing when the node sits on no edges. Data: GET /graph. + */ +export function RelatedDeployments({ name }: { name: string }) { + const { data: graph } = useGraph() + if (!graph) return null + + const nodeKind = (n: string) => graph.nodes.find((x) => x.name === n)?.kind ?? null + const edges = graph.edges.filter((e) => e.kind === "deployment") + + const dependsOn: Related[] = edges + .filter((e) => e.src === name) + .map((e) => ({ name: e.dst, kind: nodeKind(e.dst), bind: e.bind })) + const requiredBy: Related[] = edges + .filter((e) => e.dst === name) + .map((e) => ({ name: e.src, kind: nodeKind(e.src), bind: e.bind })) + + if (dependsOn.length === 0 && requiredBy.length === 0) return null + + return ( +
+

+ Dependencies +

+

+ How this deployment connects to others (declared requires). +

+
+ {dependsOn.length > 0 && ( + } + label="Depends on" + items={dependsOn} + /> + )} + {requiredBy.length > 0 && ( + } + label="Required by" + items={requiredBy} + /> + )} +
+
+ ) +} + +function RelatedGroup({ + icon, + label, + items, +}: { + icon: React.ReactNode + label: string + items: Related[] +}) { + return ( +
+ + {icon} + {label} + +
+ {items.map((r) => ( + + ))} +
+
+ ) +} + +function RelatedRow({ related }: { related: Related }) { + const inner = ( + <> + {related.name} + {related.kind && } + {related.bind && ( + · {related.bind} + )} + + ) + // A local deployment resolves to a detail page; a reference or bare ref (no + // detail route) is shown inert — there's nothing on this node to navigate to. + if (!related.kind || !NAVIGABLE.has(related.kind)) { + return
{inner}
+ } + return ( + + {inner} + + ) +} diff --git a/app/src/pages/ScheduledDetail.tsx b/app/src/pages/ScheduledDetail.tsx index a6cb944..b2c60a0 100644 --- a/app/src/pages/ScheduledDetail.tsx +++ b/app/src/pages/ScheduledDetail.tsx @@ -1,11 +1,12 @@ -import { useParams } from "react-router-dom" -import { Clock } from "lucide-react" +import { useParams, Link } from "react-router-dom" +import { Clock, Package } from "lucide-react" import { useJob } from "@/services/api/hooks" import { LogViewer } from "@/components/LogViewer" import { DetailHeader } from "@/components/detail/DetailHeader" import { ServiceControls } from "@/components/detail/ServiceControls" import { SystemdPanel } from "@/components/detail/SystemdPanel" import { ConfigPanel } from "@/components/detail/ConfigPanel" +import { RelatedDeployments } from "@/components/detail/RelatedDeployments" export function ScheduledDetailPage() { const { name } = useParams<{ name: string }>() @@ -39,20 +40,32 @@ export function ScheduledDetailPage() { - {deployment.schedule && ( -
-

- Schedule -

-
- Cron - - - {deployment.schedule} - -
+
+
+ {deployment.schedule && ( + <> + Cron + + + {deployment.schedule} + + + )} + {deployment.program && ( + <> + Program + + {deployment.program} + + + )}
- )} +
+ + diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx index 6507ecb..53557fc 100644 --- a/app/src/pages/ServiceDetail.tsx +++ b/app/src/pages/ServiceDetail.tsx @@ -8,6 +8,7 @@ import { DetailHeader } from "@/components/detail/DetailHeader" import { ServiceControls } from "@/components/detail/ServiceControls" import { SystemdPanel } from "@/components/detail/SystemdPanel" import { ConfigPanel } from "@/components/detail/ConfigPanel" +import { RelatedDeployments } from "@/components/detail/RelatedDeployments" export function ServiceDetailPage() { const { name } = useParams<{ name: string }>() @@ -35,8 +36,11 @@ export function ServiceDetailPage() { // A static is a caddy-served site, not a systemd unit — no start/stop, no logs; // it shows its served URL and the dir it serves instead of a port/launcher. const isStatic = deployment.kind === "static" || deployment.manager === "caddy" - const servedUrl = subdomainUrl(deployment.subdomain ?? deployment.id) const root = (deployment.manifest?.root as string | undefined) ?? undefined + // The gateway address to launch: a static is always served there (falls back to + // its id); a systemd service only when it's actually proxied (has a subdomain). + const launchLabel = deployment.subdomain ?? (isStatic ? deployment.id : undefined) + const launchUrl = launchLabel ? subdomainUrl(launchLabel) : null return (
@@ -48,12 +52,22 @@ export function ServiceDetailPage() { stack={deployment.stack} source={deployment.source} > - {!isStatic && ( -
- {health && } - -
- )} +
+ {!isStatic && health && } + {launchUrl && ( + + + + )} + {!isStatic && } +
@@ -68,17 +82,6 @@ export function ServiceDetailPage() { ● served by the gateway · manager: caddy - {servedUrl && ( - <> - Served at - - {deployment.subdomain ?? deployment.id} - - - )} {root && ( <> Root @@ -101,19 +104,6 @@ export function ServiceDetailPage() { {deployment.health_path} )} - {/* A static already shows its gateway URL as "Served at" above; only a - proxied (systemd) service surfaces the same thing as "Subdomain". */} - {!isStatic && deployment.subdomain && ( - <> - Subdomain - - {deployment.subdomain} - - - )} {deployment.launcher && ( <> Launch @@ -160,6 +150,8 @@ export function ServiceDetailPage() {
)} + + () @@ -63,6 +64,8 @@ export function ToolDetailPage() {
+ +
) diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index ec1eed4..7bf89a6 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -518,6 +518,10 @@ def get_service(name: str) -> ServiceDetail: # Serve the editable spec (reach/root/program) when it's in castle.yaml. spec = config.deployment(deployed.kind, name) if config is not None else None if spec is not None: + # The registry Deployment carries no `program` ref, so backfill it from + # the spec — lets the detail page link back to the program (statics too). + if summary.program is None: + summary.program = getattr(spec, "program", None) manifest = spec.model_dump(mode="json", exclude_none=True) else: manifest = { @@ -622,6 +626,12 @@ def get_job(name: str) -> JobDetail: summary = _job_from_deployed(name, deployed) if config is not None and summary.source is None: summary.source = _backfill_source(name, config) + # The registry Deployment carries no `program` ref — backfill from the spec + # so the job page can link back to its program (matches the service path). + if summary.program is None and config is not None: + spec = config.deployment("job", name) + if spec is not None: + summary.program = getattr(spec, "program", None) manifest = { "manager": deployed.manager, "launcher": deployed.launcher,