@@ -97,7 +88,6 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
))}
{sorted.length === 0 && (
@@ -158,22 +148,17 @@ function InstalledBadge({ installed }: { installed: boolean }) {
function ComponentRow({
component,
- health,
}: {
component: ComponentSummary
- health?: HealthStatus
}) {
- const hasHttp = component.port != null
const isTool = component.installed !== null
- const { mutate: serviceAction, isPending: servicePending } = useServiceAction()
const { mutate: toolAction, isPending: toolPending } = useToolAction()
- const isDown = health?.status === "down"
return (
|
{job.id}
diff --git a/app/src/components/detail/ConfigPanel.tsx b/app/src/components/detail/ConfigPanel.tsx
new file mode 100644
index 0000000..957a979
--- /dev/null
+++ b/app/src/components/detail/ConfigPanel.tsx
@@ -0,0 +1,72 @@
+import { useState } from "react"
+import { useNavigate } from "react-router-dom"
+import { useQueryClient } from "@tanstack/react-query"
+import { Check } from "lucide-react"
+import { apiClient } from "@/services/api/client"
+import type { ComponentDetail } from "@/types"
+import { ComponentFields } from "@/components/ComponentFields"
+
+interface ConfigPanelProps {
+ component: ComponentDetail
+ configSection: "services" | "jobs" | "components"
+ onRefetch: () => void
+}
+
+export function ConfigPanel({ component, configSection, onRefetch }: ConfigPanelProps) {
+ const navigate = useNavigate()
+ const qc = useQueryClient()
+ const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
+
+ const handleSave = async (compName: string, config: Record) => {
+ setMessage(null)
+ try {
+ await apiClient.put(`/config/${configSection}/${compName}`, { config })
+ setMessage({ type: "ok", text: "Saved to castle.yaml" })
+ onRefetch()
+ qc.invalidateQueries({ queryKey: ["components"] })
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : String(e)
+ setMessage({ type: "error", text: msg })
+ }
+ }
+
+ const handleDelete = async (compName: string) => {
+ try {
+ await apiClient.delete(`/config/${configSection}/${compName}`)
+ qc.invalidateQueries({ queryKey: ["components"] })
+ navigate("/")
+ } catch (e: unknown) {
+ const msg = e instanceof Error ? e.message : String(e)
+ setMessage({ type: "error", text: msg })
+ }
+ }
+
+ return (
+ <>
+ {message && (
+
+
+ {message.type === "ok" && }
+ {message.text}
+
+
+ )}
+
+
+ Configuration
+
+
+
+ >
+ )
+}
diff --git a/app/src/components/detail/DetailHeader.tsx b/app/src/components/detail/DetailHeader.tsx
new file mode 100644
index 0000000..ca2f3cb
--- /dev/null
+++ b/app/src/components/detail/DetailHeader.tsx
@@ -0,0 +1,37 @@
+import { Link } from "react-router-dom"
+import { ArrowLeft } from "lucide-react"
+import { BehaviorBadge } from "@/components/BehaviorBadge"
+import { StackBadge } from "@/components/StackBadge"
+
+interface DetailHeaderProps {
+ backTo: string
+ backLabel: string
+ name: string
+ behavior?: string | null
+ stack?: string | null
+ source?: string | null
+ children?: React.ReactNode
+}
+
+export function DetailHeader({ backTo, backLabel, name, behavior, stack, source, children }: DetailHeaderProps) {
+ return (
+ <>
+
+ {backLabel}
+
+
+
+ {name}
+ {children}
+
+
+
+
+
+ {source && (
+ {source}
+ )}
+
+ >
+ )
+}
diff --git a/app/src/components/detail/ServiceControls.tsx b/app/src/components/detail/ServiceControls.tsx
new file mode 100644
index 0000000..5ff3a33
--- /dev/null
+++ b/app/src/components/detail/ServiceControls.tsx
@@ -0,0 +1,46 @@
+import { Play, RefreshCw, Square } from "lucide-react"
+import { useServiceAction } from "@/services/api/hooks"
+import type { HealthStatus } from "@/types"
+
+interface ServiceControlsProps {
+ name: string
+ health?: HealthStatus
+}
+
+export function ServiceControls({ name, health }: ServiceControlsProps) {
+ const { mutate, isPending } = useServiceAction()
+ const isDown = health?.status === "down"
+
+ return (
+
+ {isDown && (
+
+ )}
+
+ {!isDown && (
+
+ )}
+
+ )
+}
diff --git a/app/src/components/detail/SystemdPanel.tsx b/app/src/components/detail/SystemdPanel.tsx
new file mode 100644
index 0000000..64a3ffe
--- /dev/null
+++ b/app/src/components/detail/SystemdPanel.tsx
@@ -0,0 +1,61 @@
+import { useState } from "react"
+import { useSystemdUnit } from "@/services/api/hooks"
+import type { SystemdInfo } from "@/types"
+
+interface SystemdPanelProps {
+ name: string
+ systemd: SystemdInfo
+}
+
+export function SystemdPanel({ name, systemd }: SystemdPanelProps) {
+ const [showUnit, setShowUnit] = useState(false)
+ const { data: unitData } = useSystemdUnit(name, showUnit)
+
+ return (
+
+
+
+ Systemd
+
+
+
+
+ Unit
+ {systemd.unit_name}
+ Path
+ {systemd.unit_path}
+ {systemd.timer && (
+ <>
+ Timer
+ Active
+ >
+ )}
+
+ {showUnit && unitData && (
+
+
+ {systemd.unit_name}
+
+ {unitData.service}
+
+
+ {unitData.timer && (
+
+
+ {systemd.unit_name.replace(".service", ".timer")}
+
+
+ {unitData.timer}
+
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/app/src/lib/labels.ts b/app/src/lib/labels.ts
index 43059bb..ffbecbe 100644
--- a/app/src/lib/labels.ts
+++ b/app/src/lib/labels.ts
@@ -32,7 +32,7 @@ export const STACK_LABELS: Record = {
export const SECTION_HEADERS: Record = {
service: { title: "Services", subtitle: "Long-running processes" },
- scheduled: { title: "Scheduled", subtitle: "Systemd timers" },
+ scheduled: { title: "Scheduled Jobs", subtitle: "Systemd timers" },
component: { title: "Components", subtitle: "Software catalog" },
}
diff --git a/app/src/pages/ComponentDetail.tsx b/app/src/pages/ComponentDetail.tsx
index 715199c..cc5d146 100644
--- a/app/src/pages/ComponentDetail.tsx
+++ b/app/src/pages/ComponentDetail.tsx
@@ -1,76 +1,28 @@
-import { useState } from "react"
-import { useParams, Link, useNavigate } from "react-router-dom"
-import { ArrowLeft, Check, Play, RefreshCw, Square } from "lucide-react"
-import { useQueryClient } from "@tanstack/react-query"
-import { apiClient } from "@/services/api/client"
-import { useComponent, useStatus, useServiceAction, useEventStream, useToolDetail, useCaddyfile, useSystemdUnit } from "@/services/api/hooks"
+import { useParams } from "react-router-dom"
+import { Plug, Unplug } from "lucide-react"
+import { useComponent, useEventStream, useToolDetail, useToolAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
-import { ComponentFields } from "@/components/ComponentFields"
-import { HealthBadge } from "@/components/HealthBadge"
-import { LogViewer } from "@/components/LogViewer"
-import { BehaviorBadge } from "@/components/BehaviorBadge"
-import { StackBadge } from "@/components/StackBadge"
+import { DetailHeader } from "@/components/detail/DetailHeader"
+import { ConfigPanel } from "@/components/detail/ConfigPanel"
export function ComponentDetailPage() {
useEventStream()
- const navigate = useNavigate()
- const qc = useQueryClient()
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
- const { data: statusResp } = useStatus()
- const { mutate, isPending } = useServiceAction()
- const health = statusResp?.statuses.find((s) => s.id === name)
- const isDown = health?.status === "down"
const isTool = component?.behavior === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
- const isGateway = name === "castle-gateway"
- const { data: caddyfile } = useCaddyfile(isGateway)
- const [showUnit, setShowUnit] = useState(false)
- const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
- const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
-
- const configSection = component?.behavior === "daemon" ? "services"
- : component?.schedule ? "jobs"
- : "components"
-
- const handleSave = async (compName: string, config: Record) => {
- setMessage(null)
- try {
- await apiClient.put(`/config/${configSection}/${compName}`, { config })
- setMessage({ type: "ok", text: "Saved to castle.yaml" })
- refetch()
- qc.invalidateQueries({ queryKey: ["components"] })
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : String(e)
- setMessage({ type: "error", text: msg })
- }
- }
-
- const handleDelete = async (compName: string) => {
- try {
- await apiClient.delete(`/config/${configSection}/${compName}`)
- qc.invalidateQueries({ queryKey: ["components"] })
- navigate("/")
- } catch (e: unknown) {
- const msg = e instanceof Error ? e.message : String(e)
- setMessage({ type: "error", text: msg })
- }
- }
+ const { mutate: toolAction, isPending: toolPending } = useToolAction()
if (isLoading) {
return (
-
- Loading...
-
+ Loading...
)
}
if (error || !component) {
return (
-
- Back
-
+
Component not found
)
@@ -78,70 +30,39 @@ export function ComponentDetailPage() {
return (
-
- Back
-
-
-
- {component.id}
-
- {health && }
- {component.managed && (
-
- {isDown && (
-
- )}
+
+ {isTool && (
+
+ {component.installed ? (
- {!isDown && (
-
- )}
-
- )}
-
-
-
-
-
-
- {component.source && (
- {component.source}
+ ) : (
+
+ )}
+
)}
-
+
- {message && (
-
-
- {message.type === "ok" && }
- {message.text}
-
-
+ {component.description && (
+ {component.description}
)}
{toolDetail && (
@@ -171,6 +92,8 @@ export function ComponentDetailPage() {
{runnerLabel(toolDetail.runner)}
>
)}
+ Installed
+ {component.installed ? "Yes" : "No"}
{toolDetail.system_dependencies.length > 0 && (
@@ -198,85 +121,7 @@ export function ComponentDetailPage() {
)}
- {component.systemd && (
-
-
-
- Systemd
-
-
-
-
- Unit
- {component.systemd.unit_name}
- Path
- {component.systemd.unit_path}
- {component.systemd.timer && (
- <>
- Timer
- Active
- >
- )}
-
- {showUnit && unitData && (
-
-
- {component.systemd.unit_name}
-
- {unitData.service}
-
-
- {unitData.timer && (
-
-
- {component.systemd.unit_name.replace(".service", ".timer")}
-
-
- {unitData.timer}
-
-
- )}
-
- )}
-
- )}
-
- {isGateway && caddyfile?.content && (
-
-
- Caddyfile
-
-
- Generated reverse proxy configuration served by the gateway.
-
-
- {caddyfile.content}
-
-
- )}
-
-
-
- Configuration
-
-
-
-
- {component.managed && (
-
- Logs
-
-
- )}
+
)
}
diff --git a/app/src/pages/ComponentRedirect.tsx b/app/src/pages/ComponentRedirect.tsx
new file mode 100644
index 0000000..510b198
--- /dev/null
+++ b/app/src/pages/ComponentRedirect.tsx
@@ -0,0 +1,27 @@
+import { useParams, Navigate } from "react-router-dom"
+import { useComponent } from "@/services/api/hooks"
+
+export function ComponentRedirect() {
+ const { name } = useParams<{ name: string }>()
+ const { data: component, isLoading, error } = useComponent(name ?? "")
+
+ if (isLoading) {
+ return (
+ Loading...
+ )
+ }
+
+ if (error || !component) {
+ return
+ }
+
+ if (component.managed && !component.schedule) {
+ return
+ }
+
+ if (component.managed && component.schedule) {
+ return
+ }
+
+ return
+}
diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx
index 4ac0673..4f6e054 100644
--- a/app/src/pages/Dashboard.tsx
+++ b/app/src/pages/Dashboard.tsx
@@ -17,10 +17,11 @@ export function Dashboard() {
const { data: nodes } = useNodes()
const { data: mesh } = useMeshStatus()
- const { services, scheduled } = useMemo(() => {
- const svc = (components ?? []).filter((c) => c.managed && !c.schedule)
- const sch = (components ?? []).filter((c) => c.managed && c.schedule)
- return { services: svc, scheduled: sch }
+ const { services, scheduled, catalog } = useMemo(() => {
+ const svc = (components ?? []).filter((c) => c.category === "service")
+ const sch = (components ?? []).filter((c) => c.category === "job")
+ const cat = (components ?? []).filter((c) => c.category === "component")
+ return { services: svc, scheduled: sch, catalog: cat }
}, [components])
const statuses = statusResp?.statuses ?? []
@@ -56,10 +57,10 @@ export function Dashboard() {
{scheduled.length > 0 && (
)}
- {(components ?? []).length > 0 && (
+ {catalog.length > 0 && (
)}
diff --git a/app/src/pages/ScheduledDetail.tsx b/app/src/pages/ScheduledDetail.tsx
new file mode 100644
index 0000000..adf55b8
--- /dev/null
+++ b/app/src/pages/ScheduledDetail.tsx
@@ -0,0 +1,82 @@
+import { useParams } from "react-router-dom"
+import { Clock } from "lucide-react"
+import { useComponent, useStatus, useEventStream } 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"
+
+export function ScheduledDetailPage() {
+ useEventStream()
+ const { name } = useParams<{ name: string }>()
+ const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
+ const { data: statusResp } = useStatus()
+ const health = statusResp?.statuses.find((s) => s.id === name)
+
+ if (isLoading) {
+ return (
+ Loading...
+ )
+ }
+
+ if (error || !component) {
+ return (
+
+
+ Scheduled job not found
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ {component.schedule && (
+
+
+ Schedule
+
+
+ Cron
+
+
+ {component.schedule}
+
+ {component.systemd && (
+ <>
+ Timer unit
+
+ {component.systemd.unit_name.replace(".service", ".timer")}
+
+ >
+ )}
+
+
+ )}
+
+ {component.systemd && (
+
+ )}
+
+
+
+ {component.managed && (
+
+ Logs
+
+
+ )}
+
+ )
+}
diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx
new file mode 100644
index 0000000..8ddf2c3
--- /dev/null
+++ b/app/src/pages/ServiceDetail.tsx
@@ -0,0 +1,138 @@
+import { useParams } from "react-router-dom"
+import { Server, ExternalLink, Terminal } from "lucide-react"
+import { useComponent, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
+import { runnerLabel } from "@/lib/labels"
+import { HealthBadge } from "@/components/HealthBadge"
+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"
+
+export function ServiceDetailPage() {
+ useEventStream()
+ const { name } = useParams<{ name: string }>()
+ const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
+ const { data: statusResp } = useStatus()
+ const health = statusResp?.statuses.find((s) => s.id === name)
+ const isGateway = name === "castle-gateway"
+ const { data: caddyfile } = useCaddyfile(isGateway)
+
+ if (isLoading) {
+ return (
+ Loading...
+ )
+ }
+
+ if (error || !component) {
+ return (
+
+ )
+ }
+
+ const runner = (component.manifest.run as Record)?.runner as string | undefined
+
+ return (
+
+
+
+ {health && }
+
+
+
+
+
+
+ Overview
+
+
+ {component.port && (
+ <>
+ Port
+
+ :{component.port}
+
+ >
+ )}
+ {component.health_path && (
+ <>
+ Health
+ {component.health_path}
+ >
+ )}
+ {component.proxy_path && (
+ <>
+ Proxy
+
+ {component.proxy_path}
+
+ >
+ )}
+ {runner && (
+ <>
+ Runner
+
+
+ {runnerLabel(runner)}
+ {(component.manifest.run as Record)?.tool && (
+ <> · {(component.manifest.run as Record).tool}>
+ )}
+
+ >
+ )}
+ {component.port && (
+ <>
+ Docs
+
+ OpenAPI docs
+
+ >
+ )}
+
+
+
+ {component.systemd && (
+
+ )}
+
+ {isGateway && caddyfile?.content && (
+
+
+ Caddyfile
+
+
+ Generated reverse proxy configuration served by the gateway.
+
+
+ {caddyfile.content}
+
+
+ )}
+
+
+
+ {component.managed && (
+
+ Logs
+
+
+ )}
+
+ )
+}
diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx
index 6e110ac..a876a76 100644
--- a/app/src/router/routes.tsx
+++ b/app/src/router/routes.tsx
@@ -1,6 +1,9 @@
import { createBrowserRouter } from "react-router-dom"
import { Dashboard } from "@/pages/Dashboard"
+import { ServiceDetailPage } from "@/pages/ServiceDetail"
+import { ScheduledDetailPage } from "@/pages/ScheduledDetail"
import { ComponentDetailPage } from "@/pages/ComponentDetail"
+import { ComponentRedirect } from "@/pages/ComponentRedirect"
import { NodeDetailPage } from "@/pages/NodeDetail"
export const router = createBrowserRouter([
@@ -9,9 +12,21 @@ export const router = createBrowserRouter([
element: ,
},
{
- path: "/component/:name",
+ path: "/services/:name",
+ element: ,
+ },
+ {
+ path: "/jobs/:name",
+ element: ,
+ },
+ {
+ path: "/components/:name",
element: ,
},
+ {
+ path: "/component/:name",
+ element: ,
+ },
{
path: "/node/:hostname",
element: ,
diff --git a/app/src/types/index.ts b/app/src/types/index.ts
index 3de3d5a..daf9396 100644
--- a/app/src/types/index.ts
+++ b/app/src/types/index.ts
@@ -6,6 +6,7 @@ export interface SystemdInfo {
export interface ComponentSummary {
id: string
+ category: "component" | "service" | "job" | null
description: string | null
behavior: string | null
stack: string | null
diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py
index fb43656..ef3ef12 100644
--- a/castle-api/src/castle_api/models.py
+++ b/castle-api/src/castle_api/models.py
@@ -15,6 +15,7 @@ class ComponentSummary(BaseModel):
"""Summary of a single component."""
id: str
+ category: str | None = None # "component", "service", or "job"
description: str | None = None
behavior: str | None = None
stack: str | None = None
diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py
index 21dd164..667add4 100644
--- a/castle-api/src/castle_api/nodes.py
+++ b/castle-api/src/castle_api/nodes.py
@@ -46,6 +46,7 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSum
summaries.append(
ComponentSummary(
id=name,
+ category="job" if d.schedule else "service",
description=d.description,
behavior=d.behavior,
stack=d.stack,
diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py
index 752e105..4ba3fd9 100644
--- a/castle-api/src/castle_api/routes.py
+++ b/castle-api/src/castle_api/routes.py
@@ -47,8 +47,11 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
if deployed.behavior == "tool":
installed = shutil.which(name) is not None
+ category = "job" if deployed.schedule else "service"
+
return ComponentSummary(
id=name,
+ category=category,
description=deployed.description,
behavior=deployed.behavior,
stack=deployed.stack,
@@ -96,6 +99,7 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
return ComponentSummary(
id=name,
+ category="service",
description=description,
behavior="daemon",
stack=stack,
@@ -131,6 +135,7 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
return ComponentSummary(
id=name,
+ category="job",
description=description,
behavior="tool",
stack=stack,
@@ -173,6 +178,7 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
return ComponentSummary(
id=name,
+ category="component",
description=comp.description,
behavior=behavior,
stack=comp.stack,
@@ -264,6 +270,7 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
summaries.append(
ComponentSummary(
id=name,
+ category="job" if d.schedule else "service",
description=d.description,
behavior=d.behavior,
stack=d.stack,
|