Compare commits

...
15 changed files with 899 additions and 59 deletions

No files matched your search

@@ -0,0 +1,213 @@
/**
* Agent Network prompt-cache accounting spec.
*
* Drives the Usage & Logs page against mocked agent-network endpoints whose
* rows carry the cache fields (cached_input_tokens / cache_creation_tokens /
* cache_cost_usd) plus the per-bucket cost breakdown, and verifies the hover
* breakdowns: the access-log Tokens tooltip gains cache read/write rows and a
* cache-aware total, the Cost tooltip lists one line per billed bucket, and
* the usage overview's daily table
* surfaces the day's cache buckets. Route mocks keep the spec hermetic, so it
* passes against management builds that predate the cache fields.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp, navigateTo } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// The reported field scenario: a session-start request that wrote a ~32k-token
// prompt cache, and a follow-up that read it back.
const writeEntry = {
id: "anlog-cache-write",
service_id: "svc-e2e",
timestamp: new Date().toISOString(),
status_code: 200,
duration_ms: 29000,
input_tokens: 10,
output_tokens: 1472,
total_tokens: 33591,
cached_input_tokens: 0,
cache_creation_tokens: 32109,
cost_usd: 0.142519,
cache_cost_usd: 0.120409,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.02208,
provider: "bedrock",
model: "anthropic.claude-sonnet-4-5",
session_id: "sess-cache-write",
decision: "allow",
stream: false,
};
const readEntry = {
...writeEntry,
id: "anlog-cache-read",
output_tokens: 800,
total_tokens: 32919,
cached_input_tokens: 32109,
cache_creation_tokens: 0,
cost_usd: 0.021663,
cache_cost_usd: 0.009633,
input_cost_usd: 0.00003,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0,
output_cost_usd: 0.012,
session_id: "sess-cache-read",
};
const todayBucket = {
period_start: new Date().toISOString().slice(0, 10),
input_tokens: 20,
output_tokens: 2272,
total_tokens: 66510,
cached_input_tokens: 32109,
cache_creation_tokens: 32109,
cost_usd: 0.164182,
cache_cost_usd: 0.130042,
input_cost_usd: 0.00006,
cached_input_cost_usd: 0.009633,
cache_creation_cost_usd: 0.120409,
output_cost_usd: 0.03408,
};
async function mockCacheUsage(page: Page) {
await page.route("**/api/agent-network/access-logs*", (route) =>
route.fulfill({
json: {
data: [writeEntry, readEntry],
page: 1,
page_size: 25,
total_records: 2,
total_pages: 1,
},
}),
);
await page.route("**/api/agent-network/usage/overview*", (route) =>
route.fulfill({ json: [todayBucket] }),
);
}
async function newUsagePage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
}> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await mockCacheUsage(page);
await loginToApp(page, "owner");
return { page, close: () => context.close() };
}
// FullTooltip force-mounts its content, so a tooltip keeps its role=tooltip
// element in the DOM after closing — a bare getByRole("tooltip") accumulates
// every tooltip the test has ever opened. Radix puts role=tooltip on a
// visually-hidden span and the open/closed flag on that span's parent
// ("delayed-open"/"instant-open" vs "closed"), so match the parent that is
// currently open. Its text covers both the visible copy and the hidden one.
function tooltip(page: Page) {
return page.locator(
'[data-state="delayed-open"]:has(> [role="tooltip"]), [data-state="instant-open"]:has(> [role="tooltip"])',
);
}
// Radix's hoverable content holds a tooltip open while the pointer travels
// toward it, so moving straight to the next cell can leave two open at once.
// Escape dismisses the open one deterministically, wherever the pointer sits.
async function closeTooltip(page: Page) {
await page.keyboard.press("Escape");
await expect(tooltip(page)).toHaveCount(0);
}
test.describe.serial("Agent Network cache accounting @agent-network", () => {
test("access-log hover breaks out prompt-cache tokens and cost", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage?tab=access-logs");
const writeRow = page.getByRole("row").filter({ hasText: "$0.1425" });
await expect(writeRow).toBeVisible();
// Tokens tooltip: cache-write bucket plus a total that includes it.
await writeRow.getByText("1,472").hover();
await expect(tooltip(page)).toContainText("32,109");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("33,591");
// Cost tooltip: all four buckets are listed plus the total, including
// buckets that cost nothing — this request wrote the cache but never read
// it, so "cache read" is present with a $0.0000 amount.
await closeTooltip(page);
await writeRow.getByText("$0.1425").hover();
await expect(tooltip(page)).toContainText("input");
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("cache write");
await expect(tooltip(page)).toContainText("output");
await expect(tooltip(page)).toContainText("$0.1204");
await expect(tooltip(page)).toContainText("$0.0221");
await expect(tooltip(page)).toContainText("$0.0000");
await expect(tooltip(page)).toContainText("total");
// The follow-up request reads the cache back: read bucket filled, write
// bucket still listed as a zero row.
// No exact match: the output count renders behind an sr-only "Output:" prefix.
const readRow = page.getByRole("row").filter({ hasText: "$0.0217" });
await closeTooltip(page);
await readRow.getByText("800").hover();
await expect(tooltip(page)).toContainText("cache read");
await expect(tooltip(page)).toContainText("32,919");
await expect(tooltip(page)).toContainText(/0\s*cache write/);
} finally {
await close();
}
});
test("usage overview daily table surfaces the day's cache buckets", async ({
browser,
}) => {
const { page, close } = await newUsagePage(browser);
try {
await navigateTo(page, "/agent-network/usage");
// Row-scoped on purpose: the hovers now repeat the cell's own figure on
// their total row, and FullTooltip force-mounts that content, so a
// page-wide getByText would match the cell and the tooltip both. Radix
// portals tooltip content to <body>, outside the row.
const dayRow = page.getByRole("row").filter({ hasText: "$0.16" });
// Total Tokens includes the additive cache buckets; hover splits them out
// in the same value-then-label rows the access-log table uses.
await expect(dayRow.getByText("66,510")).toBeVisible();
await dayRow.getByText("66,510").hover();
await expect(tooltip(page)).toContainText(/20\s*input/);
await expect(tooltip(page)).toContainText(/2,272\s*output/);
await expect(tooltip(page)).toContainText(/32,109\s*cache read/);
await expect(tooltip(page)).toContainText(/32,109\s*cache write/);
await expect(tooltip(page)).toContainText(/66,510\s*total/);
// Cost hover breaks the day into the same four billed buckets.
await closeTooltip(page);
await dayRow.getByText("$0.16", { exact: true }).hover();
await expect(tooltip(page)).toContainText(/\$0\.0001\s*input/);
await expect(tooltip(page)).toContainText(/\$0\.0341\s*output/);
await expect(tooltip(page)).toContainText(/\$0\.0096\s*cache read/);
await expect(tooltip(page)).toContainText(/\$0\.1204\s*cache write/);
await expect(tooltip(page)).toContainText(/\$0\.1642\s*total/);
} finally {
await close();
}
});
});
+37 -12
View File
@@ -1,6 +1,6 @@
import { test, expect } from "../helpers/fixtures"; import { test, expect } from "../helpers/fixtures";
import { navigateTo } from "../helpers/auth"; import { navigateTo } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils"; import { clearScrollLock, generateRandomName } from "../helpers/utils";
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api"; import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
let setupKeys: string[] = []; let setupKeys: string[] = [];
@@ -118,19 +118,34 @@ async function createSetupKey(
await expect(page.getByText(opts.name)).toBeVisible(); await expect(page.getByText(opts.name)).toBeVisible();
} }
async function revokeSetupKey( // openRowActions opens a row's action dropdown. A confirmation dialog closed
// moments earlier can still leave Radix's scroll-lock artifacts on <body>
// (`pointer-events: none`, a stale overlay), and those swallow the click even
// with force — the menu then silently never opens and the item lookup times
// out. Clear them before clicking.
async function openRowActions(
page: import("@playwright/test").Page, page: import("@playwright/test").Page,
name: string, name: string,
) { ) {
// Row actions are now behind a dropdown menu. await clearScrollLock(page);
await page await page
.locator("tr") .locator("tr")
.filter({ hasText: name }) .filter({ hasText: name })
.getByTestId("setup-key-actions") .getByTestId("setup-key-actions")
.click({ force: true }); .click({ force: true });
await page }
.locator('[data-testid="revoke-setup-key"]:not([data-disabled])')
.click({ force: true }); async function revokeSetupKey(
page: import("@playwright/test").Page,
name: string,
) {
// Row actions are now behind a dropdown menu.
await openRowActions(page, name);
const revokeItem = page.locator(
'[data-testid="revoke-setup-key"]:not([data-disabled])',
);
await expect(revokeItem).toBeVisible();
await revokeItem.click({ force: true });
const responsePromise = page.waitForResponse( const responsePromise = page.waitForResponse(
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT", (resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
{ timeout: 10_000 }, { timeout: 10_000 },
@@ -151,13 +166,23 @@ async function deleteSetupKey(
name: string, name: string,
) { ) {
// Row actions are now behind a dropdown menu. // Row actions are now behind a dropdown menu.
await page await openRowActions(page, name);
.locator("tr") const deleteItem = page.locator(
.filter({ hasText: name }) '[data-testid="delete-setup-key"]:not([data-disabled])',
.getByTestId("setup-key-actions") );
.click({ force: true }); await expect(deleteItem).toBeVisible();
await page.getByTestId("delete-setup-key").click({ force: true }); await deleteItem.click({ force: true });
// Await the DELETE like revoke awaits its PUT: the handler refetches
// /setup-keys afterwards, which re-renders every row — returning before that
// settles means the next key's trigger click can land on a replaced node.
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes("/api/setup-keys/") &&
resp.request().method() === "DELETE",
{ timeout: 10_000 },
);
await page.getByTestId("confirmation.confirm").click(); await page.getByTestId("confirmation.confirm").click();
await responsePromise;
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible(); await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
await expect(page.getByRole("dialog")).not.toBeVisible(); await expect(page.getByRole("dialog")).not.toBeVisible();
} }
+11
View File
@@ -33,12 +33,21 @@ export const CrowdSecMode = {
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode]; export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export const AppSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;
export type AppSecMode = (typeof AppSecMode)[keyof typeof AppSecMode];
export interface AccessRestrictions { export interface AccessRestrictions {
allowed_cidrs?: string[]; allowed_cidrs?: string[];
blocked_cidrs?: string[]; blocked_cidrs?: string[];
allowed_countries?: string[]; allowed_countries?: string[];
blocked_countries?: string[]; blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode; crowdsec_mode?: CrowdSecMode;
appsec_mode?: AppSecMode;
} }
export interface ReverseProxyMeta { export interface ReverseProxyMeta {
@@ -122,6 +131,7 @@ export interface ReverseProxyDomain {
supports_custom_ports?: boolean; supports_custom_ports?: boolean;
require_subdomain?: boolean; require_subdomain?: boolean;
supports_crowdsec?: boolean; supports_crowdsec?: boolean;
supports_appsec?: boolean;
supports_private?: boolean; supports_private?: boolean;
} }
@@ -188,6 +198,7 @@ export interface ReverseProxyCluster {
supports_custom_ports?: boolean; supports_custom_ports?: boolean;
require_subdomain?: boolean; require_subdomain?: boolean;
supports_crowdsec?: boolean; supports_crowdsec?: boolean;
supports_appsec?: boolean;
// True when at least one connected proxy in this cluster is running embedded // True when at least one connected proxy in this cluster is running embedded
// in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. // in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel.
// Lets the dashboard distinguish per-peer / private clusters from centralised // Lets the dashboard distinguish per-peer / private clusters from centralised
@@ -26,17 +26,39 @@ export default function AgentAccessLogExpandedRow({ entry }: Readonly<Props>) {
const hasBody = hasPrompt || hasCompletion; const hasBody = hasPrompt || hasCompletion;
const denyReason = formatDenyReason(entry.denyReason); const denyReason = formatDenyReason(entry.denyReason);
const cacheRead = entry.cachedInputTokens ?? 0;
const cacheWrite = entry.cacheCreationTokens ?? 0;
const metadata: Record<string, string> = { const metadata: Record<string, string> = {
"plg.llm.provider": entry.providerId, "plg.llm.provider": entry.providerId,
"plg.llm.model": entry.model, "plg.llm.model": entry.model,
"plg.llm.input_tokens": String(entry.inputTokens), "plg.llm.input_tokens": String(entry.inputTokens),
"plg.llm.output_tokens": String(entry.outputTokens), "plg.llm.output_tokens": String(entry.outputTokens),
"plg.llm.total_tokens": String(entry.inputTokens + entry.outputTokens), "plg.llm.total_tokens": String(
entry.inputTokens + entry.outputTokens + cacheRead + cacheWrite,
),
"plg.llm.cost_usd": entry.costUsd.toFixed(6), "plg.llm.cost_usd": entry.costUsd.toFixed(6),
"plg.llm.stream": entry.stream ? "true" : "false", "plg.llm.stream": entry.stream ? "true" : "false",
"plg.agentnetwork.policy_name": entry.policyName, "plg.agentnetwork.policy_name": entry.policyName,
"plg.agentnetwork.user_groups": (entry.userGroups ?? []).join(", "), "plg.agentnetwork.user_groups": (entry.userGroups ?? []).join(", "),
}; };
if (cacheRead > 0 || cacheWrite > 0) {
metadata["plg.llm.cached_input_tokens"] = String(cacheRead);
metadata["plg.llm.cache_creation_tokens"] = String(cacheWrite);
metadata["plg.cost.usd_cache"] = (entry.cacheCostUsd ?? 0).toFixed(6);
}
// Per-bucket cost keys mirror the proxy's cost.usd_* metadata. Emitted only
// when the server sent the breakdown, so a row from an older management
// server shows no misleading zeros.
if (entry.inputCostUsd !== undefined || entry.outputCostUsd !== undefined) {
metadata["plg.cost.usd_input"] = (entry.inputCostUsd ?? 0).toFixed(6);
metadata["plg.cost.usd_cached_input"] = (
entry.cachedInputCostUsd ?? 0
).toFixed(6);
metadata["plg.cost.usd_cache_creation"] = (
entry.cacheCreationCostUsd ?? 0
).toFixed(6);
metadata["plg.cost.usd_output"] = (entry.outputCostUsd ?? 0).toFixed(6);
}
if (isDeny) { if (isDeny) {
metadata["plg.llm_policy.decision"] = "deny"; metadata["plg.llm_policy.decision"] = "deny";
metadata["plg.llm_policy.reason"] = entry.denyReason ?? "unknown"; metadata["plg.llm_policy.reason"] = entry.denyReason ?? "unknown";
+137 -18
View File
@@ -261,13 +261,14 @@ export default function AgentAccessLogTable({
</DataTableHeader> </DataTableHeader>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span <CostCell
className={ costUsd={row.original.costUsd}
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap" cacheCostUsd={row.original.cacheCostUsd}
} inputCostUsd={row.original.inputCostUsd}
> cachedInputCostUsd={row.original.cachedInputCostUsd}
${row.original.costUsd.toFixed(4)} cacheCreationCostUsd={row.original.cacheCreationCostUsd}
</span> outputCostUsd={row.original.outputCostUsd}
/>
), ),
}, },
{ {
@@ -409,6 +410,8 @@ export default function AgentAccessLogTable({
{ {
inputTokens: row.original.inputTokens, inputTokens: row.original.inputTokens,
outputTokens: row.original.outputTokens, outputTokens: row.original.outputTokens,
cachedInputTokens: row.original.cachedInputTokens,
cacheCreationTokens: row.original.cacheCreationTokens,
} as AIAccessLogEntry } as AIAccessLogEntry
} }
/> />
@@ -423,13 +426,14 @@ export default function AgentAccessLogTable({
</DataTableHeader> </DataTableHeader>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<span <CostCell
className={ costUsd={row.original.costUsd}
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap" cacheCostUsd={row.original.cacheCostUsd}
} inputCostUsd={row.original.inputCostUsd}
> cachedInputCostUsd={row.original.cachedInputCostUsd}
${row.original.costUsd.toFixed(4)} cacheCreationCostUsd={row.original.cacheCreationCostUsd}
</span> outputCostUsd={row.original.outputCostUsd}
/>
), ),
}, },
{ {
@@ -999,31 +1003,43 @@ function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
) { ) {
return <EmptyRow />; return <EmptyRow />;
} }
const total = (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0); const cacheRead = entry.cachedInputTokens ?? 0;
const cacheWrite = entry.cacheCreationTokens ?? 0;
// Anthropic-shape cache buckets are additive to input tokens, so they count toward the total.
const total =
(entry.inputTokens ?? 0) +
(entry.outputTokens ?? 0) +
cacheRead +
cacheWrite;
return ( return (
<FullTooltip <FullTooltip
content={ content={
<div className={"text-xs flex flex-col gap-1"}> <div className={"text-xs flex flex-col gap-1"}>
<div className={"flex items-center gap-2 whitespace-nowrap"}> <div className={"flex items-center gap-2 whitespace-nowrap"}>
<ArrowUpIcon size={12} className={"text-sky-400 shrink-0"} />
<span className={"font-medium"}> <span className={"font-medium"}>
{entry.inputTokens.toLocaleString()} {entry.inputTokens.toLocaleString()}
</span> </span>
<span className={"text-nb-gray-400"}>input</span> <span className={"text-nb-gray-400"}>input</span>
</div> </div>
<div className={"flex items-center gap-2 whitespace-nowrap"}> <div className={"flex items-center gap-2 whitespace-nowrap"}>
<ArrowDownIcon size={12} className={"text-netbird shrink-0"} />
<span className={"font-medium"}> <span className={"font-medium"}>
{entry.outputTokens.toLocaleString()} {entry.outputTokens.toLocaleString()}
</span> </span>
<span className={"text-nb-gray-400"}>output</span> <span className={"text-nb-gray-400"}>output</span>
</div> </div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheRead.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache read</span>
</div>
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{cacheWrite.toLocaleString()}</span>
<span className={"text-nb-gray-400"}>cache write</span>
</div>
<div <div
className={ className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap" "border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
} }
> >
<span className={"w-3 shrink-0"} aria-hidden={true} />
<span className={"font-medium text-nb-gray-200"}> <span className={"font-medium text-nb-gray-200"}>
{total.toLocaleString()} {total.toLocaleString()}
</span> </span>
@@ -1050,6 +1066,109 @@ function TokensCell({ entry }: { entry: AIAccessLogEntry }) {
); );
} }
// CostRow is one line of the Cost hover breakdown: an amount plus its label.
function CostRow({ amount, label }: { amount: number; label: string }) {
return (
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>${amount.toFixed(4)}</span>
<span className={"text-nb-gray-400 font-sans"}>{label}</span>
</div>
);
}
// CostCell renders the metered USD cost with a hover breakdown of the buckets
// it was billed from.
//
// Servers that send the per-bucket breakdown get one line per bucket the
// provider bills separately (input / output / cache read / cache write). Older
// servers send only the two aggregates, so the hover falls back to the coarse
// "input + output" vs "cache" split derivable from those — the two shapes are
// distinguished by whether inputCostUsd is defined, not by whether it is zero.
function CostCell({
costUsd,
cacheCostUsd,
inputCostUsd,
cachedInputCostUsd,
cacheCreationCostUsd,
outputCostUsd,
}: {
costUsd: number;
cacheCostUsd?: number;
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
}) {
const cache = cacheCostUsd ?? 0;
const hasBreakdown =
inputCostUsd !== undefined || outputCostUsd !== undefined;
// Nothing was metered: the request never reached a provider (denied before
// routing), or ran on a model the proxy deliberately doesn't price. A dash
// reads as "not metered" — matching TokensCell — where "$0.0000" plus a
// tooltip of zeros would imply a priced request that happened to be free.
const buckets = [
inputCostUsd ?? 0,
cachedInputCostUsd ?? 0,
cacheCreationCostUsd ?? 0,
outputCostUsd ?? 0,
];
if (costUsd === 0 && cache === 0 && buckets.every((b) => b === 0)) {
return <EmptyRow />;
}
const display = (
<span
className={
"text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono whitespace-nowrap"
}
>
${costUsd.toFixed(4)}
</span>
);
// Nothing to break out: no cache spend and no per-bucket split to show.
if (cache <= 0 && !hasBreakdown) return display;
const cacheRead = cachedInputCostUsd ?? 0;
const cacheWrite = cacheCreationCostUsd ?? 0;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1 font-mono"}>
{hasBreakdown ? (
<>
{/* All four buckets, including zeros: a zero cache-read line is
information (the request missed the cache), and a fixed set of
rows keeps the hover comparable between requests. */}
<CostRow amount={inputCostUsd ?? 0} label={"input"} />
<CostRow amount={outputCostUsd ?? 0} label={"output"} />
<CostRow amount={cacheRead} label={"cache read"} />
<CostRow amount={cacheWrite} label={"cache write"} />
</>
) : (
<>
<CostRow amount={costUsd - cache} label={"input + output"} />
<CostRow amount={cache} label={"cache"} />
</>
)}
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>
${costUsd.toFixed(4)}
</span>
<span className={"font-sans"}>total</span>
</div>
</div>
}
>
{display}
</FullTooltip>
);
}
// resolveProviderFn resolves a request's configured provider (by the router's // resolveProviderFn resolves a request's configured provider (by the router's
// stamped resolved_provider_id) — shared by the session cells so they reuse the // stamped resolved_provider_id) — shared by the session cells so they reuse the
// flat row's provider resolution. // flat row's provider resolution.
+206 -12
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import ButtonGroup from "@components/ButtonGroup"; import ButtonGroup from "@components/ButtonGroup";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink"; import InlineLink from "@components/InlineLink";
import SquareIcon from "@components/SquareIcon"; import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable"; import { DataTable } from "@components/table/DataTable";
@@ -16,6 +17,7 @@ import {
Tooltip, Tooltip,
} from "chart.js"; } from "chart.js";
import useFetchApi from "@utils/api"; import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ActivityIcon, ExternalLinkIcon } from "lucide-react"; import { ActivityIcon, ExternalLinkIcon } from "lucide-react";
import * as React from "react"; import * as React from "react";
@@ -42,6 +44,18 @@ type DayBucket = {
input: number; input: number;
output: number; output: number;
cost: number; cost: number;
// Prompt-cache accounting: read/write token buckets and the cache share of cost.
cacheRead: number;
cacheWrite: number;
cacheCost: number;
// Per-bucket cost split, so the Cost hover can break the day down the same
// way the access log breaks down a request. Undefined on older management
// servers that send only the two cost aggregates — undefined and zero mean
// different things here, so these stay optional.
inputCost?: number;
outputCost?: number;
cacheReadCost?: number;
cacheWriteCost?: number;
}; };
// AgentOverviewPanel shows account consumption over time as a per-day bar // AgentOverviewPanel shows account consumption over time as a per-day bar
@@ -206,13 +220,43 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
}, },
{ {
id: "total", id: "total",
accessorFn: (row) => row.input + row.output, accessorFn: (row) =>
row.input + row.output + row.cacheRead + row.cacheWrite,
header: ({ column }) => ( header: ({ column }) => (
<DataTableHeader column={column}>Total Tokens</DataTableHeader> <DataTableHeader column={column}>Total Tokens</DataTableHeader>
), ),
cell: ({ row }) => ( // Total includes the additive prompt-cache buckets; hover breaks them out.
<NumberCell value={row.original.input + row.original.output} /> cell: ({ row }) => {
), const d = row.original;
const total = d.input + d.output + d.cacheRead + d.cacheWrite;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1"}>
<BreakdownRow
value={d.input.toLocaleString()}
label={"input"}
/>
<BreakdownRow
value={d.output.toLocaleString()}
label={"output"}
/>
<BreakdownRow
value={d.cacheRead.toLocaleString()}
label={"cache read"}
/>
<BreakdownRow
value={d.cacheWrite.toLocaleString()}
label={"cache write"}
/>
<BreakdownTotal value={total.toLocaleString()} />
</div>
}
>
<NumberCell value={total} />
</FullTooltip>
);
},
}, },
{ {
id: "cost", id: "cost",
@@ -220,13 +264,74 @@ function DailyBreakdownTable({ daily }: { daily: DayBucket[] }) {
header: ({ column }) => ( header: ({ column }) => (
<DataTableHeader column={column}>Cost</DataTableHeader> <DataTableHeader column={column}>Cost</DataTableHeader>
), ),
cell: ({ row }) => ( // Mirrors the access log's Cost hover: one row per bucket the provider
<span // bills separately when the server sends the split, otherwise the coarse
className={"text-nb-gray-300 px-3 py-2 font-mono whitespace-nowrap"} // cache-vs-rest split derivable from the two aggregates. The shapes are
> // told apart by whether inputCost is defined, not by whether it is zero.
${row.original.cost.toFixed(2)} cell: ({ row }) => {
</span> const d = row.original;
), const hasBreakdown =
d.inputCost !== undefined || d.outputCost !== undefined;
const display = (
<span
className={
"text-nb-gray-300 px-3 py-2 font-mono whitespace-nowrap"
}
>
${d.cost.toFixed(2)}
</span>
);
// Nothing to break out: no cache spend and no per-bucket split.
if (d.cacheCost <= 0 && !hasBreakdown) return display;
return (
<FullTooltip
content={
<div className={"text-xs flex flex-col gap-1 font-mono"}>
{hasBreakdown ? (
<>
<BreakdownRow
mono={true}
value={usd(d.inputCost ?? 0)}
label={"input"}
/>
<BreakdownRow
mono={true}
value={usd(d.outputCost ?? 0)}
label={"output"}
/>
<BreakdownRow
mono={true}
value={usd(d.cacheReadCost ?? 0)}
label={"cache read"}
/>
<BreakdownRow
mono={true}
value={usd(d.cacheWriteCost ?? 0)}
label={"cache write"}
/>
</>
) : (
<>
<BreakdownRow
mono={true}
value={usd(d.cost - d.cacheCost)}
label={"input + output"}
/>
<BreakdownRow
mono={true}
value={usd(d.cacheCost)}
label={"cache"}
/>
</>
)}
<BreakdownTotal mono={true} value={usd(d.cost)} />
</div>
}
>
{display}
</FullTooltip>
);
},
}, },
], ],
[], [],
@@ -280,6 +385,59 @@ function NumberCell({ value }: { value: number }) {
); );
} }
// The two hover breakdowns below follow the access-log table's Tokens and Cost
// hovers: one "<value> <label>" row per bucket, then the day's aggregate on a
// separated total row. Buckets are listed even when zero — a zero cache-read
// line says the day never hit the cache, and a fixed set of rows keeps the
// hover comparable between days.
// usd renders a breakdown amount at the precision the router meters at. The
// Cost column rounds a whole day to cents, but the hover is there for the
// detail — prompt-cache reads in particular are routinely sub-cent.
function usd(amount: number): string {
return `$${amount.toFixed(4)}`;
}
// mono keeps cost figures aligned digit-for-digit; labels stay in the body face
// so they don't read as data.
function BreakdownRow({
value,
label,
mono = false,
}: {
value: string;
label: string;
mono?: boolean;
}) {
return (
<div className={"flex items-center gap-2 whitespace-nowrap"}>
<span className={"font-medium"}>{value}</span>
<span className={cn("text-nb-gray-400", mono && "font-sans")}>
{label}
</span>
</div>
);
}
function BreakdownTotal({
value,
mono = false,
}: {
value: string;
mono?: boolean;
}) {
return (
<div
className={
"border-t border-nb-gray-800 mt-0.5 pt-1 flex items-center gap-2 text-nb-gray-400 whitespace-nowrap"
}
>
<span className={"font-medium text-nb-gray-200"}>{value}</span>
<span className={cn(mono && "font-sans")}>total</span>
</div>
);
}
function ConsumptionByDayChart({ function ConsumptionByDayChart({
daily, daily,
metric, metric,
@@ -340,6 +498,25 @@ function ConsumptionByDayChart({
: `${ctx.dataset.label}: ${Number( : `${ctx.dataset.label}: ${Number(
ctx.parsed.y, ctx.parsed.y,
).toLocaleString()}`, ).toLocaleString()}`,
// Surface the day's prompt-cache share on hover without adding chart series.
// Gate on the figure this metric actually shows: a day with cache
// tokens but no cache spend (unpriced model) would otherwise
// render "Cache: $0.00", implying a priced-but-free day.
afterBody: (items) => {
const d = daily[items[0]?.dataIndex ?? -1];
if (!d) return [];
if (metric === "cost") {
return d.cacheCost > 0
? [`Cache: $${d.cacheCost.toFixed(2)}`]
: [];
}
return d.cacheRead + d.cacheWrite > 0
? [
`Cache read: ${d.cacheRead.toLocaleString()}`,
`Cache write: ${d.cacheWrite.toLocaleString()}`,
]
: [];
},
}, },
}, },
}, },
@@ -394,6 +571,13 @@ function toDailyBuckets(buckets: APIAgentNetworkUsageBucket[]): DayBucket[] {
input: b.input_tokens ?? 0, input: b.input_tokens ?? 0,
output: b.output_tokens ?? 0, output: b.output_tokens ?? 0,
cost: b.cost_usd ?? 0, cost: b.cost_usd ?? 0,
cacheRead: b.cached_input_tokens ?? 0,
cacheWrite: b.cache_creation_tokens ?? 0,
cacheCost: b.cache_cost_usd ?? 0,
inputCost: b.input_cost_usd,
outputCost: b.output_cost_usd,
cacheReadCost: b.cached_input_cost_usd,
cacheWriteCost: b.cache_creation_cost_usd,
}); });
} }
@@ -406,7 +590,17 @@ function toDailyBuckets(buckets: APIAgentNetworkUsageBucket[]): DayBucket[] {
// Cap at a year of days as a defensive bound against a bad range. // Cap at a year of days as a defensive bound against a bad range.
for (let i = 0; i < 366 && !cur.isAfter(end); i++) { for (let i = 0; i < 366 && !cur.isAfter(end); i++) {
const key = cur.format("YYYY-MM-DD"); const key = cur.format("YYYY-MM-DD");
out.push(map.get(key) ?? { key, input: 0, output: 0, cost: 0 }); out.push(
map.get(key) ?? {
key,
input: 0,
output: 0,
cost: 0,
cacheRead: 0,
cacheWrite: 0,
cacheCost: 0,
},
);
cur = cur.add(1, "day"); cur = cur.add(1, "day");
} }
return out; return out;
@@ -24,6 +24,17 @@ export type APIAgentNetworkAccessLog = {
output_tokens: number; output_tokens: number;
total_tokens: number; total_tokens: number;
cost_usd: number; cost_usd: number;
// Prompt-cache accounting. Optional — absent on older management servers.
cached_input_tokens?: number;
cache_creation_tokens?: number;
cache_cost_usd?: number;
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
// sum and cache_cost_usd the cache pair. Optional — absent on older
// management servers, where only the two aggregates are available.
input_cost_usd?: number;
cached_input_cost_usd?: number;
cache_creation_cost_usd?: number;
output_cost_usd?: number;
user_id?: string; user_id?: string;
source_ip?: string; source_ip?: string;
method?: string; method?: string;
@@ -63,6 +74,16 @@ export type APIAgentNetworkAccessLogSession = {
output_tokens: number; output_tokens: number;
total_tokens: number; total_tokens: number;
cost_usd: number; cost_usd: number;
cached_input_tokens?: number;
cache_creation_tokens?: number;
cache_cost_usd?: number;
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
// sum and cache_cost_usd the cache pair. Optional — absent on older
// management servers, where only the two aggregates are available.
input_cost_usd?: number;
cached_input_cost_usd?: number;
cache_creation_cost_usd?: number;
output_cost_usd?: number;
providers?: string[]; providers?: string[];
models?: string[]; models?: string[];
decision: string; decision: string;
@@ -77,6 +98,16 @@ export type APIAgentNetworkUsageBucket = {
output_tokens: number; output_tokens: number;
total_tokens: number; total_tokens: number;
cost_usd: number; cost_usd: number;
cached_input_tokens?: number;
cache_creation_tokens?: number;
cache_cost_usd?: number;
// Per-bucket cost breakdown — the base of the cost fields; cost_usd is their
// sum and cache_cost_usd the cache pair. Optional — absent on older
// management servers, where only the two aggregates are available.
input_cost_usd?: number;
cached_input_cost_usd?: number;
cache_creation_cost_usd?: number;
output_cost_usd?: number;
}; };
export type UsageGranularity = "day" | "week" | "month"; export type UsageGranularity = "day" | "week" | "month";
@@ -185,7 +216,14 @@ export function accessLogFromAgentAPI(
model: entry.model ?? "", model: entry.model ?? "",
inputTokens: entry.input_tokens ?? 0, inputTokens: entry.input_tokens ?? 0,
outputTokens: entry.output_tokens ?? 0, outputTokens: entry.output_tokens ?? 0,
cachedInputTokens: entry.cached_input_tokens ?? 0,
cacheCreationTokens: entry.cache_creation_tokens ?? 0,
costUsd: entry.cost_usd ?? 0, costUsd: entry.cost_usd ?? 0,
cacheCostUsd: entry.cache_cost_usd ?? 0,
inputCostUsd: entry.input_cost_usd,
cachedInputCostUsd: entry.cached_input_cost_usd,
cacheCreationCostUsd: entry.cache_creation_cost_usd,
outputCostUsd: entry.output_cost_usd,
durationMs: entry.duration_ms, durationMs: entry.duration_ms,
decision, decision,
denyReason: decision === "deny" ? entry.deny_reason : undefined, denyReason: decision === "deny" ? entry.deny_reason : undefined,
@@ -228,7 +266,14 @@ export function accessLogSessionFromAgentAPI(
inputTokens: session.input_tokens ?? 0, inputTokens: session.input_tokens ?? 0,
outputTokens: session.output_tokens ?? 0, outputTokens: session.output_tokens ?? 0,
totalTokens: session.total_tokens ?? 0, totalTokens: session.total_tokens ?? 0,
cachedInputTokens: session.cached_input_tokens ?? 0,
cacheCreationTokens: session.cache_creation_tokens ?? 0,
costUsd: session.cost_usd ?? 0, costUsd: session.cost_usd ?? 0,
cacheCostUsd: session.cache_cost_usd ?? 0,
inputCostUsd: session.input_cost_usd,
cachedInputCostUsd: session.cached_input_cost_usd,
cacheCreationCostUsd: session.cache_creation_cost_usd,
outputCostUsd: session.output_cost_usd,
providers: session.providers ?? [], providers: session.providers ?? [],
models: session.models ?? [], models: session.models ?? [],
decision, decision,
@@ -175,7 +175,17 @@ export type AIAccessLogEntry = {
model: string; model: string;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
// Prompt-cache buckets: read/write token counts and the cache share of costUsd. Optional — absent on older management servers.
cachedInputTokens?: number;
cacheCreationTokens?: number;
costUsd: number; costUsd: number;
cacheCostUsd?: number;
// Per-bucket cost breakdown. Undefined (not 0) when the server predates the
// breakdown, so the UI can tell "no split available" from "split is zero".
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
durationMs: number; durationMs: number;
decision: AIAccessLogDecision; decision: AIAccessLogDecision;
denyReason?: string; denyReason?: string;
@@ -213,7 +223,16 @@ export type AIAccessLogSession = {
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
totalTokens: number; totalTokens: number;
cachedInputTokens?: number;
cacheCreationTokens?: number;
costUsd: number; costUsd: number;
cacheCostUsd?: number;
// Per-bucket cost breakdown. Undefined (not 0) when the server predates the
// breakdown, so the UI can tell "no split available" from "split is zero".
inputCostUsd?: number;
cachedInputCostUsd?: number;
cacheCreationCostUsd?: number;
outputCostUsd?: number;
providers: string[]; // distinct vendor ids seen in the session providers: string[]; // distinct vendor ids seen in the session
models: string[]; // distinct models seen in the session models: string[]; // distinct models seen in the session
decision: AIAccessLogDecision; decision: AIAccessLogDecision;
@@ -19,8 +19,13 @@ import {
SelectOption, SelectOption,
} from "@components/select/SelectDropdown"; } from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector"; import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy"; import {
AccessRestrictions,
AppSecMode,
CrowdSecMode,
} from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation"; import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
import { ReverseProxyAppSecInspection } from "@/modules/reverse-proxy/ReverseProxyAppSecInspection";
type AccessAction = "allow" | "block"; type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr"; type AccessRuleType = "country" | "ip" | "cidr";
@@ -121,6 +126,7 @@ function restrictionsToRules(
function rulesToRestrictions( function rulesToRestrictions(
rules: AccessRule[], rules: AccessRule[],
crowdsecMode?: CrowdSecMode, crowdsecMode?: CrowdSecMode,
appsecMode?: AppSecMode,
): AccessRestrictions | undefined { ): AccessRestrictions | undefined {
const allowed_countries: string[] = []; const allowed_countries: string[] = [];
const blocked_countries: string[] = []; const blocked_countries: string[] = [];
@@ -144,12 +150,14 @@ function rulesToRestrictions(
} }
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF; const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
const hasAppSec = appsecMode != null && appsecMode !== AppSecMode.OFF;
const hasAny = const hasAny =
allowed_countries.length > 0 || allowed_countries.length > 0 ||
blocked_countries.length > 0 || blocked_countries.length > 0 ||
allowed_cidrs.length > 0 || allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0 || blocked_cidrs.length > 0 ||
hasCrowdSec; hasCrowdSec ||
hasAppSec;
if (!hasAny) return undefined; if (!hasAny) return undefined;
@@ -159,6 +167,7 @@ function rulesToRestrictions(
...(allowed_cidrs.length > 0 && { allowed_cidrs }), ...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }), ...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }), ...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
...(hasAppSec && { appsec_mode: appsecMode }),
}; };
} }
@@ -167,6 +176,7 @@ type Props = {
onChange: (value: AccessRestrictions | undefined) => void; onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void; onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean; supportsCrowdSec?: boolean;
supportsAppSec?: boolean;
}; };
function validateRule(rule: AccessRule): string { function validateRule(rule: AccessRule): string {
@@ -193,6 +203,7 @@ export const ReverseProxyAccessControlRules = ({
onChange, onChange,
onValidationChange, onValidationChange,
supportsCrowdSec, supportsCrowdSec,
supportsAppSec,
}: Props) => { }: Props) => {
const [rules, dispatch] = useReducer( const [rules, dispatch] = useReducer(
rulesReducer, rulesReducer,
@@ -200,6 +211,10 @@ export const ReverseProxyAccessControlRules = ({
restrictionsToRules, restrictionsToRules,
); );
const [appsecMode, setAppsecMode] = useState<AppSecMode>(
value?.appsec_mode ?? AppSecMode.OFF,
);
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>( const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF, value?.crowdsec_mode ?? CrowdSecMode.OFF,
); );
@@ -227,8 +242,14 @@ export const ReverseProxyAccessControlRules = ({
}, [supportsCrowdSec]); }, [supportsCrowdSec]);
useEffect(() => { useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode)); if (!supportsAppSec) {
}, [rules, crowdsecMode]); setAppsecMode(AppSecMode.OFF);
}
}, [supportsAppSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode, appsecMode));
}, [rules, crowdsecMode, appsecMode]);
useEffect(() => { useEffect(() => {
onValidationChangeRef.current?.(hasErrors); onValidationChangeRef.current?.(hasErrors);
@@ -242,6 +263,12 @@ export const ReverseProxyAccessControlRules = ({
onChange={setCrowdsecMode} onChange={setCrowdsecMode}
/> />
)} )}
{supportsAppSec && (
<ReverseProxyAppSecInspection
value={appsecMode}
onChange={setAppsecMode}
/>
)}
<div> <div>
<Label>Access Control Rules</Label> <Label>Access Control Rules</Label>
<HelpText> <HelpText>
@@ -0,0 +1,107 @@
import * as React from "react";
import { ReactNode } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react";
import { HelpTooltip } from "@components/HelpTooltip";
import { AppSecMode } from "@/interfaces/ReverseProxy";
import Image from "next/image";
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";
type Props = {
value: AppSecMode;
onChange: (value: AppSecMode) => void;
};
type AppSecOption = {
label: string;
description?: string;
icon: ReactNode;
};
const APPSEC_OPTIONS: Record<AppSecMode, AppSecOption> = {
[AppSecMode.OFF]: {
label: "Disabled",
icon: <PowerOffIcon size={14} />,
},
[AppSecMode.ENFORCE]: {
label: "Enforce",
description:
"Flagged requests are blocked with 403. Every request is inspected before it reaches the service, and requests are denied if the engine is unreachable (fail-closed).",
icon: <ShieldCheckIcon size={14} />,
},
[AppSecMode.OBSERVE]: {
label: "Observe",
description:
"Flagged requests are logged but still forwarded. Use this to evaluate the rules against real traffic before enforcing.",
icon: <EyeIcon size={14} />,
},
};
export const ReverseProxyAppSecInspection = ({ value, onChange }: Props) => {
const selected = APPSEC_OPTIONS[value];
return (
<div className="flex items-center gap-0 justify-between mb-6">
<div className="flex gap-4">
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
}
>
<Image
src={CrowdSecIconImage}
alt={"CrowdSec"}
className={"rounded-[4px]"}
/>
</div>
<div>
<Label>CrowdSec AppSec (WAF)</Label>
<HelpText>
Inspect HTTP requests for exploits.{" "}
<b className={"text-white"}>Enforce</b> to block them or{" "}
<b className={"text-white"}>Observe</b> to only log without
blocking.
</HelpText>
</div>
</div>
<Select value={value} onValueChange={(v) => onChange(v as AppSecMode)}>
<SelectTrigger className="w-[260px]" data-testid="appsec-mode-trigger">
<div className="flex items-center gap-2 whitespace-nowrap">
{selected.icon}
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{Object.entries(APPSEC_OPTIONS).map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
data-testid={`appsec-mode-${mode}`}
extra={
config.description ? (
<HelpTooltip
triggerClassName="ml-[0.01rem]"
align="center"
side="right"
content={<>{config.description}</>}
/>
) : undefined
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -814,6 +814,7 @@ export default function ReverseProxyModal({
onChange={setAccessRestrictions} onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors} onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec} supportsCrowdSec={selectedDomain?.supports_crowdsec}
supportsAppSec={!isL4Mode && selectedDomain?.supports_appsec}
/> />
</div> </div>
</TabsContent> </TabsContent>
@@ -47,6 +47,15 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
icon: <ShieldAlert size={14} className={"text-green-500"} />, icon: <ShieldAlert size={14} className={"text-green-500"} />,
}); });
} }
if (cluster.supports_appsec) {
features.push({
key: "appsec",
label: "AppSec (WAF)",
description:
"Cluster has a CrowdSec AppSec endpoint configured across all active proxies, so services can inspect HTTP requests for exploits.",
icon: <ShieldAlert size={14} className={"text-green-500"} />,
});
}
if (cluster.private) { if (cluster.private) {
features.push({ features.push({
key: "private", key: "private",
@@ -56,8 +65,7 @@ export default function ClustersFeaturesCell({ cluster }: Readonly<Props>) {
Lets you publish services that are only reachable from peers in your Lets you publish services that are only reachable from peers in your
NetBird network. Required for{" "} NetBird network. Required for{" "}
<span className={"font-medium text-white"}>NetBird-Only Access</span>{" "} <span className={"font-medium text-white"}>NetBird-Only Access</span>{" "}
and{" "} and <span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
<span className={"font-medium text-white"}>Proxy Cluster</span>{" "}
target types. target types.
</> </>
), ),
@@ -111,6 +111,21 @@ export const ReverseProxyEventsAuthMethodCell = ({
icon: <ShieldOff size={12} />, icon: <ShieldOff size={12} />,
label: "CrowdSec Unavailable", label: "CrowdSec Unavailable",
}; };
case "appsec_ban":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Ban",
};
case "appsec_captcha":
return {
icon: <ShieldAlert size={12} />,
label: "AppSec Captcha",
};
case "appsec_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "AppSec Unavailable",
};
default: default:
return { return {
icon: null, icon: null,
@@ -9,6 +9,9 @@ const VERDICT_LABELS: Record<string, string> = {
crowdsec_ban: "Ban", crowdsec_ban: "Ban",
crowdsec_captcha: "Captcha", crowdsec_captcha: "Captcha",
crowdsec_throttle: "Throttle", crowdsec_throttle: "Throttle",
appsec_ban: "Ban",
appsec_captcha: "Captcha",
appsec_unavailable: "Unavailable",
}; };
type Props = { type Props = {
@@ -17,12 +20,16 @@ type Props = {
export const ReverseProxyEventsReasonCell = ({ event }: Props) => { export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
const metadata = event.metadata; const metadata = event.metadata;
const verdict = metadata?.crowdsec_verdict; // Only one of the two can be the reason a request was flagged without being
// blocked, since an enforced block sets auth_method_used instead.
const source = metadata?.appsec_verdict ? "appsec" : "crowdsec";
const verdictKey = `${source}_verdict`;
const verdict = metadata?.[verdictKey];
if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) { if (verdict && !event.auth_method_used?.startsWith(`${source}_`)) {
const verdictLabel = VERDICT_LABELS[verdict] ?? verdict; const verdictLabel = VERDICT_LABELS[verdict] ?? verdict;
const metaEntries = Object.entries(metadata!).filter( const metaEntries = Object.entries(metadata!).filter(
([k]) => k !== "crowdsec_verdict", ([k]) => k !== verdictKey,
); );
return ( return (
@@ -49,7 +56,8 @@ export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
<div className="px-3 py-2"> <div className="px-3 py-2">
<Badge variant="gray" className="gap-1.5"> <Badge variant="gray" className="gap-1.5">
<ShieldAlert size={12} className="text-yellow-500" /> <ShieldAlert size={12} className="text-yellow-500" />
CrowdSec Observe: {verdictLabel} {source === "appsec" ? "AppSec" : "CrowdSec"} Observe:{" "}
{verdictLabel}
</Badge> </Badge>
</div> </div>
</FullTooltip> </FullTooltip>
@@ -20,7 +20,7 @@ import { useMemo } from "react";
import { useCountries } from "@/contexts/CountryProvider"; import { useCountries } from "@/contexts/CountryProvider";
import { usePermissions } from "@/contexts/PermissionsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy"; import { AppSecMode, CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
type RuleEntry = { type RuleEntry = {
key: string; key: string;
@@ -44,21 +44,35 @@ export default function ReverseProxyAccessControlCell({
const canConfigure = !!permission?.services?.update; const canConfigure = !!permission?.services?.update;
const restrictions = reverseProxy.access_restrictions; const restrictions = reverseProxy.access_restrictions;
const supportsCrowdSec = domains?.find( // Services on a custom domain carry its target_cluster in proxy_cluster, so
(d) => d.domain === reverseProxy.proxy_cluster, // matching on domain alone misses them and reports no capability.
)?.supports_crowdsec; const clusterDomain = domains?.find(
(d) =>
d.domain === reverseProxy.proxy_cluster ||
d.target_cluster === reverseProxy.proxy_cluster,
);
const supportsCrowdSec = clusterDomain?.supports_crowdsec;
const hasCrowdSec = const hasCrowdSec =
supportsCrowdSec && supportsCrowdSec &&
restrictions?.crowdsec_mode != null && restrictions?.crowdsec_mode != null &&
restrictions.crowdsec_mode !== CrowdSecMode.OFF; restrictions.crowdsec_mode !== CrowdSecMode.OFF;
const supportsAppSec = clusterDomain?.supports_appsec;
const hasAppSec =
supportsAppSec &&
restrictions?.appsec_mode != null &&
restrictions.appsec_mode !== AppSecMode.OFF;
const ruleCount = const ruleCount =
(restrictions?.allowed_cidrs?.length ?? 0) + (restrictions?.allowed_cidrs?.length ?? 0) +
(restrictions?.blocked_cidrs?.length ?? 0) + (restrictions?.blocked_cidrs?.length ?? 0) +
(restrictions?.allowed_countries?.length ?? 0) + (restrictions?.allowed_countries?.length ?? 0) +
(restrictions?.blocked_countries?.length ?? 0) + (restrictions?.blocked_countries?.length ?? 0) +
(hasCrowdSec ? 1 : 0); (hasCrowdSec ? 1 : 0) +
(hasAppSec ? 1 : 0);
const rulesBadge = ( const rulesBadge = (
<Badge <Badge
@@ -163,8 +177,20 @@ export default function ReverseProxyAccessControlCell({
}); });
} }
if (hasAppSec) {
entries.push({
key: "appsec",
label: "AppSec (WAF)",
Icon: ShieldAlert,
value:
restrictions?.appsec_mode === AppSecMode.ENFORCE
? "Enforce"
: "Observe",
});
}
return entries; return entries;
}, [restrictions, countries, hasCrowdSec]); }, [restrictions, countries, hasCrowdSec, hasAppSec]);
const showRulesHover = ruleGroups.length > 0; const showRulesHover = ruleGroups.length > 0;