feat(scheduling): multi-node service placement + remote lifecycle (#54)

* feat(scheduling): multi-node service placement + remote container lifecycle

services.node_id pins a service to a node agent: explicit id, "auto"
(least-loaded online node), or "local"/empty for the local Docker host.

- DeploymentEngine gains a NodeRunner seam; ReconcileService routes
  pinned services to the agent's command queue (create_container with
  image/env/command/ports/volumes/labels/resource limits), tracked in
  container_instances with deterministic per-replica ids.
- Stop/start/restart/delete fan out to the pinned node — no more leaked
  remote containers on service delete.
- Agent create_container is idempotent (rm -f first) and honors restart
  policy, labels, memory/cpus, published+ephemeral ports, volumes, env.
- GET /nodes/options exposes id/name/status to authed users; full agent
  management stays admin-only.
- CLI services create/update --node, MCP node_id docs, OpenAPI
  node_id/node_name + /nodes/options, service-detail Placement section.

Ceilings: remote nodes run registry-pulled images only — git builds,
Traefik domains, and remote sleep detection stay local until
build-on-node and per-node ingress land.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(scheduling): node cordon/drain + schedulable gate

- node_agents.schedulable column; "auto" placement and explicit pins skip
  cordoned nodes; already-pinned services keep running (reconcile is
  unaffected — cordon is not eviction).
- POST /agents/:id/cordon|uncordon|drain (admin). Drain cordons, removes
  every service container via the agent queue, and clears the services'
  pins so they redeploy on the local host. Requires the node online —
  an offline node can't acknowledge removals.
- CLI nodes cordon|uncordon|drain (drain confirm-gated), MCP
  containr_nodes_cordon|uncordon|drain, OpenAPI paths + schedulable
  field, node-detail page cordon badge + drain/cordon controls.
- Fix: re-registering an agent no longer resets auto_prune.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(scheduling): remote registry pulls + post-run health check

- Dispatch payload carries the project owner's registry credentials for
  the image host; the agent docker-logins for the pull and logs out
  after, so private images deploy to remote nodes.
- Completed command payloads get their registry password scrubbed —
  secrets don't linger in agent_commands rows.
- Agent verifies the container is still running ~1.5s after docker run;
  crash-loops now fail the deployment with the container's last log
  lines instead of reporting "running" optimistically.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

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-09 16:19:12 +02:00
1 parent 401daf69aa
commit bc93c83cf9
28 files changed
+1565 -48

No files matched your search

