diff --git a/src/lib/server/mock.js b/src/lib/server/mock.js index 01bbdb7b..8f981b1d 100644 --- a/src/lib/server/mock.js +++ b/src/lib/server/mock.js @@ -290,6 +290,28 @@ const wafZone = { priority: 30 } ], + limits: [ + { + id: 'per-ip', + description: 'Baseline per-client limit', + key: ['ip'], + rate: 100, + window: '1m', + algorithm: 'sliding', + mode: 'enforce', + status: 429, + message: 'Too Many Requests' + }, + { + id: 'login-burst', + description: 'Protect login from credential stuffing', + key: ['ip', 'header:x-forwarded-user'], + rate: 10, + window: '10s', + algorithm: 'fixed', + mode: 'shadow' + } + ], status: 'success', action: 'create', createdAt: CREATED_AT, @@ -337,6 +359,62 @@ function wafMetrics (timeRange) { return { series, total } } +// Bucket widths per range, matching waf.metrics / waf.limitMetrics server-side. +/** @type {Record} */ +const wafBucketSeconds = { '1h': 60, '6h': 300, '12h': 600, '1d': 1200, '7d': 3600, '30d': 14400 } + +// Synthetic rate-limit metrics for the seed zone's two limits, so the +// "Rate limit activity" section has a trend to draw. Each limit gets an +// `allowed` series with large counts and a `limited` series with small counts, +// tuned so the limited share lands around the limit's target with some +// time variation across the window. +/** @param {string} [timeRange] */ +function wafLimitMetrics (timeRange) { + const now = Math.floor(Date.now() / 1000) + const range = timeRange ?? '1d' + const windowSeconds = wafRangeSeconds[range] ?? wafRangeSeconds['1d'] + const bucket = wafBucketSeconds[range] ?? wafBucketSeconds['1d'] + const from = now - windowSeconds + + /** + * @param {string} limitId + * @param {number} base typical allowed count per bucket + * @param {number} share typical limited share (0..1) + */ + const makeLimit = (limitId, base, share) => { + /** @type {[number, number][]} */ + const allowed = [] + /** @type {[number, number][]} */ + const limited = [] + let allowedTotal = 0 + let limitedTotal = 0 + for (let ts = from + bucket; ts <= now; ts += bucket) { + const a = Math.round(base * (0.7 + 0.6 * Math.random())) + allowed.push([ts, a]) + allowedTotal += a + // Drift the share over the window (sinus + noise) so the trend line + // actually trends; sparse — calm buckets emit no `limited` point. + const wave = 0.6 + 0.4 * Math.sin(((ts - from) / windowSeconds) * Math.PI * 5) + 0.4 * Math.random() + const l = Math.round(a * share * wave) + if (l > 0) { + limited.push([ts, l]) + limitedTotal += l + } + } + return [ + { limitId, result: 'allowed', total: allowedTotal, points: allowed }, + { limitId, result: 'limited', total: limitedTotal, points: limited } + ] + } + + const series = [ + ...makeLimit('per-ip', 450, 0.018), // share ~0.5–3% + ...makeLimit('login-burst', 80, 0.07) // share ~4–10% + ] + const total = series.reduce((acc, s) => acc + s.total, 0) + return { series, total } +} + // Locations (besides the seed LOCATION_ID) that have had a firewall created in // this dev session, mapped to { description, polls }. `polls` counts how many // times the zone has been read while pending; the deployer is simulated by @@ -366,6 +444,7 @@ function wafConfiguredZone (location, advance = false) { location, description: entry.description, rules: [], + limits: [], status, action: 'create', createdAt: CREATED_AT, @@ -842,6 +921,11 @@ const handlers = { if (location === LOCATION_ID) return ok(wafMetrics(args?.timeRange)) return ok({ series: [], total: 0 }) }, + 'waf.limitMetrics': (args) => { + const location = args?.location ?? LOCATION_ID + if (location === LOCATION_ID) return ok(wafLimitMetrics(args?.timeRange)) + return ok({ series: [], total: 0 }) + }, 'waf.delete': (args) => { if (args?.location) wafConfigured.delete(args.location) return ok({}) diff --git a/src/lib/waf/limits.js b/src/lib/waf/limits.js new file mode 100644 index 00000000..49164846 --- /dev/null +++ b/src/lib/waf/limits.js @@ -0,0 +1,182 @@ +// Shared rate-limit helpers for the firewall manage + limit edit pages. Limit +// ids are auto-generated and stable for the life of a limit (like rule ids), +// but limits have no priority — order doesn't matter. + +/** + * One characteristic of the bucket key. Header/cookie carry a name; the rest + * round-trip as a bare keyword ('ip', 'host', 'country', 'asn'). + * @typedef {Object} KeyRow + * @property {'ip' | 'host' | 'country' | 'asn' | 'header' | 'cookie'} type + * @property {string} name + */ + +/** + * @typedef {Object} LimitForm + * @property {string} id + * @property {string} description + * @property {KeyRow[]} key + * @property {number} rate + * @property {string} window + * @property {'fixed' | 'sliding'} algorithm + * @property {'enforce' | 'shadow'} mode + * @property {number} status + * @property {string} message + */ + +export const DEFAULT_LIMIT_STATUS = 429 +export const DEFAULT_LIMIT_MESSAGE = 'Too Many Requests' + +/** @type {Record} */ +export const modeLabels = { + enforce: 'Enforce', + shadow: 'Shadow' +} + +/** @type {Record} */ +export const keyTypeLabels = { + ip: 'IP address', + host: 'Host', + country: 'Country', + asn: 'ASN', + header: 'Header', + cookie: 'Cookie' +} + +// Window presets covering the API's allowed 1s..1h range. Zones loaded with a +// window outside this list still render — the edit page appends the loaded +// value as an extra option. +/** @type {{ value: string, label: string }[]} */ +export const windowOptions = [ + { value: '1s', label: '1 second' }, + { value: '10s', label: '10 seconds' }, + { value: '30s', label: '30 seconds' }, + { value: '1m', label: '1 minute' }, + { value: '5m', label: '5 minutes' }, + { value: '10m', label: '10 minutes' }, + { value: '30m', label: '30 minutes' }, + { value: '1h', label: '1 hour' } +] + +/** + * Parse an API key part ('ip', 'header:x-api-key', …) into a form row. + * @param {string} part + * @returns {KeyRow} + */ +export function parseKeyPart (part) { + if (part.startsWith('header:')) return { type: 'header', name: part.slice('header:'.length) } + if (part.startsWith('cookie:')) return { type: 'cookie', name: part.slice('cookie:'.length) } + if (part === 'host' || part === 'country' || part === 'asn') return { type: part, name: '' } + return { type: 'ip', name: '' } +} + +/** + * Render a form row back into the API key part shape. Header/cookie rows with + * an empty name are incomplete and yield ''. Header names are normalized to + * lowercase (HTTP headers are case-insensitive — parapet does the same); + * cookie names keep their spelling (cookies match case-sensitively). + * @param {KeyRow} row + * @returns {string} + */ +export function keyRowToApi (row) { + if (row.type === 'header' || row.type === 'cookie') { + const name = row.name.trim() + if (!name) return '' + return row.type === 'header' ? `header:${name.toLowerCase()}` : `cookie:${name}` + } + return row.type +} + +/** + * Human label for an API key, e.g. ['ip', 'header:x-api-key'] → + * "IP + Header x-api-key". + * @param {string[]} [key] + * @returns {string} + */ +export function describeKey (key) { + const parts = (key && key.length > 0 ? key : ['ip']).map((part) => { + const row = parseKeyPart(part) + if (row.type === 'header' || row.type === 'cookie') { + return `${keyTypeLabels[row.type]} ${row.name}` + } + return row.type === 'ip' ? 'IP' : keyTypeLabels[row.type] + }) + return parts.join(' + ') +} + +/** + * @param {Api.WafLimit} [limit] + * @returns {LimitForm} + */ +export function limitForm (limit) { + return { + id: limit?.id ?? '', + description: limit?.description ?? '', + key: (limit?.key?.length ? limit.key : ['ip']).map(parseKeyPart), + rate: limit?.rate ?? 100, + window: limit?.window ?? '1m', + algorithm: limit?.algorithm ?? 'fixed', + mode: limit?.mode ?? 'enforce', + status: limit?.status ?? DEFAULT_LIMIT_STATUS, + message: limit?.message ?? DEFAULT_LIMIT_MESSAGE + } +} + +/** + * Generate a stable, unique limit id that doesn't collide with `taken`. + * @param {string[]} taken + * @returns {string} + */ +export function genLimitId (taken) { + let id + do { + id = 'limit-' + Math.random().toString(36).slice(2, 8) + } while (taken.includes(id)) + return id +} + +/** + * Map API limits to form rows, giving every row a unique id. + * @param {Api.WafLimit[]} [apiLimits] + * @returns {LimitForm[]} + */ +export function normalizeLimits (apiLimits) { + /** @type {string[]} */ + const taken = [] + return (apiLimits ?? []).map((l) => { + const f = limitForm(l) + if (!f.id || taken.includes(f.id)) f.id = genLimitId(taken) + taken.push(f.id) + return f + }) +} + +/** + * Map form rows back to the API limit shape. Key rows are deduped (and + * incomplete header/cookie rows dropped), falling back to ['ip'] when nothing + * valid remains. + * @param {LimitForm[]} limits + * @returns {Api.WafLimit[]} + */ +export function toApiLimits (limits) { + return limits.map((l) => { + /** @type {string[]} */ + const key = [] + for (const row of l.key) { + const part = keyRowToApi(row) + if (part && !key.includes(part)) key.push(part) + } + if (key.length === 0) key.push('ip') + + return { + id: l.id, + description: l.description, + key, + rate: Number(l.rate) || 1, + window: l.window, + algorithm: l.algorithm === 'sliding' ? 'sliding' : 'fixed', + mode: l.mode === 'shadow' ? 'shadow' : 'enforce', + status: Number(l.status) === 503 ? 503 : DEFAULT_LIMIT_STATUS, + message: l.message || DEFAULT_LIMIT_MESSAGE + } + }) +} diff --git a/src/routes/(auth)/(project)/waf/+page.svelte b/src/routes/(auth)/(project)/waf/+page.svelte index fe1d3c94..c9673a48 100644 --- a/src/routes/(auth)/(project)/waf/+page.svelte +++ b/src/routes/(auth)/(project)/waf/+page.svelte @@ -97,6 +97,7 @@ Status Description Rules + Limits Matches (24h) @@ -133,6 +134,7 @@ {/if} {fw.rules?.length ?? 0} + {fw.limits?.length ?? 0} @@ -164,14 +166,14 @@ {/each} - + diff --git a/src/routes/(auth)/(project)/waf/create/+page.svelte b/src/routes/(auth)/(project)/waf/create/+page.svelte index 47a98c23..adb209c1 100644 --- a/src/routes/(auth)/(project)/waf/create/+page.svelte +++ b/src/routes/(auth)/(project)/waf/create/+page.svelte @@ -27,7 +27,8 @@ project, location: form.location, description: form.description, - rules: [] + rules: [], + limits: [] }, fetch) if (!resp.ok) { modal.error({ error: resp.error }) diff --git a/src/routes/(auth)/(project)/waf/edit/+page.svelte b/src/routes/(auth)/(project)/waf/edit/+page.svelte index 9ea9582d..ef660db5 100644 --- a/src/routes/(auth)/(project)/waf/edit/+page.svelte +++ b/src/routes/(auth)/(project)/waf/edit/+page.svelte @@ -14,6 +14,7 @@ normalizeRules, toApiRules } from '$lib/waf/rules' + import { normalizeLimits, toApiLimits } from '$lib/waf/limits' const { data } = $props() @@ -29,6 +30,9 @@ // The whole loaded zone's rules (ordered) are held in memory so Save can // rewrite the entire zone with the edited rule in place. const rules = untrack(() => normalizeRules(data.zone?.rules)) + // waf.set replaces the whole zone, so the zone's limits must be echoed back + // untouched — otherwise saving a rule would wipe them. + const limits = untrack(() => normalizeLimits(data.zone?.limits)) const description = untrack(() => data.zone?.description ?? '') // Index of the rule being edited, or -1 when adding a brand-new rule. const editIndex = untrack(() => (data.ruleId ? rules.findIndex((r) => r.id === data.ruleId) : -1)) @@ -102,7 +106,8 @@ project, location, description, - rules: toApiRules(nextRules) + rules: toApiRules(nextRules), + limits: toApiLimits(limits) }, fetch) if (!resp.ok) { modal.error({ error: resp.error }) diff --git a/src/routes/(auth)/(project)/waf/limit/+page.js b/src/routes/(auth)/(project)/waf/limit/+page.js new file mode 100644 index 00000000..df4ea4df --- /dev/null +++ b/src/routes/(auth)/(project)/waf/limit/+page.js @@ -0,0 +1,39 @@ +import { redirect, error } from '@sveltejs/kit' +import api from '$lib/api' + +export async function load ({ url, parent, fetch }) { + const { project } = await parent() + const location = url.searchParams.get('location') ?? '' + const limitId = url.searchParams.get('limit') + + if (!location) { + redirect(302, `/waf?project=${project}`) + } + + const manageUrl = `/waf/manage?project=${project}&location=${encodeURIComponent(location)}` + + /** @type {Api.Response} */ + const res = await api.invoke('waf.get', { project, location }, fetch) + // A missing zone is the normal "firewall not configured yet" state — render + // an empty editor (create mode). Surface anything else. + if (!res.ok && !res.error?.notFound) { + error(500, res.error?.message) + } + const zone = res.result ?? null + + // Editing a specific limit requires its zone + limit to exist; otherwise + // send the user back to the list for this location. + if (limitId) { + const found = zone?.limits?.some((l) => l.id === limitId) + if (!found) { + redirect(302, manageUrl) + } + } + + return { + project, + location, + limitId, + zone + } +} diff --git a/src/routes/(auth)/(project)/waf/limit/+page.svelte b/src/routes/(auth)/(project)/waf/limit/+page.svelte new file mode 100644 index 00000000..64d90510 --- /dev/null +++ b/src/routes/(auth)/(project)/waf/limit/+page.svelte @@ -0,0 +1,337 @@ + + + + +
+
+
+
+

