feat: deployment-scoped requires + UI editors, gateway nav, services filter

Model:
- Drop `requires` from ProgramSpec; requires is now deployment-only.
  Collapse Requirement to {ref, bind} with kind defaulting to "deployment"
  (drop the unused `version`). System-package preconditions stay on the
  program as `system_dependencies`, synthesized into {kind: system} for the
  functional check. relations.py/deploy.py read only the deployment's requires.
- create.py seeds a stack's substrate dependency onto the deployment, not the
  program. Docs + tests updated.

UI:
- Add a `requires` editor (useRequires) on service, job, and static detail
  pages — edit service→service deps (ref + optional bind env var).
- Restore the gateway route table "Open" column: a kind-aware in-app link to
  each route's deployment.
- Reformat the reach control: name baked into each option
  (localhost:PORT (local) / <name>.<domain> (internal) / <name>.<pubdomain> (public)).
- Services page: sort statics in with systemd services by name, add a
  search + kind-filter bar mirroring the programs page.
This commit is contained in:
2026-07-06 04:00:24 -07:00
parent 20bf78caf1
commit e438f45b54
13 changed files with 329 additions and 132 deletions

View File

@@ -1,6 +1,7 @@
import { useState } from "react"
import type { JobDetail } from "@/types"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
job: JobDetail
@@ -54,6 +55,9 @@ export function JobFields({ job, onSave, onDelete }: Props) {
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const { element: requiresEditor, value: requiresValue } = useRequires(
(m.requires as Requirement[]) ?? [],
)
const handleSave = async () => {
setSaving(true)
@@ -65,6 +69,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
config.schedule = schedule || undefined
config.run = applyLauncher(obj(config.run), launcher, runTarget)
config.requires = requiresValue()
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
@@ -111,6 +116,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
/>
</div>
</Field>
{requiresEditor}
{envEditor}
<FormFooter
saving={saving}

View File

@@ -2,7 +2,8 @@ import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
service: ServiceDetail
@@ -78,6 +79,9 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const { element: requiresEditor, value: requiresValue } = useRequires(
(m.requires as Requirement[]) ?? [],
)
const handleSave = async () => {
setSaving(true)
@@ -111,6 +115,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
delete config.proxy
delete config.public
config.requires = requiresValue()
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).env
@@ -197,30 +203,25 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
label="Reach"
hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<select
value={reach}
onChange={(e) => setReach(e.target.value)}
disabled={!port}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
>
<option value="off">off</option>
<option value="internal">internal</option>
<option value="public">public</option>
<option value="off">{port ? `localhost:${port} (local)` : "off"}</option>
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option>
</select>
<span className="font-mono text-[var(--muted)] text-xs">
{!port
? "set a port to expose"
: reach === "off"
? "host:port only"
: reach === "public"
? publicGatewayHost(service.id, publicDomain)
: gatewayHost(service.id, domain)}
</span>
{!port && (
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
)}
</div>
</Field>
</>
)}
{requiresEditor}
{envEditor}
<FormFooter
saving={saving}

View File

@@ -2,7 +2,8 @@ import { useState } from "react"
import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
static_: DeploymentDetail
@@ -31,6 +32,9 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record<string, string>,
)
const { element: requiresEditor, value: requiresValue } = useRequires(
(m.requires as Requirement[]) ?? [],
)
const handleSave = async () => {
setSaving(true)
@@ -43,6 +47,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
config.root = root || "dist"
config.reach = isPublic ? "public" : "internal"
delete config.public
config.requires = requiresValue()
const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).env
@@ -70,20 +75,18 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
label="Reach"
hint={`How far this static site is served. internal: ${gatewayHost(dep.id, domain)}. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)`}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<select
value={isPublic ? "public" : "internal"}
onChange={(e) => setIsPublic(e.target.value === "public")}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
>
<option value="internal">internal</option>
<option value="public">public</option>
<option value="internal">{`${gatewayHost(dep.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option>
</select>
<span className="font-mono text-[var(--muted)] text-xs">
{isPublic ? publicGatewayHost(dep.id, publicDomain) : gatewayHost(dep.id, domain)}
</span>
</div>
</Field>
{requiresEditor}
{envEditor}
<FormFooter
saving={saving}

View File

@@ -167,6 +167,78 @@ export function useEnvSecrets(initial: Record<string, string>) {
return { element, merged }
}
/** A deployment-to-deployment requirement as stored in `requires`. `kind` defaults
* to "deployment" on the backend, so the editor only surfaces ref + optional bind. */
export interface Requirement {
kind?: string
ref: string
bind?: string
}
/** Hook for editing a deployment's `requires` (serviceservice dependencies).
* Returns the editor element plus a `value()` that yields the requirement list to
* save (empty entries dropped; bind omitted when blank). */
export function useRequires(initial: Requirement[]) {
const [reqs, setReqs] = useState<Requirement[]>(() =>
(initial ?? []).map((r) => ({ ref: r.ref ?? "", bind: r.bind ?? "" })),
)
const value = (): Requirement[] =>
reqs
.filter((r) => r.ref.trim())
.map((r) =>
r.bind?.trim()
? { ref: r.ref.trim(), bind: r.bind.trim() }
: { ref: r.ref.trim() },
)
const element = (
<Field
label="Requires"
hint="Other deployments this one depends on (drawn as edges on the graph). Add an env var to project the target's URL into the environment (e.g. API_URL → https://target.<domain>)."
>
<div className="space-y-2">
{reqs.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={r.ref}
onChange={(e) =>
setReqs((p) => p.map((x, j) => (j === i ? { ...x, ref: e.target.value } : x)))
}
placeholder="deployment name"
className={`w-32 sm:w-48 min-w-0 ${INPUT} text-xs font-mono`}
/>
<span className="text-[var(--muted)] shrink-0 text-xs"></span>
<input
value={r.bind ?? ""}
onChange={(e) =>
setReqs((p) => p.map((x, j) => (j === i ? { ...x, bind: e.target.value } : x)))
}
placeholder="ENV_VAR (optional)"
className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`}
/>
<button
onClick={() => setReqs((p) => p.filter((_, j) => j !== i))}
className="text-red-400 hover:text-red-300 p-0.5 shrink-0"
title="Remove"
>
<Trash2 size={12} />
</button>
</div>
))}
<button
onClick={() => setReqs((p) => [...p, { ref: "", bind: "" }])}
className="text-xs text-[var(--primary)] hover:underline"
>
+ Add dependency
</button>
</div>
</Field>
)
return { element, value }
}
/** Save/Delete footer shared by the typed config forms. */
export function FormFooter({
saving,