@@ -2,7 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
cordonAgent,
deleteAgent,
drainAgent,
getAgent,
getAgentMetrics,
listAgentCommands,
@@ -43,6 +45,7 @@ export function NodeDetailPage() {
const [rename, setRename] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmVolumes, setConfirmVolumes] = useState(false);
const [confirmDrain, setConfirmDrain] = useState(false);
const agentQuery = useQuery({
queryKey: ['node-agent', id],
@@ -86,6 +89,14 @@ export function NodeDetailPage() {
mutationFn: () => deleteAgent(id),
onSuccess: () => navigate('/usage'),
});
const cordonMutation = useMutation({
mutationFn: (cordoned: boolean) => cordonAgent(id, cordoned),
onSuccess: invalidate,
});
const drainMutation = useMutation({
mutationFn: () => drainAgent(id),
onSuccess: () => { setConfirmDrain(false); invalidate(); },
});
const agent = agentQuery.data;
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data]);
@@ -168,6 +179,11 @@ export function NodeDetailPage() {
: 'border-[var(--error)]/40 text-[var(--error)]'}`}>
{agent.status}
</span>
{!agent.schedulable && (
<span className="v-mono text-[11px] px-2 py-1 rounded-md border border-[var(--warning)]/40 text-[var(--warning)]">
cordoned
</span>
)}
{isAdmin && (
<>
<label className="flex items-center gap-1.5 v-mono text-[11px] text-[var(--text-tertiary)] cursor-pointer" title="Daily automatic docker prune">
@@ -179,6 +195,26 @@ export function NodeDetailPage() {
/>
auto-prune
</label>
<button
onClick={() => cordonMutation.mutate(agent.schedulable)}
disabled={cordonMutation.isPending}
title={agent.schedulable
? 'Block new placements — running replicas stay'
: 'Re-open for placement'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
>
{agent.schedulable ? 'cordon' : 'uncordon'}
</button>
<button
onClick={() => setConfirmDrain(true)}
disabled={drainMutation.isPending || !online}
title={online
? 'Cordon + remove all service containers + unpin services (they redeploy locally)'
: 'Node must be online to drain'}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--warning)]/40 text-[var(--warning)] hover:bg-[var(--warning)]/10 disabled:opacity-50"
>
drain
</button>
<button
onClick={() => setConfirmVolumes(true)}
disabled={pruneMutation.isPending}
@@ -302,6 +338,17 @@ export function NodeDetailPage() {
pending={pruneMutation.isPending}
/>
)}
{confirmDrain && (
<ConfirmModal
title={`Drain ${agent.name}?`}
body="Every service container on this node is removed and the affected services are unpinned — they redeploy on the local host until re-pinned elsewhere. The node stays cordoned."
confirmLabel="drain node"
danger
onCancel={() => setConfirmDrain(false)}
onConfirm={() => drainMutation.mutate()}
pending={drainMutation.isPending}
/>
)}
{confirmDelete && (
<ConfirmModal
title={`Remove ${agent.name}?`}
@@ -48,6 +48,7 @@ import {
cloneService,
moveService,
listProjects,
listNodeOptions,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
import { useDemoMode } from '@/lib/demo-mode';
@@ -466,6 +467,7 @@ export function ServiceDetailPage() {
onSuccess: () => {
setNetworkForm(null);
setSleepDraft(null);
setNodeDraft(null);
invalidateService();
},
});
@@ -485,6 +487,13 @@ export function ServiceDetailPage() {
const [domainChecks, setDomainChecks] = useState<Record<string, DomainCheckResult> | null>(null);
const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null);
const [sleepDraft, setSleepDraft] = useState<{ id: string; enabled: boolean; minutes: string } | null>(null);
const [nodeDraft, setNodeDraft] = useState<string | null>(null);
const nodesQuery = useQuery({
queryKey: ['node-options'],
queryFn: listNodeOptions,
enabled: !isDemoMode,
staleTime: 30_000,
});
const [buildForm, setBuildForm] = useState<{
builder: string; cpuReserve: string; memoryReserve: string; staticCmd: string; staticDir: string;
} | null>(null);
@@ -2384,6 +2393,58 @@ export function ServiceDetailPage() {
</div>
)}
{!isDemoMode && (nodesQuery.data?.length ?? 0) > 0 && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="mb-3">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Placement</h3>
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">
Which node runs this service's containers. Remote nodes pull the image
from its registry — git builds and domains stay on the local host.
</p>
</div>
<div className="flex items-center gap-3 text-xs">
<select
value={nodeDraft ?? (service.nodeId || 'local')}
onChange={(e) => setNodeDraft(e.target.value)}
className="px-2 py-1.5 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
>
<option value="local">This server (local Docker)</option>
<option value="auto">Auto — least-loaded online node</option>
{(nodesQuery.data ?? []).map((n) => (
<option key={n.id} value={n.id}>
{n.name}{n.status !== 'online' ? ' (offline)' : ''}
</option>
))}
</select>
{nodeDraft !== null && (
<>
<button
type="button"
disabled={updateServiceMutation.isPending}
onClick={() => updateServiceMutation.mutate({ node_id: nodeDraft })}
className="px-3 py-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-on)] text-xs font-medium disabled:opacity-50"
>
Save
</button>
<button
type="button"
onClick={() => setNodeDraft(null)}
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
>
Cancel
</button>
</>
)}
{service.nodeName && nodeDraft === null && (
<span className="text-[var(--text-tertiary)]">running on {service.nodeName}</span>
)}
</div>
{updateServiceMutation.isError && (
<p className="mt-2 text-xs text-[var(--error)]">{(updateServiceMutation.error as Error).message}</p>
)}
</div>
)}
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
+228
View File
@@ -7977,6 +7977,51 @@ export interface paths {
patch?: never;
trace?: never;
};
"/nodes/options": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Node options for service placement
* @description Minimal id/name/status list of registered node agents — available to any authenticated user for the service placement picker. Full agent data is admin-only under /agents.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Node options */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
nodes?: {
id?: string;
name?: string;
status?: string;
}[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agents": {
parameters: {
query?: never;
@@ -8514,6 +8559,179 @@ export interface paths {
patch?: never;
trace?: never;
};
"/agents/{id}/cordon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Cordon a node
* @description Sets `schedulable=false`: new explicit pins are rejected and `auto` placement skips the node. Already-pinned services keep running and can still reconcile — drain to evict them.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Node agent ID */
id: components["parameters"]["AgentIdParam"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Node cordoned */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
id?: string;
schedulable?: boolean;
};
};
};
/** @description Agent not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agents/{id}/uncordon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Uncordon a node
* @description Re-opens the node for placement (`schedulable=true`).
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Node agent ID */
id: components["parameters"]["AgentIdParam"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Node uncordoned */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
id?: string;
schedulable?: boolean;
};
};
};
/** @description Agent not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agents/{id}/drain": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Drain a node
* @description Cordons the node, removes every service container via the agent command queue, and clears the affected services' `node_id` pins — they fall back to the local host on next deploy and can be re-pinned elsewhere. Requires the node to be online so it can acknowledge the removals.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Node agent ID */
id: components["parameters"]["AgentIdParam"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Node drained */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
id?: string;
schedulable?: boolean;
services_drained?: number;
services_unpinned?: number;
};
};
};
/** @description Agent not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Node is not online */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/agents/{id}/metrics": {
parameters: {
query?: never;
@@ -10656,6 +10874,10 @@ export interface components {
sleep_enabled?: boolean;
/** @description Idle timeout in minutes before sleeping (1-1440, default 15) */
sleep_idle_minutes?: number;
/** @description Node agent id this service is pinned to; empty/null = local Docker host. 'auto' on create/update resolves to the least-loaded online agent. Remote nodes run registry-pulled images only (no git builds, no Traefik domains). */
node_id?: string;
/** @description Display name of the pinned node agent (read-only) */
node_name?: string;
/** @description Computed public URL (published port or domain) */
public_url?: string;
/**
@@ -10726,6 +10948,8 @@ export interface components {
static_build_cmd?: string;
/** @description Output directory for the static builder (default dist) */
static_dir?: string;
/** @description Node agent id to pin to, 'auto' = least-loaded online agent; omit/empty = local host */
node_id?: string;
};
UpdateServiceRequest: {
/** @description Service name */
@@ -10787,6 +11011,8 @@ export interface components {
sleep_enabled?: boolean;
/** @description Idle timeout in minutes before sleeping (1-1440) */
sleep_idle_minutes?: number;
/** @description Node agent id to pin to, 'auto' = least-loaded online agent, 'local' = run on the Containr host (clears the pin) */
node_id?: string;
};
ServiceDomain: {
/** Format: uuid */
@@ -11904,6 +12130,8 @@ export interface components {
};
/** @description Enqueue a bounded docker prune on this node once per day. */
auto_prune?: boolean;
/** @description False on a cordoned/drained node — `auto` placement skips it and new explicit pins are rejected. */
schedulable?: boolean;
/** Format: date-time */
last_heartbeat?: string;
/** Format: date-time */
+1 -1
View File
@@ -40,7 +40,7 @@ export const bundledDocs: BundledDoc[] = [
"path": "guides/CLI.md",
"section": "guides",
"title": "Containr CLI",
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|anLine truncated
"markdown": "# Containr CLI\n\nThe CLI controls every part of a Containr instance — the same API surface\nthe web UI uses. Build it with `scripts/build-cli.sh` or\n`go build ./cmd/cli` from `app/backend`.\n\n## Authentication\n\nCreate a personal access token in the web UI under\n**Settings → Personal Access Tokens** (or `containr tokens create`), then:\n\n```bash\ncontainr auth login cnp_... # verify + store\ncontainr auth login cnp_... --url https://containr.example.com\ncontainr auth login cnp_... --name production # named profile\ncontainr auth status # show active profile + identity\ncontainr auth logout\n```\n\nResolution order for every command: `--token`/`--api-url` flags >\n`CONTAINR_TOKEN`/`CONTAINR_API_URL` env > active profile >\n`http://localhost:8080/api/v1`. The active profile is `--profile` >\n`CONTAINR_PROFILE` > `current_profile` in `~/.containr.yaml`.\n\n## Output and scripting\n\n- `--json` prints the raw API response — safe to pipe and parse.\n- `--yes` / `-y` skips confirmation on destructive commands (delete,\n revoke, restore, failover). Non-interactive shells refuse destructive\n commands without it.\n- Exit codes are stable: `0` ok, `1` error, `2` auth/permission,\n `3` not found, `4` validation, `5` unreachable.\n\n## Commands\n\n```\nauth login|logout|status token auth and profiles\ntokens list|create|revoke personal access tokens\nprojects list|get|create|delete\nservices list|get|create|update|delete|start|stop|restart|redeploy\nservices clone|move duplicate config or re-parent a service\nservices domains list|add|remove|default|check multi-domain + DNS preflight\nregistries list|add|update|remove private-image pull credentials\nvolumes list|delete docker volume inventory + cleanup (admin)\ndeploy <service-id> trigger a deployment (--no-cache, --commit-hash, --branch)\ndeployments list|get|logs|rollback|cancel\nlogs <service-id> [--follow] runtime logs (SSE follow)\nexec <service-id> -- <cmd> one-off container command\nvariables list|set|unset env vars (secrets masked; --redeploy applies them)\nvariables shared list|set|unset project shared vars — referenced as ${{shared.KEY}}\ndatabases list|get|create|update|delete|action|backup|restore|download-backup|import-backup\n import-backup <id> <file.tar.gz|-> uploads an archive as a restore\n point; ships offsite too when a backup target is assigned\ndatabases list|get|create|update|delete|action|backup|restore|download-backup\n create --public exposes the port on all interfaces;\n update <id> --public/--public=false toggles it (container recreates, data persists)\ndatabases register <name> register an external database (--type --host --port --database --username --password --ssl; probed before storing)\ndatabases test-connection probe a connection — by id, or ad-hoc flags\nbackup-targets list|add|update|remove|test\n S3-compatible archive destinations; add/update probe the bucket\n first, empty --access-key/--secret-key on update keep stored keys;\n update <db-id> --backup-target assigns a target (\"\" clears it)\ncron list|get|create|delete|trigger|executions\ntemplates list|get|create|delete|deploy|plan|import-compose|deploy-graph\n deploy --name --var K=V; plan dry-resolves expressions;\n import-compose <compose.yml|-> converts a compose file into\n a v2 graph config; deploy-graph <project-id> <config.json|->\n deploys an ad-hoc service graph\nimport compose <project-id> <compose.yml|->\n deploys a docker-compose file straight into a project —\n no template row. DB images become managed databases\n attached under their service name; --var K=V overrides\n template variables\nnodes list|get|delete|tokens node agents + onboarding tokens (admin)\nnodes add <user@host> SSH bootstrap — one session installs the agent, then never needed again (--print for manual runs)\nnodes update|prune|commands rename/auto-prune, bounded docker prune, command history\nmigrate railway pull a Railway project (services, vars, domains) via GraphQL and recreate it — --dry-run prints the plan\nmigrate dflow import a dflow project (services, vars, volumes, domains) via its Payload REST API — needs --url + --api-key, --dry-run prints the plan\nscaling status|policies|services|scale\nha status|alerts|health|policies|enable|disable|failover\nsecurity scan|vulnerabilities|metrics\ngateway services|keys|anLine truncated
},
{
"path": "guides/CLOUDFLARE_SETUP.md",
+27
View File
@@ -84,9 +84,22 @@ export type ServiceEntity = {
staticDir?: string;
sleepEnabled?: boolean;
sleepIdleMinutes?: number;
nodeId?: string;
nodeName?: string;
publicUrl?: string;
};
export type NodeOption = {
id: string;
name: string;
status: string;
};
export async function listNodeOptions(): Promise<NodeOption[]> {
const payload = await requestJson<{ nodes?: NodeOption[] }>('/nodes/options');
return payload.nodes ?? [];
}
export type ServiceDomain = {
id: string;
service_id: string;
@@ -343,6 +356,7 @@ export type NodeAgentEntity = {
capabilities: AgentCapabilities;
resources: NodeResources;
autoPrune: boolean;
schedulable: boolean;
metadata: Record<string, unknown>;
lastHeartbeat?: string;
createdAt?: string;
@@ -680,6 +694,8 @@ function normalizeService(service: RawService): ServiceEntity | null {
staticDir: service.static_dir,
sleepEnabled: service.sleep_enabled,
sleepIdleMinutes: service.sleep_idle_minutes,
nodeId: service.node_id,
nodeName: service.node_name,
publicUrl: service.public_url,
};
}
@@ -1081,6 +1097,7 @@ function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
},
lastHeartbeat: agent.last_heartbeat,
autoPrune: agent.auto_prune ?? false,
schedulable: agent.schedulable ?? true,
metadata: (agent.metadata ?? {}) as Record<string, unknown>,
createdAt: agent.created_at,
updatedAt: agent.updated_at,
@@ -1135,6 +1152,16 @@ export async function pruneAgent(id: string, body?: { until?: string; volumes?:
});
}
export async function cordonAgent(id: string, cordoned: boolean): Promise<void> {
await requestJson(`/agents/${id}/${cordoned ? 'cordon' : 'uncordon'}`, { method: 'POST' });
}
export type DrainResult = { services_drained: number; services_unpinned: number };
export async function drainAgent(id: string): Promise<DrainResult> {
return requestJson<DrainResult>(`/agents/${id}/drain`, { method: 'POST' });
}
export async function getAgent(id: string): Promise<NodeAgentEntity | null> {
const payload = await requestJson<{ agent?: RawNodeAgent }>(`/agents/${id}`);
return payload.agent ? normalizeAgent(payload.agent) : null;