feat: add ComponentGrid and ComponentTable components for displaying components in grid and table formats

feat: implement HealthBadge and RoleBadge components for displaying health status and roles

feat: create LogViewer component for streaming logs of services

feat: develop SecretsEditor for managing secrets with CRUD operations

feat: introduce ToolCard component for displaying tool information

style: add global CSS variables and styles for consistent theming

feat: set up API client for handling requests to the backend

feat: implement hooks for fetching components, statuses, and tools

feat: create routes for dashboard, component details, and tools

feat: add service management endpoints for starting, stopping, and restarting services

feat: implement event bus for handling real-time updates via SSE

feat: create health check and logs endpoints for monitoring services

feat: add tests for health and tools endpoints to ensure functionality

chore: update project configuration files and dependencies for better development experience
This commit is contained in:
2026-02-21 01:23:37 -08:00
parent f99c2dad86
commit 930bc601b7
63 changed files with 154 additions and 157 deletions

View File

@@ -0,0 +1,275 @@
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 { runnerLabel } from "@/lib/labels"
import { ComponentFields } from "@/components/ComponentFields"
import { HealthBadge } from "@/components/HealthBadge"
import { LogViewer } from "@/components/LogViewer"
import { RoleBadge } from "@/components/RoleBadge"
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?.roles.includes("tool") ?? false
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const isGateway = name === "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 handleSave = async (compName: string, config: Record<string, unknown>) => {
setMessage(null)
try {
await apiClient.put(`/config/components/${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/components/${compName}`)
qc.invalidateQueries({ queryKey: ["components"] })
navigate("/")
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setMessage({ type: "error", text: msg })
}
}
if (isLoading) {
return (
<div className="max-w-3xl mx-auto px-6 py-8 text-[var(--muted)]">
Loading...
</div>
)
}
if (error || !component) {
return (
<div className="max-w-3xl mx-auto px-6 py-8">
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-4">
<ArrowLeft size={16} /> Back
</Link>
<p className="text-red-400">Component not found</p>
</div>
)
}
return (
<div className="max-w-3xl mx-auto px-6 py-8">
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6">
<ArrowLeft size={16} /> Back
</Link>
<div className="flex items-start justify-between mb-4">
<h1 className="text-2xl font-bold">{component.id}</h1>
<div className="flex items-center gap-2">
{health && <HealthBadge status={health.status} latency={health.latency_ms} />}
{component.managed && (
<div className="flex items-center gap-1 ml-2">
{isDown && (
<button
onClick={() => mutate({ name: component.id, action: "start" })}
disabled={isPending}
className="p-1.5 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
title="Start"
>
<Play size={16} />
</button>
)}
<button
onClick={() => mutate({ name: component.id, action: "restart" })}
disabled={isPending}
className="p-1.5 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart"
>
<RefreshCw size={16} />
</button>
{!isDown && (
<button
onClick={() => mutate({ name: component.id, action: "stop" })}
disabled={isPending}
className="p-1.5 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Stop"
>
<Square size={16} />
</button>
)}
</div>
)}
</div>
</div>
<div className="flex gap-1.5 mb-6">
{component.roles.map((role) => (
<RoleBadge key={role} role={role} />
))}
</div>
{message && (
<div
className={`mb-4 px-3 py-2 rounded text-sm ${
message.type === "ok"
? "bg-green-900/30 text-green-300 border border-green-800"
: "bg-red-900/30 text-red-300 border border-red-800"
}`}
>
<span className="flex items-center gap-1.5">
{message.type === "ok" && <Check size={14} />}
{message.text}
</span>
</div>
)}
{toolDetail && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
Tool Info
</h2>
<p className="text-xs text-[var(--muted)] mb-4">
How this tool is packaged and what it depends on.
</p>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
{toolDetail.source && (
<>
<span className="text-[var(--muted)]">Source</span>
<span className="font-mono">{toolDetail.source}</span>
</>
)}
{toolDetail.version && (
<>
<span className="text-[var(--muted)]">Version</span>
<span>{toolDetail.version}</span>
</>
)}
{toolDetail.runner && (
<>
<span className="text-[var(--muted)]">Runner</span>
<span>{runnerLabel(toolDetail.runner)}</span>
</>
)}
</div>
{toolDetail.system_dependencies.length > 0 && (
<div className="mb-4">
<span className="text-sm text-[var(--muted)] block mb-1">System Dependencies</span>
<div className="flex flex-wrap gap-1">
{toolDetail.system_dependencies.map((dep) => (
<span
key={dep}
className="text-xs px-2 py-0.5 rounded bg-amber-900/30 text-amber-400 border border-amber-800"
>
{dep}
</span>
))}
</div>
</div>
)}
{toolDetail.docs && (
<div>
<span className="text-sm text-[var(--muted)] block mb-1">Documentation</span>
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)]">
{toolDetail.docs}
</pre>
</div>
)}
</div>
)}
{component.systemd && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<div className="flex items-center justify-between mb-1">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
Systemd
</h2>
<button
onClick={() => setShowUnit((v) => !v)}
className="text-xs text-[var(--primary)] hover:underline"
>
{showUnit ? "Hide unit file" : "View unit file"}
</button>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mt-3">
<span className="text-[var(--muted)]">Unit</span>
<span className="font-mono">{component.systemd.unit_name}</span>
<span className="text-[var(--muted)]">Path</span>
<span className="font-mono">{component.systemd.unit_path}</span>
{component.systemd.timer && (
<>
<span className="text-[var(--muted)]">Timer</span>
<span>Active</span>
</>
)}
</div>
{showUnit && unitData && (
<div className="mt-4 space-y-3">
<div>
<span className="text-xs text-[var(--muted)] block mb-1">{component.systemd.unit_name}</span>
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)] font-mono overflow-x-auto">
{unitData.service}
</pre>
</div>
{unitData.timer && (
<div>
<span className="text-xs text-[var(--muted)] block mb-1">
{component.systemd.unit_name.replace(".service", ".timer")}
</span>
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)] font-mono overflow-x-auto">
{unitData.timer}
</pre>
</div>
)}
</div>
)}
</div>
)}
{isGateway && caddyfile?.content && (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-1">
Caddyfile
</h2>
<p className="text-xs text-[var(--muted)] mb-3">
Generated reverse proxy configuration served by the gateway.
</p>
<pre className="text-sm whitespace-pre-wrap bg-[var(--background)] rounded p-3 border border-[var(--border)] font-mono overflow-x-auto">
{caddyfile.content}
</pre>
</div>
)}
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-4">
Configuration
</h2>
<ComponentFields
component={component}
onSave={handleSave}
onDelete={handleDelete}
/>
</div>
{component.managed && (
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">Logs</h2>
<LogViewer name={component.id} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
import { useState } from "react"
import { Link } from "react-router-dom"
import { ArrowLeft, Check, Loader2, Zap } from "lucide-react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/services/api/client"
import type { ComponentDetail } from "@/types"
import { AddComponent } from "@/components/AddComponent"
import { ComponentEditor } from "@/components/ComponentEditor"
interface ApplyResult {
ok: boolean
actions: string[]
errors: string[]
}
export function ConfigEditorPage() {
const qc = useQueryClient()
const [applying, setApplying] = useState(false)
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null)
const { data: components, isLoading, refetch } = useQuery({
queryKey: ["config-components"],
queryFn: async () => {
const list = await apiClient.get<{ id: string }[]>("/components")
const details = await Promise.all(
list.map((c) => apiClient.get<ComponentDetail>(`/components/${c.id}`))
)
return details
},
})
const handleSave = async (name: string, config: Record<string, unknown>) => {
setMessage(null)
setApplyResult(null)
try {
await apiClient.put(`/config/components/${name}`, { config })
setMessage({ type: "ok", text: `Saved ${name}` })
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 (name: string) => {
setMessage(null)
try {
await apiClient.delete(`/config/components/${name}`)
setMessage({ type: "ok", text: `Removed ${name}` })
refetch()
qc.invalidateQueries({ queryKey: ["components"] })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setMessage({ type: "error", text: msg })
}
}
const handleApply = async () => {
setApplying(true)
setMessage(null)
setApplyResult(null)
try {
const result = await apiClient.post<ApplyResult>("/config/apply")
setApplyResult(result)
setMessage({
type: result.ok ? "ok" : "error",
text: result.ok ? "Applied successfully" : "Applied with errors",
})
qc.invalidateQueries({ queryKey: ["components"] })
qc.invalidateQueries({ queryKey: ["status"] })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setMessage({ type: "error", text: msg })
} finally {
setApplying(false)
}
}
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1">
<ArrowLeft size={16} /> Dashboard
</Link>
<h1 className="text-2xl font-bold">Configuration</h1>
</div>
<button
onClick={handleApply}
disabled={applying}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40"
>
{applying ? <Loader2 size={14} className="animate-spin" /> : <Zap size={14} />}
Apply All
</button>
</div>
{message && (
<div
className={`mb-4 px-3 py-2 rounded text-sm ${
message.type === "ok"
? "bg-green-900/30 text-green-300 border border-green-800"
: "bg-red-900/30 text-red-300 border border-red-800"
}`}
>
<span className="flex items-center gap-1.5">
{message.type === "ok" && <Check size={14} />}
{message.text}
</span>
</div>
)}
{applyResult && (
<div className="mb-4 bg-[var(--card)] border border-[var(--border)] rounded-lg p-4 text-sm space-y-1">
{applyResult.actions.map((a, i) => (
<div key={i} className="text-green-400">{a}</div>
))}
{applyResult.errors.map((e, i) => (
<div key={i} className="text-red-400">{e}</div>
))}
</div>
)}
{isLoading ? (
<p className="text-[var(--muted)]">Loading components...</p>
) : (
<div className="space-y-2">
{components?.map((comp) => (
<ComponentEditor
key={comp.id}
component={comp}
onSave={handleSave}
onDelete={handleDelete}
/>
))}
<AddComponent
existingNames={components?.map((c) => c.id) ?? []}
onAdd={handleSave}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { ComponentTable } from "@/components/ComponentTable"
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks"
export function Dashboard() {
useEventStream()
const { data: components, isLoading } = useComponents()
const { data: statusResp } = useStatus()
const { data: gateway } = useGateway()
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">Castle</h1>
<p className="text-[var(--muted)] mt-1">
Personal software platform
{gateway && (
<span className="ml-2 text-sm">
&middot; {gateway.component_count} components &middot; port {gateway.port}
</span>
)}
</p>
</div>
{isLoading ? (
<p className="text-[var(--muted)]">Loading components...</p>
) : components ? (
<ComponentTable
components={components}
statuses={statusResp?.statuses ?? []}
/>
) : (
<p className="text-red-400">Failed to load components</p>
)}
</div>
)
}

42
app/src/pages/Tools.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Link } from "react-router-dom"
import { ArrowLeft } from "lucide-react"
import { useTools } from "@/services/api/hooks"
import { ToolCard } from "@/components/ToolCard"
export function ToolsPage() {
const { data: categories, isLoading } = useTools()
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<Link to="/" className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6">
<ArrowLeft size={16} /> Back
</Link>
<h1 className="text-3xl font-bold mb-2">Tools</h1>
<p className="text-sm text-[var(--muted)] mb-8">
CLI utilities grouped by category. Each tool is installed to PATH via castle and run with uv.
</p>
{isLoading ? (
<p className="text-[var(--muted)]">Loading tools...</p>
) : categories?.length ? (
<div className="space-y-8">
{categories.map((cat) => (
<section key={cat.name}>
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
{cat.name}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{cat.tools.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
</section>
))}
</div>
) : (
<p className="text-[var(--muted)]">No tools registered.</p>
)}
</div>
)
}