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)