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:
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react"
|
||||
import { Globe, RefreshCw, FileText, ExternalLink, Cable, ArrowRight } from "lucide-react"
|
||||
import type { GatewayInfo, HealthStatus } from "@/types"
|
||||
import { useApply, useCaddyfile } from "@/services/api/hooks"
|
||||
import { subdomainUrl, detailPath } from "@/lib/labels"
|
||||
@@ -74,6 +74,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
||||
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
|
||||
)}
|
||||
<th className="px-4 py-2 font-medium text-[var(--muted)]">Health</th>
|
||||
<th className="px-4 py-2 font-medium text-[var(--muted)] sr-only">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -145,6 +146,18 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
||||
<HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
{route.name && (
|
||||
<Link
|
||||
to={detailPath(route.name, route.kind)}
|
||||
title={`Go to ${route.name}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-[var(--muted)] hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
<span className="sr-only sm:not-sr-only">Open</span>
|
||||
<ArrowRight size={13} className="shrink-0" />
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,20 +1,119 @@
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import type { ServiceSummary, HealthStatus } from "@/types"
|
||||
import { ServiceCard } from "./ServiceCard"
|
||||
import { kindLabel } from "@/lib/labels"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ServiceSectionProps {
|
||||
services: ServiceSummary[]
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
// The services page mixes systemd services and caddy statics — both URL-reachable.
|
||||
const KIND_ORDER = ["service", "static"]
|
||||
|
||||
// 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",
|
||||
static: "bg-cyan-700 text-white border-cyan-600",
|
||||
}
|
||||
|
||||
export function ServiceSection({ services, statuses }: ServiceSectionProps) {
|
||||
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
|
||||
const [search, setSearch] = useState("")
|
||||
const [kind, setKind] = useState<string | null>(null)
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<string, number> = {}
|
||||
for (const s of services) {
|
||||
const k = s.kind ?? "service"
|
||||
c[k] = (c[k] ?? 0) + 1
|
||||
}
|
||||
return c
|
||||
}, [services])
|
||||
const kindsPresent = KIND_ORDER.filter((k) => counts[k])
|
||||
|
||||
// Sort by name so statics and systemd services interleave alphabetically rather
|
||||
// than clumping by kind (the registry lists them per-kind store).
|
||||
const filtered = useMemo(() => {
|
||||
let base = [...services].sort((a, b) => a.id.localeCompare(b.id))
|
||||
if (kind) base = base.filter((s) => (s.kind ?? "service") === kind)
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
base = base.filter(
|
||||
(s) =>
|
||||
s.id.toLowerCase().includes(q) ||
|
||||
(s.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}
|
||||
return base
|
||||
}, [services, search, kind])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((svc) => (
|
||||
<ServiceCard key={svc.id} service={svc} health={statusMap.get(svc.id)} />
|
||||
))}
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter services..."
|
||||
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"
|
||||
/>
|
||||
{kindsPresent.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Chip
|
||||
label={`All (${services.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>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No services match.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((svc) => (
|
||||
<ServiceCard key={svc.id} service={svc} health={statusMap.get(svc.id)} />
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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` (service→service 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,
|
||||
|
||||
Reference in New Issue
Block a user