Refactor component terminology to programs in config and manifest

- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications.
- Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks.
- Adjusted tests to reflect the new program structure and ensure proper functionality.
- Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
This commit is contained in:
2026-02-23 22:09:41 -08:00
parent f559fba143
commit 0d36e4f72a
48 changed files with 7512 additions and 632 deletions

View File

@@ -1,14 +1,13 @@
import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-react"
import { Link } from "react-router-dom"
import type { ComponentSummary, HealthStatus } from "@/types"
import type { ServiceSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge"
import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
interface ComponentCardProps {
component: ComponentSummary
component: ServiceSummary
health?: HealthStatus
}
@@ -39,7 +38,6 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
</div>
<div className="flex gap-1.5 mb-2">
<BehaviorBadge behavior={component.behavior} />
<StackBadge stack={component.stack} />
</div>

View File

@@ -1,11 +1,11 @@
import { useMemo, useState } from "react"
import { Check, Loader2, Save, Trash2 } from "lucide-react"
import type { ComponentDetail } from "@/types"
import type { AnyDetail } from "@/types"
import { runnerLabel } from "@/lib/labels"
import { SecretsEditor } from "./SecretsEditor"
interface ComponentFieldsProps {
component: ComponentDetail
component: AnyDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete?: (name: string) => Promise<void>
}
@@ -122,7 +122,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
</Field>
)}
{(component.managed || port) && (
{("managed" in component && component.managed || port) && (
<Field label="Port">
<input
value={port}
@@ -133,7 +133,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
</Field>
)}
{(component.managed || healthPath) && (
{("managed" in component && component.managed || healthPath) && (
<Field label="Health path">
<input
value={healthPath}
@@ -205,11 +205,13 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
</Field>
)}
<Field label="Systemd">
<span className="text-sm text-[var(--muted)]">
{component.managed ? "Yes" : "No"}
</span>
</Field>
{"managed" in component && (
<Field label="Systemd">
<span className="text-sm text-[var(--muted)]">
{component.managed ? "Yes" : "No"}
</span>
</Field>
)}
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
{onDelete ? (

View File

@@ -1,48 +0,0 @@
import type { ComponentSummary, HealthStatus } from "@/types"
import { BEHAVIOR_LABELS } from "@/lib/labels"
import { ComponentCard } from "./ComponentCard"
const BEHAVIOR_ORDER = ["daemon", "tool", "frontend"]
interface ComponentGridProps {
components: ComponentSummary[]
statuses: HealthStatus[]
}
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s]))
// Group by behavior
const groups = new Map<string, ComponentSummary[]>()
for (const comp of components) {
const key = comp.behavior ?? "other"
const list = groups.get(key) ?? []
list.push(comp)
groups.set(key, list)
}
return (
<div className="space-y-8">
{BEHAVIOR_ORDER.map((key) => {
const items = groups.get(key)
if (!items?.length) return null
return (
<section key={key}>
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
{BEHAVIOR_LABELS[key] ?? key}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((comp) => (
<ComponentCard
key={comp.id}
component={comp}
health={statusMap.get(comp.id)}
/>
))}
</div>
</section>
)
})}
</div>
)
}

View File

@@ -1,24 +1,18 @@
import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { ArrowDown, ArrowUp, ArrowUpDown, Plug, Unplug } from "lucide-react"
import type { ComponentSummary } from "@/types"
import { useToolAction } from "@/services/api/hooks"
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
import type { ProgramSummary } from "@/types"
import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
import { ProgramActions } from "./ProgramActions"
interface ComponentTableProps {
components: ComponentSummary[]
components: ProgramSummary[]
}
type SortKey = "id" | "stack" | "behavior" | "status"
type SortKey = "id" | "stack" | "behavior"
type SortDir = "asc" | "desc"
function installedRank(installed: boolean | null): number {
if (installed === false) return 0
if (installed === true) return 1
return 2
}
export function ComponentTable({ components }: ComponentTableProps) {
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("id")
@@ -53,8 +47,6 @@ export function ComponentTable({ components }: ComponentTableProps) {
return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
case "behavior":
return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
case "status":
return dir * (installedRank(a.installed) - installedRank(b.installed))
default:
return 0
}
@@ -67,7 +59,7 @@ export function ComponentTable({ components }: ComponentTableProps) {
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter components..."
placeholder="Filter programs..."
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
/>
</div>
@@ -79,21 +71,17 @@ export function ComponentTable({ components }: ComponentTableProps) {
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Behavior" sortKey="behavior" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((comp) => (
<ComponentRow
key={comp.id}
component={comp}
/>
<ComponentRow key={comp.id} component={comp} />
))}
{sorted.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-[var(--muted)]">
No components match.
<td colSpan={4} className="px-3 py-6 text-center text-[var(--muted)]">
No programs match.
</td>
</tr>
)}
@@ -132,33 +120,12 @@ function SortHeader({
)
}
function InstalledBadge({ installed }: { installed: boolean }) {
return installed ? (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800/50">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
installed
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-zinc-800/40 text-[var(--muted)] border border-[var(--border)]">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-500" />
not installed
</span>
)
}
function ComponentRow({
component,
}: {
component: ComponentSummary
}) {
const isTool = component.installed !== null
const { mutate: toolAction, isPending: toolPending } = useToolAction()
function ComponentRow({ component }: { component: ProgramSummary }) {
return (
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/components/${component.id}`}
to={`/programs/${component.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{component.id}
@@ -176,38 +143,7 @@ function ComponentRow({
<BehaviorBadge behavior={component.behavior} />
</td>
<td className="px-3 py-2.5">
{isTool ? (
<InstalledBadge installed={component.installed!} />
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{isTool ? (
<div className="flex items-center gap-1">
{component.installed ? (
<button
onClick={() => toolAction({ name: component.id, action: "uninstall" })}
disabled={toolPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Uninstall from PATH"
>
<Unplug size={14} />
</button>
) : (
<button
onClick={() => toolAction({ name: component.id, action: "install" })}
disabled={toolPending}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
title="Install to PATH"
>
<Plug size={14} />
</button>
)}
</div>
) : (
<span className="text-[var(--muted)]"></span>
)}
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} compact />
</td>
</tr>
)

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
import { apiClient } from "@/services/api/client"
interface LogViewerProps {
@@ -7,7 +7,14 @@ interface LogViewerProps {
follow?: boolean
}
export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
export interface LogViewerHandle {
clear: () => void
}
export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function LogViewer(
{ name, lines = 50, follow = true },
ref,
) {
const [logs, setLogs] = useState<string[]>([])
const [connected, setConnected] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null)
@@ -40,6 +47,8 @@ export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
return () => es.close()
}, [name, lines, follow])
useImperativeHandle(ref, () => ({ clear: () => setLogs([]) }), [])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
}, [logs])
@@ -68,4 +77,4 @@ export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
</pre>
</div>
)
}
})

