Dashboard: programs list deployments; Tools page; statics on Services; editable launcher
- Programs page: each card lists the program's deployments (name · kind) instead of a single kind badge; kind-filter chips removed (nav is the kind lens now). protonmail honestly shows tool + job. - New Tools nav page (Wrench) — programs with a tool deployment (/programs?kind=tool). - Services page now shows statics too: /services returns service + static, and a static renders as a caddy-served card (KindBadge, served URL, no start/stop). - Program detail: DeploymentsSection + deleteConfirm rebuilt over program.deployments; header drops the (now nonexistent) program kind. - ServiceFields / JobFields: launcher is now an editable select (python|command| container|compose|node), persisted into run.launcher via the normal save. clean pnpm build + tsc --noEmit.
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Server,
|
Server,
|
||||||
Share2,
|
Share2,
|
||||||
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -20,6 +21,7 @@ const NAV = [
|
|||||||
{ to: "/gateway", label: "Gateway", icon: Globe, end: false },
|
{ to: "/gateway", label: "Gateway", icon: Globe, end: false },
|
||||||
{ to: "/services", label: "Services", icon: Server, end: false },
|
{ to: "/services", label: "Services", icon: Server, end: false },
|
||||||
{ to: "/scheduled", label: "Scheduled", icon: Clock, end: false },
|
{ to: "/scheduled", label: "Scheduled", icon: Clock, end: false },
|
||||||
|
{ to: "/tools", label: "Tools", icon: Wrench, end: false },
|
||||||
{ to: "/programs", label: "Programs", icon: Package, end: false },
|
{ to: "/programs", label: "Programs", icon: Package, end: false },
|
||||||
{ to: "/mesh", label: "Mesh", icon: Share2, end: false },
|
{ to: "/mesh", label: "Mesh", icon: Share2, end: false },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,13 +31,23 @@ export function ProgramCard({ program }: ProgramCardProps) {
|
|||||||
>
|
>
|
||||||
{program.id}
|
{program.id}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
||||||
<KindBadge kind={program.kind} />
|
|
||||||
<StackBadge stack={program.stack} />
|
<StackBadge stack={program.stack} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* A program has no kind of its own — show its deployments (name · kind). */}
|
||||||
|
{program.deployments.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-1 mb-2">
|
||||||
|
{program.deployments.map((d) => (
|
||||||
|
<div key={d.name} className="flex items-center gap-1.5 text-xs">
|
||||||
|
<span className="font-mono text-[var(--muted)]">{d.name}</span>
|
||||||
|
<KindBadge kind={d.kind} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{program.description && (
|
{program.description && (
|
||||||
<p className="text-sm text-[var(--muted)]">{program.description}</p>
|
<p className="text-sm text-[var(--muted)]">{program.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import type { ProgramSummary } from "@/types"
|
import type { ProgramSummary } from "@/types"
|
||||||
import { ProgramCard } from "./ProgramCard"
|
import { ProgramCard } from "./ProgramCard"
|
||||||
import { kindLabel } from "@/lib/labels"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
interface ProgramListProps {
|
interface ProgramListProps {
|
||||||
programs: ProgramSummary[]
|
programs: ProgramSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIND_ORDER = ["service", "job", "tool", "static", "reference"]
|
|
||||||
|
|
||||||
// Active chip color per kind — mirrors KindBadge so the filter reads as the badge.
|
|
||||||
const KIND_ACTIVE: Record<string, string> = {
|
|
||||||
service: "bg-green-700 text-white border-green-600",
|
|
||||||
job: "bg-purple-700 text-white border-purple-600",
|
|
||||||
tool: "bg-blue-700 text-white border-blue-600",
|
|
||||||
static: "bg-cyan-700 text-white border-cyan-600",
|
|
||||||
reference: "bg-gray-600 text-gray-200 border-gray-500",
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProgramList({ programs }: ProgramListProps) {
|
export function ProgramList({ programs }: ProgramListProps) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [kind, setKind] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const c: Record<string, number> = {}
|
|
||||||
for (const p of programs) if (p.kind) c[p.kind] = (c[p.kind] ?? 0) + 1
|
|
||||||
return c
|
|
||||||
}, [programs])
|
|
||||||
const kindsPresent = KIND_ORDER.filter((k) => counts[k])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
|
let base = [...programs].sort((a, b) => a.id.localeCompare(b.id))
|
||||||
if (kind) base = base.filter((p) => p.kind === kind)
|
|
||||||
if (search) {
|
if (search) {
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
base = base.filter(
|
base = base.filter(
|
||||||
@@ -42,34 +20,17 @@ export function ProgramList({ programs }: ProgramListProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
}, [programs, search, kind])
|
}, [programs, search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4">
|
||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Filter programs..."
|
placeholder="Filter programs..."
|
||||||
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
|
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 flex-wrap gap-1.5">
|
|
||||||
<Chip
|
|
||||||
label={`All (${programs.length})`}
|
|
||||||
active={kind === null}
|
|
||||||
activeClass="bg-[var(--primary)] text-white border-[var(--primary)]"
|
|
||||||
onClick={() => setKind(null)}
|
|
||||||
/>
|
|
||||||
{kindsPresent.map((k) => (
|
|
||||||
<Chip
|
|
||||||
key={k}
|
|
||||||
label={`${kindLabel(k)} (${counts[k]})`}
|
|
||||||
active={kind === k}
|
|
||||||
activeClass={KIND_ACTIVE[k]}
|
|
||||||
onClick={() => setKind(kind === k ? null : k)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
@@ -84,29 +45,3 @@ export function ProgramList({ programs }: ProgramListProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Chip({
|
|
||||||
label,
|
|
||||||
active,
|
|
||||||
activeClass,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
active: boolean
|
|
||||||
activeClass: string
|
|
||||||
onClick: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
"text-xs px-2.5 py-1 rounded-full border transition-colors",
|
|
||||||
active
|
|
||||||
? activeClass
|
|
||||||
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useServiceAction } from "@/services/api/hooks"
|
|||||||
import { launcherLabel, subdomainUrl } from "@/lib/labels"
|
import { launcherLabel, subdomainUrl } from "@/lib/labels"
|
||||||
import { HealthBadge } from "./HealthBadge"
|
import { HealthBadge } from "./HealthBadge"
|
||||||
import { StackBadge } from "./StackBadge"
|
import { StackBadge } from "./StackBadge"
|
||||||
|
import { KindBadge } from "./KindBadge"
|
||||||
|
|
||||||
interface ServiceCardProps {
|
interface ServiceCardProps {
|
||||||
service: ServiceSummary
|
service: ServiceSummary
|
||||||
@@ -37,7 +38,9 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
{/* A static (caddy-served) "service" is distinguished from a systemd one. */}
|
||||||
|
{service.kind === "static" && <KindBadge kind="static" />}
|
||||||
<StackBadge stack={service.stack} />
|
<StackBadge stack={service.stack} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm
|
|||||||
* lifecycle is shown inline here; service/job deployments link to their own pages
|
* lifecycle is shown inline here; service/job deployments link to their own pages
|
||||||
* where start/stop lives. This is the single home for "how this program runs". */
|
* where start/stop lives. This is the single home for "how this program runs". */
|
||||||
export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
||||||
const { services, jobs, kind } = program
|
const { deployments } = program
|
||||||
const none = services.length === 0 && jobs.length === 0
|
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
const { data: allServices } = useServices()
|
const { data: allServices } = useServices()
|
||||||
@@ -29,6 +28,11 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
|||||||
launcher: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
|
launcher: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tool/static deployments are 1:1 with the program (same name) — their
|
||||||
|
// lifecycle is shown inline; service/job deployments link to their own pages.
|
||||||
|
const inline = deployments.filter((d) => d.kind === "tool" || d.kind === "static")
|
||||||
|
const linked = deployments.filter((d) => d.kind === "service" || d.kind === "job")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -47,8 +51,13 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* The program's own path/caddy deployment — its lifecycle, inline. */}
|
{/* The program's own path/caddy deployment — its lifecycle, inline. */}
|
||||||
{kind === "tool" && <PathLifecycle name={program.id} active={program.active} />}
|
{inline.map((d) =>
|
||||||
{kind === "static" && <StaticStatus name={program.id} active={program.active} />}
|
d.kind === "tool" ? (
|
||||||
|
<PathLifecycle key={d.name} name={program.id} active={program.active} />
|
||||||
|
) : (
|
||||||
|
<StaticStatus key={d.name} name={program.id} active={program.active} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<CreateDeploymentForm
|
<CreateDeploymentForm
|
||||||
@@ -59,40 +68,27 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Service/job deployments — managed on their own detail pages. */}
|
{/* Service/job deployments — managed on their own detail pages. */}
|
||||||
{none ? (
|
{deployments.length === 0 && !creating ? (
|
||||||
(kind === "tool" || kind === "static") ? null : (
|
<p className="text-sm text-[var(--muted)]">No deployment yet.</p>
|
||||||
<p className="text-sm text-[var(--muted)]">
|
) : linked.length > 0 ? (
|
||||||
{kind === "service"
|
|
||||||
? "No service yet — this program isn't deployed."
|
|
||||||
: "No deployment yet."}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5 mt-1">
|
<div className="space-y-1.5 mt-1">
|
||||||
{services.map((s) => (
|
{linked.map((d) => (
|
||||||
<Link
|
<Link
|
||||||
key={s}
|
key={d.name}
|
||||||
to={`/services/${s}`}
|
to={d.kind === "job" ? `/jobs/${d.name}` : `/services/${d.name}`}
|
||||||
className="flex items-center gap-2 text-sm hover:text-[var(--primary)] transition-colors"
|
className="flex items-center gap-2 text-sm hover:text-[var(--primary)] transition-colors"
|
||||||
>
|
>
|
||||||
<Server size={14} className="text-[var(--muted)]" />
|
{d.kind === "job" ? (
|
||||||
<span className="font-medium">{s}</span>
|
<Clock size={14} className="text-[var(--muted)]" />
|
||||||
<span className="text-xs text-[var(--muted)]">service</span>
|
) : (
|
||||||
</Link>
|
<Server size={14} className="text-[var(--muted)]" />
|
||||||
))}
|
)}
|
||||||
{jobs.map((j) => (
|
<span className="font-medium">{d.name}</span>
|
||||||
<Link
|
<span className="text-xs text-[var(--muted)]">{d.kind}</span>
|
||||||
key={j}
|
|
||||||
to={`/jobs/${j}`}
|
|
||||||
className="flex items-center gap-2 text-sm hover:text-[var(--primary)] transition-colors"
|
|
||||||
>
|
|
||||||
<Clock size={14} className="text-[var(--muted)]" />
|
|
||||||
<span className="font-medium">{j}</span>
|
|
||||||
<span className="text-xs text-[var(--muted)]">job</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import type { JobDetail } from "@/types"
|
import type { JobDetail } from "@/types"
|
||||||
import { launcherLabel } from "@/lib/labels"
|
|
||||||
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
|
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +11,28 @@ interface Props {
|
|||||||
type Obj = Record<string, unknown>
|
type Obj = Record<string, unknown>
|
||||||
const obj = (v: unknown): Obj => (v as Obj) ?? {}
|
const obj = (v: unknown): Obj => (v as Obj) ?? {}
|
||||||
|
|
||||||
|
// A job is a systemd deployment; its launcher is editable like a service's.
|
||||||
|
const LAUNCHERS = ["python", "command", "container", "compose", "node"]
|
||||||
|
|
||||||
|
function applyLauncher(run: Obj, launcher: string, target: string): Obj {
|
||||||
|
const out: Obj = { ...run, launcher }
|
||||||
|
const t = target.trim()
|
||||||
|
if (launcher === "command") {
|
||||||
|
out.argv = t.split(/\s+/).filter(Boolean)
|
||||||
|
delete out.program
|
||||||
|
} else if (launcher === "python") {
|
||||||
|
out.program = t
|
||||||
|
delete out.argv
|
||||||
|
} else if (launcher === "container") {
|
||||||
|
if (t) out.image = t
|
||||||
|
} else if (launcher === "node") {
|
||||||
|
if (t) out.script = t
|
||||||
|
} else if (launcher === "compose") {
|
||||||
|
if (t) out.file = t
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/** Edit a job's deployment config (schedule / run / env). */
|
/** Edit a job's deployment config (schedule / run / env). */
|
||||||
export function JobFields({ job, onSave, onDelete }: Props) {
|
export function JobFields({ job, onSave, onDelete }: Props) {
|
||||||
const m = job.manifest
|
const m = job.manifest
|
||||||
@@ -22,12 +43,17 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
|||||||
|
|
||||||
const [description, setDescription] = useState((m.description as string) ?? "")
|
const [description, setDescription] = useState((m.description as string) ?? "")
|
||||||
const [schedule, setSchedule] = useState((m.schedule as string) ?? "")
|
const [schedule, setSchedule] = useState((m.schedule as string) ?? "")
|
||||||
|
const [launcher, setLauncher] = useState((run.launcher as string) ?? "command")
|
||||||
const [runTarget, setRunTarget] = useState(
|
const [runTarget, setRunTarget] = useState(
|
||||||
(run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
|
(run.program as string) ||
|
||||||
|
((run.argv as string[]) ?? []).join(" ") ||
|
||||||
|
(run.image as string) ||
|
||||||
|
(run.script as string) ||
|
||||||
|
(run.file as string) ||
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
|
|
||||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||||
const launcher = (run.launcher as string) ?? "?"
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -38,10 +64,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
|||||||
config.description = description || undefined
|
config.description = description || undefined
|
||||||
config.schedule = schedule || undefined
|
config.schedule = schedule || undefined
|
||||||
|
|
||||||
const runOut = obj(config.run)
|
config.run = applyLauncher(obj(config.run), launcher, runTarget)
|
||||||
if (launcher === "command") runOut.argv = runTarget.split(" ").filter(Boolean)
|
|
||||||
else if (launcher === "python") runOut.program = runTarget
|
|
||||||
config.run = runOut
|
|
||||||
|
|
||||||
const env = merged()
|
const env = merged()
|
||||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||||
@@ -67,13 +90,26 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
|||||||
placeholder="0 2 * * *"
|
placeholder="0 2 * * *"
|
||||||
hint="Cron expression — castle generates a systemd timer that runs the job on this schedule."
|
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.">
|
<Field label="Runs" hint="How the job runs on each tick, then exits: the launcher and its target (a console script, command/argv, image, node script, or compose file).">
|
||||||
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} · </span>
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<select
|
||||||
value={runTarget}
|
value={launcher}
|
||||||
onChange={(e) => setRunTarget(e.target.value)}
|
onChange={(e) => setLauncher(e.target.value)}
|
||||||
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="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
/>
|
>
|
||||||
|
{LAUNCHERS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-[var(--muted)]">·</span>
|
||||||
|
<input
|
||||||
|
value={runTarget}
|
||||||
|
onChange={(e) => setRunTarget(e.target.value)}
|
||||||
|
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)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
{envEditor}
|
{envEditor}
|
||||||
<FormFooter
|
<FormFooter
|
||||||
|
|||||||
@@ -172,12 +172,13 @@ export function ProgramFields({ program, onSave, onDelete }: Props) {
|
|||||||
* confirm enumerates exactly what will happen. Source on disk is always kept. */
|
* confirm enumerates exactly what will happen. Source on disk is always kept. */
|
||||||
function deleteConfirm(program: ProgramDetail): string {
|
function deleteConfirm(program: ProgramDetail): string {
|
||||||
const actions: string[] = []
|
const actions: string[] = []
|
||||||
// The program's own 1:1 deployment (tool/static) isn't listed in services/jobs.
|
// Enumerate the cascade — one line per deployment, keyed on its kind.
|
||||||
if (program.kind === "tool") actions.push(`uninstall ${program.id} from your PATH`)
|
const named: string[] = []
|
||||||
else if (program.kind === "static")
|
for (const d of program.deployments) {
|
||||||
actions.push(`stop serving ${program.id} (drop its gateway route)`)
|
if (d.kind === "tool") actions.push(`uninstall ${d.name} from your PATH`)
|
||||||
// Named service/job deployments that reference this program.
|
else if (d.kind === "static") actions.push(`stop serving ${d.name}`)
|
||||||
const named = [...program.services, ...program.jobs]
|
else named.push(d.name)
|
||||||
|
}
|
||||||
if (named.length) actions.push(`stop, disable, and remove: ${named.join(", ")}`)
|
if (named.length) actions.push(`stop, disable, and remove: ${named.join(", ")}`)
|
||||||
// Always: the catalog entry itself.
|
// Always: the catalog entry itself.
|
||||||
actions.push(`remove "${program.id}" from the catalog`)
|
actions.push(`remove "${program.id}" from the catalog`)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import type { ServiceDetail } from "@/types"
|
import type { ServiceDetail } from "@/types"
|
||||||
import { launcherLabel } from "@/lib/labels"
|
|
||||||
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
|
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,6 +11,31 @@ interface Props {
|
|||||||
type Obj = Record<string, unknown>
|
type Obj = Record<string, unknown>
|
||||||
const obj = (v: unknown): Obj => (v as Obj) ?? {}
|
const obj = (v: unknown): Obj => (v as Obj) ?? {}
|
||||||
|
|
||||||
|
// The systemd launch mechanisms (a service is manager=systemd). Editable, so a
|
||||||
|
// mis-set launcher can be corrected; the primary "Runs" target maps per launcher.
|
||||||
|
const LAUNCHERS = ["python", "command", "container", "compose", "node"]
|
||||||
|
|
||||||
|
/** Fold the "Runs" text into the run block for the chosen launcher, preserving
|
||||||
|
* any other run fields (args, ports, package_manager, …) already present. */
|
||||||
|
function applyLauncher(run: Obj, launcher: string, target: string): Obj {
|
||||||
|
const out: Obj = { ...run, launcher }
|
||||||
|
const t = target.trim()
|
||||||
|
if (launcher === "command") {
|
||||||
|
out.argv = t.split(/\s+/).filter(Boolean)
|
||||||
|
delete out.program
|
||||||
|
} else if (launcher === "python") {
|
||||||
|
out.program = t
|
||||||
|
delete out.argv
|
||||||
|
} else if (launcher === "container") {
|
||||||
|
if (t) out.image = t
|
||||||
|
} else if (launcher === "node") {
|
||||||
|
if (t) out.script = t
|
||||||
|
} else if (launcher === "compose") {
|
||||||
|
if (t) out.file = t
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/** Edit a service's deployment config (run / expose / proxy / env). */
|
/** Edit a service's deployment config (run / expose / proxy / env). */
|
||||||
export function ServiceFields({ service, onSave, onDelete }: Props) {
|
export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||||
const m = service.manifest
|
const m = service.manifest
|
||||||
@@ -23,8 +47,14 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
|
||||||
const [description, setDescription] = useState((m.description as string) ?? "")
|
const [description, setDescription] = useState((m.description as string) ?? "")
|
||||||
|
const [launcher, setLauncher] = useState((run.launcher as string) ?? "python")
|
||||||
const [runProgram, setRunProgram] = useState(
|
const [runProgram, setRunProgram] = useState(
|
||||||
(run.program as string) ?? ((run.argv as string[]) ?? []).join(" "),
|
(run.program as string) ||
|
||||||
|
((run.argv as string[]) ?? []).join(" ") ||
|
||||||
|
(run.image as string) ||
|
||||||
|
(run.script as string) ||
|
||||||
|
(run.file as string) ||
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
|
const [port, setPort] = useState(internal.port != null ? String(internal.port) : "")
|
||||||
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
const [health, setHealth] = useState((httpExpose.health_path as string) ?? "")
|
||||||
@@ -33,8 +63,6 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
|
|
||||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||||
|
|
||||||
const launcher = (run.launcher as string) ?? "?"
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setSaved(false)
|
setSaved(false)
|
||||||
@@ -43,12 +71,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
delete config.id
|
delete config.id
|
||||||
config.description = description || undefined
|
config.description = description || undefined
|
||||||
|
|
||||||
// Only python/command launchers are edited here; other launchers
|
// Rebuild the run block for the chosen launcher, preserving other fields.
|
||||||
// (container/node/compose) keep their original run block untouched.
|
config.run = applyLauncher(obj(config.run), launcher, runProgram)
|
||||||
const runOut = obj(config.run)
|
|
||||||
if (launcher === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
|
|
||||||
else if (launcher === "python") runOut.program = runProgram
|
|
||||||
config.run = runOut
|
|
||||||
|
|
||||||
if (port) {
|
if (port) {
|
||||||
config.expose = {
|
config.expose = {
|
||||||
@@ -81,14 +105,27 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
|||||||
<TextField label="Description" value={description} onChange={setDescription} />
|
<TextField label="Description" value={description} onChange={setDescription} />
|
||||||
<Field
|
<Field
|
||||||
label="Runs"
|
label="Runs"
|
||||||
hint="The console script (python runner) or command this service executes."
|
hint="How this service starts: the launcher, and its target — a console script (python), a command/argv (command), an image (container), a script (node), or a compose file (compose)."
|
||||||
>
|
>
|
||||||
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} · </span>
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<select
|
||||||
value={runProgram}
|
value={launcher}
|
||||||
onChange={(e) => setRunProgram(e.target.value)}
|
onChange={(e) => setLauncher(e.target.value)}
|
||||||
className="w-full sm: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="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||||
/>
|
>
|
||||||
|
{LAUNCHERS.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-[var(--muted)]">·</span>
|
||||||
|
<input
|
||||||
|
value={runProgram}
|
||||||
|
onChange={(e) => setRunProgram(e.target.value)}
|
||||||
|
className="w-full sm: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)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
<TextField
|
<TextField
|
||||||
label="Port"
|
label="Port"
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ export function ProgramDetailPage() {
|
|||||||
// A static (caddy) deployment with build outputs is served by the gateway in
|
// A static (caddy) deployment with build outputs is served by the gateway in
|
||||||
// place at its own subdomain — show where.
|
// place at its own subdomain — show where.
|
||||||
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
|
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
|
||||||
|
const isStatic = deployment.deployments.some((d) => d.kind === "static")
|
||||||
const servedAt =
|
const servedAt =
|
||||||
deployment.kind === "static" && buildOutputs?.length
|
isStatic && buildOutputs?.length
|
||||||
? (subdomainUrl(deployment.id) ?? `${deployment.id}.<gateway.domain>`)
|
? (subdomainUrl(deployment.id) ?? `${deployment.id}.<gateway.domain>`)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -41,7 +42,6 @@ export function ProgramDetailPage() {
|
|||||||
backTo="/programs"
|
backTo="/programs"
|
||||||
backLabel="Back to Programs"
|
backLabel="Back to Programs"
|
||||||
name={deployment.id}
|
name={deployment.id}
|
||||||
kind={deployment.kind}
|
|
||||||
stack={deployment.stack}
|
stack={deployment.stack}
|
||||||
source={deployment.source}
|
source={deployment.source}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function Services() {
|
|||||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Services"
|
title="Services"
|
||||||
subtitle="Long-running processes"
|
subtitle="Long-running & served — systemd daemons and caddy statics"
|
||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
onClick={() => setCreating((c) => !c)}
|
onClick={() => setCreating((c) => !c)}
|
||||||
|
|||||||
23
app/src/pages/Tools.tsx
Normal file
23
app/src/pages/Tools.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { usePrograms } from "@/services/api/hooks"
|
||||||
|
import { ProgramList } from "@/components/ProgramList"
|
||||||
|
import { PageHeader } from "@/components/PageHeader"
|
||||||
|
|
||||||
|
export function Tools() {
|
||||||
|
// Tools are program-centric — a tool is a program installed on PATH (its path
|
||||||
|
// deployment is 1:1 and trivial), so we list the programs that have one.
|
||||||
|
const { data: programs, isLoading } = usePrograms("tool")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<PageHeader title="Tools" subtitle="CLIs installed on your PATH" />
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-[var(--muted)]">Loading...</p>
|
||||||
|
) : programs && programs.length > 0 ? (
|
||||||
|
<ProgramList programs={programs} />
|
||||||
|
) : (
|
||||||
|
<p className="text-[var(--muted)]">No tools yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Layout } from "@/components/Layout"
|
|||||||
import { Overview } from "@/pages/Overview"
|
import { Overview } from "@/pages/Overview"
|
||||||
import { Services } from "@/pages/Services"
|
import { Services } from "@/pages/Services"
|
||||||
import { Scheduled } from "@/pages/Scheduled"
|
import { Scheduled } from "@/pages/Scheduled"
|
||||||
|
import { Tools } from "@/pages/Tools"
|
||||||
import { Programs } from "@/pages/Programs"
|
import { Programs } from "@/pages/Programs"
|
||||||
import { GatewayPage } from "@/pages/GatewayPage"
|
import { GatewayPage } from "@/pages/GatewayPage"
|
||||||
import { MeshPage } from "@/pages/MeshPage"
|
import { MeshPage } from "@/pages/MeshPage"
|
||||||
@@ -20,6 +21,7 @@ export const router = createBrowserRouter([
|
|||||||
{ index: true, element: <Overview /> },
|
{ index: true, element: <Overview /> },
|
||||||
{ path: "services", element: <Services /> },
|
{ path: "services", element: <Services /> },
|
||||||
{ path: "scheduled", element: <Scheduled /> },
|
{ path: "scheduled", element: <Scheduled /> },
|
||||||
|
{ path: "tools", element: <Tools /> },
|
||||||
{ path: "programs", element: <Programs /> },
|
{ path: "programs", element: <Programs /> },
|
||||||
{ path: "gateway", element: <GatewayPage /> },
|
{ path: "gateway", element: <GatewayPage /> },
|
||||||
{ path: "mesh", element: <MeshPage /> },
|
{ path: "mesh", element: <MeshPage /> },
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ export interface ServiceSummary {
|
|||||||
id: string
|
id: string
|
||||||
description: string | null
|
description: string | null
|
||||||
stack: string | null
|
stack: string | null
|
||||||
manager?: string | null // systemd for a service
|
kind: string | null // service | static
|
||||||
launcher: string | null // python | command | container | compose | node
|
manager: string | null // systemd | caddy
|
||||||
|
launcher: string | null // python | command | container | compose | node (systemd only)
|
||||||
run_target: string | null
|
run_target: string | null
|
||||||
port: number | null
|
port: number | null
|
||||||
health_path: string | null
|
health_path: string | null
|
||||||
@@ -43,10 +44,16 @@ export interface JobDetail extends JobSummary {
|
|||||||
manifest: Record<string, unknown>
|
manifest: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A program's deployment (name + its derived kind). A program has no kind of its
|
||||||
|
// own — it has deployments, each with a kind (a program can be a tool AND a job).
|
||||||
|
export interface DeploymentRef {
|
||||||
|
name: string
|
||||||
|
kind: string // service | job | tool | static | reference
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProgramSummary {
|
export interface ProgramSummary {
|
||||||
id: string
|
id: string
|
||||||
description: string | null
|
description: string | null
|
||||||
kind: string | null // derived: service | job | tool | static | reference
|
|
||||||
stack: string | null
|
stack: string | null
|
||||||
version: string | null
|
version: string | null
|
||||||
source: string | null
|
source: string | null
|
||||||
@@ -57,8 +64,7 @@ export interface ProgramSummary {
|
|||||||
installed: boolean | null
|
installed: boolean | null
|
||||||
active: boolean | null
|
active: boolean | null
|
||||||
actions: string[]
|
actions: string[]
|
||||||
services: string[]
|
deployments: DeploymentRef[]
|
||||||
jobs: string[]
|
|
||||||
node: string | null
|
node: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user