diff --git a/web/components/server/server-security-page.tsx b/web/components/server/server-security-page.tsx
new file mode 100644
index 00000000..c56039a9
--- /dev/null
+++ b/web/components/server/server-security-page.tsx
@@ -0,0 +1,420 @@
+"use client";
+
+import {
+ type Activity,
+ AlertTriangle,
+ Ban,
+ CheckCircle2,
+ Clock3,
+ Database,
+ Radio,
+ ShieldCheck,
+ XCircle,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import useSWR from "swr";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { StatusBadge } from "@/components/ui/status-badge";
+import type {
+ CrowdSecAlert,
+ CrowdSecDecision,
+ CrowdSecHealth,
+} from "@/db/schema";
+import { formatDateTime, formatRelativeTime, getTimestamp } from "@/lib/date";
+import { fetcher } from "@/lib/fetcher";
+
+type ServerStatus = "pending" | "online" | "offline" | "unknown";
+type HealthState = "healthy" | "degraded" | "stale" | "not-reported";
+
+type SecurityStatusResponse = {
+ status: ServerStatus;
+ crowdsecHealth: CrowdSecHealth | null;
+};
+
+const STALE_AFTER_MS = 120_000;
+
+const statePresentation = {
+ healthy: {
+ label: "Healthy",
+ icon: CheckCircle2,
+ className: "text-emerald-600 dark:text-emerald-400",
+ },
+ degraded: {
+ label: "Degraded",
+ icon: AlertTriangle,
+ className: "text-amber-600 dark:text-amber-400",
+ },
+ stale: {
+ label: "Stale",
+ icon: Clock3,
+ className: "text-amber-600 dark:text-amber-400",
+ },
+ "not-reported": {
+ label: "Not reported",
+ icon: XCircle,
+ className: "text-muted-foreground",
+ },
+} satisfies Record
;
+
+function Status({ state }: { state: HealthState }) {
+ const presentation = statePresentation[state];
+ return (
+
+ );
+}
+
+function isOlderThan(value: string | undefined, now: number) {
+ const timestamp = getTimestamp(value);
+ return !Number.isFinite(timestamp) || now - timestamp > STALE_AFTER_MS;
+}
+
+function getOverallState(
+ status: ServerStatus,
+ health: CrowdSecHealth | null,
+ now: number,
+): HealthState {
+ if (!health) return "not-reported";
+ if (status !== "online" || isOlderThan(health.checkedAt, now)) return "stale";
+
+ const bouncerFailed =
+ !health.bouncer.available ||
+ !health.bouncer.registered ||
+ health.bouncer.revoked ||
+ isOlderThan(health.bouncer.lastPullAt, now);
+ if (
+ !health.lapi.available ||
+ !health.metrics.available ||
+ bouncerFailed ||
+ !health.decisions.available ||
+ !health.alerts.available
+ ) {
+ return "degraded";
+ }
+ return "healthy";
+}
+
+function ComponentCard({
+ title,
+ description,
+ available,
+ icon: Icon,
+ children,
+}: {
+ title: string;
+ description: string;
+ available: boolean;
+ icon: typeof Activity;
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {title}
+
+ {description}
+
+
+
+ {children && {children} }
+
+ );
+}
+
+function DateValue({ value }: { value?: string }) {
+ if (!value) return Never ;
+ return (
+
+ {formatRelativeTime(value)} ({formatDateTime(value)})
+
+ );
+}
+
+function formatBouncerError(error: string) {
+ switch (error) {
+ case "command_failed":
+ return "CrowdSec status command failed";
+ case "invalid_output":
+ return "CrowdSec returned an invalid status response";
+ default:
+ return "CrowdSec bouncer status is unavailable";
+ }
+}
+
+export function ServerSecurityPage({
+ serverId,
+ initialServerStatus,
+ initialHealth,
+}: {
+ serverId: string;
+ initialServerStatus: ServerStatus;
+ initialHealth: CrowdSecHealth | null;
+}) {
+ const { data } = useSWR(
+ `/api/servers/${serverId}/security`,
+ fetcher,
+ { refreshInterval: 10_000 },
+ );
+ const status = data === undefined ? initialServerStatus : data.status;
+ const health = data === undefined ? initialHealth : data.crowdsecHealth;
+ const [now, setNow] = useState(null);
+ useEffect(() => {
+ const refreshNow = () => setNow(Date.now());
+ refreshNow();
+ const interval = window.setInterval(refreshNow, 10_000);
+ return () => window.clearInterval(interval);
+ }, []);
+ // Match the snapshot on the server render; the client clock takes over after
+ // hydration so stale state advances without creating a hydration mismatch.
+ const currentTime = now ?? getTimestamp(health?.checkedAt, 0);
+ const overallState = getOverallState(status, health, currentTime);
+ const bouncerAvailable = Boolean(
+ health?.bouncer.available &&
+ health.bouncer.registered &&
+ !health.bouncer.revoked &&
+ !isOlderThan(health.bouncer.lastPullAt, currentTime),
+ );
+ const bouncerRegistration = health?.bouncer.revoked
+ ? "Revoked"
+ : health?.bouncer.registered
+ ? "Registered"
+ : "Missing";
+
+ return (
+
+
+
+
+
+
+ CrowdSec Protection
+
+
+ Threat detection and automated blocking for public traffic.
+
+
+
+
+
+ Last checked:
+
+
+
+
+
+
+
+ {[
+ ["Read", health?.metrics.reads],
+ ["Parsed", health?.metrics.parsed],
+ ["Unparsed", health?.metrics.unparsed],
+ ].map(([label, value]) => (
+
+
{label}
+
+ {value ?? "—"}
+
+
+ ))}
+
+
+
+
+
+
Registration
+ {bouncerRegistration}
+
+
+
Last decision pull
+
+
+
+
+ {health?.bouncer.error && (
+
+ {formatBouncerError(health.bouncer.error)}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+type SecurityListProps =
+ | {
+ title: string;
+ description: string;
+ available: boolean;
+ truncated: boolean;
+ records: CrowdSecDecision[];
+ kind: "decisions";
+ }
+ | {
+ title: string;
+ description: string;
+ available: boolean;
+ truncated: boolean;
+ records: CrowdSecAlert[];
+ kind: "alerts";
+ };
+
+function SecurityList(props: SecurityListProps) {
+ const { title, description, available, truncated, records, kind } = props;
+ return (
+
+
+ {title}
+ {description}
+
+
+ {!available ? (
+
+ {title} are unavailable from the latest check.
+
+ ) : records.length === 0 ? (
+
+ {kind === "decisions"
+ ? "No active blocks."
+ : "No threats detected in the last 24 hours."}
+
+ ) : kind === "decisions" ? (
+
+ ) : (
+
+ )}
+ {available && truncated && (
+
+ Showing the newest reported records; additional results were
+ truncated.
+
+ )}
+
+
+ );
+}
+
+function DecisionRows({ records }: { records: CrowdSecDecision[] }) {
+ return (
+
+
+ Active CrowdSec blocks
+
+
+ Target
+ Action
+ Reason
+ Origin
+ Expiry
+
+
+
+ {records.map((record, index) => (
+
+
+
+ {record.scope}
+
+ {record.value}
+
+ {record.action}
+ {record.reason || "—"}
+ {record.origin || "—"}
+
+
+
+
+ ))}
+
+
+
+ );
+}
+
+function AlertRows({ records }: { records: CrowdSecAlert[] }) {
+ return (
+
+
+ Recent CrowdSec threats
+
+
+ Detected
+ Scenario
+ Source IP
+ Country
+ Events
+
+
+
+ {records.map((record) => (
+
+
+
+
+ {record.scenario || "—"}
+ {record.sourceIp || "—"}
+ {record.country || "—"}
+
+ {record.eventCount}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/web/components/server/server-tabs.tsx b/web/components/server/server-tabs.tsx
index 9f7ee806..4874983b 100644
--- a/web/components/server/server-tabs.tsx
+++ b/web/components/server/server-tabs.tsx
@@ -4,13 +4,20 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
-export function ServerTabs({ serverId }: { serverId: string }) {
+export function ServerTabs({
+ serverId,
+ isProxy,
+}: {
+ serverId: string;
+ isProxy: boolean;
+}) {
const pathname = usePathname();
const basePath = `/dashboard/servers/${serverId}`;
const tabs = [
{ name: "Overview", href: basePath },
{ name: "Metrics", href: `${basePath}/metrics` },
{ name: "Logs", href: `${basePath}/logs` },
+ ...(isProxy ? [{ name: "Security", href: `${basePath}/security` }] : []),
{ name: "Settings", href: `${basePath}/settings` },
];
diff --git a/web/db/queries.ts b/web/db/queries.ts
index b3277037..410c79bc 100644
--- a/web/db/queries.ts
+++ b/web/db/queries.ts
@@ -17,7 +17,6 @@ import {
services,
settings,
} from "@/db/schema";
-import type { HealthStats } from "@/db/types";
import type {
ControlPlaneUpdateState,
ControlPlaneUpgradeState,
@@ -33,10 +32,6 @@ import {
DEFAULT_SMTP_PORT,
DEFAULT_SMTP_TIMEOUT,
} from "@/lib/settings-keys";
-import {
- type NodeMetricsSnapshot,
- queryNodeMetricsSnapshots,
-} from "@/lib/victoria-metrics";
export async function listProjects() {
const [projectList, serviceCounts, onlineCounts, environmentCounts] =
@@ -149,6 +144,7 @@ export const getServerDetails = cache(async (id: string) => {
networkHealth: servers.networkHealth,
containerHealth: servers.containerHealth,
agentHealth: servers.agentHealth,
+ crowdsecHealth: servers.crowdsecHealth,
agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion,
agentUpgradeStatus: servers.agentUpgradeStatus,
agentUpgradeStartedAt: servers.agentUpgradeStartedAt,
@@ -165,49 +161,14 @@ export async function getClusterHealth() {
const allServers = await db
.select({
id: servers.id,
- name: servers.name,
status: servers.status,
networkHealth: servers.networkHealth,
containerHealth: servers.containerHealth,
agentHealth: servers.agentHealth,
- agentUpgradeTargetVersion: servers.agentUpgradeTargetVersion,
- agentUpgradeStatus: servers.agentUpgradeStatus,
- agentUpgradeStartedAt: servers.agentUpgradeStartedAt,
- agentUpgradeError: servers.agentUpgradeError,
})
.from(servers);
const onlineServers = allServers.filter((s) => s.status === "online");
- const metricsByServer = await queryNodeMetricsSnapshots(
- onlineServers.map((server) => server.id),
- ).catch((error) => {
- console.error("[cluster-health] failed to query metrics:", error);
- return new Map();
- });
-
- const serversWithHealth = allServers.map((server) => ({
- ...server,
- healthStats: metricSnapshotToHealthStats(metricsByServer.get(server.id)),
- }));
- const serversWithCurrentMetrics = serversWithHealth.filter(
- (server) => server.status === "online" && server.healthStats,
- );
-
- let avgCpuUsage = 0;
- let avgMemoryUsage = 0;
-
- if (serversWithCurrentMetrics.length > 0) {
- const cpuSum = serversWithCurrentMetrics.reduce(
- (sum, s) => sum + (s.healthStats?.cpuUsagePercent ?? 0),
- 0,
- );
- const memSum = serversWithCurrentMetrics.reduce(
- (sum, s) => sum + (s.healthStats?.memoryUsagePercent ?? 0),
- 0,
- );
- avgCpuUsage = cpuSum / serversWithCurrentMetrics.length;
- avgMemoryUsage = memSum / serversWithCurrentMetrics.length;
- }
const networkHealthy = onlineServers.filter(
(s) => s.networkHealth?.tunnelUp,
@@ -220,44 +181,15 @@ export async function getClusterHealth() {
summary: {
totalServers: allServers.length,
onlineServers: onlineServers.length,
- avgCpuUsage,
- avgMemoryUsage,
networkHealthy,
containerHealthy,
},
- servers: serversWithHealth,
- };
-}
-
-export function metricSnapshotToHealthStats(
- snapshot:
- | {
- cpuUsagePercent: number | null;
- memoryUsagePercent: number | null;
- memoryUsedBytes: number | null;
- diskUsagePercent: number | null;
- diskUsedBytes: number | null;
- }
- | null
- | undefined,
-): HealthStats | null {
- if (!snapshot) return null;
- if (
- snapshot.cpuUsagePercent === null &&
- snapshot.memoryUsagePercent === null &&
- snapshot.memoryUsedBytes === null &&
- snapshot.diskUsagePercent === null &&
- snapshot.diskUsedBytes === null
- ) {
- return null;
- }
-
- return {
- cpuUsagePercent: snapshot.cpuUsagePercent ?? 0,
- memoryUsagePercent: snapshot.memoryUsagePercent ?? 0,
- memoryUsedMb: Math.round((snapshot.memoryUsedBytes ?? 0) / 1024 / 1024),
- diskUsagePercent: snapshot.diskUsagePercent ?? 0,
- diskUsedGb: Math.round((snapshot.diskUsedBytes ?? 0) / 1024 / 1024 / 1024),
+ servers: allServers.map((server) => ({
+ id: server.id,
+ networkHealth: server.networkHealth,
+ containerHealth: server.containerHealth,
+ agentHealth: server.agentHealth,
+ })),
};
}
diff --git a/web/db/schema.ts b/web/db/schema.ts
index 1c48e43c..221eaeaa 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -352,6 +352,52 @@ export type AgentHealth = {
capabilities?: string[];
};
+export type CrowdSecDecision = {
+ scope: string;
+ value: string;
+ action: string;
+ reason: string;
+ origin: string;
+ expiresAt?: string;
+};
+
+export type CrowdSecAlert = {
+ id: number;
+ detectedAt: string;
+ scenario: string;
+ sourceIp: string;
+ country: string;
+ eventCount: number;
+};
+
+export type CrowdSecHealth = {
+ checkedAt: string;
+ lapi: { available: boolean };
+ metrics: {
+ available: boolean;
+ reads: number;
+ parsed: number;
+ unparsed: number;
+ };
+ bouncer: {
+ available: boolean;
+ error?: string;
+ registered: boolean;
+ revoked: boolean;
+ lastPullAt?: string;
+ };
+ decisions: {
+ available: boolean;
+ truncated: boolean;
+ records: CrowdSecDecision[];
+ };
+ alerts: {
+ available: boolean;
+ truncated: boolean;
+ records: CrowdSecAlert[];
+ };
+};
+
export type AgentUpgradeStatus =
| "idle"
| "queued"
@@ -380,6 +426,7 @@ export const servers = pgTable("servers", {
networkHealth: jsonb("network_health").$type(),
containerHealth: jsonb("container_health").$type(),
agentHealth: jsonb("agent_health").$type(),
+ crowdsecHealth: jsonb("crowdsec_health").$type(),
agentUpgradeTargetVersion: text("agent_upgrade_target_version"),
agentUpgradeStatus: text("agent_upgrade_status", {
enum: ["idle", "queued", "upgrading", "succeeded", "failed"],
diff --git a/web/db/types.ts b/web/db/types.ts
index 1cc45813..49a9811b 100644
--- a/web/db/types.ts
+++ b/web/db/types.ts
@@ -41,14 +41,6 @@ export type DeploymentStatus = NonNullable;
export type RolloutStatus = NonNullable;
export type BuildStatus = NonNullable;
-export type HealthStats = {
- cpuUsagePercent: number;
- memoryUsagePercent: number;
- memoryUsedMb: number;
- diskUsagePercent: number;
- diskUsedGb: number;
-};
-
export type ServiceWithDetails = Service & {
activeConfig?: DeployedConfig | null;
currentSource: SourceConfig;
diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts
index 77e49cef..d5cd6462 100644
--- a/web/lib/agent-status.ts
+++ b/web/lib/agent-status.ts
@@ -3,6 +3,7 @@ import { db } from "@/db";
import {
type AgentHealth,
type ContainerHealth,
+ type CrowdSecHealth,
deployments,
type NetworkHealth,
rollouts,
@@ -654,6 +655,7 @@ export type StatusReport = {
networkHealth?: NetworkHealth;
containerHealth?: ContainerHealth;
agentHealth?: AgentHealth;
+ crowdsecHealth?: CrowdSecHealth;
deploymentErrors?: DeploymentError[];
};
@@ -696,6 +698,9 @@ export async function applyStatusReport(
if (report.containerHealth) {
updateData.containerHealth = report.containerHealth;
}
+ if (report.crowdsecHealth) {
+ updateData.crowdsecHealth = report.crowdsecHealth;
+ }
if (report.agentHealth) {
updateData.agentHealth = report.agentHealth;
diff --git a/web/lib/inngest/functions/service-deletion-workflow.ts b/web/lib/inngest/functions/service-deletion-workflow.ts
index 847359db..15bed589 100644
--- a/web/lib/inngest/functions/service-deletion-workflow.ts
+++ b/web/lib/inngest/functions/service-deletion-workflow.ts
@@ -117,9 +117,16 @@ export const serviceDeletionWorkflow = inngest.createFunction(
const createdBackupIds = await step.run(
"start-delete-backups",
async () => {
- const deployment = setup.runningDeployment;
- if (!deployment?.containerId) {
- throw new Error("No active deployment found for deletion backup");
+ const backupTarget = setup.runningDeployment?.containerId
+ ? setup.runningDeployment
+ : setup.service.lockedServerId
+ ? {
+ serverId: setup.service.lockedServerId,
+ containerId: null,
+ }
+ : null;
+ if (!backupTarget) {
+ throw new Error("No server found for deletion backup");
}
const ids: string[] = [];
@@ -133,16 +140,16 @@ export const serviceDeletionWorkflow = inngest.createFunction(
volumeId: volume.id,
volumeName: volume.name,
serviceId,
- serverId: deployment.serverId,
+ serverId: backupTarget.serverId,
status: "pending",
storagePath,
isDeletionBackup: true,
});
- await enqueueWork(deployment.serverId, "backup_volume", {
+ await enqueueWork(backupTarget.serverId, "backup_volume", {
backupId,
serviceId,
- containerId: deployment.containerId,
+ containerId: backupTarget.containerId,
volumeName: volume.name,
storagePath,
storageConfig: {
diff --git a/web/tests/agent-status.test.ts b/web/tests/agent-status.test.ts
index e7553218..c7a0c54b 100644
--- a/web/tests/agent-status.test.ts
+++ b/web/tests/agent-status.test.ts
@@ -2,11 +2,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const selectResults: unknown[][] = [];
+ const updateData: unknown[] = [];
function createQuery(result: unknown[] = []) {
const query = {
from: vi.fn(() => query),
- set: vi.fn(() => query),
+ set: vi.fn((data: unknown) => {
+ updateData.push(data);
+ return query;
+ }),
where: vi.fn(() => query),
returning: vi.fn(() => query),
// biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable.
@@ -21,6 +25,7 @@ const mocks = vi.hoisted(() => {
return {
selectResults,
+ updateData,
db: {
select: vi.fn(() => createQuery(selectResults.shift() ?? [])),
update: vi.fn(() => createQuery()),
@@ -57,11 +62,72 @@ import { inngest } from "@/lib/inngest/client";
beforeEach(() => {
mocks.selectResults.length = 0;
+ mocks.updateData.length = 0;
mocks.db.select.mockClear();
mocks.db.update.mockClear();
mocks.db.delete.mockClear();
});
+describe("agent status CrowdSec health", () => {
+ it("persists a supplied snapshot unchanged and preserves it when omitted", async () => {
+ const crowdsecHealth = {
+ checkedAt: "2026-08-04T12:00:00Z",
+ lapi: { available: true },
+ metrics: {
+ available: true,
+ reads: 120,
+ parsed: 115,
+ unparsed: 5,
+ },
+ bouncer: {
+ available: true,
+ registered: true,
+ revoked: false,
+ lastPullAt: "2026-08-04T11:59:00Z",
+ },
+ decisions: {
+ available: true,
+ truncated: false,
+ records: [
+ {
+ scope: "Ip",
+ value: "192.0.2.1",
+ action: "ban",
+ reason: "test-scenario",
+ origin: "crowdsec",
+ expiresAt: "2026-08-04T13:00:00Z",
+ },
+ ],
+ },
+ alerts: {
+ available: true,
+ truncated: false,
+ records: [
+ {
+ id: 42,
+ detectedAt: "2026-08-04T11:58:00Z",
+ scenario: "test-scenario",
+ sourceIp: "192.0.2.1",
+ country: "US",
+ eventCount: 3,
+ },
+ ],
+ },
+ };
+
+ await applyStatusReport("server_1", { containers: [], crowdsecHealth });
+
+ expect(mocks.updateData[0]).toEqual(
+ expect.objectContaining({ crowdsecHealth }),
+ );
+
+ mocks.updateData.length = 0;
+ await applyStatusReport("server_1", { containers: [] });
+
+ expect(mocks.updateData[0]).not.toHaveProperty("crowdsecHealth");
+ });
+});
+
describe("agent status serverless attachment", () => {
it("does not attach reported containers to sleeping deployments", () => {
expect(shouldAttachReportedContainer("pending")).toBe(true);