diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx new file mode 100644 index 0000000..a783275 --- /dev/null +++ b/app/src/components/detail/CreateDeploymentForm.tsx @@ -0,0 +1,186 @@ +import { useState } from "react" +import { useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "react-router-dom" +import { X } from "lucide-react" +import { apiClient } from "@/services/api/client" +import { Field, TextField } from "./fields" + +const SELECT = + "bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]" + +export interface CreatePrefill { + name?: string + program?: string + runTarget?: string + runner?: string +} + +/** Create a service or job in castle.yaml, then deploy (and start, for a + * service). The UI twin of `castle expose`. Reachable standalone or prefilled + * from a program page. */ +export function CreateDeploymentForm({ + kind, + prefill, + existingNames, + onCancel, +}: { + kind: "service" | "job" + prefill?: CreatePrefill + existingNames: string[] + onCancel: () => void +}) { + const qc = useQueryClient() + const navigate = useNavigate() + + const [name, setName] = useState(prefill?.name ?? "") + const [program] = useState(prefill?.program ?? "") + const [description, setDescription] = useState("") + const [runner, setRunner] = useState(prefill?.runner ?? "python") + const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "") + const [port, setPort] = useState("") + const [portEnv, setPortEnv] = useState("") + const [health, setHealth] = useState("/health") + const [path, setPath] = useState("") + const [host, setHost] = useState("") + const [schedule, setSchedule] = useState("0 2 * * *") + const [busy, setBusy] = useState(null) + const [error, setError] = useState("") + + const nameError = + name && !/^[a-z0-9][a-z0-9-]*$/.test(name) + ? "lowercase letters, numbers, hyphens" + : existingNames.includes(name) + ? "already exists" + : "" + + const buildConfig = (): Record => { + const run = + runner === "command" + ? { runner: "command", argv: runTarget.split(" ").filter(Boolean) } + : { runner: "python", program: runTarget || name } + const base: Record = { + ...(program ? { program } : {}), + ...(description ? { description } : {}), + run, + manage: { systemd: {} }, + } + if (kind === "job") { + base.schedule = schedule + return base + } + if (port) { + base.expose = { + http: { + internal: { port: parseInt(port, 10), ...(portEnv ? { port_env: portEnv } : {}) }, + ...(health ? { health_path: health } : {}), + }, + } + } + if (path || host) { + base.proxy = { + caddy: { + ...(path ? { path_prefix: path.startsWith("/") ? path : `/${path}` } : {}), + ...(host ? { host } : {}), + }, + } + } + return base + } + + const submit = async () => { + if (!name || nameError) return + setError("") + try { + setBusy("Saving…") + await apiClient.put(`/config/${kind}s/${name}`, { config: buildConfig() }) + setBusy("Deploying…") + await apiClient.post(`/deploy`, { name }) + if (kind === "service") { + setBusy("Starting…") + await apiClient.post(`/services/${name}/start`, {}) + } + qc.invalidateQueries({ queryKey: [`${kind}s`] }) + qc.invalidateQueries({ queryKey: ["programs"] }) + qc.invalidateQueries({ queryKey: ["status"] }) + navigate(kind === "service" ? `/services/${name}` : `/jobs/${name}`) + } catch (e: unknown) { + let msg = e instanceof Error ? e.message : String(e) + try { + msg = JSON.parse((e as Error).message).detail ?? msg + } catch { + /* keep msg */ + } + setError(msg) + setBusy(null) + } + } + + return ( +
+
+

New {kind}{program ? ` for ${program}` : ""}

+ +
+ + {error && ( +
+ {error} +
+ )} + + + setName(e.target.value.toLowerCase())} + placeholder={kind === "service" ? "my-service" : "my-job"} + autoFocus + className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]" + /> + {nameError &&

{nameError}

} +
+ + + + + + + + + {kind === "service" ? ( + <> + + + + + + + ) : ( + + )} + +
+ + +
+
+ ) +} diff --git a/app/src/components/detail/DeploymentsSection.tsx b/app/src/components/detail/DeploymentsSection.tsx index 53f0e83..3578f08 100644 --- a/app/src/components/detail/DeploymentsSection.tsx +++ b/app/src/components/detail/DeploymentsSection.tsx @@ -1,24 +1,68 @@ +import { useState } from "react" import { Link } from "react-router-dom" -import { Server, Clock } from "lucide-react" +import { Server, Clock, Plus } from "lucide-react" import type { ProgramDetail } from "@/types" +import { useServices, useJobs } from "@/services/api/hooks" +import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm" /** The services and jobs that deploy a program. A program → 0-N services and * 0-N jobs; these are convenience links, not ownership (a deployment can run - * anything, program-backed or not). */ + * anything, program-backed or not). The Create buttons just prefill the + * standalone create form with sensible values. */ export function DeploymentsSection({ program }: { program: ProgramDetail }) { const { services, jobs, behavior } = program const none = services.length === 0 && jobs.length === 0 + const [creating, setCreating] = useState<"service" | "job" | null>(null) + + const { data: allServices } = useServices() + const { data: allJobs } = useJobs() + const existing = + creating === "service" + ? (allServices ?? []).map((s) => s.id) + : (allJobs ?? []).map((j) => j.id) + + const prefill: CreatePrefill = { + name: program.id, + program: program.id, + runTarget: program.id, + runner: program.stack?.startsWith("python") || !program.stack ? "python" : "command", + } return (
-

- Deployments -

+
+

+ Deployments +

+
+ + +
+

Services and jobs that run this program.

- {none ? ( + {creating && ( + setCreating(null)} + /> + )} + + {none && !creating ? (

{behavior === "daemon" ? "No service yet — this daemon isn't deployed." diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index 0e2708a..f3b490a 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -1,3 +1,5 @@ +import { useState } from "react" +import { Plus } from "lucide-react" import { useServices, useJobs, usePrograms, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks" import { GatewayPanel } from "@/components/GatewayPanel" import { MeshPanel } from "@/components/MeshPanel" @@ -6,6 +8,7 @@ import { ServiceSection } from "@/components/ServiceSection" import { ScheduledSection } from "@/components/ScheduledSection" import { ProgramTable } from "@/components/ProgramTable" import { SectionHeader } from "@/components/SectionHeader" +import { CreateDeploymentForm } from "@/components/detail/CreateDeploymentForm" export function Dashboard() { @@ -17,9 +20,14 @@ export function Dashboard() { const { data: gateway } = useGateway() const { data: nodes } = useNodes() const { data: mesh } = useMeshStatus() + const [creating, setCreating] = useState<"service" | "job" | null>(null) const statuses = statusResp?.statuses ?? [] const isLoading = loadingServices || loadingJobs || loadingPrograms + const existing = + creating === "service" + ? (services ?? []).map((s) => s.id) + : (jobs ?? []).map((j) => j.id) return (

@@ -42,6 +50,30 @@ export function Dashboard() {
)} +
+ + +
+ {creating && ( +
+ setCreating(null)} + /> +
+ )} + {isLoading ? (

Loading...

) : (