Skip to content
15 changes: 14 additions & 1 deletion docs/public/reference/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ After each poll, the agent sends a heartbeat (`POST /api/agent/heartbeat`) that
- Host system metrics (CPU, memory, disk, network)
- Recent stdout/stderr log lines from each pipeline process
- Agent and Vector version information
- Node labels (optional key-value metadata for selective deployment)

---

Expand All @@ -75,6 +76,7 @@ After each poll, the agent sends a heartbeat (`POST /api/agent/heartbeat`) that
| `VF_VECTOR_BIN` | No | `vector` | Path to the Vector binary. Use if Vector is not on the system `PATH`. |
| `VF_POLL_INTERVAL` | No | `15s` | How often to poll the server for config changes. Accepts Go duration syntax (e.g., `10s`, `1m`). |
| `VF_LOG_LEVEL` | No | `info` | Agent log level: `debug`, `info`, `warn`, `error` |
| `VF_LABELS` | No | -- | Comma-separated key=value pairs reported to the server on each heartbeat (e.g., `region=us-east-1,tier=production`). Labels set via the UI take precedence over agent-reported values. Used for selective pipeline deployment. |

{% hint style="warning" %}
`VF_URL` is the only strictly required variable. However, `VF_TOKEN` must be set on the first run for enrollment. After the agent writes its node token to disk, `VF_TOKEN` can be removed.
Expand Down Expand Up @@ -167,6 +169,10 @@ Key fields:
- **`certFiles`**: Certificate data written to `<VF_DATA_DIR>/certs/` before starting the pipeline.
- **`pendingAction`**: Server-initiated action (currently only `self_update`).

{% hint style="info" %}
When a pipeline has a **node selector** configured (via the deploy dialog), the config response only includes pipelines whose selector labels match this node's labels. A pipeline with no node selector deploys to all nodes.
{% endhint %}