View File

@@ -0,0 +1,167 @@
import { useState } from "react"
import {
FileCheck,
FlaskConical,
Hammer,
Loader2,
Plug,
ShieldCheck,
Sparkles,
Unplug,
type LucideIcon,
} from "lucide-react"
import { useProgramAction } from "@/services/api/hooks"
interface ActionConfig {
icon: LucideIcon
label: string
color: string
hoverBg: string
borderColor: string
}
const ACTION_CONFIG: Record<string, ActionConfig> = {
build: { icon: Hammer, label: "Build", color: "text-blue-400", hoverBg: "hover:bg-blue-800/30", borderColor: "border-blue-800" },
test: { icon: FlaskConical, label: "Test", color: "text-purple-400", hoverBg: "hover:bg-purple-800/30", borderColor: "border-purple-800" },
lint: { icon: Sparkles, label: "Lint", color: "text-amber-400", hoverBg: "hover:bg-amber-800/30", borderColor: "border-amber-800" },
"type-check": { icon: FileCheck, label: "Type Check", color: "text-cyan-400", hoverBg: "hover:bg-cyan-800/30", borderColor: "border-cyan-800" },
check: { icon: ShieldCheck, label: "Check All", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" },
install: { icon: Plug, label: "Install", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" },
uninstall: { icon: Unplug, label: "Uninstall", color: "text-red-400", hoverBg: "hover:bg-red-800/30", borderColor: "border-red-800" },
}
const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
interface ProgramActionsProps {
name: string
actions: string[]
installed?: boolean | null
compact?: boolean
}
function visibleActions(
actions: string[],
installed: boolean | null | undefined,
compact: boolean,
): string[] {
if (compact) {
// Table: only install/uninstall based on state
if (installed === true) return actions.includes("uninstall") ? ["uninstall"] : []
if (installed === false) return actions.includes("install") ? ["install"] : []
// null — show install if available (frontends, etc.)
return actions.includes("install") ? ["install"] : []
}
// Detail page: dev actions always, install/uninstall based on state
const visible: string[] = []
for (const a of DEV_ACTIONS) {
if (actions.includes(a)) visible.push(a)
}
if (installed === true) {
if (actions.includes("uninstall")) visible.push("uninstall")
} else if (installed === false) {
if (actions.includes("install")) visible.push("install")
} else {
// null — show both if available
if (actions.includes("install")) visible.push("install")
if (actions.includes("uninstall")) visible.push("uninstall")
}
return visible
}
export function ProgramActions({ name, actions, installed, compact }: ProgramActionsProps) {
const { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null)
const [output, setOutput] = useState<{ action: string; text: string; ok: boolean } | null>(null)
const visible = visibleActions(actions, installed, !!compact)
const handleAction = (action: string) => {
setRunningAction(action)
setOutput(null)
mutate(
{ name, action },
{
onSuccess: (data) => {
setRunningAction(null)
// Show output for dev actions on detail page
if (!compact && DEV_ACTIONS.includes(action) && data.output) {
setOutput({ action, text: data.output, ok: true })
}
},
onError: (err) => {
setRunningAction(null)
if (!compact && DEV_ACTIONS.includes(action)) {
// Extract detail from API error JSON
let text = String(err)
try {
const parsed = JSON.parse((err as Error).message)
text = parsed.detail ?? text
} catch {
text = (err as Error).message ?? text
}
setOutput({ action, text, ok: false })
}
},
},
)
}
if (visible.length === 0) return null
return (
<div>
<div className="flex items-center gap-1 flex-wrap">
{visible.map((action) => {
const config = ACTION_CONFIG[action]
if (!config) return null
const Icon = config.icon
const isRunning = isPending && runningAction === action
if (compact) {
return (
<button
key={action}
onClick={() => handleAction(action)}
disabled={isPending}
className={`p-1 rounded ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
title={config.label}
>
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
</button>
)
}
return (
<button
key={action}
onClick={() => handleAction(action)}
disabled={isPending}
className={`flex items-center gap-1.5 px-2 py-1 text-sm rounded border ${config.borderColor} ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
>
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
{config.label}
</button>
)
})}
</div>
{output && !compact && (
<div className={`mt-3 rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}>
<div className={`flex items-center justify-between px-3 py-1.5 text-xs ${output.ok ? "text-green-400 bg-green-900/20" : "text-red-400 bg-red-900/20"}`}>
<span>{ACTION_CONFIG[output.action]?.label ?? output.action} {output.ok ? "ok" : "error"}</span>
<button
onClick={() => setOutput(null)}
className="text-[var(--muted)] hover:text-[var(--foreground)]"
>
dismiss
</button>
</div>
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-64 overflow-y-auto bg-black/40">
{output.text}
</pre>
</div>
)}
</div>
)
}

View File

@@ -1,12 +1,12 @@
import { Link } from "react-router-dom"
import { Play, RefreshCw, Square } from "lucide-react"
import type { ComponentSummary, HealthStatus } from "@/types"
import type { JobSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { SectionHeader } from "./SectionHeader"
import { StackBadge } from "./StackBadge"
interface ScheduledSectionProps {
jobs: ComponentSummary[]
jobs: JobSummary[]
statuses: HealthStatus[]
}
@@ -38,7 +38,7 @@ export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) {
)
}
function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthStatus }) {
function ScheduledRow({ job, health }: { job: JobSummary; health?: HealthStatus }) {
const { mutate, isPending } = useServiceAction()
const isDown = health?.status === "down"

View File

@@ -1,10 +1,10 @@
import { useMemo } from "react"
import type { ComponentSummary, HealthStatus } from "@/types"
import type { ServiceSummary, HealthStatus } from "@/types"
import { ComponentCard } from "./ComponentCard"
import { SectionHeader } from "./SectionHeader"
interface ServiceSectionProps {
services: ComponentSummary[]
services: ServiceSummary[]
statuses: HealthStatus[]
}

View File

@@ -3,12 +3,12 @@ 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 type { AnyDetail } from "@/types"
import { ComponentFields } from "@/components/ComponentFields"
interface ConfigPanelProps {
component: ComponentDetail
configSection: "services" | "jobs" | "components"
component: AnyDetail
configSection: "services" | "jobs" | "programs"
onRefetch: () => void
}
@@ -23,7 +23,7 @@ export function ConfigPanel({ component, configSection, onRefetch }: ConfigPanel
await apiClient.put(`/config/${configSection}/${compName}`, { config })
setMessage({ type: "ok", text: "Saved to castle.yaml" })
onRefetch()
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: [configSection] })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setMessage({ type: "error", text: msg })
@@ -33,7 +33,7 @@ export function ConfigPanel({ component, configSection, onRefetch }: ConfigPanel
const handleDelete = async (compName: string) => {
try {
await apiClient.delete(`/config/${configSection}/${compName}`)
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: [configSection] })
navigate("/")
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)

View File

@@ -33,7 +33,7 @@ export const STACK_LABELS: Record<string, string> = {
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
service: { title: "Services", subtitle: "Long-running processes" },
scheduled: { title: "Scheduled Jobs", subtitle: "Systemd timers" },
component: { title: "Components", subtitle: "Software catalog" },
program: { title: "Programs", subtitle: "Software catalog" },
}
export function runnerLabel(runner: string): string {

View File

@@ -1,17 +1,16 @@
import { useParams } from "react-router-dom"
import { Plug, Unplug } from "lucide-react"
import { useComponent, useEventStream, useToolDetail, useToolAction } from "@/services/api/hooks"
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { ProgramActions } from "@/components/ProgramActions"
export function ComponentDetailPage() {
useEventStream()
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
const isTool = component?.behavior === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const { mutate: toolAction, isPending: toolPending } = useToolAction()
if (isLoading) {
return (
@@ -23,7 +22,7 @@ export function ComponentDetailPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader backTo="/" backLabel="Back" name={name ?? ""} />
<p className="text-red-400">Component not found</p>
<p className="text-red-400">Program not found</p>
</div>
)
}
@@ -32,33 +31,13 @@ export function ComponentDetailPage() {
<div className="max-w-3xl mx-auto px-6 py-8">
<DetailHeader
backTo="/"
backLabel="Back to Components"
backLabel="Back to Programs"
name={component.id}
behavior={component.behavior}
stack={component.stack}
source={component.source}
>
{isTool && (
<div className="flex items-center gap-2">
{component.installed ? (
<button
onClick={() => toolAction({ name: component.id, action: "uninstall" })}
disabled={toolPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-red-800 text-red-400 hover:bg-red-800/20 transition-colors disabled:opacity-40"
>
<Unplug size={14} /> Uninstall
</button>
) : (
<button
onClick={() => toolAction({ name: component.id, action: "install" })}
disabled={toolPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-green-800 text-green-400 hover:bg-green-800/20 transition-colors disabled:opacity-40"
>
<Plug size={14} /> Install to PATH
</button>
)}
</div>
)}
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} />
</DetailHeader>
{component.description && (
@@ -121,7 +100,7 @@ export function ComponentDetailPage() {
</div>
)}
<ConfigPanel component={component} configSection="components" onRefetch={refetch} />
<ConfigPanel component={component} configSection="programs" onRefetch={refetch} />
</div>
)
}

View File

@@ -12,7 +12,7 @@ export function ComponentRedirect() {
}
if (error || !component) {
return <Navigate to={`/components/${name}`} replace />
return <Navigate to={`/programs/${name}`} replace />
}
if (component.managed && !component.schedule) {
@@ -20,8 +20,8 @@ export function ComponentRedirect() {
}
if (component.managed && component.schedule) {
return <Navigate to={`/scheduled/${name}`} replace />
return <Navigate to={`/jobs/${name}`} replace />
}
return <Navigate to={`/components/${name}`} replace />
return <Navigate to={`/programs/${name}`} replace />
}

View File

@@ -1,5 +1,4 @@
import { useMemo } from "react"
import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
import { useServices, useJobs, usePrograms, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
import { GatewayPanel } from "@/components/GatewayPanel"
import { MeshPanel } from "@/components/MeshPanel"
import { NodeBar } from "@/components/NodeBar"
@@ -11,20 +10,16 @@ import { SectionHeader } from "@/components/SectionHeader"
export function Dashboard() {
useEventStream()
const { data: components, isLoading } = useComponents()
const { data: services, isLoading: loadingServices } = useServices()
const { data: jobs, isLoading: loadingJobs } = useJobs()
const { data: programs, isLoading: loadingPrograms } = usePrograms()
const { data: statusResp } = useStatus()
const { data: gateway } = useGateway()
const { data: nodes } = useNodes()
const { data: mesh } = useMeshStatus()
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 ?? []
const isLoading = loadingServices || loadingJobs || loadingPrograms
return (
<div className="max-w-6xl mx-auto px-6 py-8">
@@ -51,16 +46,16 @@ export function Dashboard() {
<p className="text-[var(--muted)]">Loading...</p>
) : (
<div className="space-y-10">
{services.length > 0 && (
{services && services.length > 0 && (
<ServiceSection services={services} statuses={statuses} />
)}
{scheduled.length > 0 && (
<ScheduledSection jobs={scheduled} statuses={statuses} />
{jobs && jobs.length > 0 && (
<ScheduledSection jobs={jobs} statuses={statuses} />
)}
{catalog.length > 0 && (
{programs && programs.length > 0 && (
<section>
<SectionHeader section="component" />
<ComponentTable components={catalog} />
<SectionHeader section="program" />
<ComponentTable components={programs} />
</section>
)}
</div>

View File

@@ -1,7 +1,8 @@
import { useRef } from "react"
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 { Clock, Trash2 } from "lucide-react"
import { useJob, useStatus, useEventStream } from "@/services/api/hooks"
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ServiceControls } from "@/components/detail/ServiceControls"
import { SystemdPanel } from "@/components/detail/SystemdPanel"
@@ -9,8 +10,9 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
export function ScheduledDetailPage() {
useEventStream()
const logRef = useRef<LogViewerHandle>(null)
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
const { data: component, isLoading, error, refetch } = useJob(name ?? "")
const { data: statusResp } = useStatus()
const health = statusResp?.statuses.find((s) => s.id === name)
@@ -35,7 +37,7 @@ export function ScheduledDetailPage() {
backTo="/"
backLabel="Back to Jobs"
name={component.id}
behavior={component.behavior}
behavior="tool"
stack={component.stack}
source={component.source}
>
@@ -73,8 +75,16 @@ export function ScheduledDetailPage() {
{component.managed && (
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer name={component.id} />
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Logs</h2>
<button
onClick={() => logRef.current?.clear()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
>
<Trash2 size={14} /> Clear
</button>
</div>
<LogViewer ref={logRef} name={component.id} />
</div>
)}
</div>

View File

@@ -1,9 +1,10 @@
import { useRef } from "react"
import { useParams } from "react-router-dom"
import { Server, ExternalLink, Terminal } from "lucide-react"
import { useComponent, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
import { Server, ExternalLink, Terminal, Trash2 } from "lucide-react"
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels"
import { HealthBadge } from "@/components/HealthBadge"
import { LogViewer } from "@/components/LogViewer"
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader"
import { ServiceControls } from "@/components/detail/ServiceControls"
import { SystemdPanel } from "@/components/detail/SystemdPanel"
@@ -11,8 +12,9 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
export function ServiceDetailPage() {
useEventStream()
const logRef = useRef<LogViewerHandle>(null)
const { name } = useParams<{ name: string }>()
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
const { data: component, isLoading, error, refetch } = useService(name ?? "")
const { data: statusResp } = useStatus()
const health = statusResp?.statuses.find((s) => s.id === name)
const isGateway = name === "castle-gateway"
@@ -41,7 +43,7 @@ export function ServiceDetailPage() {
backTo="/"
backLabel="Back to Services"
name={component.id}
behavior={component.behavior}
behavior="daemon"
stack={component.stack}
source={component.source}
>
@@ -129,8 +131,16 @@ export function ServiceDetailPage() {
{component.managed && (
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer name={component.id} />
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Logs</h2>
<button
onClick={() => logRef.current?.clear()}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
>
<Trash2 size={14} /> Clear
</button>
</div>
<LogViewer ref={logRef} name={component.id} />
</div>
)}
</div>

View File

@@ -20,7 +20,7 @@ export const router = createBrowserRouter([
element: <ScheduledDetailPage />,
},
{
path: "/components/:name",
path: "/programs/:name",
element: <ComponentDetailPage />,
},
{

View File

@@ -2,8 +2,13 @@ import { useEffect } from "react"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import { apiClient } from "./client"
import type {
ComponentSummary,
ComponentDetail,
ServiceSummary,
ServiceDetail,
JobSummary,
JobDetail,
ProgramSummary,
ProgramDetail,
StatusResponse,
GatewayInfo,
ServiceActionResponse,
@@ -15,13 +20,7 @@ import type {
ToolDetail,
} from "@/types"
export function useComponents() {
return useQuery({
queryKey: ["components"],
queryFn: () => apiClient.get<ComponentSummary[]>("/components"),
})
}
// Legacy compat hook — used by ConfigEditorPage and ComponentRedirect
export function useComponent(name: string) {
return useQuery({
queryKey: ["components", name],
@@ -30,6 +29,51 @@ export function useComponent(name: string) {
})
}
export function useServices() {
return useQuery({
queryKey: ["services"],
queryFn: () => apiClient.get<ServiceSummary[]>("/services"),
})
}
export function useService(name: string) {
return useQuery({
queryKey: ["services", name],
queryFn: () => apiClient.get<ServiceDetail>(`/services/${name}`),
enabled: !!name,
})
}
export function useJobs() {
return useQuery({
queryKey: ["jobs"],
queryFn: () => apiClient.get<JobSummary[]>("/jobs"),
})
}
export function useJob(name: string) {
return useQuery({
queryKey: ["jobs", name],
queryFn: () => apiClient.get<JobDetail>(`/jobs/${name}`),
enabled: !!name,
})
}
export function usePrograms() {
return useQuery({
queryKey: ["programs"],
queryFn: () => apiClient.get<ProgramSummary[]>("/programs"),
})
}
export function useProgram(name: string) {
return useQuery({
queryKey: ["programs", name],
queryFn: () => apiClient.get<ProgramDetail>(`/programs/${name}`),
enabled: !!name,
})
}
export function useStatus() {
return useQuery({
queryKey: ["status"],
@@ -97,15 +141,15 @@ export function useServiceAction() {
})
}
export function useToolAction() {
export function useProgramAction() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ name, action }: { name: string; action: "install" | "uninstall" }) =>
apiClient.post<{ component: string; action: string; status: string }>(
`/tools/${name}/${action}`,
mutationFn: ({ name, action }: { name: string; action: string }) =>
apiClient.post<{ component: string; action: string; status: string; output: string }>(
`/programs/${name}/${action}`,
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: ["programs"] })
},
})
}
@@ -171,9 +215,10 @@ export function useEventStream() {
})
es.addEventListener("service-action", () => {
// Health event already pushes correct status; just refetch components
// Health event already pushes correct status; just refetch services/jobs
// in case the action changed what's available
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: ["services"] })
qc.invalidateQueries({ queryKey: ["jobs"] })
})
es.addEventListener("mesh", () => {

View File

@@ -4,9 +4,65 @@ export interface SystemdInfo {
timer: boolean
}
export interface ServiceSummary {
id: string
description: string | null
stack: string | null
runner: string | null
port: number | null
health_path: string | null
proxy_path: string | null
managed: boolean
systemd: SystemdInfo | null
source: string | null
node: string | null
}
export interface ServiceDetail extends ServiceSummary {
manifest: Record<string, unknown>
}
export interface JobSummary {
id: string
description: string | null
stack: string | null
runner: string | null
schedule: string | null
managed: boolean
systemd: SystemdInfo | null
source: string | null
node: string | null
}
export interface JobDetail extends JobSummary {
manifest: Record<string, unknown>
}
export interface ProgramSummary {
id: string
description: string | null
behavior: string | null
stack: string | null
runner: string | null
version: string | null
source: string | null
system_dependencies: string[]
installed: boolean | null
actions: string[]
node: string | null
}
export interface ProgramDetail extends ProgramSummary {
manifest: Record<string, unknown>
}
// Union for shared detail components (ConfigPanel, ComponentFields)
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
export interface ComponentSummary {
id: string
category: "component" | "service" | "job" | null
category: "program" | "service" | "job" | null
description: string | null
behavior: string | null
stack: string | null