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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
167
app/src/components/ProgramActions.tsx
Normal file
167
app/src/components/ProgramActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const router = createBrowserRouter([
|
||||
element: <ScheduledDetailPage />,
|
||||
},
|
||||
{
|
||||
path: "/components/:name",
|
||||
path: "/programs/:name",
|
||||
element: <ComponentDetailPage />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user