{% hint style="info" %}
When a node is in **maintenance mode**, the config response returns an empty `pipelines` array. The agent stops all running pipelines but continues sending heartbeats. See [Fleet Management](../user-guide/fleet.md#maintenance-mode) for details.
{% endhint %}
Expand All @@ -177,6 +183,9 @@ Called after every poll. Sends status and metrics for all managed pipelines.

**Headers:** `Authorization: Bearer <node-token>`, `Content-Type: application/json`

Key fields:
- **`labels`** (optional): Key-value pairs describing this node. Labels set via the `VF_LABELS` environment variable are reported here. The server merges them with any labels set through the UI, with UI-set labels taking precedence.

**Request:**
```json
{
Expand Down Expand Up @@ -212,7 +221,11 @@ Called after every poll. Sends status and metrics for all managed pipelines.
},
"agentVersion": "0.5.0",
"vectorVersion": "vector 0.41.1",
"deploymentMode": "STANDALONE"
"deploymentMode": "STANDALONE",
"labels": {
"region": "us-east-1",
"tier": "production"
}
}
```

Expand Down
48 changes: 48 additions & 0 deletions docs/public/user-guide/fleet.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All enrolled agent nodes are displayed in a table with the following columns:
| **Name** | The node name. Click it to open the node detail page. You can rename nodes from the detail view. |
| **Host:Port** | The hostname or IP address and API port the agent is listening on. |
| **Environment** | The environment the node is enrolled in. |
| **Labels** | Key-value labels assigned to the node, shown as `key=value` badges. See [Node Labels](#node-labels) below. |
| **Version** | The Vector version running on the node. |
| **Agent Version** | The VectorFlow agent version, plus deployment mode (Docker or Binary). An **Update available** badge appears when a newer version exists. |
| **Status** | Current health status (see statuses below). |
Expand Down Expand Up @@ -104,6 +105,53 @@ Docker-based agents are updated by pulling the latest image. The **Update** butt

Below the node list, the **Pipeline Deployment Matrix** shows a grid of all deployed pipelines across all nodes in the environment. This lets you see at a glance which pipelines are running on which nodes and their current status.

## Node labels

Labels are key-value pairs you can attach to nodes for organization and selective deployment. Common uses include tagging nodes by region, role, tier, or any custom dimension relevant to your infrastructure.

### Viewing labels

Labels appear as `key=value` badges in the **Labels** column of the fleet table. Nodes with no labels show an empty column.

### Adding and editing labels

{% stepper %}
{% step %}
### Open the node detail page
Click a node name in the fleet table to open its detail page.
{% endstep %}
{% step %}
### Edit labels
In the **Labels** card, click the **Edit** button.
{% endstep %}
{% step %}
### Add or modify labels
Use the key-value input pairs to add, modify, or remove labels. Click **Add Label** to add a new pair, or click the **X** button to remove a row.
{% endstep %}
{% step %}
### Save
Click **Save Labels** to persist the changes.
{% endstep %}
{% endstepper %}

{% hint style="info" %}
Editing labels requires the **Editor** role or above on the team.
{% endhint %}

### Agent-reported labels

Agents can also report labels in their heartbeat payload. When a label is reported by the agent and also set via the UI, the **UI value takes precedence**. This lets you override agent-reported labels without them being overwritten on the next heartbeat.

### Selective deployment with labels

When deploying a pipeline, you can optionally restrict deployment to nodes matching specific labels. In the deploy dialog, the **Target Nodes** selector lets you pick from all labels in the environment. Selected labels are combined with AND logic -- a node must have all selected labels to receive the pipeline.

The deploy dialog shows a live count of matching nodes (e.g., "3 of 5 nodes match") so you can verify your selection before deploying. When no labels are selected, the pipeline deploys to all nodes in the environment (backward compatible).

{% hint style="warning" %}
Changing a pipeline's node selector on a subsequent deploy updates the targeting. Nodes that no longer match will stop the pipeline on their next poll.
{% endhint %}

## Maintenance mode

Maintenance mode lets you temporarily stop all pipelines on a node without removing it from the fleet. This is useful for host upgrades, kernel patches, disk maintenance, or any situation where you need the node idle but still connected.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "VectorNode" ADD COLUMN "labels" JSONB DEFAULT '{}';

-- AlterTable
ALTER TABLE "Pipeline" ADD COLUMN "nodeSelector" JSONB;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ model VectorNode {
pendingAction Json?
maintenanceMode Boolean @default(false)
maintenanceModeAt DateTime?
labels Json? @default("{}")
pipelineStatuses NodePipelineStatus[]
nodeMetrics NodeMetric[]
pipelineLogs PipelineLog[]
Expand Down Expand Up @@ -200,6 +201,7 @@ model Pipeline {
edges PipelineEdge[]
versions PipelineVersion[]
globalConfig Json?
nodeSelector Json?
isDraft Boolean @default(true)
isSystem Boolean @default(false)
deployedAt DateTime?
Expand Down
124 changes: 123 additions & 1 deletion src/app/(dashboard)/fleet/[nodeId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import { useParams, useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { ArrowLeft, ShieldOff, Trash2, Activity, Terminal, Server, Pencil, Check, X, Wrench } from "lucide-react";
import { ArrowLeft, ShieldOff, Trash2, Activity, Terminal, Server, Pencil, Check, X, Wrench, Plus, Tag } from "lucide-react";
import { NodeLogs } from "@/components/fleet/node-logs";
import { toast } from "sonner";
import { useState } from "react";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/ui/status-badge";
import { Input } from "@/components/ui/input";
Expand Down Expand Up @@ -57,6 +58,8 @@ export default function NodeDetailPage() {

const [isRenaming, setIsRenaming] = useState(false);
const [editName, setEditName] = useState("");
const [isEditingLabels, setIsEditingLabels] = useState(false);
const [editLabels, setEditLabels] = useState<Array<{ key: string; value: string }>>([]);

const nodeQuery = useQuery(
trpc.fleet.get.queryOptions(
Expand Down Expand Up @@ -141,6 +144,35 @@ export default function NodeDetailPage() {
}),
);

const labelsMutation = useMutation(
trpc.fleet.updateLabels.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.fleet.get.queryKey({ id: params.nodeId }) });
queryClient.invalidateQueries({ queryKey: trpc.fleet.list.queryKey() });
toast.success("Labels updated");
setIsEditingLabels(false);
},
}),
);

function handleStartEditLabels() {
const labels = (node?.labels as Record<string, string>) ?? {};
const entries = Object.entries(labels).map(([key, value]) => ({ key, value }));
if (entries.length === 0) entries.push({ key: "", value: "" });
setEditLabels(entries);
setIsEditingLabels(true);
}

function handleSaveLabels() {
if (!node) return;
const labels: Record<string, string> = {};
for (const { key, value } of editLabels) {
const k = key.trim();
if (k) labels[k] = value.trim();
}
labelsMutation.mutate({ nodeId: node.id, labels });
}

function handleMaintenanceToggle() {
if (!node) return;
if (!node.maintenanceMode) {
Expand Down Expand Up @@ -357,6 +389,96 @@ export default function NodeDetailPage() {
</CardContent>
</Card>

{/* Node Labels */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Tag className="h-5 w-5" />
Labels
</span>
{!isEditingLabels && (
<Button variant="outline" size="sm" onClick={handleStartEditLabels}>
<Pencil className="mr-1 h-3.5 w-3.5" />
Edit
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
{isEditingLabels ? (
<div className="space-y-3">
{editLabels.map((label, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
placeholder="Key"
value={label.key}
onChange={(e) => {
const next = [...editLabels];
next[idx] = { ...next[idx], key: e.target.value };
setEditLabels(next);
}}
className="flex-1"
/>
<span className="text-muted-foreground">=</span>
<Input
placeholder="Value"
value={label.value}
onChange={(e) => {
const next = [...editLabels];
next[idx] = { ...next[idx], value: e.target.value };
setEditLabels(next);
}}
className="flex-1"
/>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
setEditLabels(editLabels.filter((_, i) => i !== idx));
}}
aria-label="Remove label"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => setEditLabels([...editLabels, { key: "", value: "" }])}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Label
</Button>
<div className="flex items-center gap-2 pt-2">
<Button size="sm" onClick={handleSaveLabels} disabled={labelsMutation.isPending}>
{labelsMutation.isPending ? "Saving..." : "Save Labels"}
</Button>
<Button variant="outline" size="sm" onClick={() => setIsEditingLabels(false)}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
{Object.entries((node.labels as Record<string, string>) ?? {}).length > 0 ? (
Object.entries((node.labels as Record<string, string>) ?? {}).map(
([k, v]) => (
<Badge key={k} variant="outline">
{k}={v}
</Badge>
),
)
) : (
<p className="text-sm text-muted-foreground">No labels assigned</p>
)}
</div>
)}
</CardContent>
</Card>

</div>

<Separator />
Expand Down
12 changes: 12 additions & 0 deletions src/app/(dashboard)/fleet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export default function FleetPage() {
<TableHead>Name</TableHead>
<TableHead>Host:Port</TableHead>
<TableHead>Environment</TableHead>
<TableHead>Labels</TableHead>
<TableHead>Version</TableHead>
<TableHead>Agent Version</TableHead>
<TableHead>Status</TableHead>
Expand All @@ -143,6 +144,17 @@ export default function FleetPage() {
<TableCell>
<Badge variant="secondary">{node.environment.name}</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{Object.entries(
(node.labels as Record<string, string>) ?? {},
).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-xs">
{k}={v}
</Badge>
))}
</div>
</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{node.vectorVersion?.split(" ")[1] ?? "—"}
</TableCell>
Expand Down
15 changes: 13 additions & 2 deletions src/app/api/agent/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function GET(request: Request) {
// Fetch the node to check for pending actions (e.g., self-update)
const node = await prisma.vectorNode.findUnique({
where: { id: agent.nodeId },
select: { pendingAction: true, maintenanceMode: true },
select: { pendingAction: true, maintenanceMode: true, labels: true },
});

if (node?.maintenanceMode) {
Expand Down Expand Up @@ -53,7 +53,7 @@ export async function GET(request: Request) {
// Agents receive the config snapshot from PipelineVersion — NOT live node/edge
// data — so that saving in the editor doesn't affect agents until an explicit
// deploy confirms the change.
const pipelines = await prisma.pipeline.findMany({
const deployedPipelines = await prisma.pipeline.findMany({
where: {
environmentId: agent.environmentId,
isDraft: false,
Expand All @@ -62,6 +62,7 @@ export async function GET(request: Request) {
select: {
id: true,
name: true,
nodeSelector: true,
versions: {
orderBy: { version: "desc" },
take: 1,
Expand All @@ -70,6 +71,16 @@ export async function GET(request: Request) {
},
});

// Filter pipelines by nodeSelector matching this node's labels.
// Pipelines without a nodeSelector (or empty selector) deploy to all nodes.
const nodeLabels = (node?.labels as Record<string, string>) ?? {};
const pipelines = deployedPipelines.filter((p) => {
const selector = (p.nodeSelector as Record<string, string>) ?? {};
return Object.entries(selector).every(
([key, value]) => nodeLabels[key] === value,
);
});

const pipelineConfigs = [];
const certBasePath = "/var/lib/vf-agent/certs";

Expand Down
15 changes: 14 additions & 1 deletion src/app/api/agent/heartbeat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ const heartbeatSchema = z.object({
error: z.string().optional(),
})).optional(),
updateError: z.string().max(500).optional(),
labels: z.record(z.string(), z.string()).optional(),
});

let lastCleanup = 0;
Expand Down Expand Up @@ -151,7 +152,7 @@ export async function POST(request: Request) {
}

// Update node heartbeat and metadata
await prisma.vectorNode.update({
const node = await prisma.vectorNode.update({
where: { id: agent.nodeId },
data: {
lastHeartbeat: now,
Expand All @@ -166,6 +167,18 @@ export async function POST(request: Request) {
},
});

// Merge agent-reported labels with existing UI-set labels.
// UI-set labels take precedence over agent-reported labels.
// Uses a single atomic operation to avoid TOCTOU race with fleet.updateLabels:
// agent labels are the base, existing DB labels override on top.
if (parsed.data.labels) {
await prisma.$executeRaw`
UPDATE "VectorNode"
SET labels = ${JSON.stringify(parsed.data.labels)}::jsonb || labels
WHERE id = ${node.id}
`;
}

// Read previous snapshots BEFORE upserting so we can compute deltas correctly
const prevSnapshots = new Map<string, {
eventsIn: bigint;
Expand Down
Loading
Loading