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"))