ui: field help text + editable program commands
- Add a 'hint' to the shared Field/TextField; annotate the config forms with
short, accurate descriptions of each field:
- program: source (working copy castle builds from), behavior (tool/daemon/
frontend), stack (optional toolchain template), repo/ref (for clone),
system deps (listed for reference, NOT auto-installed), commands.
- service: port (listened-on, health-checked, proxied; map via ${port}),
health path, proxy path vs proxy host, runs.
- job: schedule (cron → systemd timer), runs.
- ProgramFields: the Commands section is now editable (build/test/lint/
type-check/run), so a program with no stack can declare its own dev verbs
by hand — a declared command overrides the stack default. Verified the
commands/build shape round-trips through the config API.
App builds clean.
This commit is contained in:
@@ -58,8 +58,16 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextField label="Description" value={description} onChange={setDescription} />
|
<TextField label="Description" value={description} onChange={setDescription} />
|
||||||
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />
|
<TextField
|
||||||
<Field label="Runs">
|
label="Schedule"
|
||||||
|
value={schedule}
|
||||||
|
onChange={setSchedule}
|
||||||
|
width="w-48"
|
||||||
|
mono
|
||||||
|
placeholder="0 2 * * *"
|
||||||
|
hint="Cron expression — castle generates a systemd timer that runs the job on this schedule."
|
||||||
|
/>
|
||||||
|
<Field label="Runs" hint="The console script or command the job runs on each tick, then exits.">
|
||||||
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} · </span>
|
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} · </span>
|
||||||
<input
|
<input
|
||||||
value={runTarget}
|
value={runTarget}
|
||||||
|
|||||||
@@ -5,13 +5,20 @@ import { Field, TextField, FormFooter } from "./fields"
|
|||||||
const SELECT =
|
const SELECT =
|
||||||
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||||
|
|
||||||
|
// Verbs the commands editor exposes. `build` lives in `build.commands`; the rest
|
||||||
|
// in `commands`. A declared command overrides the stack default (or supplies the
|
||||||
|
// verb when there's no stack).
|
||||||
|
const VERBS = ["build", "test", "lint", "type-check", "run"]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
program: ProgramDetail
|
program: ProgramDetail
|
||||||
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
||||||
onDelete?: (name: string) => Promise<void>
|
onDelete?: (name: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Edit a program's catalog config (source identity), not how it's deployed. */
|
type Obj = Record<string, unknown>
|
||||||
|
|
||||||
|
/** Edit a program's catalog config (its source identity), not how it's deployed. */
|
||||||
export function ProgramFields({ program, onSave, onDelete }: Props) {
|
export function ProgramFields({ program, onSave, onDelete }: Props) {
|
||||||
const m = program.manifest
|
const m = program.manifest
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -26,11 +33,24 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
|
|||||||
const [ref, setRef] = useState((m.ref as string) ?? "")
|
const [ref, setRef] = useState((m.ref as string) ?? "")
|
||||||
const [deps, setDeps] = useState(((m.system_dependencies as string[]) ?? []).join(", "))
|
const [deps, setDeps] = useState(((m.system_dependencies as string[]) ?? []).join(", "))
|
||||||
|
|
||||||
|
const readVerb = (verb: string): string => {
|
||||||
|
if (verb === "build") {
|
||||||
|
const c = (m.build as { commands?: string[][] } | undefined)?.commands
|
||||||
|
return (c?.[0] ?? []).join(" ")
|
||||||
|
}
|
||||||
|
const cmds = (m.commands as Record<string, string[][]> | undefined) ?? {}
|
||||||
|
const key = verb === "type-check" && !cmds["type-check"] ? "type_check" : verb
|
||||||
|
return (cmds[key]?.[0] ?? []).join(" ")
|
||||||
|
}
|
||||||
|
const [cmds, setCmds] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(VERBS.map((v) => [v, readVerb(v)])),
|
||||||
|
)
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
try {
|
try {
|
||||||
const config: Record<string, unknown> = { ...m }
|
const config: Obj = { ...m }
|
||||||
delete config.id
|
delete config.id
|
||||||
config.description = description || undefined
|
config.description = description || undefined
|
||||||
config.source = source || undefined
|
config.source = source || undefined
|
||||||
@@ -39,10 +59,20 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
|
|||||||
config.version = version || undefined
|
config.version = version || undefined
|
||||||
config.repo = repo || undefined
|
config.repo = repo || undefined
|
||||||
config.ref = ref || undefined
|
config.ref = ref || undefined
|
||||||
config.system_dependencies = deps
|
config.system_dependencies = deps.split(",").map((d) => d.trim()).filter(Boolean)
|
||||||
.split(",")
|
|
||||||
.map((d) => d.trim())
|
const commands: Record<string, string[][]> = {}
|
||||||
.filter(Boolean)
|
let buildArgv: string[][] | null = null
|
||||||
|
for (const v of VERBS) {
|
||||||
|
const s = (cmds[v] ?? "").trim()
|
||||||
|
if (!s) continue
|
||||||
|
const argv: string[][] = [s.split(/\s+/)]
|
||||||
|
if (v === "build") buildArgv = argv
|
||||||
|
else commands[v] = argv
|
||||||
|
}
|
||||||
|
if (buildArgv) config.build = { ...((m.build as Obj) ?? {}), commands: buildArgv }
|
||||||
|
config.commands = Object.keys(commands).length ? commands : undefined
|
||||||
|
|
||||||
await onSave(program.id, config)
|
await onSave(program.id, config)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
@@ -53,9 +83,24 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextField label="Description" value={description} onChange={setDescription} />
|
<TextField
|
||||||
<TextField label="Source" value={source} onChange={setSource} mono placeholder="/data/repos/my-prog" />
|
label="Description"
|
||||||
<Field label="Behavior">
|
value={description}
|
||||||
|
onChange={setDescription}
|
||||||
|
hint="One-line summary. A service or job running this program inherits it when it has none of its own."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Source"
|
||||||
|
value={source}
|
||||||
|
onChange={setSource}
|
||||||
|
mono
|
||||||
|
placeholder="/data/repos/my-prog"
|
||||||
|
hint="The working copy on disk. Castle runs dev verbs and builds here. Absolute path, or repo:<name> for castle's own programs."
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Behavior"
|
||||||
|
hint="What this program is. tool = a CLI you invoke; daemon = a long-running server; frontend = a web UI. Drives how it activates and deploys."
|
||||||
|
>
|
||||||
<select value={behavior} onChange={(e) => setBehavior(e.target.value)} className={`w-48 ${SELECT}`}>
|
<select value={behavior} onChange={(e) => setBehavior(e.target.value)} className={`w-48 ${SELECT}`}>
|
||||||
<option value="">(none)</option>
|
<option value="">(none)</option>
|
||||||
<option value="tool">tool</option>
|
<option value="tool">tool</option>
|
||||||
@@ -63,7 +108,10 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
|
|||||||
<option value="frontend">frontend</option>
|
<option value="frontend">frontend</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Stack">
|
<Field
|
||||||
|
label="Stack"
|
||||||
|
hint="Optional toolchain template — seeds default dev-verb commands and a scaffold for new code. Leave empty and declare commands below to wire in any repo."
|
||||||
|
>
|
||||||
<select value={stack} onChange={(e) => setStack(e.target.value)} className={`w-48 ${SELECT}`}>
|
<select value={stack} onChange={(e) => setStack(e.target.value)} className={`w-48 ${SELECT}`}>
|
||||||
<option value="">(none)</option>
|
<option value="">(none)</option>
|
||||||
<option value="python-cli">python-cli</option>
|
<option value="python-cli">python-cli</option>
|
||||||
@@ -71,23 +119,49 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
|
|||||||
<option value="react-vite">react-vite</option>
|
<option value="react-vite">react-vite</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<TextField label="Version" value={version} onChange={setVersion} width="w-32" />
|
<TextField label="Version" value={version} onChange={setVersion} width="w-32" hint="Optional metadata." />
|
||||||
<TextField label="Repo" value={repo} onChange={setRepo} mono placeholder="https://github.com/me/x.git" />
|
<TextField
|
||||||
<TextField label="Ref" value={ref} onChange={setRef} width="w-48" placeholder="branch / tag / commit" />
|
label="Repo"
|
||||||
<TextField label="System deps" value={deps} onChange={setDeps} placeholder="pandoc, poppler-utils" />
|
value={repo}
|
||||||
|
onChange={setRepo}
|
||||||
|
mono
|
||||||
|
placeholder="https://github.com/me/x.git"
|
||||||
|
hint="Git URL so 'castle program clone' can fetch the source on a fresh machine. An existing working copy at Source takes precedence."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Ref"
|
||||||
|
value={ref}
|
||||||
|
onChange={setRef}
|
||||||
|
width="w-48"
|
||||||
|
placeholder="branch / tag / commit"
|
||||||
|
hint="Optional ref to clone (branch, tag, or commit)."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="System deps"
|
||||||
|
value={deps}
|
||||||
|
onChange={setDeps}
|
||||||
|
placeholder="pandoc, poppler-utils"
|
||||||
|
hint="OS packages the program needs. Listed for reference — castle does not install them."
|
||||||
|
/>
|
||||||
|
|
||||||
{program.commands && Object.keys(program.commands).length > 0 && (
|
<Field
|
||||||
<Field label="Commands">
|
label="Commands"
|
||||||
<div className="space-y-1 pt-1.5">
|
hint="How to build/test/lint/run this program. A declared command overrides the stack default — and is how a program with no stack gets its dev verbs. One argv per line (e.g. ruff check .)."
|
||||||
{Object.entries(program.commands).map(([verb, cmds]) => (
|
>
|
||||||
<div key={verb} className="flex gap-2 text-xs">
|
<div className="space-y-1.5 pt-1.5">
|
||||||
<span className="text-[var(--muted)] w-20 shrink-0">{verb}</span>
|
{VERBS.map((verb) => (
|
||||||
<span className="font-mono break-all">{cmds.map((a) => a.join(" ")).join(" && ")}</span>
|
<div key={verb} className="flex items-center gap-2">
|
||||||
</div>
|
<span className="text-xs text-[var(--muted)] w-20 shrink-0 font-mono">{verb}</span>
|
||||||
))}
|
<input
|
||||||
</div>
|
value={cmds[verb] ?? ""}
|
||||||
</Field>
|
onChange={(e) => setCmds((c) => ({ ...c, [verb]: e.target.value }))}
|
||||||
)}
|
placeholder={stack ? "(stack default)" : "—"}
|
||||||
|
className="flex-1 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<FormFooter
|
<FormFooter
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextField label="Description" value={description} onChange={setDescription} />
|
<TextField label="Description" value={description} onChange={setDescription} />
|
||||||
<Field label="Runs">
|
<Field
|
||||||
|
label="Runs"
|
||||||
|
hint="The console script (python runner) or command this service executes."
|
||||||
|
>
|
||||||
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} · </span>
|
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} · </span>
|
||||||
<input
|
<input
|
||||||
value={runProgram}
|
value={runProgram}
|
||||||
@@ -96,10 +99,41 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
|
<TextField
|
||||||
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono placeholder="/health" />
|
label="Port"
|
||||||
<TextField label="Proxy path" value={proxyPath} onChange={setProxyPath} width="w-48" mono placeholder="/my-service" />
|
value={port}
|
||||||
<TextField label="Proxy host" value={proxyHost} onChange={setProxyHost} mono placeholder="my-service.lan" />
|
onChange={setPort}
|
||||||
|
width="w-32"
|
||||||
|
mono
|
||||||
|
placeholder="9001"
|
||||||
|
hint="The port the service listens on. Castle health-checks and proxies this port; map it to the program's own var with ${port} in Environment."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Health path"
|
||||||
|
value={health}
|
||||||
|
onChange={setHealth}
|
||||||
|
width="w-48"
|
||||||
|
mono
|
||||||
|
placeholder="/health"
|
||||||
|
hint="HTTP path castle polls to report up/down."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Proxy path"
|
||||||
|
value={proxyPath}
|
||||||
|
onChange={setProxyPath}
|
||||||
|
width="w-48"
|
||||||
|
mono
|
||||||
|
placeholder="/my-service"
|
||||||
|
hint="Gateway prefix — reachable at gateway:9000<path>/ (reverse-proxied to the port)."
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Proxy host"
|
||||||
|
value={proxyHost}
|
||||||
|
onChange={setProxyHost}
|
||||||
|
mono
|
||||||
|
placeholder="my-service.lan"
|
||||||
|
hint="Optional: route a whole hostname to this service instead of a path (lets a root-based app serve unchanged)."
|
||||||
|
/>
|
||||||
{envEditor}
|
{envEditor}
|
||||||
<FormFooter
|
<FormFooter
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
|||||||
@@ -5,11 +5,22 @@ import { SecretsEditor } from "@/components/SecretsEditor"
|
|||||||
const INPUT =
|
const INPUT =
|
||||||
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||||
|
|
||||||
export function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
export function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
hint?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<label className="w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label>
|
<label className="w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label>
|
||||||
<div className="flex-1">{children}</div>
|
<div className="flex-1">
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-xs text-[var(--muted)] mt-1 leading-snug">{hint}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -21,6 +32,7 @@ export function TextField({
|
|||||||
placeholder,
|
placeholder,
|
||||||
mono,
|
mono,
|
||||||
width,
|
width,
|
||||||
|
hint,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
@@ -28,9 +40,10 @@ export function TextField({
|
|||||||
placeholder?: string
|
placeholder?: string
|
||||||
mono?: boolean
|
mono?: boolean
|
||||||
width?: string
|
width?: string
|
||||||
|
hint?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Field label={label}>
|
<Field label={label} hint={hint}>
|
||||||
<input
|
<input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
|||||||
Reference in New Issue
Block a user