diff --git a/prisma/migrations/20260307000000_add_node_maintenance_mode/migration.sql b/prisma/migrations/20260307000000_add_node_maintenance_mode/migration.sql
new file mode 100644
index 00000000..a7ad5937
--- /dev/null
+++ b/prisma/migrations/20260307000000_add_node_maintenance_mode/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "VectorNode" ADD COLUMN "maintenanceMode" BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE "VectorNode" ADD COLUMN "maintenanceModeAt" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 74048c4f..f0db424c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -105,6 +105,8 @@ model VectorNode {
os String?
deploymentMode DeploymentMode @default(UNKNOWN)
pendingAction Json?
+ maintenanceMode Boolean @default(false)
+ maintenanceModeAt DateTime?
pipelineStatuses NodePipelineStatus[]
nodeMetrics NodeMetric[]
pipelineLogs PipelineLog[]
diff --git a/src/app/(dashboard)/fleet/[nodeId]/page.tsx b/src/app/(dashboard)/fleet/[nodeId]/page.tsx
index 04e8ac8d..fe7e0b47 100644
--- a/src/app/(dashboard)/fleet/[nodeId]/page.tsx
+++ b/src/app/(dashboard)/fleet/[nodeId]/page.tsx
@@ -3,7 +3,7 @@
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 } from "lucide-react";
+import { ArrowLeft, ShieldOff, Trash2, Activity, Terminal, Server, Pencil, Check, X, Wrench } from "lucide-react";
import { NodeLogs } from "@/components/fleet/node-logs";
import { toast } from "sonner";
import { useState } from "react";
@@ -131,6 +131,32 @@ export default function NodeDetailPage() {
})
);
+ const maintenanceMutation = useMutation(
+ trpc.fleet.setMaintenanceMode.mutationOptions({
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: trpc.fleet.get.queryKey({ id: params.nodeId }) });
+ queryClient.invalidateQueries({ queryKey: trpc.fleet.list.queryKey() });
+ queryClient.invalidateQueries({ queryKey: trpc.fleet.listWithPipelineStatus.queryKey() });
+ },
+ }),
+ );
+
+ function handleMaintenanceToggle() {
+ if (!node) return;
+ if (!node.maintenanceMode) {
+ const runningCount = node.pipelineStatuses.filter(
+ (s) => s.status === "RUNNING"
+ ).length;
+ if (!confirm(
+ `Enter maintenance mode for "${node.name}"?\n\nThis will stop ${runningCount} running pipeline(s) on this node. Pipelines will automatically resume when maintenance mode is turned off.`
+ )) return;
+ }
+ maintenanceMutation.mutate({
+ nodeId: node.id,
+ enabled: !node.maintenanceMode,
+ });
+ }
+
function handleRevoke() {
if (!node) return;
if (!confirm(`Revoke token for "${node.name}"? The agent will no longer be able to connect.`)) {
@@ -217,6 +243,19 @@ export default function NodeDetailPage() {
+
{node.nodeTokenHash && (
+ {node.maintenanceMode && (
+
+
+
+
+ This node is in maintenance mode
+
+
+ All pipelines are stopped. They will automatically resume when maintenance mode is turned off.
+
+
+
+
+ )}
+
{/* Node Details */}
diff --git a/src/app/(dashboard)/fleet/page.tsx b/src/app/(dashboard)/fleet/page.tsx
index e74d6483..b20644e7 100644
--- a/src/app/(dashboard)/fleet/page.tsx
+++ b/src/app/(dashboard)/fleet/page.tsx
@@ -23,6 +23,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
+import { Wrench } from "lucide-react";
import { DeploymentMatrix } from "@/components/fleet/deployment-matrix";
import { formatLastSeen } from "@/lib/format";
import { nodeStatusVariant, nodeStatusLabel } from "@/lib/status";
@@ -87,6 +88,15 @@ export default function FleetPage() {
}),
);
+ const setMaintenance = useMutation(
+ trpc.fleet.setMaintenanceMode.mutationOptions({
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: trpc.fleet.list.queryKey() });
+ queryClient.invalidateQueries({ queryKey: trpc.fleet.listWithPipelineStatus.queryKey() });
+ },
+ }),
+ );
+
return (
{isLoading ? (
@@ -161,54 +171,83 @@ export default function FleetPage() {
-
- {nodeStatusLabel(node.status)}
-
+ {node.maintenanceMode ? (
+
+
+ Maintenance
+
+ ) : (
+
+ {nodeStatusLabel(node.status)}
+
+ )}
{formatLastSeen(node.lastSeen)}
- {node.pendingAction ? (
-
- Update pending...
-
- ) : node.deploymentMode === "DOCKER" ? (
- getNodeLatest(node).version &&
- node.agentVersion &&
- isVersionOlder(node.agentVersion, getNodeLatest(node).version ?? "") ? (
-
-
-
-
-
-
- Update via Docker image pull
-
- ) : null
- ) : getNodeLatest(node).version &&
- node.agentVersion &&
- isVersionOlder(node.agentVersion, getNodeLatest(node).version ?? "") ? (
+
- ) : null}
+ {node.pendingAction ? (
+
+ Update pending...
+
+ ) : node.deploymentMode === "DOCKER" ? (
+ getNodeLatest(node).version &&
+ node.agentVersion &&
+ isVersionOlder(node.agentVersion, getNodeLatest(node).version ?? "") ? (
+
+
+
+
+
+
+ Update via Docker image pull
+
+ ) : null
+ ) : getNodeLatest(node).version &&
+ node.agentVersion &&
+ isVersionOlder(node.agentVersion, getNodeLatest(node).version ?? "") ? (
+
+ ) : null}
+
))}
diff --git a/src/app/api/agent/config/route.ts b/src/app/api/agent/config/route.ts
index 7dec76ed..4f3beded 100644
--- a/src/app/api/agent/config/route.ts
+++ b/src/app/api/agent/config/route.ts
@@ -16,9 +16,26 @@ 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 },
+ select: { pendingAction: true, maintenanceMode: true },
});
+ if (node?.maintenanceMode) {
+ const environment = await prisma.environment.findUnique({
+ where: { id: agent.environmentId },
+ select: { secretBackend: true },
+ });
+ const settings = await prisma.systemSettings.findUnique({
+ where: { id: "singleton" },
+ select: { fleetPollIntervalMs: true },
+ });
+ return NextResponse.json({
+ pipelines: [],
+ pollIntervalMs: settings?.fleetPollIntervalMs ?? 15_000,
+ secretBackend: environment?.secretBackend ?? "BUILTIN",
+ pendingAction: node.pendingAction ?? undefined,
+ });
+ }
+
const environment = await prisma.environment.findUnique({
where: { id: agent.environmentId },
select: {
diff --git a/src/components/fleet/deployment-matrix.tsx b/src/components/fleet/deployment-matrix.tsx
index b10da0a7..1fc0b58e 100644
--- a/src/components/fleet/deployment-matrix.tsx
+++ b/src/components/fleet/deployment-matrix.tsx
@@ -3,7 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Badge } from "@/components/ui/badge";
-import { Minus } from "lucide-react";
+import { Minus, Wrench } from "lucide-react";
import Link from "next/link";
import { StatusDot } from "@/components/ui/status-dot";
import { pipelineStatusVariant, pipelineStatusLabel } from "@/lib/status";
@@ -51,10 +51,18 @@ export function DeploymentMatrix({ environmentId }: DeploymentMatrixProps) {
{nodes.map((node) => (
{node.name}
{node.host}
+ {node.maintenanceMode && (
+
+
+ Maintenance
+
+ )}
|
))}
@@ -79,7 +87,7 @@ export function DeploymentMatrix({ environmentId }: DeploymentMatrixProps) {
if (!ps) {
return (
-
+ |
@@ -90,7 +98,7 @@ export function DeploymentMatrix({ environmentId }: DeploymentMatrixProps) {
const isOutdated = ps.version < pipeline.latestVersion;
return (
- |
+ |
{isOutdated ? (
{
+ const node = await prisma.vectorNode.findUnique({
+ where: { id: input.nodeId },
+ });
+ if (!node) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Node not found" });
+ }
+ return prisma.vectorNode.update({
+ where: { id: input.nodeId },
+ data: {
+ maintenanceMode: input.enabled,
+ maintenanceModeAt: input.enabled ? new Date() : null,
+ },
+ });
+ }),
+
listWithPipelineStatus: protectedProcedure
.input(z.object({ environmentId: z.string() }))
.use(withTeamAccess("VIEWER"))
|