Adds custom domains.
This commit is contained in:
@@ -5,7 +5,6 @@ import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
|
||||
import { launcherLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
import { KindBadge } from "./KindBadge"
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: ServiceSummary
|
||||
@@ -27,7 +26,15 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
|
||||
>
|
||||
{service.id}
|
||||
</Link>
|
||||
{health ? (
|
||||
{/* A static site is served from disk by the gateway, so it's inherently
|
||||
"up" — show a green pill in the same slot as a service's health, rather
|
||||
than a separate kind badge, so statics and services read consistently. */}
|
||||
{service.kind === "static" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full bg-green-800/50 text-green-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
static
|
||||
</span>
|
||||
) : health ? (
|
||||
<HealthBadge status={health.status} latency={health.latency_ms} />
|
||||
) : hasHttp ? (
|
||||
<HealthBadge status="unknown" />
|
||||
@@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
|
||||
</div>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { X } from "lucide-react"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
import { useGateway } from "@/services/api/hooks"
|
||||
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
|
||||
import { Field, TextField } from "./fields"
|
||||
import { Field, TextField, PublicHostRadios } 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)]"
|
||||
@@ -57,6 +57,9 @@ export function CreateDeploymentForm({
|
||||
const [health, setHealth] = useState("/health")
|
||||
const [proxy, setProxy] = useState(true)
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
// Optional exact public FQDN (apex / another zone) overriding <name>.<public_domain>.
|
||||
const [publicHost, setPublicHost] = useState("")
|
||||
const [publicMode, setPublicMode] = useState<"default" | "custom">("default")
|
||||
const [schedule, setSchedule] = useState("0 2 * * *")
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
@@ -81,7 +84,14 @@ export function CreateDeploymentForm({
|
||||
...(description ? { description } : {}),
|
||||
}
|
||||
if (kind === "tool") return { ...base, manager: "path" }
|
||||
if (kind === "static") return { ...base, manager: "caddy", root }
|
||||
if (kind === "static") {
|
||||
const cfg: Record<string, unknown> = { ...base, manager: "caddy", root }
|
||||
if (isPublic) {
|
||||
cfg.reach = "public"
|
||||
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// systemd (service or job)
|
||||
const cfg: Record<string, unknown> = {
|
||||
@@ -103,7 +113,10 @@ export function CreateDeploymentForm({
|
||||
}
|
||||
}
|
||||
if (proxy) cfg.proxy = true
|
||||
if (proxy && isPublic) cfg.public = true
|
||||
if (proxy && isPublic) {
|
||||
cfg.public = true
|
||||
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -231,6 +244,17 @@ export function CreateDeploymentForm({
|
||||
</label>
|
||||
</Field>
|
||||
)}
|
||||
{proxy && isPublic && (
|
||||
<Field label="Public address" hint="Where it's published on the internet.">
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -239,14 +263,33 @@ export function CreateDeploymentForm({
|
||||
)}
|
||||
|
||||
{kind === "static" && (
|
||||
<TextField
|
||||
label="Root"
|
||||
value={root}
|
||||
onChange={setRoot}
|
||||
width="w-48"
|
||||
mono
|
||||
placeholder="dist"
|
||||
/>
|
||||
<>
|
||||
<TextField
|
||||
label="Root"
|
||||
value={root}
|
||||
onChange={setRoot}
|
||||
width="w-48"
|
||||
mono
|
||||
placeholder="dist"
|
||||
/>
|
||||
<Field label="Public" hint={`A static site is always served at ${gatewayHost(name || "<name>", domain)}. Enable to also publish it to the internet via the Cloudflare tunnel.`}>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
|
||||
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
|
||||
</label>
|
||||
</Field>
|
||||
{isPublic && (
|
||||
<Field label="Public address" hint="Where it's published on the internet.">
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, useRequires } from "./fields"
|
||||
import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
|
||||
import type { Requirement } from "./fields"
|
||||
|
||||
interface Props {
|
||||
@@ -77,6 +77,13 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
const [reach, setReach] = useState(
|
||||
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"),
|
||||
)
|
||||
// Optional exact public FQDN (apex or another zone) that overrides the default
|
||||
// <name>.<public_domain>. Only applies when reach is public; `publicMode` picks
|
||||
// default vs a custom host, and `publicHost` holds the custom value.
|
||||
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
|
||||
const [publicMode, setPublicMode] = useState<"default" | "custom">(
|
||||
m.public_host ? "custom" : "default",
|
||||
)
|
||||
|
||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||
const { element: requiresEditor, value: requiresValue } = useRequires(
|
||||
@@ -111,6 +118,12 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
}
|
||||
// reach needs a port to route through the gateway; without one it's off.
|
||||
config.reach = port ? reach : "off"
|
||||
// public_host only applies to a public service using a custom domain; else
|
||||
// clear it (send null so the PATCH merge drops any stale override).
|
||||
config.public_host =
|
||||
config.reach === "public" && publicMode === "custom" && publicHost.trim()
|
||||
? publicHost.trim()
|
||||
: null
|
||||
}
|
||||
delete config.proxy
|
||||
delete config.public
|
||||
@@ -201,7 +214,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
/>
|
||||
<Field
|
||||
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.`}
|
||||
hint="How far this service is exposed. off: host:port only. internal: via the gateway. public: also to the internet via the Cloudflare tunnel."
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
@@ -210,15 +223,36 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
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">{port ? `localhost:${port} (local)` : "off"}</option>
|
||||
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option>
|
||||
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option>
|
||||
<option value="off">off</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
{!port && (
|
||||
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
{port && (
|
||||
<Field label="Address" hint="Where this service is reachable.">
|
||||
{reach === "off" && (
|
||||
<span className="font-mono text-xs text-[var(--muted)]">localhost:{port}</span>
|
||||
)}
|
||||
{reach === "internal" && (
|
||||
<span className="font-mono text-xs text-[var(--muted)]">
|
||||
{gatewayHost(service.id, domain)}
|
||||
</span>
|
||||
)}
|
||||
{reach === "public" && (
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(service.id, publicDomain)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{requiresEditor}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, useRequires } from "./fields"
|
||||
import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
|
||||
import type { Requirement } from "./fields"
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +29,11 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
const [isPublic, setIsPublic] = useState(
|
||||
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public",
|
||||
)
|
||||
// Optional exact public FQDN (apex or another zone) overriding <name>.<public_domain>.
|
||||
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
|
||||
const [publicMode, setPublicMode] = useState<"default" | "custom">(
|
||||
m.public_host ? "custom" : "default",
|
||||
)
|
||||
const { element: envEditor, merged } = useEnvSecrets(
|
||||
obj(obj(m.defaults).env) as Record<string, string>,
|
||||
)
|
||||
@@ -46,6 +51,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
config.description = description || null
|
||||
config.root = root || "dist"
|
||||
config.reach = isPublic ? "public" : "internal"
|
||||
// public_host only applies to a public site using a custom domain; else clear
|
||||
// (null) so the merge drops any stale override.
|
||||
config.public_host =
|
||||
isPublic && publicMode === "custom" && publicHost.trim() ? publicHost.trim() : null
|
||||
delete config.public
|
||||
config.requires = requiresValue()
|
||||
const env = merged()
|
||||
@@ -73,7 +82,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
/>
|
||||
<Field
|
||||
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'.)`}
|
||||
hint="How far this static site is served. internal: via the gateway. 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-1.5">
|
||||
<select
|
||||
@@ -81,11 +90,24 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
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">{`${gatewayHost(dep.id, domain)} (internal)`}</option>
|
||||
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Address" hint="Where this site is reachable.">
|
||||
{isPublic ? (
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(dep.id, publicDomain)}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-[var(--muted)]">{gatewayHost(dep.id, domain)}</span>
|
||||
)}
|
||||
</Field>
|
||||
{requiresEditor}
|
||||
{envEditor}
|
||||
<FormFooter
|
||||
|
||||
@@ -55,6 +55,53 @@ export function TextField({
|
||||
)
|
||||
}
|
||||
|
||||
/** The public-address sub-control shown when a deployment's reach is `public`:
|
||||
* a default/custom choice. `default` uses `<name>.<public_domain>` (shown
|
||||
* read-only); `custom` reveals a text input for an exact FQDN (apex or another
|
||||
* zone) that becomes the deployment's `public_host`. */
|
||||
export function PublicHostRadios({
|
||||
mode,
|
||||
onModeChange,
|
||||
value,
|
||||
onChange,
|
||||
defaultHost,
|
||||
}: {
|
||||
mode: "default" | "custom"
|
||||
onModeChange: (m: "default" | "custom") => void
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
defaultHost: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" checked={mode === "default"} onChange={() => onModeChange("default")} />
|
||||
<span className="font-mono text-xs text-[var(--muted)]">
|
||||
{defaultHost} <span className="opacity-60">· default</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" checked={mode === "custom"} onChange={() => onModeChange("custom")} />
|
||||
<span className="text-sm">custom domain</span>
|
||||
</label>
|
||||
{mode === "custom" && (
|
||||
<div className="ml-6 space-y-1">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="example.com"
|
||||
className={`w-64 ${INPUT} font-mono`}
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted)] leading-snug">
|
||||
An exact hostname — an apex (example.com) or a name in another zone. The
|
||||
Cloudflare token(s) need DNS:Edit on that zone.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A value that is *exactly* a secret ref → fully editable via SecretsEditor.
|
||||
const SECRET_RE = /^\$\{secret:([^}]+)\}$/
|
||||
// A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These
|
||||
|
||||
Reference in New Issue
Block a user