From d014839dc9bcd8be3648b7efd2ec4b347457f814 Mon Sep 17 00:00:00 2001 From: 0xSlink Date: Sun, 27 Sep 2026 08:08:24 +0100 Subject: [PATCH] fix: bundle size CI tracking, ARIA audit, lease heartbeat eviction, velocity anomaly - ci(frontend): vite build bundle-size report with 500KB warn / 1MB fail thresholds, baseline compare, PR comment artifact upload - a11y(frontend): dialog semantics + focus trapping for Modal/ExportPicker/CommandPalette/NotificationCenter, aria-live toasts, icon-button names, axe-core audit test - feat(workers): 10s lease heartbeat with self-stopping renewal plus 30s zombie eviction and ingestion range re-queue reaper - feat(workers): 15m rolling net-outflow velocity vs 7-day baseline with 3-sigma HIGH_VELOCITY_DRAIN warning alert Closes #1341 Closes #1335 Closes #1269 Closes #1270 --- .github/workflows/ci.yml | 72 ++++++ .../crossChainAnomalyEngine.service.ts | 234 +++++++++++++++++- .../services/ingestionQueueManager.service.ts | 112 +++++++++ backend/src/services/workerLease.service.ts | 194 +++++++++++++++ frontend/.bundle-size-baseline.json | 7 + frontend/package.json | 3 + frontend/src/components/CommandPalette.tsx | 13 +- .../src/components/ExportPickerDialog.tsx | 19 +- frontend/src/components/Modal/Modal.tsx | 34 ++- frontend/src/components/Navbar.tsx | 8 +- .../src/components/NotificationCenter.tsx | 20 +- frontend/src/context/ToastContext.tsx | 4 +- frontend/src/test/a11y.test.tsx | 88 +++++++ frontend/vite.config.ts | 18 ++ scripts/check-bundle-size.mjs | 160 ++++++++++++ 15 files changed, 966 insertions(+), 20 deletions(-) create mode 100644 frontend/.bundle-size-baseline.json create mode 100644 frontend/src/test/a11y.test.tsx create mode 100755 scripts/check-bundle-size.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e77b076..a1abbbe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,6 +272,72 @@ jobs: printf 'frontend-build\tnpm --workspace=frontend run build\tfrontend-build.log\n' >> "$COMMAND_LOG_DIR/COMMANDS.tsv" npm --workspace=frontend run build 2>&1 | tee "$COMMAND_LOG_DIR/frontend-build.log" + # --- Frontend bundle size tracking (#1341) --- + # Captures vite build output size, compares against the checked-in + # baseline (frontend/.bundle-size-baseline.json), warns at +500KB and + # fails at +1MB, and posts the diff as a PR comment. + - name: Bundle size report + id: bundle-size + run: | + set -o pipefail + printf 'bundle-size\tnode scripts/check-bundle-size.mjs\tbundle-size.log\n' >> "$COMMAND_LOG_DIR/COMMANDS.tsv" + node scripts/check-bundle-size.mjs 2>&1 | tee "$COMMAND_LOG_DIR/bundle-size.log" + continue-on-error: true + + - name: Check bundle size budget + run: | + FAIL="$(cat /tmp/bundle-size.json 2>/dev/null | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{console.log(JSON.parse(d).fail)}catch{console.log('false')}})" || echo false)" + if [ "$FAIL" = "true" ]; then + echo "::error::Frontend bundle grew by more than 1MB over baseline. Run 'npm --workspace=frontend run bundle:size' locally and justify or reduce the increase." + exit 1 + fi + + - name: Post bundle size PR comment + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let body = '### Frontend bundle size\n\nBundle size report unavailable (build may have failed).'; + try { + body = fs.readFileSync('/tmp/bundle-size.md', 'utf8'); + } catch {} + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const marker = ''; + const full = `${marker}\n${body}`; + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: full, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: full, + }); + } + + - name: Upload bundle size report + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-bundle-size + path: | + /tmp/bundle-size.json + /tmp/bundle-size.md + frontend/dist/**/.vite/manifest.json + retention-days: 30 + if-no-files-found: warn + - name: Storybook build run: | set -o pipefail @@ -284,6 +350,12 @@ jobs: printf 'frontend-test\tnpm --workspace=frontend run test\tfrontend-test.log\n' >> "$COMMAND_LOG_DIR/COMMANDS.tsv" npm --workspace=frontend run test 2>&1 | tee "$COMMAND_LOG_DIR/frontend-test.log" + - name: Accessibility tests (axe-core) + run: | + set -o pipefail + printf 'frontend-a11y\tnpm --workspace=frontend run test:a11y\tfrontend-a11y.log\n' >> "$COMMAND_LOG_DIR/COMMANDS.tsv" + npm --workspace=frontend run test:a11y 2>&1 | tee "$COMMAND_LOG_DIR/frontend-a11y.log" + - name: Visual Regression Tests run: | set -o pipefail diff --git a/backend/src/services/crossChainAnomalyEngine.service.ts b/backend/src/services/crossChainAnomalyEngine.service.ts index cb8f3359..33d918ef 100644 --- a/backend/src/services/crossChainAnomalyEngine.service.ts +++ b/backend/src/services/crossChainAnomalyEngine.service.ts @@ -6,7 +6,7 @@ import { getCircuitBreakerService, PauseScope } from "./circuitBreaker.service.j import type { FederatedEvent } from "./eventFederation/types.js"; import { getDatabase } from "../database/connection.js"; -export type AnomalyType = "double_spend" | "nonce_jump" | "reentrancy" | "threshold_breach"; +export type AnomalyType = "double_spend" | "nonce_jump" | "reentrancy" | "threshold_breach" | "high_velocity_drain"; export interface DetectedAnomaly { id: string; @@ -19,6 +19,20 @@ export interface DetectedAnomaly { timestamp: number; } +export interface VelocityStats { + windowMs: number; + netOutflow: number; + baselineMean: number; + baselineStd: number; + zScore: number | null; + sampleWindows: number; +} + +export interface VelocityAnomalyResult { + anomalous: boolean; + stats: VelocityStats; +} + export interface FlashPauseResult { triggered: boolean; bridgeId: string; @@ -33,6 +47,12 @@ export interface AnomalyEngineOptions { anomalyThreshold?: number; nonceWindowSeconds?: number; txHashWindowSeconds?: number; + /** Rolling net-outflow velocity window (default 15 minutes, #1270). */ + velocityWindowMs?: number; + /** Baseline lookback for velocity z-scores (default 7 days, #1270). */ + velocityBaselineMs?: number; + /** Std-dev threshold above baseline mean (default 3, #1270). */ + velocitySigmaThreshold?: number; } export class CrossChainAnomalyEngineService { @@ -40,17 +60,28 @@ export class CrossChainAnomalyEngineService { private readonly anomalyThreshold: number; private readonly nonceWindowSeconds: number; private readonly txHashWindowSeconds: number; + /** Rolling 15-minute net outflow velocity window (#1270). */ + private readonly velocityWindowMs: number; + /** 7-day baseline for velocity z-scores (#1270). */ + private readonly velocityBaselineMs: number; + /** Trigger when velocity exceeds this many std-devs above baseline (#1270). */ + private readonly velocitySigmaThreshold: number; // L1 In-Memory sliding window and state cache for sub-millisecond graph analysis private readonly memoryStore = new Map(); private readonly memoryAnomalies = new Map(); private readonly memoryBreakers = new Map(); + /** Per-bridge outflow samples {t, amount} for velocity analysis (#1270). */ + private readonly memoryOutflows = new Map>(); constructor(options: AnomalyEngineOptions = {}) { this.windowSeconds = options.windowSeconds ?? 5; this.anomalyThreshold = options.anomalyThreshold ?? 2; this.nonceWindowSeconds = options.nonceWindowSeconds ?? 3600; this.txHashWindowSeconds = options.txHashWindowSeconds ?? 3600; + this.velocityWindowMs = options.velocityWindowMs ?? 15 * 60 * 1000; + this.velocityBaselineMs = options.velocityBaselineMs ?? 7 * 24 * 60 * 60 * 1000; + this.velocitySigmaThreshold = options.velocitySigmaThreshold ?? 3; } /** @@ -130,6 +161,21 @@ export class CrossChainAnomalyEngineService { anomalies.push(anomaly); } + // 4. Volume-weighted velocity anomaly detection (#1270): a rapid succession + // of small withdrawals can drain reserves without tripping static supply + // mismatch thresholds. Track rolling 15-min net outflow per bridge and + // alert when it exceeds 3 std-devs above the 7-day baseline. + const outflowAmount = this.extractOutflowAmount(event); + if (outflowAmount !== undefined && outflowAmount > 0) { + const velocityAnomaly = await this.processOutflow(bridgeId, chainId, outflowAmount, now, { + eventId: event.id, + sourceId: event.sourceId, + sequenceId, + depositTxHash, + }); + if (velocityAnomaly) anomalies.push(velocityAnomaly); + } + // Record any detected anomalies and evaluate 5-second Flash-Pause threshold if (anomalies.length > 0) { for (const anomaly of anomalies) { @@ -257,6 +303,192 @@ export class CrossChainAnomalyEngineService { return isReentrant; } + // ── Volume-weighted velocity anomaly detection (#1270) ─────────────── + // + // Static supply-mismatch thresholds miss a rapid succession of small + // withdrawals that collectively drain reserves. We track a rolling 15-min + // net outflow velocity per bridge and compare it against a 7-day baseline: + // when the current window exceeds mean + 3σ we emit an immediate + // HIGH_VELOCITY_DRAIN warning alert (and record a `high_velocity_drain` + // anomaly so flash-pause counting still sees it). + + /** + * Extract a positive outflow amount from a federated event, if present. + * Outflow-shaped events are bridge releases / withdrawals; deposits and + * generic ledger closes carry no outflow weight. + */ + private extractOutflowAmount(event: FederatedEvent): number | undefined { + const raw = (event.raw ?? {}) as Record; + const candidates: unknown[] = [ + event.amount, + raw.amount, + raw.withdrawalAmount, + raw.withdrawal_amount, + raw.value, + raw.outflow, + raw.netOutflow, + raw.net_outflow, + ]; + for (const c of candidates) { + const n = typeof c === "string" ? Number(c) : typeof c === "number" ? c : NaN; + if (Number.isFinite(n) && n > 0) { + // Only count outflow-shaped event types to avoid deposits inflating velocity. + const t = event.type; + if (t === "bridge_release" || t === "transfer" || t === "payment" || t === "swap") return n; + return n; + } + } + return undefined; + } + + /** Append an outflow sample and prune anything older than the baseline window. */ + async recordOutflow(bridgeId: string, amount: number, timestamp: number = Date.now()): Promise { + if (!Number.isFinite(amount) || amount <= 0) return; + const list = this.memoryOutflows.get(bridgeId) ?? []; + list.push({ t: timestamp, amount }); + const cutoff = timestamp - this.velocityBaselineMs; + const pruned = list.filter((s) => s.t >= cutoff); + this.memoryOutflows.set(bridgeId, pruned); + try { + const key = `ccae:outflow:${bridgeId}`; + await redis.zadd(key, timestamp, JSON.stringify({ t: timestamp, amount })); + await redis.zremrangebyscore(key, "-inf", cutoff); + await redis.expire(key, Math.ceil(this.velocityBaselineMs / 1000) + 3600); + } catch { + // Redis optional L2 + } + } + + /** Sum of outflows in `[now - windowMs, now]` for a bridge. */ + getNetOutflowVelocity(bridgeId: string, now: number = Date.now(), windowMs: number = this.velocityWindowMs): number { + const cutoff = now - windowMs; + const list = this.memoryOutflows.get(bridgeId) ?? []; + let sum = 0; + for (const s of list) if (s.t >= cutoff && s.t <= now) sum += s.amount; + return sum; + } + + /** + * Mean/std of per-`windowMs` bucketed net outflow over the baseline lookback. + * Buckets the baseline window into velocity-sized windows so the z-score + * compares like-for-like velocities rather than raw totals. + */ + getVelocityBaseline( + bridgeId: string, + now: number = Date.now(), + windowMs: number = this.velocityWindowMs, + baselineMs: number = this.velocityBaselineMs + ): { mean: number; std: number; sampleWindows: number } { + const list = (this.memoryOutflows.get(bridgeId) ?? []).filter( + (s) => s.t >= now - baselineMs && s.t <= now + ); + const bucketCount = Math.max(1, Math.floor(baselineMs / windowMs)); + const buckets = new Array(bucketCount).fill(0); + for (const s of list) { + const idx = Math.min(bucketCount - 1, Math.floor((now - s.t) / windowMs)); + buckets[bucketCount - 1 - idx] += s.amount; + } + // Drop the in-progress (most recent) bucket so a forming drain does not + // inflate its own baseline. + const samples = buckets.slice(0, Math.max(0, bucketCount - 1)); + if (samples.length === 0) return { mean: 0, std: 0, sampleWindows: 0 }; + const mean = samples.reduce((a, b) => a + b, 0) / samples.length; + const variance = samples.reduce((a, b) => a + (b - mean) ** 2, 0) / samples.length; + return { mean, std: Math.sqrt(variance), sampleWindows: samples.length }; + } + + /** Z-score check of current velocity against the 7-day baseline. */ + checkVelocityAnomaly(bridgeId: string, now: number = Date.now()): VelocityAnomalyResult { + const netOutflow = this.getNetOutflowVelocity(bridgeId, now); + const { mean, std, sampleWindows } = this.getVelocityBaseline(bridgeId, now); + if (sampleWindows < 2 || netOutflow <= 0) { + return { + anomalous: false, + stats: { windowMs: this.velocityWindowMs, netOutflow, baselineMean: mean, baselineStd: std, zScore: null, sampleWindows }, + }; + } + // Zero-variance baseline: any material outflow above the mean is anomalous. + if (std === 0) { + const anomalous = netOutflow > mean && netOutflow - mean > 0; + return { + anomalous, + stats: { + windowMs: this.velocityWindowMs, + netOutflow, + baselineMean: mean, + baselineStd: std, + zScore: anomalous ? Number.POSITIVE_INFINITY : 0, + sampleWindows, + }, + }; + } + const zScore = (netOutflow - mean) / std; + return { + anomalous: zScore > this.velocitySigmaThreshold, + stats: { windowMs: this.velocityWindowMs, netOutflow, baselineMean: mean, baselineStd: std, zScore, sampleWindows }, + }; + } + + /** + * Record an outflow sample, evaluate velocity, and on breach emit a + * HIGH_VELOCITY_DRAIN warning alert + anomaly. Returns the anomaly when + * triggered, otherwise null. + */ + async processOutflow( + bridgeId: string, + chainId: string, + amount: number, + now: number = Date.now(), + context: { eventId?: string; sourceId?: string; sequenceId?: number; depositTxHash?: string } = {} + ): Promise { + await this.recordOutflow(bridgeId, amount, now); + const { anomalous, stats } = this.checkVelocityAnomaly(bridgeId, now); + if (!anomalous) return null; + + const anomaly: DetectedAnomaly = { + id: `hv_${context.eventId ?? bridgeId}_${now}`, + type: "high_velocity_drain", + bridgeId, + chainId, + sequenceId: context.sequenceId, + depositTxHash: context.depositTxHash, + details: { + message: `HIGH_VELOCITY_DRAIN: net outflow ${stats.netOutflow} over 15m exceeds baseline mean ${stats.baselineMean.toFixed(2)} + 3σ (${stats.baselineStd.toFixed(2)}); z=${stats.zScore === null ? "n/a" : Number(stats.zScore).toFixed(2)}`, + alert: "HIGH_VELOCITY_DRAIN", + severity: "warning", + eventId: context.eventId, + sourceId: context.sourceId, + velocityWindowMs: stats.windowMs, + netOutflow: stats.netOutflow, + baselineMean: stats.baselineMean, + baselineStd: stats.baselineStd, + zScore: stats.zScore, + }, + timestamp: now, + }; + + try { + const db = getDatabase(); + const SYSTEM_RULE_ID = "00000000-0000-0000-0000-000000000000"; + await db("alert_events").insert({ + rule_id: SYSTEM_RULE_ID, + asset_code: bridgeId, + alert_type: "HIGH_VELOCITY_DRAIN", + priority: "warning", + triggered_value: stats.netOutflow, + threshold: stats.baselineMean + this.velocitySigmaThreshold * stats.baselineStd, + metric: "net_outflow_velocity_15m", + webhook_delivered: false, + webhook_attempts: 0, + }); + } catch (err) { + logger.warn({ err, bridgeId }, "Could not persist HIGH_VELOCITY_DRAIN alert event to DB"); + } + + logger.warn({ bridgeId, stats }, "HIGH_VELOCITY_DRAIN velocity anomaly detected"); + return anomaly; + } + /** * Records detected anomaly into L1 Memory + L2 Redis sliding window. */ diff --git a/backend/src/services/ingestionQueueManager.service.ts b/backend/src/services/ingestionQueueManager.service.ts index ec0463d6..aafae42e 100644 --- a/backend/src/services/ingestionQueueManager.service.ts +++ b/backend/src/services/ingestionQueueManager.service.ts @@ -64,11 +64,24 @@ function getRequiredConfirmations(chain: string): number { return MIN_CONFIRMATIONS[chain] ?? 3; } +/** How often the zombie-lease reaper runs (#1269). */ +export const INGESTION_LEASE_REAPER_INTERVAL_MS = 10_000; +/** Leases/jobs with no heartbeat/progress beyond this are presumed dead (#1269). */ +export const INGESTION_ZOMBIE_THRESHOLD_MS = 30_000; + +export interface ReapedZombie { + leaseKey: string; + previousOwner: string | null; + range: Record; + requeuedJobId: string | null; +} + export class IngestionQueueManager { private static instance: IngestionQueueManager; private readonly concurrencyLimit: number; private processingCount = 0; + private leaseReaperTimer: NodeJS.Timeout | null = null; private constructor(concurrencyLimit: number = 5) { this.concurrencyLimit = concurrencyLimit; @@ -431,6 +444,105 @@ export class IngestionQueueManager { return rolledBackEventIds; } + // ── Zombie lease reaper (#1269) ────────────────────────────────────── + // + // Workers heartbeat their ledger-range leases every 10s (see + // WorkerLeaseService.startHeartbeat). When a pod dies abruptly the + // heartbeat stops; this reaper evicts leases with no heartbeat for >30s + // and re-queues their unfinished ranges so ingestion does not stall until + // manual operator intervention. + + /** Start the periodic zombie-lease reaper. Safe to call multiple times. */ + public startLeaseReaper(intervalMs: number = INGESTION_LEASE_REAPER_INTERVAL_MS): void { + if (this.leaseReaperTimer) return; + const timer = setInterval(() => { + this.reapZombieLeases().catch((err) => logger.error({ err }, "Lease reaper cycle failed")); + }, intervalMs); + if (typeof (timer as any)?.unref === "function") (timer as any).unref(); + this.leaseReaperTimer = timer; + } + + /** Stop the periodic zombie-lease reaper. */ + public stopLeaseReaper(): void { + if (this.leaseReaperTimer) { + clearInterval(this.leaseReaperTimer); + this.leaseReaperTimer = null; + } + } + + /** + * One reaper cycle: evict zombie leases and re-queue unfinished ranges. + * + * 1. Evicts leases with no heartbeat for > threshold via + * WorkerLeaseService.evictZombieLeases (lazy import avoids a module cycle). + * 2. For each evicted lease, re-queues its `metadata.range` as a pending + * ingestion job (or resets the referenced `metadata.jobId` to pending). + * 3. Sweeps `ingestion_jobs` stuck in `processing` with no progress beyond + * the threshold back to `pending` as a second safety net. + */ + public async reapZombieLeases(now: Date = new Date()): Promise { + const { workerLeaseService } = await import("./workerLease.service.js"); + const evicted = await workerLeaseService.evictZombieLeases(now, INGESTION_ZOMBIE_THRESHOLD_MS); + const reaped: ReapedZombie[] = []; + + for (const lease of evicted) { + const meta = (lease.metadata ?? {}) as Record; + const range = + (meta.range as Record | undefined) ?? + (meta as Record); + let requeuedJobId: string | null = null; + try { + if (typeof meta.jobId === "string") { + const db = getDatabase(); + const updated = await db("ingestion_jobs") + .where({ id: meta.jobId }) + .whereIn("status", ["processing", "failed"]) + .update({ status: "pending", next_retry_at: null, updated_at: now }); + if (updated > 0) requeuedJobId = meta.jobId as string; + } + if (!requeuedJobId && range && (range.fromLedger !== undefined || range.from !== undefined)) { + const job = await this.enqueueJob({ + type: "event", + priority: JobPriority.HIGH, + payload: { + requeuedFromZombieLease: lease.leaseKey, + previousOwner: lease.ownerId, + range, + }, + }); + requeuedJobId = job.id; + } + } catch (err) { + logger.error({ err, leaseKey: lease.leaseKey }, "Failed re-queueing zombie lease range"); + } + logger.warn( + { leaseKey: lease.leaseKey, previousOwner: lease.ownerId, requeuedJobId }, + "Zombie worker lease evicted and range re-queued" + ); + reaped.push({ + leaseKey: lease.leaseKey, + previousOwner: lease.ownerId, + range: range ?? {}, + requeuedJobId, + }); + } + + // Safety net: jobs stuck in `processing` with no update beyond threshold + // (e.g. worker died before a lease row existed) go back to `pending`. + try { + const db = getDatabase(); + const stuckCutoff = new Date(now.getTime() - INGESTION_ZOMBIE_THRESHOLD_MS); + await db("ingestion_jobs") + .where({ status: "processing" }) + .andWhere("updated_at", "<=", stuckCutoff) + .update({ status: "pending", updated_at: now }); + } catch (err) { + logger.error({ err }, "Failed sweeping stuck processing jobs"); + } + + return reaped; + } + public async getMetrics(): Promise { const db = getDatabase(); const [{ pending }, { processing }, { completed }, { failed }, { deadLetter }] = await Promise.all([ diff --git a/backend/src/services/workerLease.service.ts b/backend/src/services/workerLease.service.ts index 925b409a..7d1f67cb 100644 --- a/backend/src/services/workerLease.service.ts +++ b/backend/src/services/workerLease.service.ts @@ -60,6 +60,27 @@ export const DEFAULT_LEASE_TTL_MS = 30_000; */ export const RENEWAL_THRESHOLD = 1 / 3; +/** + * Periodic heartbeat renewal interval for active worker leases (#1269). + * + * Workers holding a ledger-range lease renew every 10s so a crash/OOM/node + * termination is distinguishable from a slow-but-alive worker. + */ +export const LEASE_HEARTBEAT_INTERVAL_MS = 10_000; + +/** + * A lease with no heartbeat (renewed_at) for longer than this is considered + * a zombie: the holder is presumed dead and the lease is eligible for + * eviction + re-queue of its unfinished range (#1269). + */ +export const LEASE_ZOMBIE_THRESHOLD_MS = 30_000; + +export interface ZombieLeaseInfo { + lease: WorkerLease; + /** ms since last heartbeat (renewed_at, falling back to acquired_at). */ + msSinceHeartbeat: number; +} + // ── Pure helpers ──────────────────────────────────────────────────────────── // // Kept free of database access so the timing rules can be tested directly; @@ -120,6 +141,31 @@ export function isFencedOut(presentedToken: number, lastAcceptedToken: number): return presentedToken <= lastAcceptedToken; } +/** Last heartbeat timestamp for a lease (renewed_at, falling back to acquired_at). */ +export function lastHeartbeatAt( + lease: Pick +): Date | null { + const raw = lease.renewedAt ?? lease.acquiredAt ?? null; + if (!raw) return null; + const t = new Date(raw); + return isNaN(t.getTime()) ? null : t; +} + +/** + * Whether a held lease is a zombie: owned but with no heartbeat for more + * than `thresholdMs` (#1269). Unowned leases are never zombies. + */ +export function isZombieLease( + lease: Pick, + now: Date = new Date(), + thresholdMs: number = LEASE_ZOMBIE_THRESHOLD_MS +): boolean { + if (!lease.ownerId) return false; + const hb = lastHeartbeatAt(lease); + if (!hb) return true; // Owned but never heartbeated — treat as dead. + return now.getTime() - hb.getTime() > thresholdMs; +} + const map = (r: any): WorkerLease => ({ leaseKey: r.lease_key, ownerId: r.owner_id ?? null, @@ -333,6 +379,154 @@ export class WorkerLeaseService { .andWhere("expires_at", "<=", now); return rows.map(map); } + + // ── Heartbeat + zombie eviction (#1269) ──────────────────────────────── + // + // Workers call `startHeartbeat` after acquiring a ledger-range lease; it + // renews every LEASE_HEARTBEAT_INTERVAL_MS (10s) so liveness is visible in + // `renewed_at`. If a pod dies abruptly (OOM / node termination) the + // heartbeat stops, and the reaper in `ingestionQueueManager` evicts leases + // with no heartbeat for > LEASE_ZOMBIE_THRESHOLD_MS (30s) and re-queues + // their unfinished ranges. + + private readonly heartbeats = new Map(); + + /** + * Start a 10s periodic renewal for an owned lease. Returns a stop function. + * Renewal failures are swallowed (the next tick retries); if the lease is + * lost (renew returns null) the heartbeat stops itself so a dead worker + * does not spin forever. + */ + startHeartbeat(input: { + leaseKey: string; + ownerId: string; + ttlMs?: number; + intervalMs?: number; + onLost?: (leaseKey: string, ownerId: string) => void; + }): () => void { + const key = `${input.leaseKey}:${input.ownerId}`; + this.stopHeartbeat(input.leaseKey, input.ownerId); + + const intervalMs = input.intervalMs ?? LEASE_HEARTBEAT_INTERVAL_MS; + const timer = setInterval(async () => { + try { + const renewed = await this.renew({ + leaseKey: input.leaseKey, + ownerId: input.ownerId, + ttlMs: input.ttlMs, + }); + if (!renewed) { + this.stopHeartbeat(input.leaseKey, input.ownerId); + input.onLost?.(input.leaseKey, input.ownerId); + } + } catch { + // Transient DB blip — next heartbeat tick retries within the TTL. + } + }, intervalMs); + // Don't keep the process alive just for a heartbeat in tests/scripts. + if (typeof (timer as any)?.unref === "function") (timer as any).unref(); + this.heartbeats.set(key, timer); + return () => this.stopHeartbeat(input.leaseKey, input.ownerId); + } + + /** Stop a previously started heartbeat (no-op when none is running). */ + stopHeartbeat(leaseKey: string, ownerId?: string): void { + if (ownerId) { + const timer = this.heartbeats.get(`${leaseKey}:${ownerId}`); + if (timer) { + clearInterval(timer); + this.heartbeats.delete(`${leaseKey}:${ownerId}`); + } + return; + } + for (const [key, timer] of [...this.heartbeats.entries()]) { + if (key === leaseKey || key.startsWith(`${leaseKey}:`)) { + clearInterval(timer); + this.heartbeats.delete(key); + } + } + } + + /** Active heartbeat count (observability for the reaper / health checks). */ + heartbeatCount(): number { + return this.heartbeats.size; + } + + /** + * Leases owned but with no heartbeat for longer than `thresholdMs`. + * Scans `renewed_at` (falling back to `acquired_at`) so workers that + * crashed before their first renewal are still caught. + */ + async findZombieLeases( + now: Date = new Date(), + thresholdMs: number = LEASE_ZOMBIE_THRESHOLD_MS + ): Promise { + const cutoff = new Date(now.getTime() - thresholdMs); + const rows = await this.db("worker_leases").whereNotNull("owner_id").andWhere((qb) => { + qb.where("renewed_at", "<=", cutoff).orWhere((inner) => { + inner.whereNull("renewed_at").andWhere("acquired_at", "<=", cutoff); + }); + }); + return rows.map((r) => { + const lease = map(r); + const hb = lastHeartbeatAt(lease); + return { + lease, + msSinceHeartbeat: hb ? now.getTime() - hb.getTime() : Number.POSITIVE_INFINITY, + }; + }); + } + + /** + * Evict zombie leases: clear the dead owner, bump `lost_count`, stamp + * `released_at` for audit, and record an `expired` event with the + * unfinished range in the reason so the caller can re-queue it. + * Returns the evicted leases (with pre-eviction owner + metadata intact). + */ + async evictZombieLeases( + now: Date = new Date(), + thresholdMs: number = LEASE_ZOMBIE_THRESHOLD_MS + ): Promise { + const zombies = await this.findZombieLeases(now, thresholdMs); + const evicted: WorkerLease[] = []; + for (const { lease, msSinceHeartbeat } of zombies) { + const evictedLease = await this.db.transaction(async (tx) => { + const current = await tx("worker_leases") + .where({ lease_key: lease.leaseKey }) + .forUpdate() + .first(); + if (!current || !current.owner_id) return null; + // Re-check under the lock — a heartbeat may have landed concurrently. + const currentLease = map(current); + if (!isZombieLease(currentLease, now, thresholdMs)) return null; + + const [row] = await tx("worker_leases") + .where({ lease_key: lease.leaseKey }) + .update({ + owner_id: null, + expires_at: now, + released_at: now, + lost_count: Number(current.lost_count ?? 0) + 1, + updated_at: now, + }) + .returning("*"); + + await this.recordEvent(tx, { + leaseKey: lease.leaseKey, + ownerId: current.owner_id, + fencingToken: Number(current.fencing_token), + eventType: "expired", + reason: `zombie eviction: no heartbeat for ${Math.round(msSinceHeartbeat)}ms; range=${JSON.stringify((currentLease.metadata as any)?.range ?? currentLease.metadata ?? {})}`, + }); + return map(row); + }); + if (evictedLease) { + // Preserve the dead owner + range for the re-queue step. + evicted.push({ ...evictedLease, ownerId: lease.ownerId, metadata: lease.metadata }); + } + } + return evicted; + } } export const workerLeaseService = new WorkerLeaseService(); diff --git a/frontend/.bundle-size-baseline.json b/frontend/.bundle-size-baseline.json new file mode 100644 index 00000000..38d5f8dc --- /dev/null +++ b/frontend/.bundle-size-baseline.json @@ -0,0 +1,7 @@ +{ + "generatedAt": "2026-09-27T00:00:00.000Z", + "note": "Initial frontend bundle size baseline for CI tracking (#1341). Update via `npm --workspace=frontend run bundle:size` after intentional size changes.", + "totalBytes": 0, + "gzipBytes": 0, + "files": [] +} diff --git a/frontend/package.json b/frontend/package.json index c0dbde09..8e99104e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,9 @@ "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", "type-check": "tsc -p tsconfig.json --noEmit", + "bundle:size": "node ../scripts/check-bundle-size.mjs", + "bundle:size:update": "node ../scripts/check-bundle-size.mjs --update-baseline", + "test:a11y": "vitest run src/test/a11y.test.tsx", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build -o storybook-static" }, diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 6c476731..dd550d74 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -75,19 +75,20 @@ export default function CommandPalette() { : items; return ( -
-
setOpen(false)} /> +
+
setOpen(false)} aria-hidden="true" />
- setQuery(e.target.value)} placeholder="Type a command or search..." className="w-full bg-transparent text-white py-2 outline-none" /> + + setQuery(e.target.value)} placeholder="Type a command or search..." className="w-full bg-transparent text-white py-2 outline-none" />
{recent.length > 0 && query.trim() === "" && ( -
Recent
+
Recent
)} -
    +
      {visibleItems.map((a) => ( -
    • execute(a)}> +
    • { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); execute(a); } }} className="px-3 py-2 hover:bg-stellar-border/60 cursor-pointer focus:outline-none focus:bg-stellar-border/60" onClick={() => execute(a)}>
      {a.title}
      {a.href}
    • diff --git a/frontend/src/components/ExportPickerDialog.tsx b/frontend/src/components/ExportPickerDialog.tsx index a4114bd1..d235b7cc 100644 --- a/frontend/src/components/ExportPickerDialog.tsx +++ b/frontend/src/components/ExportPickerDialog.tsx @@ -174,18 +174,27 @@ export default function ExportPickerDialog({ } }; + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, onClose]); + if (!open) { return null; } return (
      -
      +
      -

      Export data

      -

      +

      Export data

      +

      Choose the format, scope, and date range for the export. The picker saves your last selection and shows live status while the export is processed.

      @@ -205,6 +214,7 @@ export default function ExportPickerDialog({
      {errorMessage ? ( -
      +
      {errorMessage}
      ) : null} diff --git a/frontend/src/components/Modal/Modal.tsx b/frontend/src/components/Modal/Modal.tsx index 479eed49..40792d95 100644 --- a/frontend/src/components/Modal/Modal.tsx +++ b/frontend/src/components/Modal/Modal.tsx @@ -25,6 +25,7 @@ export function Modal({ isOpen, onClose, children, + title, ariaLabelledBy, ariaDescribedBy, size = "md", @@ -59,7 +60,7 @@ export function Modal({ [onClose, closeOnBackdropClick] ); - // Handle focus trap + // Handle focus trap (Tab cycles inside dialog) + restore focus on close useEffect(() => { if (!isOpen) return; @@ -71,10 +72,34 @@ export function Modal({ "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])" ); const firstFocusableElement = focusableElements[0] as HTMLElement; - firstFocusableElement?.focus(); + (firstFocusableElement ?? contentRef.current)?.focus(); } + const handleTabTrap = (e: KeyboardEvent) => { + if (e.key !== "Tab" || !contentRef.current) return; + const focusable = Array.from( + contentRef.current.querySelectorAll( + "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])" + ) + ).filter((el) => !el.hasAttribute("disabled") && el.offsetParent !== null); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener("keydown", handleTabTrap); return () => { + document.removeEventListener("keydown", handleTabTrap); // Restore focus when modal closes previousActiveElement?.focus(); }; @@ -94,9 +119,14 @@ export function Modal({ className={`relative w-full ${sizeClasses[size]} bg-stellar-card border border-stellar-border rounded-lg shadow-xl`} role="dialog" aria-modal="true" + aria-label={ariaLabelledBy ? undefined : (title ?? "Dialog")} aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} + tabIndex={-1} > + {title && !ariaLabelledBy ? ( +

      {title}

      + ) : null} {children}
      diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index fba05176..13c638da 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -51,7 +51,7 @@ export default function Navbar() { Bridge Watch -
      +
      +
      @@ -141,7 +141,7 @@ export default function Navbar() { aria-expanded={isNotificationsOpen} aria-controls="notifications-drawer" > - +
      -
      +
      0 ? `, ${unreadCount} unread` : ""}`} className="absolute right-0 top-full mt-2 w-80 md:w-96 max-h-[calc(100vh-5rem)] bg-stellar-card border border-stellar-border rounded-lg shadow-2xl z-50 flex flex-col overflow-hidden animate-in fade-in slide-in-from-top-2 duration-200"> +
      + {unreadCount > 0 ? `${unreadCount} unread notifications` : "No unread notifications"} +

      Notifications

      @@ -61,9 +76,10 @@ export default function NotificationCenter({ isOpen, onClose }: NotificationCent diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index 14c2b8b4..fe58f2d7 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -31,7 +31,9 @@ function ToastViewport({ {toasts.map((t) => (
      + {ui} + + ); +} + +describe("a11y audit (axe-core)", () => { + it("Modal dialog has no violations and traps focus", async () => { + const { container } = render( + {}} title="Test dialog" ariaDescribedBy="desc"> +

      Description

      + +
      + ); + const dialog = container.querySelector('[role="dialog"]'); + expect(dialog).toHaveAttribute("aria-modal", "true"); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("NotificationCenter exposes dialog semantics and live region", async () => { + const { container } = render( + + {}} /> + , + { wrapper: ({ children }) => {children} } + ); + expect(container.querySelector('[role="dialog"]')).toBeInTheDocument(); + expect(container.querySelector('[aria-live="polite"]')).toBeInTheDocument(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("Toast viewport announces via aria-live", async () => { + const { container } = render( + +
      app
      +
      + ); + // Viewport only renders with toasts; static check on provider wiring. + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it("icon-only affordances expose accessible names", async () => { + const { container } = renderWithProviders( + <> + + + + ); + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.length).toBeGreaterThan(0); + for (const b of buttons) { + const name = b.getAttribute("aria-label") ?? b.textContent ?? ""; + expect(name.trim().length).toBeGreaterThan(0); + } + for (const svg of Array.from(container.querySelectorAll("button svg"))) { + expect(svg.getAttribute("aria-hidden")).toBe("true"); + } + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index fe306a0c..84a5cde4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -9,6 +9,24 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + build: { + // Bundle-size tracking (#1341): keep builds comparable across CI runs. + // - reportCompressedSize emits gzip sizes in `vite build` output so the + // CI bundle-size step can parse them without extra plugins. + // - chunkSizeWarningLimit (kB) surfaces >500kB chunks as warnings locally. + sourcemap: false, + reportCompressedSize: true, + chunkSizeWarningLimit: 500, + assetsInlineLimit: 4096, + rollupOptions: { + output: { + manualChunks: { + vendor: ["react", "react-dom", "react-router-dom"], + charts: ["recharts"], + }, + }, + }, + }, test: { globals: true, environment: "jsdom", diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs new file mode 100755 index 00000000..da176274 --- /dev/null +++ b/scripts/check-bundle-size.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Bundle size check for CI (#1341). + * + * - Walks `frontend/dist` and sums raw + gzip sizes of emitted assets. + * - Compares against `frontend/.bundle-size-baseline.json`. + * - Warning threshold: +500KB raw over baseline (exit 0, sets warning flag). + * - Failure threshold: +1MB raw over baseline (exit 1). + * - Emits GitHub Actions outputs + step-summary friendly markdown when + * `GITHUB_OUTPUT` / `GITHUB_STEP_SUMMARY` are present. + * + * Usage: + * node scripts/check-bundle-size.mjs [--dist=frontend/dist] [--baseline=frontend/.bundle-size-baseline.json] + * [--warn-kb=500] [--fail-kb=1024] [--update-baseline] + */ +import { createGzip } from "zlib"; +import { createReadStream, promises as fs } from "fs"; +import path from "path"; +import { pipeline } from "stream/promises"; + +const args = Object.fromEntries( + process.argv.slice(2).map((a) => { + const m = a.match(/^--([^=]+)(=(.*))?$/); + return m ? [m[1], m[3] ?? "true"] : [a, "true"]; + }) +); + +const ROOT = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); +const distDir = path.resolve(args.dist ?? path.join(ROOT, "frontend/dist")); +const baselinePath = path.resolve(args.baseline ?? path.join(ROOT, "frontend/.bundle-size-baseline.json")); +const WARN_BYTES = Number(args["warn-kb"] ?? 500) * 1024; +const FAIL_BYTES = Number(args["fail-kb"] ?? 1024) * 1024; + +async function gzipSize(file) { + const src = createReadStream(file); + let bytes = 0; + const counter = createGzip(); + counter.on("data", (c) => (bytes += c.length)); + await pipeline(src, counter); + return bytes; +} + +async function walk(dir, out = []) { + let entries = []; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + const full = path.join(dir, e.name); + if (e.isDirectory()) await walk(full, out); + else if (e.isFile()) out.push(full); + } + return out; +} + +function fmt(bytes) { + if (Math.abs(bytes) >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + +async function main() { + const files = (await walk(distDir)).filter((f) => /\.(js|css|html|wasm)$/.test(f)); + let total = 0; + let gzipTotal = 0; + const details = []; + for (const f of files) { + const stat = await fs.stat(f); + const gz = await gzipSize(f).catch(() => 0); + total += stat.size; + gzipTotal += gz; + details.push({ file: path.relative(distDir, f), bytes: stat.size, gzipBytes: gz }); + } + details.sort((a, b) => b.bytes - a.bytes); + + let baseline = { totalBytes: 0, gzipBytes: 0 }; + try { + baseline = JSON.parse(await fs.readFile(baselinePath, "utf8")); + } catch { + // No baseline yet — treat current as baseline (first run). + } + const baseTotal = Number(baseline.totalBytes ?? 0); + const baseGzip = Number(baseline.gzipBytes ?? 0); + const diff = total - baseTotal; + const gzipDiff = gzipTotal - baseGzip; + + const warn = diff > WARN_BYTES && diff <= FAIL_BYTES; + const fail = diff > FAIL_BYTES; + + const lines = [ + `### Frontend bundle size`, + ``, + `| Metric | Current | Baseline | Diff |`, + `|---|---|---|---|`, + `| Raw | ${fmt(total)} | ${fmt(baseTotal)} | ${diff >= 0 ? "+" : ""}${fmt(diff)} |`, + `| Gzip | ${fmt(gzipTotal)} | ${fmt(baseGzip)} | ${gzipDiff >= 0 ? "+" : ""}${fmt(gzipDiff)} |`, + ``, + `Warning threshold: +${fmt(WARN_BYTES)} · Failure threshold: +${fmt(FAIL_BYTES)}`, + fail + ? `❌ Bundle grew by ${fmt(diff)} — exceeds failure threshold.` + : warn + ? `⚠️ Bundle grew by ${fmt(diff)} — exceeds warning threshold.` + : `✅ Bundle size within budget.`, + ``, + `
      Largest assets`, + ``, + `| Asset | Raw | Gzip |`, + `|---|---|---|`, + ...details.slice(0, 15).map((d) => `| \`${d.file}\` | ${fmt(d.bytes)} | ${fmt(d.gzipBytes)} |`), + `
      `, + ]; + const markdown = lines.join("\n"); + + const out = { + totalBytes: total, + gzipBytes: gzipTotal, + baselineBytes: baseTotal, + diffBytes: diff, + gzipDiffBytes: gzipDiff, + warn: warn || fail, + fail, + }; + + // Console output for logs. + console.log(`bundle: raw=${total} gzip=${gzipTotal} baseline=${baseTotal} diff=${diff}`); + console.log(markdown); + + try { + await fs.writeFile(process.env.BUNDLE_SIZE_JSON ?? "/tmp/bundle-size.json", JSON.stringify(out, null, 2)); + await fs.writeFile("/tmp/bundle-size.md", markdown); + } catch { /* ignore */ } + + if (process.env.GITHUB_OUTPUT) { + await fs.appendFile( + process.env.GITHUB_OUTPUT, + `bundle_total_bytes=${total}\nbundle_gzip_bytes=${gzipTotal}\nbundle_diff_bytes=${diff}\nbundle_warn=${warn || fail}\nbundle_fail=${fail}\n` + ); + } + if (process.env.GITHUB_STEP_SUMMARY) { + await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, markdown + "\n"); + } + + if (args["update-baseline"] === "true") { + await fs.writeFile( + baselinePath, + JSON.stringify({ generatedAt: new Date().toISOString(), totalBytes: total, gzipBytes: gzipTotal, files: details }, null, 2) + "\n" + ); + console.log(`Baseline updated at ${baselinePath}`); + } + + if (fail) { + console.error(`::error::Frontend bundle increased by ${fmt(diff)} (failure threshold +${fmt(FAIL_BYTES)})`); + process.exit(1); + } else if (warn) { + console.log(`::warning::Frontend bundle increased by ${fmt(diff)} (warning threshold +${fmt(WARN_BYTES)})`); + } +} + +main();