feat(nodes): per-node default domain with auto service hostnames (#65)

node_agents.default_domain gives each agent a base domain. Services
placed on it (explicit pin or spread) with no explicit service_domains
get <name>.<default_domain> — resolved in serviceDomainNames so edge
routing, wake placeholders, and public_url all see the same names.
Spread services union every eligible node's base domain; explicit
domains always win.

Surfaces: PUT /agents/:id {default_domain} (hostname-validated), CLI
nodes update --domain, MCP arg docs, OpenAPI + regenerated types,
node-detail inline editor.

Also marks the already-shipped backup-import roadmap item.

Generated with [Devin](https://devin.ai)

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomáš DvořákandDevin authored and GitHub committed 2026-10-10 00:07:30 +02:00
1 parent a4bb7ad559
commit 1409186a1f
17 files changed
+294 -41

No files matched your search

@@ -48,6 +48,7 @@ export function NodeDetailPage() {
const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false);
const [tagDraft, setTagDraft] = useState<string | null>(null);
const [domainDraft, setDomainDraft] = useState<string | null>(null);
const agentQuery = useQuery({
queryKey: ['node-agent', id],
@@ -80,8 +81,8 @@ export function NodeDetailPage() {
queryClient.invalidateQueries({ queryKey: ['usage-agents'] });
};
const updateMutation = useMutation({
mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[] }) => updateAgent(id, body),
onSuccess: () => { setRename(null); setTagDraft(null); invalidate(); },
mutationFn: (body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }) => updateAgent(id, body),
onSuccess: () => { setRename(null); setTagDraft(null); setDomainDraft(null); invalidate(); },
});
const pruneMutation = useMutation({
mutationFn: (volumes: boolean) => pruneAgent(id, { volumes }),
@@ -218,6 +219,44 @@ export function NodeDetailPage() {
</>
)}
</p>
<p className="v-mono mt-1 text-[11px] text-[var(--text-tertiary)] flex items-center gap-1.5 flex-wrap">
domain:
{domainDraft === null ? (
<>
<span className={agent.defaultDomain ? 'text-[var(--text-secondary)]' : 'text-[var(--text-muted)]'}>
{agent.defaultDomain || 'none'}
</span>
{isAdmin && (
<button
onClick={() => setDomainDraft(agent.defaultDomain || '')}
className="text-[var(--text-muted)] hover:text-[var(--text-primary)]"
title="Node base domain — domainless services placed here get <name>.<domain> hostnames"
>
<Pencil size={11} />
</button>
)}
</>
) : (
<>
<input
value={domainDraft}
onChange={(e) => setDomainDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() });
if (e.key === 'Escape') setDomainDraft(null);
}}
autoFocus
placeholder="apps.node1.example.com — empty clears"
className="rounded-md border border-[var(--border-subtle)] bg-[var(--bg-tertiary)] px-2 py-0.5 text-[11px] text-[var(--text-primary)] w-64"
/>
<button
onClick={() => updateMutation.mutate({ default_domain: domainDraft.trim().toLowerCase() })}
className="text-[var(--accent-primary)] hover:underline"
>save</button>
<button onClick={() => setDomainDraft(null)} className="text-[var(--text-muted)] hover:underline">cancel</button>
</>
)}
</p>
</div>
<div className="flex items-center gap-3">
<span className={`v-mono text-[11px] px-2 py-1 rounded-md border ${online
+3 -1
View File
@@ -8179,7 +8179,7 @@ export interface paths {
};
/**
* Update node agent
* @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag.
* @description Partial update — accepts any subset of agent fields (name, status, auto_prune, tags, default_domain). `tags` sets the node's placement labels; services with placement_tags only schedule onto agents carrying every required tag. `default_domain` gives the node a base domain — domainless services placed there get `<name>.<default_domain>` auto-hostnames; empty string clears it.
*/
put: {
parameters: {
@@ -12297,6 +12297,8 @@ export interface components {
schedulable?: boolean;
/** @description Operator-set placement labels; services with placement_tags require all of them on the target node. */
tags?: string[];
/** @description Node base domain — services placed on this node (pin or spread) with no explicit service_domains get `<name>.<default_domain>` hostnames automatically. Wildcard DNS for `*.<default_domain>` must resolve to the Containr edge. */
default_domain?: string;
/** Format: date-time */
last_heartbeat?: string;
/** Format: date-time */
+3 -1
View File
@@ -362,6 +362,7 @@ export type NodeAgentEntity = {
autoPrune: boolean;
schedulable: boolean;
tags: string[];
defaultDomain?: string;
metadata: Record<string, unknown>;
lastHeartbeat?: string;
createdAt?: string;
@@ -1107,6 +1108,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
autoPrune: agent.auto_prune ?? false,
schedulable: agent.schedulable ?? true,
tags: Array.isArray(agent.tags) ? agent.tags : [],
defaultDomain: agent.default_domain || '',
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at,
updatedAt: agent.updated_at,
@@ -1146,7 +1148,7 @@ export async function listAgents(): Promise<NodeAgentEntity[]> {
return normalizeAgentArray(payload.agents);
}
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[] }): Promise<NodeAgentEntity | null> {
export async function updateAgent(id: string, body: { name?: string; auto_prune?: boolean; tags?: string[]; default_domain?: string }): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`, {
method: 'PUT',
body: JSON.stringify(body),