refactor: Update component structure to use behavior and stack attributes, enhancing clarity and functionality across services, tools, and frontends
This commit is contained in:
39
CLAUDE.md
39
CLAUDE.md
@@ -13,8 +13,11 @@ Castle is a personal software platform — a monorepo of independent projects
|
||||
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
|
||||
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
|
||||
|
||||
The section determines the category — no role derivation. Services and jobs
|
||||
can reference a component via `component:` for description fallthrough.
|
||||
Each component has a **stack** (development toolchain: python-fastapi,
|
||||
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
|
||||
frontend). Scheduling, systemd management, and proxying are orthogonal
|
||||
operations. Services and jobs reference a component via `component:` for
|
||||
description fallthrough.
|
||||
|
||||
**Key principle:** Regular projects must never depend on castle. They accept standard
|
||||
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway)
|
||||
@@ -25,22 +28,22 @@ know about castle internals.
|
||||
When creating a new service, tool, or frontend, follow the detailed guides:
|
||||
|
||||
- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
|
||||
- @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing)
|
||||
- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
|
||||
- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns
|
||||
- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing)
|
||||
- @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
|
||||
- @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
# Service
|
||||
castle create my-service --type service --description "Does something"
|
||||
# Daemon (python-fastapi)
|
||||
castle create my-service --stack python-fastapi --description "Does something"
|
||||
cd components/my-service && uv sync
|
||||
uv run my-service # starts on auto-assigned port
|
||||
castle service enable my-service # register with systemd
|
||||
castle gateway reload # update reverse proxy routes
|
||||
|
||||
# Tool
|
||||
castle create my-tool --type tool --description "Does something"
|
||||
# Tool (python-cli)
|
||||
castle create my-tool --stack python-cli --description "Does something"
|
||||
cd components/my-tool && uv sync
|
||||
```
|
||||
|
||||
@@ -53,9 +56,11 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
||||
|
||||
```bash
|
||||
castle list # List all components
|
||||
castle list --type service # Filter by category
|
||||
castle list --behavior daemon # Filter by behavior
|
||||
castle list --stack python-cli # Filter by stack
|
||||
castle info <component> # Show details (--json for machine-readable)
|
||||
castle create <name> --type service # Scaffold new project
|
||||
castle create <name> --stack python-fastapi # Scaffold new project
|
||||
castle deploy [component] # Deploy to runtime (registry + systemd + Caddyfile)
|
||||
castle test [project] # Run tests (one or all)
|
||||
castle lint [project] # Run linter (one or all)
|
||||
castle sync # Update submodules + uv sync all
|
||||
@@ -73,7 +78,14 @@ castle services start|stop # Start/stop everything
|
||||
|
||||
- **Gateway**: Caddy reverse proxy at port 9000, config generated from `castle.yaml`
|
||||
into `~/.castle/generated/Caddyfile`. Dashboard served at root.
|
||||
- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`
|
||||
- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`.
|
||||
Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy`
|
||||
shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`).
|
||||
- **Containers**: `runner: container` services use Docker (preferred on this system
|
||||
due to rootless podman UID mapping issues). Deploy resolves the runtime via
|
||||
`shutil.which("docker")`.
|
||||
- **MQTT**: Mosquitto broker runs as `castle-mqtt` (Docker container on port 1883).
|
||||
Data in `/data/castle/castle-mqtt/`, config in `/data/castle/castle-mqtt/config/`.
|
||||
- **Data**: Service data lives in `/data/castle/<service-name>/`, passed via env var.
|
||||
- **Secrets**: `~/.castle/secrets/` — never in project directories.
|
||||
|
||||
@@ -93,7 +105,8 @@ Gateway:
|
||||
- `GET /gateway/caddyfile` — Generated Caddyfile content
|
||||
- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy
|
||||
|
||||
Nodes (mesh):
|
||||
Mesh:
|
||||
- `GET /mesh/status` — MQTT connection state, broker info, peer list
|
||||
- `GET /nodes` — All known nodes (local + discovered remote)
|
||||
- `GET /nodes/{hostname}` — Node detail with deployed components
|
||||
|
||||
|
||||
83
README.md
83
README.md
@@ -25,22 +25,23 @@ open http://localhost:9000
|
||||
|
||||
```bash
|
||||
# Service — FastAPI app with health endpoint, systemd unit, gateway route
|
||||
castle create my-api --type service --description "Does something useful"
|
||||
castle create my-api --stack python-fastapi --description "Does something useful"
|
||||
cd components/my-api && uv sync
|
||||
castle test my-api
|
||||
castle service enable my-api
|
||||
castle gateway reload
|
||||
|
||||
# Standalone tool — CLI tool with argparse, stdin/stdout, Unix pipes
|
||||
castle create my-tool --type tool --description "Does something"
|
||||
castle create my-tool --stack python-cli --description "Does something"
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
castle list [--role ROLE] [--json] List all components
|
||||
castle info NAME [--json] Show component details
|
||||
castle create NAME --type TYPE Scaffold a new component
|
||||
castle list [--behavior B] [--stack S] [--json] List all components
|
||||
castle info NAME [--json] Show component details
|
||||
castle create NAME --stack STACK Scaffold a new component
|
||||
castle deploy [NAME] Deploy component(s) to runtime
|
||||
castle run NAME Run component in foreground
|
||||
castle test [NAME] Run tests (one or all)
|
||||
castle lint [NAME] Run linter (one or all)
|
||||
@@ -56,7 +57,13 @@ castle tool info NAME Show tool details
|
||||
|
||||
## Registry
|
||||
|
||||
`castle.yaml` is the single source of truth. Components declare **what they do** (run, expose, manage, install, build, triggers) and roles are **derived** from those declarations.
|
||||
`castle.yaml` is the single source of truth with three sections:
|
||||
|
||||
- **`components:`** — Software catalog (source, install, tool metadata, build)
|
||||
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
|
||||
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
|
||||
|
||||
Services and jobs can reference a component via `component:` for description fallthrough.
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
@@ -66,6 +73,10 @@ components:
|
||||
central-context:
|
||||
description: Content storage API
|
||||
source: components/central-context
|
||||
|
||||
services:
|
||||
central-context:
|
||||
component: central-context
|
||||
run:
|
||||
runner: python
|
||||
tool: central-context
|
||||
@@ -78,22 +89,13 @@ components:
|
||||
manage:
|
||||
systemd: {}
|
||||
|
||||
notification-bridge:
|
||||
description: Desktop notification forwarder
|
||||
source: components/notification-bridge
|
||||
jobs:
|
||||
backup-collect:
|
||||
component: backup-collect
|
||||
run:
|
||||
runner: python
|
||||
tool: notification-bridge
|
||||
defaults:
|
||||
env:
|
||||
CENTRAL_CONTEXT_URL: http://localhost:9001
|
||||
BUCKET_NAME: notifications
|
||||
expose:
|
||||
http:
|
||||
internal: { port: 9002 }
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy: { path_prefix: /notifications }
|
||||
runner: command
|
||||
argv: [backup-collect]
|
||||
schedule: "0 2 * * *"
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
@@ -106,15 +108,16 @@ automatically by `castle deploy`. Only non-convention values need `defaults.env`
|
||||
```
|
||||
castle.yaml <- component registry (single source of truth)
|
||||
cli/ <- castle CLI
|
||||
core/ <- castle-core library (models, config, generators)
|
||||
castle-api/ <- Castle API (dashboard backend)
|
||||
app/ <- Castle web app (React/Vite frontend)
|
||||
components/ <- all non-infrastructure components
|
||||
central-context/ <- content storage API (git submodule)
|
||||
notification-bridge/ <- desktop notification forwarder (git submodule)
|
||||
protonmail/ <- email sync tool/job
|
||||
devbox-connect/ <- SSH tunnel manager
|
||||
pdf2md/ <- standalone tool (each tool is its own project)
|
||||
...
|
||||
docs/ <- architecture docs and component guides
|
||||
ruff.toml <- shared lint config
|
||||
pyrightconfig.json <- shared type checking config
|
||||
```
|
||||
@@ -137,6 +140,7 @@ pyrightconfig.json <- shared type checking config
|
||||
| central-context | 9001 | Content storage API |
|
||||
| notification-bridge | 9002 | Desktop notification forwarder |
|
||||
| castle-api | 9020 | Castle API (dashboard backend) |
|
||||
| castle-mqtt | 1883 | MQTT broker for mesh coordination (Mosquitto container) |
|
||||
|
||||
### Jobs
|
||||
|
||||
@@ -171,3 +175,38 @@ pyrightconfig.json <- shared type checking config
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| castle-app | Castle web dashboard (React/Vite/TypeScript) |
|
||||
|
||||
## Mesh Coordination
|
||||
|
||||
Castle nodes can discover each other via MQTT and mDNS, forming a personal infrastructure mesh. All mesh features are opt-in — single-node works without them.
|
||||
|
||||
```bash
|
||||
# Enable on castle-api (via systemd drop-in or env vars)
|
||||
CASTLE_API_MQTT_ENABLED=true # Connect to MQTT broker
|
||||
CASTLE_API_MQTT_HOST=localhost # Broker address (default)
|
||||
CASTLE_API_MQTT_PORT=1883 # Broker port (default)
|
||||
CASTLE_API_MDNS_ENABLED=true # Advertise/discover via mDNS
|
||||
```
|
||||
|
||||
When enabled, the API publishes the node's registry to `castle/{hostname}/registry` (retained) and subscribes to other nodes. The gateway can proxy to services on remote nodes. The dashboard shows discovered nodes, cross-node routes, and mesh connection status.
|
||||
|
||||
## API
|
||||
|
||||
`castle-api` runs on port 9020 and is proxied at `/api` through the gateway.
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /health` | Health check |
|
||||
| `GET /stream` | SSE stream (health, service-action, mesh events) |
|
||||
| `GET /components` | List all components (`?include_remote=true` for cross-node) |
|
||||
| `GET /components/{name}` | Component detail |
|
||||
| `GET /status` | Live health for all services |
|
||||
| `GET /gateway` | Gateway info with route table |
|
||||
| `GET /gateway/caddyfile` | Generated Caddyfile content |
|
||||
| `POST /gateway/reload` | Regenerate Caddyfile and reload Caddy |
|
||||
| `GET /mesh/status` | Mesh connection state (MQTT, mDNS, peers) |
|
||||
| `GET /nodes` | All known nodes (local + remote) |
|
||||
| `GET /nodes/{hostname}` | Node detail with deployed components |
|
||||
| `POST /services/{name}/{action}` | Start/stop/restart a service |
|
||||
| `GET /tools` | List all tools |
|
||||
| `POST /tools/{name}/install` | Install tool to PATH |
|
||||
|
||||
24
app/src/components/BehaviorBadge.tsx
Normal file
24
app/src/components/BehaviorBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels"
|
||||
|
||||
const behaviorColors: Record<string, string> = {
|
||||
daemon: "bg-green-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
frontend: "bg-yellow-600 text-black",
|
||||
}
|
||||
|
||||
export function BehaviorBadge({ behavior }: { behavior: string | null }) {
|
||||
if (!behavior) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
behaviorColors[behavior] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
title={BEHAVIOR_DESCRIPTIONS[behavior]}
|
||||
>
|
||||
{behaviorLabel(behavior)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ComponentCardProps {
|
||||
component: ComponentSummary
|
||||
@@ -37,8 +38,9 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-2">
|
||||
<RoleBadge role={component.category} />
|
||||
<div className="flex gap-1.5 mb-2">
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
<StackBadge stack={component.stack} />
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
import type { ComponentDetail } from "@/types"
|
||||
import { ComponentFields } from "./ComponentFields"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ComponentEditorProps {
|
||||
component: ComponentDetail
|
||||
@@ -25,7 +26,8 @@ export function ComponentEditor({ component, onSave, onDelete }: ComponentEditor
|
||||
<span className="text-sm text-[var(--muted)]">{component.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RoleBadge role={component.category} />
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
<StackBadge stack={component.stack} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
||||
try {
|
||||
const config: Record<string, unknown> = { ...m }
|
||||
delete config.id
|
||||
delete config.category
|
||||
delete config.behavior
|
||||
config.description = description || undefined
|
||||
|
||||
// Merge plain env + secret references back together
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { CATEGORY_LABELS } from "@/lib/labels"
|
||||
import { BEHAVIOR_LABELS } from "@/lib/labels"
|
||||
import { ComponentCard } from "./ComponentCard"
|
||||
|
||||
const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"]
|
||||
const BEHAVIOR_ORDER = ["daemon", "tool", "frontend"]
|
||||
|
||||
interface ComponentGridProps {
|
||||
components: ComponentSummary[]
|
||||
@@ -12,24 +12,24 @@ interface ComponentGridProps {
|
||||
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
|
||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
||||
|
||||
// Group by category
|
||||
// Group by behavior
|
||||
const groups = new Map<string, ComponentSummary[]>()
|
||||
for (const comp of components) {
|
||||
const cat = comp.category
|
||||
const list = groups.get(cat) ?? []
|
||||
const key = comp.behavior ?? "other"
|
||||
const list = groups.get(key) ?? []
|
||||
list.push(comp)
|
||||
groups.set(cat, list)
|
||||
groups.set(key, list)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const items = groups.get(cat)
|
||||
{BEHAVIOR_ORDER.map((key) => {
|
||||
const items = groups.get(key)
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<section key={cat}>
|
||||
<section key={key}>
|
||||
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
|
||||
{CATEGORY_LABELS[cat] ?? cat}
|
||||
{BEHAVIOR_LABELS[key] ?? key}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((comp) => (
|
||||
|
||||
@@ -3,26 +3,24 @@ import { Link } from "react-router-dom"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Play, RefreshCw, Square, Trash2 } from "lucide-react"
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction, useToolAction } from "@/services/api/hooks"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ComponentTableProps {
|
||||
components: ComponentSummary[]
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status"
|
||||
type SortKey = "id" | "stack" | "behavior" | "status"
|
||||
type SortDir = "asc" | "desc"
|
||||
|
||||
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number {
|
||||
// Health takes priority for services
|
||||
if (s) {
|
||||
if (s.status === "down") return 0
|
||||
if (s.status === "up") return 3
|
||||
return 2
|
||||
}
|
||||
// Installed state for tools
|
||||
if (installed === false) return 1
|
||||
if (installed === true) return 3
|
||||
return 2
|
||||
@@ -30,13 +28,7 @@ function statusRank(s: HealthStatus | undefined, installed: boolean | null): num
|
||||
|
||||
export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const c of components) set.add(c.category)
|
||||
return Array.from(set).sort()
|
||||
}, [components])
|
||||
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortKey, setSortKey] = useState<SortKey>("id")
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||
@@ -51,20 +43,14 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = components
|
||||
if (categoryFilter) {
|
||||
list = list.filter((c) => c.category === categoryFilter)
|
||||
}
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter(
|
||||
(c) =>
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
(c.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [components, categoryFilter, search])
|
||||
if (!search) return components
|
||||
const q = search.toLowerCase()
|
||||
return components.filter(
|
||||
(c) =>
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
(c.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}, [components, search])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === "asc" ? 1 : -1
|
||||
@@ -72,14 +58,10 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
switch (sortKey) {
|
||||
case "id":
|
||||
return dir * a.id.localeCompare(b.id)
|
||||
case "category":
|
||||
return dir * a.category.localeCompare(b.category)
|
||||
case "runner":
|
||||
return dir * (a.runner ?? "").localeCompare(b.runner ?? "")
|
||||
case "schedule":
|
||||
return dir * (a.schedule ?? "").localeCompare(b.schedule ?? "")
|
||||
case "port":
|
||||
return dir * ((a.port ?? 0) - (b.port ?? 0))
|
||||
case "stack":
|
||||
return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
|
||||
case "behavior":
|
||||
return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
|
||||
case "status":
|
||||
return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed))
|
||||
default:
|
||||
@@ -90,51 +72,22 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<div className="mb-4">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter components..."
|
||||
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 className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setCategoryFilter(null)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
categoryFilter === null
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
categoryFilter === cat
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Category" sortKey="category" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Schedule" sortKey="schedule" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Port" sortKey="port" 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>
|
||||
@@ -149,7 +102,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-3 py-6 text-center text-[var(--muted)]">
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-[var(--muted)]">
|
||||
No components match.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -222,7 +175,6 @@ function ComponentRow({
|
||||
<Link
|
||||
to={`/component/${component.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
title={component.systemd?.unit_path ?? undefined}
|
||||
>
|
||||
{component.id}
|
||||
</Link>
|
||||
@@ -233,16 +185,10 @@ function ComponentRow({
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<RoleBadge role={component.category} />
|
||||
<StackBadge stack={component.stack} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{component.runner ? runnerLabel(component.runner) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||
{component.schedule ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||
{component.port ?? "—"}
|
||||
<td className="px-3 py-2.5">
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{health ? (
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
import type { ComponentSummary } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
import { SortHeader, useSort } from "./SortHeader"
|
||||
|
||||
type JobSortKey = "id" | "timer"
|
||||
|
||||
interface JobSectionProps {
|
||||
jobs: ComponentSummary[]
|
||||
}
|
||||
|
||||
export function JobSection({ jobs }: JobSectionProps) {
|
||||
const { sortKey, sortDir, toggleSort } = useSort<JobSortKey>("id")
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === "asc" ? 1 : -1
|
||||
return [...jobs].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case "id":
|
||||
return dir * a.id.localeCompare(b.id)
|
||||
case "timer": {
|
||||
const aTimer = a.systemd?.timer ? 1 : 0
|
||||
const bTimer = b.systemd?.timer ? 1 : 0
|
||||
return dir * (aTimer - bTimer)
|
||||
}
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}, [jobs, sortKey, sortDir])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader category="job" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
|
||||
<SortHeader label="Timer" sortKey="timer" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((job) => (
|
||||
<JobRow key={job.id} job={job} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function JobRow({ job }: { job: ComponentSummary }) {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const hasTimer = job.systemd?.timer ?? false
|
||||
|
||||
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={`/component/${job.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{job.id}
|
||||
</Link>
|
||||
{job.description && (
|
||||
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
|
||||
{job.description}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||
{job.schedule ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{hasTimer ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-purple-900/40 text-purple-400 border border-purple-800/50">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
|
||||
active
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{job.managed && (
|
||||
<button
|
||||
onClick={() => mutate({ name: job.id, action: "restart" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CATEGORY_DESCRIPTIONS } from "@/lib/labels"
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
service: "bg-green-700 text-white",
|
||||
job: "bg-purple-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
frontend: "bg-yellow-600 text-black",
|
||||
component: "bg-gray-600 text-gray-200",
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
categoryColors[role] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
title={CATEGORY_DESCRIPTIONS[role]}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
107
app/src/components/ScheduledSection.tsx
Normal file
107
app/src/components/ScheduledSection.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { Play, RefreshCw, Square } from "lucide-react"
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ScheduledSectionProps {
|
||||
jobs: ComponentSummary[]
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) {
|
||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader section="scheduled" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Name</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Status</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<ScheduledRow key={job.id} job={job} health={statusMap.get(job.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthStatus }) {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const isDown = health?.status === "down"
|
||||
|
||||
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={`/component/${job.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{job.id}
|
||||
</Link>
|
||||
{job.description && (
|
||||
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
|
||||
{job.description}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-xs">{job.schedule}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<StackBadge stack={job.stack} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{health ? (
|
||||
<span className={`text-xs ${health.status === "up" ? "text-green-400" : "text-red-400"}`}>
|
||||
{health.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{isDown && (
|
||||
<button
|
||||
onClick={() => mutate({ name: job.id, action: "start" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Start"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => mutate({ name: job.id, action: "restart" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{!isDown && (
|
||||
<button
|
||||
onClick={() => mutate({ name: job.id, action: "stop" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { SECTION_HEADERS } from "@/lib/labels"
|
||||
|
||||
export function SectionHeader({ category }: { category: string }) {
|
||||
const info = SECTION_HEADERS[category]
|
||||
export function SectionHeader({ section }: { section: string }) {
|
||||
const info = SECTION_HEADERS[section]
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<h2 className="text-lg font-semibold">{info?.title ?? category}</h2>
|
||||
<h2 className="text-lg font-semibold">{info?.title ?? section}</h2>
|
||||
<p className="text-sm text-[var(--muted)]">{info?.subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ServiceSection({ services, statuses }: ServiceSectionProps) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader category="service" />
|
||||
<SectionHeader section="service" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((svc) => (
|
||||
<ComponentCard key={svc.id} component={svc} health={statusMap.get(svc.id)} />
|
||||
|
||||
11
app/src/components/StackBadge.tsx
Normal file
11
app/src/components/StackBadge.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { stackLabel } from "@/lib/labels"
|
||||
|
||||
export function StackBadge({ stack }: { stack: string | null }) {
|
||||
if (!stack) return null
|
||||
|
||||
return (
|
||||
<span className="text-[0.65rem] font-mono px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--muted)]">
|
||||
{stackLabel(stack)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Download, Trash2 } from "lucide-react"
|
||||
import type { ComponentSummary } from "@/types"
|
||||
import { useToolAction } from "@/services/api/hooks"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
import { SortHeader, useSort } from "./SortHeader"
|
||||
|
||||
type ToolSortKey = "id" | "status"
|
||||
|
||||
function statusRank(installed: boolean | null): number {
|
||||
if (installed === false) return 0
|
||||
if (installed === true) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
interface ToolSectionProps {
|
||||
tools: ComponentSummary[]
|
||||
}
|
||||
|
||||
export function ToolSection({ tools }: ToolSectionProps) {
|
||||
const { sortKey, sortDir, toggleSort } = useSort<ToolSortKey>("id")
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === "asc" ? 1 : -1
|
||||
return [...tools].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case "id":
|
||||
return dir * a.id.localeCompare(b.id)
|
||||
case "status":
|
||||
return dir * (statusRank(a.installed) - statusRank(b.installed))
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
}, [tools, sortKey, sortDir])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader category="tool" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Description</th>
|
||||
<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((tool) => (
|
||||
<ToolRow key={tool.id} tool={tool} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolRow({ tool }: { tool: ComponentSummary }) {
|
||||
const { mutate, isPending } = useToolAction()
|
||||
|
||||
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={`/component/${tool.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{tool.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)] truncate max-w-xs">
|
||||
{tool.description ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{tool.installed !== null ? (
|
||||
tool.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>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[var(--muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{tool.installed !== null && (
|
||||
tool.installed ? (
|
||||
<button
|
||||
onClick={() => mutate({ name: tool.id, action: "uninstall" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||
title="Uninstall from PATH"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => mutate({ name: tool.id, action: "install" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Install to PATH"
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -6,30 +6,44 @@ export const RUNNER_LABELS: Record<string, string> = {
|
||||
remote: "Remote",
|
||||
}
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
service: "Services",
|
||||
job: "Jobs",
|
||||
tool: "Tools",
|
||||
frontend: "Frontends",
|
||||
component: "Components",
|
||||
export const BEHAVIOR_LABELS: Record<string, string> = {
|
||||
daemon: "Daemon",
|
||||
tool: "Tool",
|
||||
frontend: "Frontend",
|
||||
}
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
service: "Long-running daemon",
|
||||
job: "Scheduled task",
|
||||
tool: "CLI utility installed to PATH",
|
||||
frontend: "Built static assets",
|
||||
component: "Software component",
|
||||
export const BEHAVIOR_DESCRIPTIONS: Record<string, string> = {
|
||||
daemon: "Long-running process that exposes ports",
|
||||
tool: "CLI utility or scheduled task",
|
||||
frontend: "Built web application",
|
||||
}
|
||||
|
||||
export const STACK_LABELS: Record<string, string> = {
|
||||
"python-fastapi": "Python / FastAPI",
|
||||
"python-cli": "Python / CLI",
|
||||
"react-vite": "React / Vite",
|
||||
rust: "Rust",
|
||||
go: "Go",
|
||||
bash: "Bash",
|
||||
container: "Container",
|
||||
command: "Command",
|
||||
remote: "Remote",
|
||||
}
|
||||
|
||||
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
|
||||
service: { title: "Services", subtitle: "Long-running daemons managed by systemd" },
|
||||
job: { title: "Jobs", subtitle: "Scheduled tasks with cron timers" },
|
||||
tool: { title: "Tools", subtitle: "CLI utilities installed to PATH" },
|
||||
frontend: { title: "Frontends", subtitle: "Built web applications" },
|
||||
component: { title: "Other", subtitle: "Software catalog entries" },
|
||||
service: { title: "Services", subtitle: "Long-running processes" },
|
||||
scheduled: { title: "Scheduled", subtitle: "Systemd timers" },
|
||||
component: { title: "Components", subtitle: "Software catalog" },
|
||||
}
|
||||
|
||||
export function runnerLabel(runner: string): string {
|
||||
return RUNNER_LABELS[runner] ?? runner
|
||||
}
|
||||
|
||||
export function behaviorLabel(behavior: string): string {
|
||||
return BEHAVIOR_LABELS[behavior] ?? behavior
|
||||
}
|
||||
|
||||
export function stackLabel(stack: string): string {
|
||||
return STACK_LABELS[stack] ?? stack
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ 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"
|
||||
import { BehaviorBadge } from "@/components/BehaviorBadge"
|
||||
import { StackBadge } from "@/components/StackBadge"
|
||||
|
||||
export function ComponentDetailPage() {
|
||||
useEventStream()
|
||||
@@ -20,7 +21,7 @@ export function ComponentDetailPage() {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||
const isDown = health?.status === "down"
|
||||
const isTool = component?.category === "tool"
|
||||
const isTool = component?.behavior === "tool"
|
||||
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||
const isGateway = name === "castle-gateway"
|
||||
const { data: caddyfile } = useCaddyfile(isGateway)
|
||||
@@ -28,8 +29,8 @@ export function ComponentDetailPage() {
|
||||
const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
|
||||
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
||||
|
||||
const configSection = component?.category === "service" ? "services"
|
||||
: component?.category === "job" ? "jobs"
|
||||
const configSection = component?.behavior === "daemon" ? "services"
|
||||
: component?.schedule ? "jobs"
|
||||
: "components"
|
||||
|
||||
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
||||
@@ -121,7 +122,8 @@ export function ComponentDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<RoleBadge role={component.category} />
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
<StackBadge stack={component.stack} />
|
||||
{component.source && (
|
||||
<span className="text-sm text-[var(--muted)] font-mono">{component.source}</span>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
|
||||
import { GatewayPanel } from "@/components/GatewayPanel"
|
||||
import { MeshPanel } from "@/components/MeshPanel"
|
||||
import { NodeBar } from "@/components/NodeBar"
|
||||
import { ServiceSection } from "@/components/ServiceSection"
|
||||
import { JobSection } from "@/components/JobSection"
|
||||
import { ToolSection } from "@/components/ToolSection"
|
||||
import { ScheduledSection } from "@/components/ScheduledSection"
|
||||
import { ComponentTable } from "@/components/ComponentTable"
|
||||
import { SectionHeader } from "@/components/SectionHeader"
|
||||
|
||||
|
||||
export function Dashboard() {
|
||||
useEventStream()
|
||||
const { data: components, isLoading } = useComponents()
|
||||
@@ -17,16 +17,10 @@ export function Dashboard() {
|
||||
const { data: nodes } = useNodes()
|
||||
const { data: mesh } = useMeshStatus()
|
||||
|
||||
const { services, jobs, tools, frontends, other } = useMemo(() => {
|
||||
const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components }
|
||||
for (const c of components ?? []) {
|
||||
if (c.category === "service") s.services!.push(c)
|
||||
else if (c.category === "job") s.jobs!.push(c)
|
||||
else if (c.category === "tool") s.tools!.push(c)
|
||||
else if (c.category === "frontend") s.frontends!.push(c)
|
||||
else s.other!.push(c)
|
||||
}
|
||||
return { services: s.services!, jobs: s.jobs!, tools: s.tools!, frontends: s.frontends!, other: s.other! }
|
||||
const { services, scheduled } = useMemo(() => {
|
||||
const svc = (components ?? []).filter((c) => c.managed && !c.schedule)
|
||||
const sch = (components ?? []).filter((c) => c.managed && c.schedule)
|
||||
return { services: svc, scheduled: sch }
|
||||
}, [components])
|
||||
|
||||
const statuses = statusResp?.statuses ?? []
|
||||
@@ -59,62 +53,13 @@ export function Dashboard() {
|
||||
{services.length > 0 && (
|
||||
<ServiceSection services={services} statuses={statuses} />
|
||||
)}
|
||||
{jobs.length > 0 && (
|
||||
<JobSection jobs={jobs} />
|
||||
{scheduled.length > 0 && (
|
||||
<ScheduledSection jobs={scheduled} statuses={statuses} />
|
||||
)}
|
||||
{tools.length > 0 && (
|
||||
<ToolSection tools={tools} />
|
||||
)}
|
||||
{frontends.length > 0 && (
|
||||
{(components ?? []).length > 0 && (
|
||||
<section>
|
||||
<SectionHeader category="frontend" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{frontends.map((fe) => (
|
||||
<tr key={fe.id} 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={`/component/${fe.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{fe.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{fe.description ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{other.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader category="component" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{other.map((c) => (
|
||||
<tr key={c.id} 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={`/component/${c.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{c.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{c.description ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SectionHeader section="component" />
|
||||
<ComponentTable components={components ?? []} statuses={statuses} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useParams, Link } from "react-router-dom"
|
||||
import { ArrowLeft, Server } from "lucide-react"
|
||||
import { useNode } from "@/services/api/hooks"
|
||||
import { RoleBadge } from "@/components/RoleBadge"
|
||||
import { BehaviorBadge } from "@/components/BehaviorBadge"
|
||||
import { StackBadge } from "@/components/StackBadge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function NodeDetailPage() {
|
||||
@@ -62,7 +63,8 @@ export function NodeDetailPage() {
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Component</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Category</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Behavior</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
|
||||
</tr>
|
||||
@@ -85,7 +87,10 @@ export function NodeDetailPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<RoleBadge role={comp.category} />
|
||||
<BehaviorBadge behavior={comp.behavior} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<StackBadge stack={comp.stack} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{comp.runner ?? "—"}
|
||||
|
||||
@@ -7,7 +7,8 @@ export interface SystemdInfo {
|
||||
export interface ComponentSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
category: string
|
||||
behavior: string | null
|
||||
stack: string | null
|
||||
runner: string | null
|
||||
port: number | null
|
||||
health_path: string | null
|
||||
|
||||
@@ -16,7 +16,8 @@ class ComponentSummary(BaseModel):
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
category: str
|
||||
behavior: str | None = None
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
|
||||
@@ -44,8 +44,10 @@ def _registry_to_json(registry: NodeRegistry) -> str:
|
||||
for name, comp in registry.deployed.items():
|
||||
entry: dict = {
|
||||
"runner": comp.runner,
|
||||
"category": comp.category,
|
||||
"behavior": comp.behavior,
|
||||
}
|
||||
if comp.stack:
|
||||
entry["stack"] = comp.stack
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
if comp.port is not None:
|
||||
@@ -79,7 +81,8 @@ def _json_to_registry(payload: str) -> NodeRegistry:
|
||||
run_cmd=comp_data.get("run_cmd", []),
|
||||
env=comp_data.get("env", {}),
|
||||
description=comp_data.get("description"),
|
||||
category=comp_data.get("category", "service"),
|
||||
behavior=comp_data.get("behavior", "daemon"),
|
||||
stack=comp_data.get("stack"),
|
||||
port=comp_data.get("port"),
|
||||
health_path=comp_data.get("health_path"),
|
||||
proxy_path=comp_data.get("proxy_path"),
|
||||
|
||||
@@ -47,7 +47,8 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSum
|
||||
ComponentSummary(
|
||||
id=name,
|
||||
description=d.description,
|
||||
category=d.category,
|
||||
behavior=d.behavior,
|
||||
stack=d.stack,
|
||||
runner=d.runner,
|
||||
port=d.port,
|
||||
health_path=d.health_path,
|
||||
|
||||
@@ -44,13 +44,14 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
||||
|
||||
# Check if tool is installed on PATH
|
||||
installed: bool | None = None
|
||||
if deployed.category == "tool":
|
||||
if deployed.behavior == "tool":
|
||||
installed = shutil.which(name) is not None
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
category=deployed.category,
|
||||
behavior=deployed.behavior,
|
||||
stack=deployed.stack,
|
||||
runner=deployed.runner,
|
||||
port=deployed.port,
|
||||
health_path=deployed.health_path,
|
||||
@@ -83,18 +84,21 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
|
||||
|
||||
description = svc.description
|
||||
source = None
|
||||
stack = None
|
||||
if svc.component and svc.component in config.components:
|
||||
comp = config.components[svc.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
stack = comp.stack
|
||||
|
||||
runner = svc.run.runner
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=description,
|
||||
category="service",
|
||||
behavior="daemon",
|
||||
stack=stack,
|
||||
runner=runner,
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
@@ -117,16 +121,19 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
||||
|
||||
description = job.description
|
||||
source = None
|
||||
stack = None
|
||||
if job.component and job.component in config.components:
|
||||
comp = config.components[job.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
stack = comp.stack
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=description,
|
||||
category="job",
|
||||
behavior="tool",
|
||||
stack=stack,
|
||||
runner=job.run.runner,
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
@@ -137,16 +144,16 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
||||
|
||||
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
|
||||
# Determine category
|
||||
# Determine behavior
|
||||
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
||||
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
||||
|
||||
if is_tool:
|
||||
category = "tool"
|
||||
behavior = "tool"
|
||||
elif is_frontend:
|
||||
category = "frontend"
|
||||
behavior = "frontend"
|
||||
else:
|
||||
category = "component"
|
||||
behavior = None
|
||||
|
||||
source = comp.source
|
||||
|
||||
@@ -167,7 +174,8 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=comp.description,
|
||||
category=category,
|
||||
behavior=behavior,
|
||||
stack=comp.stack,
|
||||
runner=runner,
|
||||
version=comp.tool.version if comp.tool else None,
|
||||
source=source,
|
||||
@@ -232,16 +240,19 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
||||
if ref and ref in config.components:
|
||||
s.source = config.components[ref].source
|
||||
|
||||
# Components (tools/frontends) — always listed, even if a
|
||||
# service/job with the same name exists. A component is
|
||||
# "what software exists", services/jobs are "how it runs".
|
||||
# Components (tools/frontends) not already represented by a
|
||||
# service or job entry. Skip if the component has no
|
||||
# distinct behavior (e.g. it's just the software identity
|
||||
# behind a service).
|
||||
for name, comp in config.components.items():
|
||||
if name in seen:
|
||||
continue
|
||||
summary = _summary_from_component(name, comp, root)
|
||||
if summary.behavior is None:
|
||||
continue
|
||||
summary.node = local_hostname
|
||||
# Skip if this exact category is already represented
|
||||
# (e.g. a deployed tool already in the list)
|
||||
if not any(s.id == name and s.category == summary.category for s in summaries):
|
||||
summaries.append(summary)
|
||||
summaries.append(summary)
|
||||
seen.add(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -254,7 +265,8 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
||||
ComponentSummary(
|
||||
id=name,
|
||||
description=d.description,
|
||||
category=d.category,
|
||||
behavior=d.behavior,
|
||||
stack=d.stack,
|
||||
runner=d.runner,
|
||||
port=d.port,
|
||||
health_path=d.health_path,
|
||||
@@ -306,7 +318,8 @@ def get_component(name: str) -> ComponentDetail:
|
||||
"health_path": deployed.health_path,
|
||||
"proxy_path": deployed.proxy_path,
|
||||
"managed": deployed.managed,
|
||||
"category": deployed.category,
|
||||
"behavior": deployed.behavior,
|
||||
"stack": deployed.stack,
|
||||
}
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
|
||||
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
|
||||
},
|
||||
description="Test service",
|
||||
category="service",
|
||||
behavior="daemon",
|
||||
port=19000,
|
||||
health_path="/health",
|
||||
proxy_path="/test-svc",
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestComponents:
|
||||
assert svc["health_path"] == "/health"
|
||||
assert svc["proxy_path"] == "/test-svc"
|
||||
assert svc["managed"] is True
|
||||
assert svc["category"] == "service"
|
||||
assert svc["behavior"] == "daemon"
|
||||
|
||||
def test_tool_has_no_port(self, client: TestClient) -> None:
|
||||
"""Tool component has no port."""
|
||||
@@ -42,14 +42,14 @@ class TestComponents:
|
||||
data = response.json()
|
||||
tool = next(c for c in data if c["id"] == "test-tool")
|
||||
assert tool["port"] is None
|
||||
assert tool["category"] == "tool"
|
||||
assert tool["behavior"] == "tool"
|
||||
|
||||
def test_job_has_schedule(self, client: TestClient) -> None:
|
||||
"""Job component has schedule."""
|
||||
response = client.get("/components")
|
||||
data = response.json()
|
||||
job = next(c for c in data if c["id"] == "test-job")
|
||||
assert job["category"] == "job"
|
||||
assert job["behavior"] == "tool"
|
||||
assert job["schedule"] == "0 2 * * *"
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ def _make_registry() -> NodeRegistry:
|
||||
run_cmd=["uv", "run", "my-svc"],
|
||||
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
|
||||
description="My service",
|
||||
category="service",
|
||||
behavior="daemon",
|
||||
stack="python-fastapi",
|
||||
port=9001,
|
||||
health_path="/health",
|
||||
proxy_path="/my-svc",
|
||||
@@ -25,7 +26,8 @@ def _make_registry() -> NodeRegistry:
|
||||
"my-job": DeployedComponent(
|
||||
runner="command",
|
||||
run_cmd=["my-job"],
|
||||
category="job",
|
||||
behavior="tool",
|
||||
stack="python-cli",
|
||||
schedule="0 2 * * *",
|
||||
),
|
||||
},
|
||||
@@ -54,6 +56,8 @@ class TestRegistrySerialization:
|
||||
assert svc.health_path == "/health"
|
||||
assert svc.proxy_path == "/my-svc"
|
||||
assert svc.managed is True
|
||||
assert svc.behavior == "daemon"
|
||||
assert svc.stack == "python-fastapi"
|
||||
|
||||
def test_job_fields_preserved(self) -> None:
|
||||
original = _make_registry()
|
||||
@@ -63,7 +67,8 @@ class TestRegistrySerialization:
|
||||
job = restored.deployed["my-job"]
|
||||
assert job.runner == "command"
|
||||
assert job.schedule == "0 2 * * *"
|
||||
assert job.category == "job"
|
||||
assert job.behavior == "tool"
|
||||
assert job.stack == "python-cli"
|
||||
|
||||
def test_optional_fields_omitted(self) -> None:
|
||||
"""Fields like port, health_path are None when not set."""
|
||||
|
||||
@@ -45,7 +45,7 @@ class TestNodesList:
|
||||
runner="python",
|
||||
run_cmd=["svc"],
|
||||
port=9050,
|
||||
category="service",
|
||||
behavior="daemon",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
21
castle.yaml
21
castle.yaml
@@ -6,29 +6,35 @@ components:
|
||||
description: Content storage API useful to get context into my data dir from anywhere
|
||||
on the LAN
|
||||
source: components/central-context
|
||||
stack: python-fastapi
|
||||
notification-bridge:
|
||||
description: Desktop notification forwarder. This taps into your notifications
|
||||
system on the desktop and will forward all notifications the the central context
|
||||
server.
|
||||
source: components/notification-bridge
|
||||
stack: python-fastapi
|
||||
castle-api:
|
||||
description: Castle API
|
||||
source: castle-api
|
||||
stack: python-fastapi
|
||||
protonmail:
|
||||
description: ProtonMail email sync via Bridge
|
||||
source: components/protonmail
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: protonmail
|
||||
backup-collect:
|
||||
description: Collect files from various sources into backup directory
|
||||
source: components/backup-collect
|
||||
stack: python-cli
|
||||
tool:
|
||||
system_dependencies:
|
||||
- rsync
|
||||
castle-app:
|
||||
description: Castle web app
|
||||
source: app
|
||||
stack: react-vite
|
||||
build:
|
||||
commands:
|
||||
- - pnpm
|
||||
@@ -38,18 +44,21 @@ components:
|
||||
devbox-connect:
|
||||
description: SSH tunnel manager with auto-reconnect
|
||||
source: components/devbox-connect
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: devbox-connect
|
||||
mbox2eml:
|
||||
description: MBOX to EML email converter
|
||||
source: components/mbox2eml
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: mbox2eml
|
||||
android-backup:
|
||||
description: Backup Android device using ADB
|
||||
source: components/android-backup
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: android-backup
|
||||
@@ -59,12 +68,14 @@ components:
|
||||
browser:
|
||||
description: Browse the web using natural language via browser-use
|
||||
source: components/browser
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: browser
|
||||
docx-extractor:
|
||||
description: Extract content and metadata from Word .docx files
|
||||
source: components/docx-extractor
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: docx-extractor
|
||||
@@ -74,6 +85,7 @@ components:
|
||||
docx2md:
|
||||
description: Convert Word .docx files to Markdown
|
||||
source: components/docx2md
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: docx2md
|
||||
@@ -83,18 +95,21 @@ components:
|
||||
gpt:
|
||||
description: OpenAI text generation utility
|
||||
source: components/gpt
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: gpt
|
||||
html2text:
|
||||
description: Convert HTML content to plain text
|
||||
source: components/html2text
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: html2text
|
||||
md2pdf:
|
||||
description: Convert Markdown files to PDF
|
||||
source: components/md2pdf
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: md2pdf
|
||||
@@ -105,18 +120,21 @@ components:
|
||||
mdscraper:
|
||||
description: Combine text files into a single markdown document
|
||||
source: components/mdscraper
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: mdscraper
|
||||
pdf-extractor:
|
||||
description: Extract content and metadata from PDF files
|
||||
source: components/pdf-extractor
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: pdf-extractor
|
||||
pdf2md:
|
||||
description: Convert PDF files to Markdown
|
||||
source: components/pdf2md
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: pdf2md
|
||||
@@ -127,18 +145,21 @@ components:
|
||||
schedule:
|
||||
description: Manage systemd user timers and scheduled tasks
|
||||
source: components/schedule
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: schedule
|
||||
search:
|
||||
description: Manage self-contained searchable collections of files
|
||||
source: components/search
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: search
|
||||
text-extractor:
|
||||
description: Extract content and metadata from text files
|
||||
source: components/text-extractor
|
||||
stack: python-cli
|
||||
install:
|
||||
path:
|
||||
alias: text-extractor
|
||||
|
||||
@@ -22,6 +22,13 @@ from castle_cli.manifest import (
|
||||
)
|
||||
from castle_cli.templates.scaffold import scaffold_project
|
||||
|
||||
# Stack determines default behavior and scaffold template
|
||||
STACK_DEFAULTS: dict[str, str] = {
|
||||
"python-fastapi": "daemon",
|
||||
"python-cli": "tool",
|
||||
"react-vite": "frontend",
|
||||
}
|
||||
|
||||
|
||||
def next_available_port(config: object) -> int:
|
||||
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
|
||||
@@ -42,7 +49,8 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
"""Create a new project."""
|
||||
config = load_config()
|
||||
name = args.name
|
||||
proj_type = args.type
|
||||
stack = args.stack
|
||||
behavior = STACK_DEFAULTS.get(stack)
|
||||
|
||||
if name in config.components or name in config.services or name in config.jobs:
|
||||
print(f"Error: '{name}' already exists in castle.yaml")
|
||||
@@ -55,9 +63,9 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
print(f"Error: directory 'components/{name}' already exists")
|
||||
return 1
|
||||
|
||||
# Determine port for services
|
||||
# Determine port for daemons
|
||||
port = args.port
|
||||
if proj_type == "service" and port is None:
|
||||
if behavior == "daemon" and port is None:
|
||||
port = next_available_port(config)
|
||||
|
||||
# Package name: convert kebab-case to snake_case
|
||||
@@ -68,18 +76,19 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
project_dir=project_dir,
|
||||
name=name,
|
||||
package_name=package_name,
|
||||
proj_type=proj_type,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
stack=stack,
|
||||
description=args.description or f"A castle {stack} component",
|
||||
port=port,
|
||||
)
|
||||
|
||||
# Build entries
|
||||
if proj_type == "service":
|
||||
if behavior == "daemon":
|
||||
# Component for software identity
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
description=args.description or f"A castle {stack} component",
|
||||
source=f"components/{name}",
|
||||
stack=stack,
|
||||
)
|
||||
# Service for deployment
|
||||
config.services[name] = ServiceSpec(
|
||||
@@ -95,32 +104,34 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
proxy=ProxySpec(caddy=CaddySpec(path_prefix=f"/{name}")),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
elif proj_type == "tool":
|
||||
elif behavior == "tool":
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
description=args.description or f"A castle {stack} component",
|
||||
source=f"components/{name}",
|
||||
stack=stack,
|
||||
tool=ToolSpec(),
|
||||
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
||||
)
|
||||
else:
|
||||
# library or other
|
||||
# frontend or other
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
description=args.description or f"A castle {stack} component",
|
||||
source=f"components/{name}",
|
||||
stack=stack,
|
||||
)
|
||||
|
||||
save_config(config)
|
||||
|
||||
print(f"Created {proj_type} '{name}' at {project_dir}")
|
||||
print(f"Created {stack} component '{name}' at {project_dir}")
|
||||
if port:
|
||||
print(f" Port: {port}")
|
||||
print(" Registered in castle.yaml")
|
||||
print("\nNext steps:")
|
||||
print(f" cd components/{name}")
|
||||
print(" uv sync")
|
||||
if proj_type == "service":
|
||||
if behavior == "daemon":
|
||||
print(f" uv run {name} # starts on port {port}")
|
||||
print(f" castle deploy {name} # deploy to ~/.castle/")
|
||||
print(f" castle test {name}")
|
||||
|
||||
@@ -155,12 +155,18 @@ def _build_deployed_service(
|
||||
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
|
||||
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
|
||||
|
||||
# Resolve stack from referenced component
|
||||
stack = None
|
||||
if svc.component and svc.component in config.components:
|
||||
stack = config.components[svc.component].stack
|
||||
|
||||
return DeployedComponent(
|
||||
runner=run.runner,
|
||||
run_cmd=run_cmd,
|
||||
env=env,
|
||||
description=_resolve_description(config, svc),
|
||||
category="service",
|
||||
behavior="daemon",
|
||||
stack=stack,
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
proxy_path=proxy_path,
|
||||
@@ -189,12 +195,18 @@ def _build_deployed_job(
|
||||
# Build run_cmd
|
||||
run_cmd = _build_run_cmd(run, env)
|
||||
|
||||
# Resolve stack from referenced component
|
||||
stack = None
|
||||
if job.component and job.component in config.components:
|
||||
stack = config.components[job.component].stack
|
||||
|
||||
return DeployedComponent(
|
||||
runner=run.runner,
|
||||
run_cmd=run_cmd,
|
||||
env=env,
|
||||
description=_resolve_description(config, job),
|
||||
category="job",
|
||||
behavior="tool",
|
||||
stack=stack,
|
||||
schedule=job.schedule,
|
||||
managed=True,
|
||||
)
|
||||
|
||||
@@ -51,18 +51,29 @@ def run_info(args: argparse.Namespace) -> int:
|
||||
print(f"\n{BOLD}{name}{RESET}")
|
||||
print(f"{'─' * 40}")
|
||||
|
||||
# Determine category
|
||||
# Determine behavior
|
||||
if service:
|
||||
print(f" {BOLD}category{RESET}: service")
|
||||
print(f" {BOLD}behavior{RESET}: daemon")
|
||||
elif job:
|
||||
print(f" {BOLD}category{RESET}: job")
|
||||
print(f" {BOLD}behavior{RESET}: tool")
|
||||
elif component:
|
||||
if component.tool or (component.install and component.install.path):
|
||||
print(f" {BOLD}category{RESET}: tool")
|
||||
print(f" {BOLD}behavior{RESET}: tool")
|
||||
elif component.build:
|
||||
print(f" {BOLD}category{RESET}: frontend")
|
||||
print(f" {BOLD}behavior{RESET}: frontend")
|
||||
else:
|
||||
print(f" {BOLD}category{RESET}: component")
|
||||
print(f" {BOLD}behavior{RESET}: —")
|
||||
|
||||
# Show stack
|
||||
stack = None
|
||||
if component and component.stack:
|
||||
stack = component.stack
|
||||
elif service and service.component and service.component in config.components:
|
||||
stack = config.components[service.component].stack
|
||||
elif job and job.component and job.component in config.components:
|
||||
stack = config.components[job.component].stack
|
||||
if stack:
|
||||
print(f" {BOLD}stack{RESET}: {stack}")
|
||||
|
||||
# Component info
|
||||
if component:
|
||||
@@ -174,17 +185,26 @@ def _info_json(
|
||||
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
|
||||
if service:
|
||||
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
|
||||
data["category"] = "service"
|
||||
data["behavior"] = "daemon"
|
||||
if job:
|
||||
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
|
||||
data["category"] = "job"
|
||||
data["behavior"] = "tool"
|
||||
if not service and not job and component:
|
||||
if component.tool or (component.install and component.install.path):
|
||||
data["category"] = "tool"
|
||||
data["behavior"] = "tool"
|
||||
elif component.build:
|
||||
data["category"] = "frontend"
|
||||
else:
|
||||
data["category"] = "component"
|
||||
data["behavior"] = "frontend"
|
||||
|
||||
# Resolve stack
|
||||
stack = None
|
||||
if component and component.stack:
|
||||
stack = component.stack
|
||||
elif service and service.component and service.component in config.components:
|
||||
stack = config.components[service.component].stack
|
||||
elif job and job.component and job.component in config.components:
|
||||
stack = config.components[job.component].stack
|
||||
if stack:
|
||||
data["stack"] = stack
|
||||
|
||||
if deployed:
|
||||
data["deployed"] = {
|
||||
|
||||
@@ -20,13 +20,23 @@ CYAN = "\033[96m"
|
||||
MAGENTA = "\033[95m"
|
||||
YELLOW = "\033[93m"
|
||||
|
||||
CATEGORY_COLORS: dict[str, str] = {
|
||||
"service": GREEN,
|
||||
"job": MAGENTA,
|
||||
BEHAVIOR_COLORS: dict[str, str] = {
|
||||
"daemon": GREEN,
|
||||
"tool": CYAN,
|
||||
"frontend": YELLOW,
|
||||
}
|
||||
|
||||
STACK_DISPLAY: dict[str, str] = {
|
||||
"python-fastapi": "python-fastapi",
|
||||
"python-cli": "python-cli",
|
||||
"react-vite": "react-vite",
|
||||
"rust": "rust",
|
||||
"go": "go",
|
||||
"bash": "bash",
|
||||
"container": "container",
|
||||
"command": "command",
|
||||
}
|
||||
|
||||
|
||||
def _load_deployed() -> dict[str, object] | None:
|
||||
"""Try to load deployed state from registry, return None if unavailable."""
|
||||
@@ -39,26 +49,48 @@ def _load_deployed() -> dict[str, object] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_stack(config: object, name: str) -> str | None:
|
||||
"""Resolve stack from component reference or direct component."""
|
||||
# Check services for component ref
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
comp_name = svc.component
|
||||
if comp_name and comp_name in config.components:
|
||||
return config.components[comp_name].stack
|
||||
# Check jobs for component ref
|
||||
if name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
comp_name = job.component
|
||||
if comp_name and comp_name in config.components:
|
||||
return config.components[comp_name].stack
|
||||
# Direct component
|
||||
if name in config.components:
|
||||
return config.components[name].stack
|
||||
return None
|
||||
|
||||
|
||||
def run_list(args: argparse.Namespace) -> int:
|
||||
"""List all components, services, and jobs."""
|
||||
config = load_config()
|
||||
deployed = _load_deployed()
|
||||
|
||||
filter_type = getattr(args, "type", None)
|
||||
filter_behavior = getattr(args, "behavior", None)
|
||||
filter_stack = getattr(args, "stack", None)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
return _list_json(config, deployed, filter_type)
|
||||
return _list_json(config, deployed, filter_behavior, filter_stack)
|
||||
|
||||
any_output = False
|
||||
|
||||
# Services
|
||||
if not filter_type or filter_type == "service":
|
||||
if config.services:
|
||||
# Daemons (services)
|
||||
if not filter_behavior or filter_behavior == "daemon":
|
||||
services = _filter_by_stack(config.services, config, filter_stack)
|
||||
if services:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["service"]
|
||||
print(f"\n{BOLD}{color}Services{RESET}")
|
||||
color = BEHAVIOR_COLORS["daemon"]
|
||||
print(f"\n{BOLD}{color}Daemons{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, svc in config.services.items():
|
||||
for name, svc in services.items():
|
||||
port_str = ""
|
||||
if svc.expose and svc.expose.http:
|
||||
port_str = f" :{svc.expose.http.internal.port}"
|
||||
@@ -68,17 +100,20 @@ def run_list(args: argparse.Namespace) -> int:
|
||||
else:
|
||||
status = f"{DIM}?{RESET}"
|
||||
|
||||
stack = _resolve_stack(config, name)
|
||||
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
|
||||
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
|
||||
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
|
||||
print(f" {status} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
|
||||
|
||||
# Jobs
|
||||
if not filter_type or filter_type == "job":
|
||||
if config.jobs:
|
||||
# Scheduled (jobs)
|
||||
if not filter_behavior or filter_behavior == "tool":
|
||||
jobs = _filter_by_stack(config.jobs, config, filter_stack)
|
||||
if jobs:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["job"]
|
||||
print(f"\n{BOLD}{color}Jobs{RESET}")
|
||||
color = MAGENTA
|
||||
print(f"\n{BOLD}{color}Scheduled{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, job in config.jobs.items():
|
||||
for name, job in jobs.items():
|
||||
if deployed is not None:
|
||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
||||
else:
|
||||
@@ -88,29 +123,37 @@ def run_list(args: argparse.Namespace) -> int:
|
||||
sched = f" {DIM}[{job.schedule}]{RESET}"
|
||||
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
|
||||
|
||||
# Tools
|
||||
if not filter_type or filter_type == "tool":
|
||||
tools = config.tools
|
||||
if tools:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["tool"]
|
||||
print(f"\n{BOLD}{color}Tools{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, comp in tools.items():
|
||||
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
|
||||
print(f" {BOLD}{name}{RESET}{desc}")
|
||||
# Components (tools, frontends, etc.)
|
||||
show_tools = not filter_behavior or filter_behavior == "tool"
|
||||
show_frontends = not filter_behavior or filter_behavior == "frontend"
|
||||
|
||||
# Frontends
|
||||
if not filter_type or filter_type == "frontend":
|
||||
frontends = config.frontends
|
||||
if frontends:
|
||||
if show_tools or show_frontends:
|
||||
# Collect non-daemon components
|
||||
comps: dict[str, tuple[str, str | None, str | None]] = {}
|
||||
|
||||
if show_tools:
|
||||
for name, comp in config.tools.items():
|
||||
if filter_stack and comp.stack != filter_stack:
|
||||
continue
|
||||
comps[name] = ("tool", comp.stack, comp.description)
|
||||
|
||||
if show_frontends:
|
||||
for name, comp in config.frontends.items():
|
||||
if filter_stack and comp.stack != filter_stack:
|
||||
continue
|
||||
if name not in comps:
|
||||
comps[name] = ("frontend", comp.stack, comp.description)
|
||||
|
||||
if comps:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["frontend"]
|
||||
print(f"\n{BOLD}{color}Frontends{RESET}")
|
||||
color = CYAN
|
||||
print(f"\n{BOLD}{color}Components{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, comp in frontends.items():
|
||||
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
|
||||
print(f" {BOLD}{name}{RESET}{desc}")
|
||||
for name, (behavior, stack, description) in comps.items():
|
||||
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
|
||||
behavior_str = f" {behavior}"
|
||||
desc = f" {DIM}{description}{RESET}" if description else ""
|
||||
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
|
||||
|
||||
if not any_output:
|
||||
print("No components found.")
|
||||
@@ -122,47 +165,83 @@ def run_list(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _filter_by_stack(
|
||||
items: dict[str, object],
|
||||
config: object,
|
||||
filter_stack: str | None,
|
||||
) -> dict[str, object]:
|
||||
"""Filter items by stack if a filter is provided."""
|
||||
if not filter_stack:
|
||||
return items
|
||||
return {
|
||||
name: item
|
||||
for name, item in items.items()
|
||||
if _resolve_stack(config, name) == filter_stack
|
||||
}
|
||||
|
||||
|
||||
def _list_json(
|
||||
config: object, deployed: dict | None, filter_type: str | None
|
||||
config: object,
|
||||
deployed: dict | None,
|
||||
filter_behavior: str | None,
|
||||
filter_stack: str | None,
|
||||
) -> int:
|
||||
"""Output JSON list of all entries."""
|
||||
output = []
|
||||
|
||||
if not filter_type or filter_type == "service":
|
||||
if not filter_behavior or filter_behavior == "daemon":
|
||||
for name, svc in config.services.items():
|
||||
stack = _resolve_stack(config, name)
|
||||
if filter_stack and stack != filter_stack:
|
||||
continue
|
||||
entry: dict = {
|
||||
"name": name,
|
||||
"category": "service",
|
||||
"behavior": "daemon",
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
}
|
||||
if stack:
|
||||
entry["stack"] = stack
|
||||
if svc.description:
|
||||
entry["description"] = svc.description
|
||||
if svc.expose and svc.expose.http:
|
||||
entry["port"] = svc.expose.http.internal.port
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "job":
|
||||
if not filter_behavior or filter_behavior == "tool":
|
||||
for name, job in config.jobs.items():
|
||||
stack = _resolve_stack(config, name)
|
||||
if filter_stack and stack != filter_stack:
|
||||
continue
|
||||
entry = {
|
||||
"name": name,
|
||||
"category": "job",
|
||||
"behavior": "tool",
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
"schedule": job.schedule,
|
||||
}
|
||||
if stack:
|
||||
entry["stack"] = stack
|
||||
if job.description:
|
||||
entry["description"] = job.description
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "tool":
|
||||
if not filter_behavior or filter_behavior == "tool":
|
||||
for name, comp in config.tools.items():
|
||||
entry = {"name": name, "category": "tool"}
|
||||
if filter_stack and comp.stack != filter_stack:
|
||||
continue
|
||||
entry = {"name": name, "behavior": "tool"}
|
||||
if comp.stack:
|
||||
entry["stack"] = comp.stack
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "frontend":
|
||||
if not filter_behavior or filter_behavior == "frontend":
|
||||
for name, comp in config.frontends.items():
|
||||
entry = {"name": name, "category": "frontend"}
|
||||
if filter_stack and comp.stack != filter_stack:
|
||||
continue
|
||||
entry = {"name": name, "behavior": "frontend"}
|
||||
if comp.stack:
|
||||
entry["stack"] = comp.stack
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
output.append(entry)
|
||||
|
||||
@@ -21,9 +21,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
# castle list
|
||||
list_parser = subparsers.add_parser("list", help="List all components")
|
||||
list_parser.add_argument(
|
||||
"--type",
|
||||
choices=["service", "job", "tool", "frontend"],
|
||||
help="Filter by type",
|
||||
"--behavior",
|
||||
choices=["daemon", "tool", "frontend"],
|
||||
help="Filter by behavior",
|
||||
)
|
||||
list_parser.add_argument(
|
||||
"--stack",
|
||||
help="Filter by stack (e.g. python-cli, python-fastapi, react-vite)",
|
||||
)
|
||||
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
@@ -31,13 +35,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
create_parser = subparsers.add_parser("create", help="Create a new project")
|
||||
create_parser.add_argument("name", help="Project name")
|
||||
create_parser.add_argument(
|
||||
"--type",
|
||||
choices=["service", "tool", "library"],
|
||||
"--stack",
|
||||
choices=["python-cli", "python-fastapi", "react-vite"],
|
||||
required=True,
|
||||
help="Project type",
|
||||
help="Development stack (determines scaffold template and default behavior)",
|
||||
)
|
||||
create_parser.add_argument("--description", default="", help="Project description")
|
||||
create_parser.add_argument("--port", type=int, help="Port number (services only)")
|
||||
create_parser.add_argument("--port", type=int, help="Port number (daemons only)")
|
||||
# castle info
|
||||
info_parser = subparsers.add_parser("info", help="Show component details")
|
||||
info_parser.add_argument("project", help="Component name")
|
||||
|
||||
@@ -9,19 +9,17 @@ def scaffold_project(
|
||||
project_dir: Path,
|
||||
name: str,
|
||||
package_name: str,
|
||||
proj_type: str,
|
||||
stack: str,
|
||||
description: str,
|
||||
port: int | None = None,
|
||||
) -> None:
|
||||
"""Scaffold a new project from templates."""
|
||||
if proj_type == "service":
|
||||
"""Scaffold a new project from templates based on stack."""
|
||||
if stack == "python-fastapi":
|
||||
_scaffold_service(project_dir, name, package_name, description, port or 9000)
|
||||
elif proj_type == "tool":
|
||||
elif stack == "python-cli":
|
||||
_scaffold_tool(project_dir, name, package_name, description)
|
||||
elif proj_type == "library":
|
||||
_scaffold_library(project_dir, name, package_name, description)
|
||||
else:
|
||||
raise ValueError(f"Unknown project type: {proj_type}")
|
||||
raise ValueError(f"No scaffold template for stack: {stack}")
|
||||
|
||||
|
||||
def _scaffold_service(
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestCreateCommand:
|
||||
|
||||
args = Namespace(
|
||||
name="my-api",
|
||||
type="service",
|
||||
stack="python-fastapi",
|
||||
description="My API service",
|
||||
port=9050,
|
||||
)
|
||||
@@ -60,7 +60,7 @@ class TestCreateCommand:
|
||||
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
args = Namespace(name="my-tool2", type="tool", description="My tool", port=None)
|
||||
args = Namespace(name="my-tool2", stack="python-cli", description="My tool", port=None)
|
||||
result = run_create(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -73,27 +73,6 @@ class TestCreateCommand:
|
||||
assert comp.tool is not None
|
||||
assert comp.install is not None
|
||||
|
||||
def test_create_library(self, castle_root: Path) -> None:
|
||||
"""Create a new library project."""
|
||||
with (
|
||||
patch("castle_cli.commands.create.load_config") as mock_load,
|
||||
patch("castle_cli.commands.create.save_config"),
|
||||
):
|
||||
config = load_config(castle_root)
|
||||
mock_load.return_value = config
|
||||
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
args = Namespace(name="my-lib", type="library", description="My library", port=None)
|
||||
result = run_create(args)
|
||||
|
||||
assert result == 0
|
||||
project_dir = castle_root / "components" / "my-lib"
|
||||
assert project_dir.exists()
|
||||
assert (project_dir / "src" / "my_lib" / "__init__.py").exists()
|
||||
assert (project_dir / "CLAUDE.md").exists()
|
||||
assert "my-lib" in config.components
|
||||
|
||||
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
|
||||
"""Creating a project with existing name fails."""
|
||||
with patch("castle_cli.commands.create.load_config") as mock_load:
|
||||
@@ -105,7 +84,7 @@ class TestCreateCommand:
|
||||
# test-svc exists in the services section
|
||||
args = Namespace(
|
||||
name="test-svc",
|
||||
type="service",
|
||||
stack="python-fastapi",
|
||||
description="Duplicate",
|
||||
port=None,
|
||||
)
|
||||
@@ -126,7 +105,7 @@ class TestCreateCommand:
|
||||
|
||||
args = Namespace(
|
||||
name="auto-port-svc",
|
||||
type="service",
|
||||
stack="python-fastapi",
|
||||
description="Auto port",
|
||||
port=None,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ class TestInfoCommand:
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "test-svc" in output
|
||||
assert "service" in output
|
||||
assert "daemon" in output
|
||||
assert "19000" in output
|
||||
|
||||
def test_info_tool(self, castle_root: Path, capsys) -> None:
|
||||
@@ -73,7 +73,7 @@ class TestInfoCommand:
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
data = json.loads(output)
|
||||
assert data["category"] == "service"
|
||||
assert data["behavior"] == "daemon"
|
||||
assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
|
||||
|
||||
def test_info_shows_env(self, castle_root: Path, capsys) -> None:
|
||||
@@ -91,8 +91,8 @@ class TestInfoCommand:
|
||||
output = capsys.readouterr().out
|
||||
assert "TEST_SVC_DATA_DIR" in output
|
||||
|
||||
def test_info_shows_category(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays category instead of roles."""
|
||||
def test_info_shows_behavior(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays behavior."""
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
@@ -104,5 +104,5 @@ class TestInfoCommand:
|
||||
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "category" in output
|
||||
assert "service" in output
|
||||
assert "behavior" in output
|
||||
assert "daemon" in output
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestListCommand:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type=None, json=False)
|
||||
args = Namespace(behavior=None, stack=None, json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -27,13 +27,13 @@ class TestListCommand:
|
||||
assert "test-svc" in captured.out
|
||||
assert "test-tool" in captured.out
|
||||
|
||||
def test_list_filter_service(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to services."""
|
||||
def test_list_filter_daemon(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to daemons."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type="service", json=False)
|
||||
args = Namespace(behavior="daemon", stack=None, json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -47,7 +47,7 @@ class TestListCommand:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type="tool", json=False)
|
||||
args = Namespace(behavior="tool", stack=None, json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -56,12 +56,12 @@ class TestListCommand:
|
||||
assert "test-svc" not in captured.out
|
||||
|
||||
def test_list_filter_job(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to jobs."""
|
||||
"""List filtered to jobs (shown under tool behavior)."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type="job", json=False)
|
||||
args = Namespace(behavior="tool", stack=None, json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -75,7 +75,7 @@ class TestListCommand:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type=None, json=True)
|
||||
args = Namespace(behavior=None, stack=None, json=True)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -85,4 +85,4 @@ class TestListCommand:
|
||||
assert "test-svc" in names
|
||||
assert "test-tool" in names
|
||||
svc = next(p for p in data if p["name"] == "test-svc")
|
||||
assert svc["category"] == "service"
|
||||
assert svc["behavior"] == "daemon"
|
||||
|
||||
@@ -208,6 +208,7 @@ class ComponentSpec(BaseModel):
|
||||
description: str | None = None
|
||||
|
||||
source: str | None = None
|
||||
stack: str | None = None
|
||||
|
||||
install: InstallSpec | None = None
|
||||
tool: ToolSpec | None = None
|
||||
|
||||
@@ -35,7 +35,8 @@ class DeployedComponent:
|
||||
run_cmd: list[str]
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
description: str | None = None
|
||||
category: str = "service"
|
||||
behavior: str = "daemon"
|
||||
stack: str | None = None
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
proxy_path: str | None = None
|
||||
@@ -77,12 +78,18 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
|
||||
deployed: dict[str, DeployedComponent] = {}
|
||||
for name, comp_data in data.get("deployed", {}).items():
|
||||
# Support both old "category" and new "behavior" keys for migration
|
||||
behavior = comp_data.get("behavior")
|
||||
if behavior is None:
|
||||
category = comp_data.get("category", "service")
|
||||
behavior = "daemon" if category == "service" else "tool" if category in ("job", "tool") else "frontend" if category == "frontend" else category
|
||||
deployed[name] = DeployedComponent(
|
||||
runner=comp_data.get("runner", "command"),
|
||||
run_cmd=comp_data.get("run_cmd", []),
|
||||
env=comp_data.get("env", {}),
|
||||
description=comp_data.get("description"),
|
||||
category=comp_data.get("category", "service"),
|
||||
behavior=behavior,
|
||||
stack=comp_data.get("stack"),
|
||||
port=comp_data.get("port"),
|
||||
health_path=comp_data.get("health_path"),
|
||||
proxy_path=comp_data.get("proxy_path"),
|
||||
@@ -120,7 +127,9 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
entry["env"] = comp.env
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
entry["category"] = comp.category
|
||||
entry["behavior"] = comp.behavior
|
||||
if comp.stack:
|
||||
entry["stack"] = comp.stack
|
||||
if comp.port is not None:
|
||||
entry["port"] = comp.port
|
||||
if comp.health_path:
|
||||
|
||||
@@ -118,7 +118,7 @@ Discriminated union on `runner`:
|
||||
|--------|------|--------|------------|
|
||||
| `python` | `uv sync` | `which(tool)` → installed binary | `tool`, `args` |
|
||||
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
|
||||
| `container` | *(none)* | `podman run` | `image`, `command`, `ports`, `volumes` |
|
||||
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
||||
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
|
||||
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
|
||||
|
||||
@@ -209,10 +209,10 @@ semantics as services.
|
||||
|
||||
```bash
|
||||
# Service — scaffolds project, assigns port, registers in castle.yaml
|
||||
castle create my-service --type service --description "Does something"
|
||||
castle create my-service --stack python-fastapi --description "Does something"
|
||||
|
||||
# Tool — scaffolds under components/
|
||||
castle create my-tool --type tool --description "Does something"
|
||||
castle create my-tool --stack python-cli --description "Does something"
|
||||
```
|
||||
|
||||
### Manually
|
||||
@@ -256,7 +256,7 @@ services:
|
||||
### Service lifecycle
|
||||
|
||||
```bash
|
||||
castle create my-service --type service # 1. Scaffold + register
|
||||
castle create my-service --stack python-fastapi # 1. Scaffold + register
|
||||
cd components/my-service && uv sync # 2. Install deps
|
||||
# ... implement ...
|
||||
castle test my-service # 3. Run tests
|
||||
@@ -276,7 +276,7 @@ castle service disable my-service # Stop and remove systemd unit
|
||||
### Tool lifecycle
|
||||
|
||||
```bash
|
||||
castle create my-tool --type tool # 1. Scaffold + register
|
||||
castle create my-tool --stack python-cli # 1. Scaffold + register
|
||||
cd components/my-tool && uv sync # 2. Install deps
|
||||
# ... implement ...
|
||||
castle test my-tool # 3. Run tests
|
||||
|
||||
@@ -20,9 +20,10 @@ is self-sufficient. The mesh is optional.
|
||||
Castle service is just a well-behaved Unix daemon that happens to
|
||||
be registered in a manifest.
|
||||
|
||||
3. **Section is category.** Components, services, and jobs live in
|
||||
separate sections of `castle.yaml`. The section determines the
|
||||
category — no role derivation needed.
|
||||
3. **Stack and behavior.** Each component has a *stack* (development
|
||||
toolchain: python-fastapi, python-cli, react-vite) and a *behavior*
|
||||
(runtime role: daemon, tool, frontend). Scheduling, systemd management,
|
||||
and proxying are orthogonal operations — not behaviors.
|
||||
|
||||
4. **Language-agnostic above the build line.** Below the build line,
|
||||
every language is different (uv, pnpm, cargo, go). Above it,
|
||||
@@ -210,7 +211,8 @@ deployed:
|
||||
env:
|
||||
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
|
||||
CENTRAL_CONTEXT_PORT: "9001"
|
||||
category: service
|
||||
behavior: daemon
|
||||
stack: python-fastapi
|
||||
port: 9001
|
||||
health_path: /health
|
||||
proxy_path: /central-context
|
||||
@@ -321,32 +323,36 @@ Personal software platform
|
||||
│ /devbox-api devbox-api 9020 devbox ● up │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
Services · Long-running daemons managed by systemd
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Mesh ● connected mqtt://localhost:1883 0 peers │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
Daemons · Long-running processes that expose ports
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ castle-api │ │ central-ctx │ │ notif-bridge │
|
||||
│ ● up 5ms │ │ ● up 12ms │ │ ● up 8ms │
|
||||
│ :9020 │ │ :9001 │ │ :9002 │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
|
||||
Jobs · Scheduled tasks with cron timers
|
||||
Name Schedule Timer
|
||||
protonmail-sync */5 * * * * active
|
||||
backup-collect 0 2 * * * active
|
||||
|
||||
Tools · CLI utilities installed to PATH
|
||||
Name Status
|
||||
pdf2md ● installed
|
||||
gpt ● installed
|
||||
Components · Software catalog
|
||||
Name Stack Behavior Schedule Status
|
||||
pdf2md Python / CLI tool — installed
|
||||
protonmail Python / CLI tool */5 * * * * installed
|
||||
castle-app React / Vite frontend — —
|
||||
backup-collect Python / CLI tool 0 2 * * * —
|
||||
```
|
||||
|
||||
**Key components:**
|
||||
- **GatewayPanel** — Route table with live health badges, reload button,
|
||||
collapsible Caddyfile viewer. Node column appears when multi-node.
|
||||
- **MeshPanel** — MQTT connection status (connected/disconnected badge),
|
||||
broker address, mDNS status, peer count with links. Hidden when mesh
|
||||
is disabled.
|
||||
- **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node
|
||||
mode. Each node links to `/node/{hostname}`.
|
||||
- **ServiceSection** — Service cards in a responsive grid.
|
||||
- **JobSection** — Sortable table of scheduled tasks.
|
||||
- **ToolSection** — Sortable table of CLI tools with install/uninstall.
|
||||
- **ServiceSection** — Daemon cards in a responsive grid.
|
||||
- **ComponentTable** — Unified sortable table for all non-daemon components
|
||||
(tools, frontends) with Stack, Behavior, Schedule, and Status columns.
|
||||
|
||||
**Real-time updates:**
|
||||
- SSE stream at `/stream` pushes `health`, `service-action`, and `mesh`
|
||||
@@ -523,10 +529,15 @@ What exists today:
|
||||
- **Mesh infrastructure** — MQTT client (paho-mqtt), mDNS discovery
|
||||
(python-zeroconf), MeshStateManager, all wired into API lifespan.
|
||||
Opt-in via `CASTLE_API_MQTT_ENABLED` / `CASTLE_API_MDNS_ENABLED`.
|
||||
- **Node API** — `GET /nodes`, `GET /nodes/{hostname}` endpoints.
|
||||
- **MQTT broker** — Mosquitto running as `castle-mqtt` Docker container
|
||||
on port 1883, managed by systemd. Config and data in
|
||||
`/data/castle/castle-mqtt/`.
|
||||
- **Node API** — `GET /mesh/status`, `GET /nodes`, `GET /nodes/{hostname}`.
|
||||
`GET /components?include_remote=true` for cross-node component listing.
|
||||
- **Gateway panel** — Dedicated UI showing route table, health per route,
|
||||
reload button, Caddyfile viewer. Cross-node routes shown when multi-node.
|
||||
- **Mesh panel** — Dashboard UI showing MQTT connection status, broker
|
||||
address, mDNS state, peer count. Hidden when mesh is disabled.
|
||||
- **Node-aware UI** — NodeBar (hidden single-node), node detail page,
|
||||
mesh SSE events for live node discovery updates.
|
||||
- **Cross-node routing** — Caddyfile generator accepts remote registries,
|
||||
@@ -538,9 +549,8 @@ What doesn't exist yet:
|
||||
support them via `command` runner, but no examples exist yet)
|
||||
- **Build automation** — Castle records build specs but doesn't
|
||||
orchestrate builds (each project builds independently)
|
||||
- **Mosquitto deployment** — Registered in castle.yaml as `castle-mqtt`
|
||||
container service, but not yet deployed (needs `castle deploy` +
|
||||
container runtime)
|
||||
- **Multi-machine testing** — Mesh infrastructure is built and running
|
||||
on one node, but not yet tested with a second Castle node
|
||||
|
||||
## Technology Map
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Examples: `components/pdf2md/`, `components/gpt/`, `components/protonmail/`
|
||||
## Creating a new tool
|
||||
|
||||
```bash
|
||||
castle create my-tool --type tool --description "Does something"
|
||||
castle create my-tool --stack python-cli --description "Does something"
|
||||
cd components/my-tool && uv sync
|
||||
```
|
||||
|
||||
@@ -433,8 +433,8 @@ uv run ruff format . # Format
|
||||
`castle create` generates all of this automatically:
|
||||
|
||||
```bash
|
||||
castle create my-service --type service --description "Does something useful"
|
||||
castle create my-service --stack python-fastapi --description "Does something useful"
|
||||
```
|
||||
|
||||
See @docs/component-registry.md for manifest fields, role derivation, and
|
||||
the full service lifecycle (enable, logs, gateway reload).
|
||||
See @docs/component-registry.md for manifest fields, castle.yaml structure,
|
||||
and the full service lifecycle (enable, logs, gateway reload).
|
||||
@@ -116,7 +116,7 @@ handles serving directly from the build output.
|
||||
components:
|
||||
my-frontend:
|
||||
description: Web dashboard
|
||||
source: my-frontend
|
||||
source: components/my-frontend
|
||||
build:
|
||||
commands:
|
||||
- ["pnpm", "build"]
|
||||
@@ -124,8 +124,10 @@ components:
|
||||
- dist/
|
||||
```
|
||||
|
||||
For production, Caddy serves the static files — add a service entry with a
|
||||
proxy spec:
|
||||
For production, Caddy serves the static `dist/` output directly — no
|
||||
Node process needed. See [Serving with Caddy](#serving-with-caddy) below.
|
||||
|
||||
For development with Vite's dev server, add a service entry:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
Reference in New Issue
Block a user