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:
275
app/src/pages/ComponentDetail.tsx
Normal file
275
app/src/pages/ComponentDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
app/src/pages/ConfigEditor.tsx
Normal file
146
app/src/pages/ConfigEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
app/src/pages/Dashboard.tsx
Normal file
36
app/src/pages/Dashboard.tsx
Normal 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">
|
||||
· {gateway.component_count} components · 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
42
app/src/pages/Tools.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user