{isCreate ? 'Add limit' : 'Edit limit'}

+
+

Rate limit in {location}

+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
Count requests by
+

+ Requests sharing every characteristic below share one counter bucket. + Defaults to client IP. +

+
+ +
+ {#each draft.key as row, i (i)} +
+
+
+ + +
+
+ {/if} +
+ {#if draft.key.length > 1} + + {/if} +
+ {/each} +
+ +
+ +
+
+ +
+ +
+
+
Allow at most
+

+ Requests over this rate are counted — and rejected when enforcing. +

+
+ +
+
+ +
+ +
+
+ requests per + +
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+ {/if} + +
+ +
+ + + {#if !canSave} +

+ {#if !keysComplete} + Name every header/cookie characteristic to save this limit. + {:else} + Set a rate of at least 1 request per window to save this limit. + {/if} +

+ {/if} +
+ + + + diff --git a/src/routes/(auth)/(project)/waf/manage/+page.svelte b/src/routes/(auth)/(project)/waf/manage/+page.svelte index f6efb6b6..82c3efa0 100644 --- a/src/routes/(auth)/(project)/waf/manage/+page.svelte +++ b/src/routes/(auth)/(project)/waf/manage/+page.svelte @@ -5,6 +5,7 @@ import api from '$lib/api' import DangerZone from '$lib/components/DangerZone.svelte' import { actionLabels, normalizeRules, toApiRules } from '$lib/waf/rules' + import { describeKey, keyRowToApi, modeLabels, normalizeLimits, toApiLimits } from '$lib/waf/limits' const { data } = $props() @@ -15,6 +16,7 @@ // (e.g. from the edit page) reloads the loader, which re-seeds this copy. let description = $state(untrack(() => data.zone?.description ?? '')) let rules = $state(untrack(() => normalizeRules(data.zone?.rules))) + let limits = $state(untrack(() => normalizeLimits(data.zone?.limits))) let loadedLocation = untrack(() => data.location ?? '') @@ -28,6 +30,7 @@ loadedLocation = next.location ?? '' description = next.zone?.description ?? '' rules = normalizeRules(next.zone?.rules) + limits = normalizeLimits(next.zone?.limits) } }) }) @@ -49,17 +52,23 @@ const zone = resp.result ?? null description = zone?.description ?? '' rules = normalizeRules(zone?.rules) + limits = normalizeLimits(zone?.limits) } - // Persist the whole zone (priority follows row order). On error, surface it - // and reload from the server so the UI matches reality. - /** @param {import('$lib/waf/rules').RuleForm[]} nextRules */ - async function persistZone (nextRules) { + // Persist the whole zone (priority follows row order). waf.set replaces the + // entire zone, so rules and limits must always travel together. On error, + // surface it and reload from the server so the UI matches reality. + /** + * @param {import('$lib/waf/rules').RuleForm[]} nextRules + * @param {import('$lib/waf/limits').LimitForm[]} [nextLimits] + */ + async function persistZone (nextRules, nextLimits = limits) { const resp = await api.invoke('waf.set', { project, location, description, - rules: toApiRules(nextRules) + rules: toApiRules(nextRules), + limits: toApiLimits(nextLimits) }, fetch) if (!resp.ok) { modal.error({ error: resp.error }) @@ -106,6 +115,30 @@ goto(`/waf/edit?project=${project}&location=${encodeURIComponent(location)}`) } + /** @param {number} i */ + function removeLimit (i) { + const limit = limits[i] + if (!limit) return + modal.confirm({ + title: `Delete rate limit ${limit.id}?`, + yes: 'Delete', + callback: async () => { + const next = limits.filter((_, k) => k !== i) + limits = next + await persistZone(rules, next) + } + }) + } + + /** @param {import('$lib/waf/limits').LimitForm} limit */ + function editLimit (limit) { + goto(`/waf/limit?project=${project}&location=${encodeURIComponent(location)}&limit=${encodeURIComponent(limit.id)}`) + } + + function addLimit () { + goto(`/waf/limit?project=${project}&location=${encodeURIComponent(location)}`) + } + async function saveDescription () { if (savingDescription) return savingDescription = true @@ -118,7 +151,7 @@ function deleteZone () { modal.confirm({ - title: `Disable the firewall in ${location}? All rules will be removed.`, + title: `Disable the firewall in ${location}? All rules and rate limits will be removed.`, yes: 'Disable', callback: async () => { const resp = await api.invoke('waf.delete', { project, location }, fetch) @@ -268,7 +301,87 @@ - +
+
+
+ +
+
+
Rate limits
+

+ Limit how often clients can hit your routes. Shadow mode counts + matches without rejecting. +

+
+
+ +
+ + + + + + + + + + + + {#each limits as limit, i (limit.id)} + + + + + + + + {/each} + {#if limits.length === 0} + + + + {/if} + + + + + + +
DescriptionKeyLimitModeActions
+ {#if limit.description} + {limit.description} + {:else} + + {/if} + {describeKey(limit.key.map(keyRowToApi).filter(Boolean))} + {limit.rate} / {limit.window} + + + {modeLabels[limit.mode] ?? limit.mode} + + +
+ + +
+
+ No rate limits yet. Add a limit to throttle traffic. +
+ +
+
+ + @@ -296,4 +409,22 @@ color: hsl(var(--hsl-positive)); background-color: hsl(var(--hsl-positive) / 0.12); } + + .mode-badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.5; + color: hsl(var(--hsl-content) / 0.75); + background-color: hsl(var(--hsl-content) / 0.08); + } + + /* Shadow only observes — render it muted so Enforce stands out. */ + .mode-badge[data-mode='shadow'] { + color: hsl(var(--hsl-content) / 0.5); + background-color: hsl(var(--hsl-content) / 0.05); + } diff --git a/src/routes/(auth)/(project)/waf/metrics/+page.svelte b/src/routes/(auth)/(project)/waf/metrics/+page.svelte index 81a0a075..3b8fce48 100644 --- a/src/routes/(auth)/(project)/waf/metrics/+page.svelte +++ b/src/routes/(auth)/(project)/waf/metrics/+page.svelte @@ -6,7 +6,9 @@ import * as modal from '$lib/modal' import * as format from '$lib/format' import { actionLabels } from '$lib/waf/rules' + import { describeKey, modeLabels } from '$lib/waf/limits' import WafActivityChart from '$lib/components/WafActivityChart.svelte' + import LineChart from '$lib/components/LineChart.svelte' const { data } = $props() @@ -20,6 +22,10 @@ (data.zone?.rules ?? []).map((/** @type {Api.WafRule} */ r) => [r.id, r]) )) + // Configured limits drive the rate-limit section: no limits, no section. + const limits = $derived(/** @type {Api.WafLimit[]} */ (data.zone?.limits ?? [])) + const hasLimits = $derived(limits.length > 0) + // Stacks render block on top of log on top of allow — most-severe last so it // sits at the top of the column. /** @type {Api.WafAction[]} */ @@ -53,6 +59,7 @@ let loading = $state(true) let result = $state(/** @type {Api.WafMetricsResult | null} */ (null)) + let limitResult = $state(/** @type {Api.WafLimitMetricsResult | null} */ (null)) // Anchor the time window to the moment of the latest fetch so the chart grid // and "as of" line agree. let fetchedAt = $state(Math.floor(Date.now() / 1000)) @@ -63,13 +70,19 @@ async function fetchMetrics () { loading = true try { - /** @type {Api.Response} */ - const res = await api.invoke('waf.metrics', { project, location, timeRange: range }, fetch) + const args = { project, location, timeRange: range } + const [res, limitRes] = await Promise.all([ + /** @type {Promise>} */ (api.invoke('waf.metrics', args, fetch)), + hasLimits + ? /** @type {Promise>} */ (api.invoke('waf.limitMetrics', args, fetch)) + : Promise.resolve(/** @type {Api.Response} */ ({ ok: true, result: { series: [], total: 0 } })) + ]) if (!res.ok) { modal.error({ error: res.error }) return } result = res.result ?? { series: [], total: 0 } + limitResult = (limitRes.ok ? limitRes.result : null) ?? { series: [], total: 0 } fetchedAt = Math.floor(Date.now() / 1000) } finally { loading = false @@ -94,6 +107,7 @@ u.searchParams.set('range', r) replaceState(u, {}) result = null + limitResult = null fetchMetrics() } @@ -184,8 +198,70 @@ return total > 0 ? `${Math.round((v / total) * 100)}%` : '—' } + // Per-limit (allowed, limited) bucket counts, joined from the sparse series. + const limitBuckets = $derived.by(() => { + /** @type {Record, limited: Record }>} */ + const byLimit = {} + for (const s of limitResult?.series ?? []) { + const entry = byLimit[s.limitId] ??= { allowed: {}, limited: {} } + const m = entry[s.result] + if (!m) continue + for (const [ts, v] of s.points ?? []) { + m[ts] = (m[ts] ?? 0) + v + } + } + return byLimit + }) + + // One share line per configured limit: limited / (allowed + limited) as a + // percentage, over the union of that limit's bucket timestamps. A missing + // series counts as 0, so a bucket with only limited traffic reads 100%. + const limitShareSeries = $derived.by(() => { + /** @type {import('$lib/charts/util').LineSeries[]} */ + const out = [] + for (const limit of limits) { + const entry = limitBuckets[limit.id] + if (!entry) continue + const xs = [...new Set([...Object.keys(entry.allowed), ...Object.keys(entry.limited)])] + .map(Number) + .sort((a, b) => a - b) + if (!xs.length) continue + const name = limit.description || limit.id + out.push({ + name: limit.mode === 'shadow' ? `${name} · shadow` : name, + dashed: limit.mode === 'shadow', + points: xs.map((ts) => { + const allowed = entry.allowed[ts] ?? 0 + const limited = entry.limited[ts] ?? 0 + const t = allowed + limited + return { x: ts * 1000, y: t > 0 ? (limited / t) * 100 : 0 } + }) + }) + } + return out + }) + + // Summary rows — every configured limit gets one, with — when it saw no + // traffic in the window. + const limitRows = $derived(limits.map((limit) => { + const entry = limitBuckets[limit.id] + const sum = (/** @type {Record | undefined} */ m) => + Object.values(m ?? {}).reduce((acc, v) => acc + v, 0) + const allowed = sum(entry?.allowed) + const limited = sum(entry?.limited) + const traffic = allowed + limited + return { limit, allowed, limited, traffic, share: traffic > 0 ? (limited / traffic) * 100 : null } + })) + + /** @param {number} v */ + function sharePct (v) { + return `${v >= 10 ? v.toFixed(1) : v.toFixed(2)}%` + } + const showSpinner = $derived(loading && !result) const isEmpty = $derived(!loading && total === 0) + const limitSpinner = $derived(loading && !limitResult) + const limitEmpty = $derived(!limitSpinner && limitShareSeries.length === 0) {/if} +{#if hasLimits} +
+
+
+
Rate limit activity
+

Share of requests over each limit — size shadow limits here before enforcing them.

+
+
+ +
+ {#if limitSpinner} +
+ +
+ {:else if limitEmpty} +
+ +

No rate limit activity in this range.

+

No request hit a configured limit's bucket in the {RANGE_LABEL[range]}.

+
+ {:else} + `${format.count(v)}%`} + formatValue={(v) => sharePct(v)} + legend /> + {/if} +
+ +
+ + + + + + + + + + + + {#each limitRows as row (row.limit.id)} + + + + + + + + {/each} + +
LimitModeAllowedLimitedShare
+
+ {row.limit.description || row.limit.id} + {describeKey(row.limit.key)} · {row.limit.rate} / {row.limit.window} +
+
+ + {modeLabels[row.limit.mode ?? 'enforce'] ?? row.limit.mode} + + + {#if row.traffic > 0} + {row.allowed.toLocaleString()} + {:else} + + {/if} + + {#if row.traffic > 0} + {row.limited.toLocaleString()} + {#if row.limit.mode === 'shadow'} + would be limited + {/if} + {:else} + + {/if} + + {#if row.share != null} + {sharePct(row.share)} + {:else} + + {/if} +
+
+
+{/if} + diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 14552fcd..9e1fe05f 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -486,11 +486,30 @@ declare namespace Api { priority: number } + export type WafLimit = { + id: string + description: string + // Bucket key characteristics. Allowed entries: 'ip' | 'host' | 'asn' | + // 'country' | 'header:' | 'cookie:'. Defaults to ['ip']. + key: string[] + // Requests per window per key; must be > 0. + rate: number + // Go duration string, 1s..1h (e.g. '10s', '1m', '1h'). + window: string + algorithm?: 'fixed' | 'sliding' + // shadow counts matches but never rejects (default enforce). + mode?: 'enforce' | 'shadow' + // 429 (default) or 503 only. + status?: number + message?: string + } + export type WafZone = { project: string location: string description: string rules: WafRule[] + limits: WafLimit[] status: 'pending' | 'success' | 'error' action: 'create' | 'delete' createdAt: string @@ -517,6 +536,19 @@ declare namespace Api { total: number } + export type WafLimitMetricsSeries = { + limitId: string + result: 'allowed' | 'limited' + total: number + // [unixSeconds, count], time-ordered; sparse — missing bucket = 0 + points: [number, number][] + } + + export type WafLimitMetricsResult = { + series: WafLimitMetricsSeries[] + total: number + } + export type DropboxItem = { downloadUrl: string filename: string