From f559fba143bf976d4b810bc1e615406fdf0b03bf Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 23 Feb 2026 17:02:55 -0800 Subject: [PATCH] feat: Introduce job and service categorization for components, enhancing routing and display logic across the application --- app/src/components/ComponentCard.tsx | 2 +- app/src/components/ComponentTable.tsx | 78 ++---- app/src/components/ScheduledSection.tsx | 2 +- app/src/components/detail/ConfigPanel.tsx | 72 ++++++ app/src/components/detail/DetailHeader.tsx | 37 +++ app/src/components/detail/ServiceControls.tsx | 46 ++++ app/src/components/detail/SystemdPanel.tsx | 61 +++++ app/src/lib/labels.ts | 2 +- app/src/pages/ComponentDetail.tsx | 233 +++--------------- app/src/pages/ComponentRedirect.tsx | 27 ++ app/src/pages/Dashboard.tsx | 13 +- app/src/pages/ScheduledDetail.tsx | 82 ++++++ app/src/pages/ServiceDetail.tsx | 138 +++++++++++ app/src/router/routes.tsx | 17 +- app/src/types/index.ts | 1 + castle-api/src/castle_api/models.py | 1 + castle-api/src/castle_api/nodes.py | 1 + castle-api/src/castle_api/routes.py | 7 + 18 files changed, 552 insertions(+), 268 deletions(-) create mode 100644 app/src/components/detail/ConfigPanel.tsx create mode 100644 app/src/components/detail/DetailHeader.tsx create mode 100644 app/src/components/detail/ServiceControls.tsx create mode 100644 app/src/components/detail/SystemdPanel.tsx create mode 100644 app/src/pages/ComponentRedirect.tsx create mode 100644 app/src/pages/ScheduledDetail.tsx create mode 100644 app/src/pages/ServiceDetail.tsx diff --git a/app/src/components/ComponentCard.tsx b/app/src/components/ComponentCard.tsx index 5083a96..863f266 100644 --- a/app/src/components/ComponentCard.tsx +++ b/app/src/components/ComponentCard.tsx @@ -26,7 +26,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
{component.id} diff --git a/app/src/components/ComponentTable.tsx b/app/src/components/ComponentTable.tsx index e0eb2b7..f7c14d3 100644 --- a/app/src/components/ComponentTable.tsx +++ b/app/src/components/ComponentTable.tsx @@ -1,34 +1,25 @@ import { useMemo, useState } from "react" import { Link } from "react-router-dom" -import { ArrowDown, ArrowUp, ArrowUpDown, Download, Play, RefreshCw, Square, Trash2 } from "lucide-react" -import type { ComponentSummary, HealthStatus } from "@/types" -import { useServiceAction, useToolAction } from "@/services/api/hooks" -import { HealthBadge } from "./HealthBadge" +import { ArrowDown, ArrowUp, ArrowUpDown, Plug, Unplug } from "lucide-react" +import type { ComponentSummary } from "@/types" +import { useToolAction } from "@/services/api/hooks" import { BehaviorBadge } from "./BehaviorBadge" import { StackBadge } from "./StackBadge" interface ComponentTableProps { components: ComponentSummary[] - statuses: HealthStatus[] } type SortKey = "id" | "stack" | "behavior" | "status" type SortDir = "asc" | "desc" -function statusRank(s: HealthStatus | undefined, installed: boolean | null): number { - if (s) { - if (s.status === "down") return 0 - if (s.status === "up") return 3 - return 2 - } - if (installed === false) return 1 - if (installed === true) return 3 +function installedRank(installed: boolean | null): number { + if (installed === false) return 0 + if (installed === true) return 1 return 2 } -export function ComponentTable({ components, statuses }: ComponentTableProps) { - const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) - +export function ComponentTable({ components }: ComponentTableProps) { const [search, setSearch] = useState("") const [sortKey, setSortKey] = useState("id") const [sortDir, setSortDir] = useState("asc") @@ -63,12 +54,12 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { case "behavior": return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "") case "status": - return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed)) + return dir * (installedRank(a.installed) - installedRank(b.installed)) default: return 0 } }) - }, [filtered, sortKey, sortDir, statusMap]) + }, [filtered, sortKey, sortDir]) return (
@@ -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 ( {component.id} @@ -191,49 +176,14 @@ function ComponentRow({ - {health ? ( - - ) : hasHttp ? ( - - ) : isTool ? ( + {isTool ? ( ) : ( )} - {component.managed ? ( -
- {isDown && ( - - )} - - {!isDown && ( - - )} -
- ) : isTool ? ( + {isTool ? (
{component.installed ? ( ) : ( )}
diff --git a/app/src/components/ScheduledSection.tsx b/app/src/components/ScheduledSection.tsx index f27d887..64270a2 100644 --- a/app/src/components/ScheduledSection.tsx +++ b/app/src/components/ScheduledSection.tsx @@ -46,7 +46,7 @@ function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthS {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 ( +
+ +

Service not found

+
+ ) + } + + 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,