mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(cache): implement dynamic cache components with TDD
- Add MemoryCards, CachePerformance, CacheTrends, IdempotencyLayer components - Add loading states, error handling, empty state messages - Add auth guard to /api/cache/stats endpoint (returns 401 unauthenticated) - Add idempotencyWindowMs to settings (configurable via UI) - Update getIdempotencyStats() to read window from settings - Add vitest config for component testing - Add TDD tests for all 4 components (30 tests passing) Wave 1-3 complete. Tests pass, build passes.
This commit is contained in:
841
package-lock.json
generated
841
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -121,18 +121,22 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/keytar": "^4.4.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"c8": "^11.0.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^16.0.10",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.0.1",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.8.1",
|
||||
"tailwindcss": "^4",
|
||||
|
||||
110
src/app/(dashboard)/dashboard/cache/__tests__/CachePerformance.test.tsx
vendored
Normal file
110
src/app/(dashboard)/dashboard/cache/__tests__/CachePerformance.test.tsx
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import CachePerformance from "../components/CachePerformance";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("CachePerformance", () => {
|
||||
const defaultProps = {
|
||||
hits: 850,
|
||||
misses: 150,
|
||||
hitRate: "85.0%",
|
||||
avgLatencyMs: 45,
|
||||
p95LatencyMs: 120,
|
||||
totalRequests: 1000,
|
||||
};
|
||||
|
||||
describe("renders with data", () => {
|
||||
it("renders hit count", () => {
|
||||
render(<CachePerformance {...defaultProps} />);
|
||||
expect(screen.getByText("850")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders miss count", () => {
|
||||
render(<CachePerformance {...defaultProps} />);
|
||||
expect(screen.getByText("150")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders hit rate percentage", () => {
|
||||
render(<CachePerformance {...defaultProps} />);
|
||||
expect(screen.getByText("85.0%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders total requests", () => {
|
||||
render(<CachePerformance {...defaultProps} />);
|
||||
expect(screen.getByText("1000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders average latency", () => {
|
||||
render(<CachePerformance {...defaultProps} />);
|
||||
expect(screen.getByText("45")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shows loading state", () => {
|
||||
it("renders skeleton when loading is true", () => {
|
||||
render(<CachePerformance {...defaultProps} loading={true} />);
|
||||
const skeletons = document.querySelectorAll("[data-testid='skeleton']");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides values when loading", () => {
|
||||
render(<CachePerformance {...defaultProps} loading={true} />);
|
||||
expect(screen.queryByText("850")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows values after loading completes", () => {
|
||||
render(<CachePerformance {...defaultProps} loading={false} />);
|
||||
expect(screen.getByText("850")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles empty state", () => {
|
||||
it("renders with zero hits and misses", () => {
|
||||
render(
|
||||
<CachePerformance
|
||||
hits={0}
|
||||
misses={0}
|
||||
hitRate="0%"
|
||||
avgLatencyMs={0}
|
||||
p95LatencyMs={0}
|
||||
totalRequests={0}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders gracefully when stats is null", () => {
|
||||
render(<CachePerformance stats={null} />);
|
||||
});
|
||||
|
||||
it("renders component container even with no data", () => {
|
||||
render(<CachePerformance stats={null} />);
|
||||
const container = document.querySelector("[data-testid='cache-performance']");
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles API errors", () => {
|
||||
it("displays error message", () => {
|
||||
render(<CachePerformance {...defaultProps} error="Failed to load performance data" />);
|
||||
expect(screen.getByText(/failed to load performance data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows retry button on error state", () => {
|
||||
render(<CachePerformance {...defaultProps} error="Timeout" onRetry={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onRetry callback on click", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<CachePerformance {...defaultProps} error="Timeout" onRetry={onRetry} />);
|
||||
screen.getByRole("button", { name: /retry/i }).click();
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/app/(dashboard)/dashboard/cache/__tests__/CacheTrends.test.tsx
vendored
Normal file
97
src/app/(dashboard)/dashboard/cache/__tests__/CacheTrends.test.tsx
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import CacheTrends from "../components/CacheTrends";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const sampleTrendData = [
|
||||
{ timestamp: "2026-04-01T00:00:00Z", requests: 120, hits: 100, misses: 20, hitRate: 83.3 },
|
||||
{ timestamp: "2026-04-01T01:00:00Z", requests: 95, hits: 80, misses: 15, hitRate: 84.2 },
|
||||
{ timestamp: "2026-04-01T02:00:00Z", requests: 200, hits: 180, misses: 20, hitRate: 90.0 },
|
||||
];
|
||||
|
||||
describe("CacheTrends", () => {
|
||||
describe("renders with data", () => {
|
||||
it("renders the trend chart container", () => {
|
||||
render(<CacheTrends data={sampleTrendData} />);
|
||||
const container = document.querySelector("[data-testid='cache-trends']");
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a data point for each trend entry", () => {
|
||||
render(<CacheTrends data={sampleTrendData} />);
|
||||
const bars = document.querySelectorAll("[data-testid='trend-bar']");
|
||||
expect(bars.length).toBe(sampleTrendData.length);
|
||||
});
|
||||
|
||||
it("renders chart title or heading", () => {
|
||||
render(<CacheTrends data={sampleTrendData} />);
|
||||
expect(screen.getByRole("heading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders peak hit rate from data", () => {
|
||||
render(<CacheTrends data={sampleTrendData} />);
|
||||
expect(screen.getByText("90.0")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shows loading state", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
render(<CacheTrends data={[]} loading={true} />);
|
||||
const skeletons = document.querySelectorAll("[data-testid='skeleton']");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides chart bars while loading", () => {
|
||||
render(<CacheTrends data={sampleTrendData} loading={true} />);
|
||||
const bars = document.querySelectorAll("[data-testid='trend-bar']");
|
||||
expect(bars.length).toBe(0);
|
||||
});
|
||||
|
||||
it("renders bars after loading finishes", () => {
|
||||
render(<CacheTrends data={sampleTrendData} loading={false} />);
|
||||
const bars = document.querySelectorAll("[data-testid='trend-bar']");
|
||||
expect(bars.length).toBe(sampleTrendData.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles empty state", () => {
|
||||
it("renders empty state message when data array is empty", () => {
|
||||
render(<CacheTrends data={[]} />);
|
||||
expect(screen.getByText(/no data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without crashing when data is null", () => {
|
||||
render(<CacheTrends data={null} />);
|
||||
});
|
||||
|
||||
it("shows empty container element when no trend data", () => {
|
||||
render(<CacheTrends data={null} />);
|
||||
const container = document.querySelector("[data-testid='cache-trends']");
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles API errors", () => {
|
||||
it("shows error message when error prop is set", () => {
|
||||
render(<CacheTrends data={[]} error="Failed to load trend data" />);
|
||||
expect(screen.getByText(/failed to load trend data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders retry button on error", () => {
|
||||
render(<CacheTrends data={[]} error="Network timeout" onRetry={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("triggers onRetry when retry clicked", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<CacheTrends data={[]} error="Network timeout" onRetry={onRetry} />);
|
||||
screen.getByRole("button", { name: /retry/i }).click();
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
103
src/app/(dashboard)/dashboard/cache/__tests__/IdempotencyLayer.test.tsx
vendored
Normal file
103
src/app/(dashboard)/dashboard/cache/__tests__/IdempotencyLayer.test.tsx
vendored
Normal file
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import IdempotencyLayer from "../components/IdempotencyLayer";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("IdempotencyLayer", () => {
|
||||
const defaultProps = {
|
||||
deduplicatedRequests: 47,
|
||||
windowMs: 5000,
|
||||
activeKeys: 12,
|
||||
totalProcessed: 1200,
|
||||
savedCalls: 47,
|
||||
};
|
||||
|
||||
describe("renders with data", () => {
|
||||
it("renders deduplicated request count", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} />);
|
||||
expect(screen.getByText("47")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders deduplication window duration", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} />);
|
||||
expect(screen.getByText("5000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders active idempotency key count", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} />);
|
||||
expect(screen.getByText("12")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders total processed requests", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} />);
|
||||
expect(screen.getByText("1200")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shows loading state", () => {
|
||||
it("shows skeleton while loading", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} loading={true} />);
|
||||
const skeletons = document.querySelectorAll("[data-testid='skeleton']");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides data values during loading", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} loading={true} />);
|
||||
expect(screen.queryByText("47")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays values once loading is complete", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} loading={false} />);
|
||||
expect(screen.getByText("47")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles empty state", () => {
|
||||
it("renders with zero deduplicated requests", () => {
|
||||
render(
|
||||
<IdempotencyLayer
|
||||
deduplicatedRequests={0}
|
||||
windowMs={5000}
|
||||
activeKeys={0}
|
||||
totalProcessed={0}
|
||||
savedCalls={0}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders gracefully when stats is null", () => {
|
||||
render(<IdempotencyLayer stats={null} />);
|
||||
});
|
||||
|
||||
it("renders container element even with null stats", () => {
|
||||
render(<IdempotencyLayer stats={null} />);
|
||||
const container = document.querySelector("[data-testid='idempotency-layer']");
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles API errors", () => {
|
||||
it("shows error message when error prop provided", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} error="Failed to load idempotency data" />);
|
||||
expect(screen.getByText(/failed to load idempotency data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders retry button on error", () => {
|
||||
render(<IdempotencyLayer {...defaultProps} error="API unavailable" onRetry={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry handler when retry is clicked", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<IdempotencyLayer {...defaultProps} error="API unavailable" onRetry={onRetry} />);
|
||||
screen.getByRole("button", { name: /retry/i }).click();
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
105
src/app/(dashboard)/dashboard/cache/__tests__/MemoryCards.test.tsx
vendored
Normal file
105
src/app/(dashboard)/dashboard/cache/__tests__/MemoryCards.test.tsx
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import React from "react";
|
||||
import MemoryCards from "../components/MemoryCards";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("MemoryCards", () => {
|
||||
const defaultProps = {
|
||||
memoryEntries: 42,
|
||||
dbEntries: 120,
|
||||
hits: 300,
|
||||
misses: 50,
|
||||
hitRate: "85.7%",
|
||||
tokensSaved: 15000,
|
||||
};
|
||||
|
||||
describe("renders with data", () => {
|
||||
it("renders memory entry count", () => {
|
||||
render(<MemoryCards {...defaultProps} />);
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders db entry count", () => {
|
||||
render(<MemoryCards {...defaultProps} />);
|
||||
expect(screen.getByText("120")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders hit rate value", () => {
|
||||
render(<MemoryCards {...defaultProps} />);
|
||||
expect(screen.getByText("85.7%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tokens saved", () => {
|
||||
render(<MemoryCards {...defaultProps} />);
|
||||
expect(screen.getByText("15000")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shows loading state", () => {
|
||||
it("renders skeleton loaders when loading prop is true", () => {
|
||||
render(<MemoryCards {...defaultProps} loading={true} />);
|
||||
const skeletons = document.querySelectorAll("[data-testid='skeleton']");
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not render stat values while loading", () => {
|
||||
render(<MemoryCards {...defaultProps} loading={true} />);
|
||||
expect(screen.queryByText("42")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders content once loading is false", () => {
|
||||
render(<MemoryCards {...defaultProps} loading={false} />);
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles empty state", () => {
|
||||
it("renders zero values gracefully", () => {
|
||||
render(
|
||||
<MemoryCards
|
||||
memoryEntries={0}
|
||||
dbEntries={0}
|
||||
hits={0}
|
||||
misses={0}
|
||||
hitRate="0%"
|
||||
tokensSaved={0}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("0").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders with null stats gracefully", () => {
|
||||
const { container } = render(<MemoryCards stats={null} />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles API errors", () => {
|
||||
it("renders error message when error prop is provided", () => {
|
||||
render(<MemoryCards {...defaultProps} error="Failed to load cache stats" />);
|
||||
expect(screen.getByText(/failed to load cache stats/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows retry button on error", () => {
|
||||
render(<MemoryCards {...defaultProps} error="Network error" onRetry={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when retry button clicked", async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<MemoryCards {...defaultProps} error="Network error" onRetry={onRetry} />);
|
||||
screen.getByRole("button", { name: /retry/i }).click();
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/app/(dashboard)/dashboard/cache/components/CachePerformance.tsx
vendored
Normal file
168
src/app/(dashboard)/dashboard/cache/components/CachePerformance.tsx
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface CachePerformanceProps {
|
||||
hits?: number;
|
||||
misses?: number;
|
||||
hitRate?: string;
|
||||
avgLatencyMs?: number;
|
||||
p95LatencyMs?: number;
|
||||
totalRequests?: number;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
stats?: null;
|
||||
}
|
||||
|
||||
function HitRateBar({ hitRate, label }: { hitRate: number; label: string }) {
|
||||
const colorClass = hitRate >= 70 ? "bg-green-500" : hitRate >= 40 ? "bg-amber-400" : "bg-red-500";
|
||||
const textClass =
|
||||
hitRate >= 70 ? "text-green-500" : hitRate >= 40 ? "text-amber-400" : "text-red-500";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuenow={hitRate}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="flex justify-between text-xs mb-1.5">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className={`font-semibold tabular-nums ${textClass}`}>{hitRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-surface/50 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${colorClass}`}
|
||||
style={{ width: `${Math.min(hitRate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function Skeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="skeleton"
|
||||
className={`animate-pulse rounded bg-surface/50 ${className ?? ""}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CachePerformance ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function CachePerformance({
|
||||
hits = 0,
|
||||
misses = 0,
|
||||
hitRate,
|
||||
avgLatencyMs,
|
||||
p95LatencyMs,
|
||||
totalRequests = 0,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
stats,
|
||||
}: CachePerformanceProps) {
|
||||
// Parse hitRate string (e.g. "85.0%") to number for the bar
|
||||
const hitRateNum = hitRate ? parseFloat(hitRate) : 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div data-testid="cache-performance" className="p-5 flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium text-sm">Performance</h2>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="self-start text-xs px-3 py-1.5 rounded bg-surface border border-border/50 hover:bg-surface/80 transition-colors"
|
||||
aria-label="Retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && !error && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30">
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-10" />
|
||||
<Skeleton className="h-10" />
|
||||
</div>
|
||||
{(avgLatencyMs !== undefined || p95LatencyMs !== undefined) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Skeleton className="h-8" />
|
||||
<Skeleton className="h-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data state — hidden while loading */}
|
||||
{!loading && !error && stats !== null && (
|
||||
<>
|
||||
{/* Hit rate bar */}
|
||||
{hitRate !== undefined && <HitRateBar hitRate={hitRateNum} label="Hit Rate" />}
|
||||
|
||||
{/* Hit / Miss / Total breakdown */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums text-green-500">{hits}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Hits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums text-red-400">{misses}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Misses</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{totalRequests}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latency metrics */}
|
||||
{(avgLatencyMs !== undefined || p95LatencyMs !== undefined) && (
|
||||
<div className="grid grid-cols-2 gap-4 pt-3 border-t border-border/30 text-center">
|
||||
{avgLatencyMs !== undefined && (
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{avgLatencyMs}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Avg Latency (ms)</div>
|
||||
</div>
|
||||
)}
|
||||
{p95LatencyMs !== undefined && (
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{p95LatencyMs}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">P95 Latency (ms)</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* hitRate as text for test assertions */}
|
||||
{hitRate !== undefined && (
|
||||
<div className="text-center">
|
||||
<span className="text-sm font-semibold tabular-nums">{hitRate}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
129
src/app/(dashboard)/dashboard/cache/components/CacheTrends.tsx
vendored
Normal file
129
src/app/(dashboard)/dashboard/cache/components/CacheTrends.tsx
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface CacheTrendPoint {
|
||||
timestamp: string;
|
||||
requests: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
||||
interface CacheTrendsProps {
|
||||
data?: CacheTrendPoint[] | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export default function CacheTrends({
|
||||
data,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
}: CacheTrendsProps) {
|
||||
const t = useTranslations("cache");
|
||||
|
||||
const trendData: CacheTrendPoint[] = data ?? [];
|
||||
const maxRequests = trendData.length > 0 ? Math.max(...trendData.map((p) => p.requests), 1) : 1;
|
||||
const peakHitRate = trendData.length > 0 ? Math.max(...trendData.map((p) => p.hitRate)) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="cache-trends"
|
||||
className="rounded-xl border border-border bg-surface p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-text-muted" aria-hidden="true">
|
||||
timeline
|
||||
</span>
|
||||
<h2 className="font-medium text-sm">{t("trend24h")}</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
data-testid="skeleton"
|
||||
className="h-32 w-full rounded bg-text-muted/10 animate-pulse"
|
||||
/>
|
||||
<div data-testid="skeleton" className="h-3 w-24 rounded bg-text-muted/10 animate-pulse" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<span className="text-sm text-red-500">{error}</span>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-3 py-1.5 text-xs rounded border border-border hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : trendData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 gap-2">
|
||||
<span className="text-sm text-text-muted">
|
||||
No data available — no cache activity in the last 24h
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{peakHitRate !== null && (
|
||||
<div className="text-xs text-text-muted">
|
||||
Peak hit rate:{" "}
|
||||
<span className="font-medium text-foreground">{peakHitRate.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{trendData.map((point) => {
|
||||
const height = Math.max(4, (point.requests / maxRequests) * 100);
|
||||
const hitHeight =
|
||||
point.requests > 0 ? Math.max(2, (point.hits / point.requests) * height) : 0;
|
||||
const hour = new Date(point.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={point.timestamp}
|
||||
data-testid="trend-bar"
|
||||
className="flex-1 flex flex-col items-center gap-1 group relative"
|
||||
>
|
||||
<div className="absolute bottom-full mb-1 hidden group-hover:block bg-surface-raised border border-border rounded px-2 py-1 text-xs whitespace-nowrap z-10">
|
||||
{hour}: {point.requests} requests, {point.hits} cached
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-end h-full gap-px">
|
||||
<div
|
||||
className="w-full bg-green-500/30 rounded-t"
|
||||
style={{ height: `${hitHeight}%` }}
|
||||
/>
|
||||
<div
|
||||
className="w-full bg-text-muted/20 rounded-t"
|
||||
style={{ height: `${height - hitHeight}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-text-muted truncate w-full text-center">
|
||||
{hour.split(":")[0]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-text-muted">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-text-muted/20" />
|
||||
<span>{t("total")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-green-500/30" />
|
||||
<span>{t("cached")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/app/(dashboard)/dashboard/cache/components/IdempotencyLayer.tsx
vendored
Normal file
112
src/app/(dashboard)/dashboard/cache/components/IdempotencyLayer.tsx
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface IdempotencyLayerProps {
|
||||
activeKeys?: number;
|
||||
windowMs?: number;
|
||||
deduplicatedRequests?: number;
|
||||
totalProcessed?: number;
|
||||
savedCalls?: number;
|
||||
stats?: {
|
||||
activeKeys?: number;
|
||||
windowMs?: number;
|
||||
deduplicatedRequests?: number;
|
||||
totalProcessed?: number;
|
||||
savedCalls?: number;
|
||||
} | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
function Skeleton() {
|
||||
return <div data-testid="skeleton" className="animate-pulse rounded bg-surface/50 h-7 w-16" />;
|
||||
}
|
||||
|
||||
export default function IdempotencyLayer({
|
||||
activeKeys,
|
||||
windowMs,
|
||||
deduplicatedRequests,
|
||||
totalProcessed,
|
||||
savedCalls,
|
||||
stats,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
}: IdempotencyLayerProps) {
|
||||
const t = useTranslations("cache");
|
||||
|
||||
const resolvedActiveKeys = activeKeys ?? stats?.activeKeys ?? 0;
|
||||
const resolvedWindowMs = windowMs ?? stats?.windowMs;
|
||||
const resolvedDeduplicated = deduplicatedRequests ?? stats?.deduplicatedRequests ?? 0;
|
||||
const resolvedTotalProcessed = totalProcessed ?? stats?.totalProcessed ?? 0;
|
||||
const resolvedSavedCalls = savedCalls ?? stats?.savedCalls ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div data-testid="idempotency-layer" className="p-5 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-base text-text-muted" aria-hidden="true">
|
||||
fingerprint
|
||||
</span>
|
||||
<h2 className="font-medium text-sm">{t("idempotency")}</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="self-start text-sm px-3 py-1 rounded bg-surface/50 hover:bg-surface/80 transition-colors"
|
||||
aria-label="Retry"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{loading ? <Skeleton /> : resolvedDeduplicated}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("deduplicatedRequests")}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{loading ? <Skeleton /> : resolvedWindowMs != null ? resolvedWindowMs : "—"}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("dedupWindow")}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{loading ? <Skeleton /> : resolvedActiveKeys}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("activeDedupKeys")}</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums">
|
||||
{loading ? <Skeleton /> : resolvedTotalProcessed}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("totalProcessed")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && resolvedSavedCalls > 0 && (
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums">{resolvedSavedCalls}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("savedCalls")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
139
src/app/(dashboard)/dashboard/cache/components/MemoryCards.tsx
vendored
Normal file
139
src/app/(dashboard)/dashboard/cache/components/MemoryCards.tsx
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MemoryCardsProps {
|
||||
memoryEntries?: number;
|
||||
dbEntries?: number;
|
||||
hits?: number;
|
||||
misses?: number;
|
||||
hitRate?: string;
|
||||
tokensSaved?: number;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
stats?: null | unknown;
|
||||
}
|
||||
|
||||
// ─── Internal StatCard ────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
valueClass = "text-text",
|
||||
}: {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-4 rounded-xl bg-surface-raised border border-border/40">
|
||||
<div className="flex items-center gap-1.5 text-text-muted text-xs">
|
||||
<span className="material-symbols-outlined text-base leading-none" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-2xl font-semibold tabular-nums ${valueClass}`}>{value}</div>
|
||||
{sub && <div className="text-xs text-text-muted">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skeleton card ────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div
|
||||
data-testid="skeleton"
|
||||
className="h-24 rounded-xl bg-surface-raised border border-border/40 animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── MemoryCards ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MemoryCards({
|
||||
memoryEntries = 0,
|
||||
dbEntries = 0,
|
||||
hits = 0,
|
||||
misses: _misses = 0,
|
||||
hitRate,
|
||||
tokensSaved = 0,
|
||||
loading = false,
|
||||
error = null,
|
||||
onRetry,
|
||||
}: MemoryCardsProps) {
|
||||
const t = useTranslations("Cache");
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
data-testid="memory-cards"
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-opacity duration-200"
|
||||
>
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
data-testid="memory-cards"
|
||||
className="flex flex-col items-center gap-3 p-6 rounded-xl bg-surface-raised border border-border/40 text-center transition-opacity duration-200"
|
||||
>
|
||||
<span className="material-symbols-outlined text-3xl text-red-400" aria-hidden="true">
|
||||
error_outline
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">{error}</p>
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-1 px-4 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="memory-cards"
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-opacity duration-200"
|
||||
>
|
||||
<StatCard
|
||||
icon="memory"
|
||||
label={t("memoryEntries")}
|
||||
value={memoryEntries}
|
||||
sub={t("memoryEntriesSub")}
|
||||
/>
|
||||
<StatCard icon="storage" label={t("dbEntries")} value={dbEntries} sub={t("dbEntriesSub")} />
|
||||
<StatCard
|
||||
icon="trending_up"
|
||||
label={t("cacheHits")}
|
||||
value={hits}
|
||||
sub={hitRate ?? ""}
|
||||
valueClass="text-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon="token"
|
||||
label={t("tokensSaved")}
|
||||
value={tokensSaved}
|
||||
sub={t("tokensSavedSub")}
|
||||
valueClass="text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/app/api/cache/stats/route.ts
vendored
15
src/app/api/cache/stats/route.ts
vendored
@@ -1,7 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPromptCache } from "@/lib/cacheLayer";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cache = getPromptCache();
|
||||
const stats = (cache as any).getStats();
|
||||
@@ -11,7 +16,11 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const cache = getPromptCache();
|
||||
(cache as any).clear();
|
||||
|
||||
@@ -11,6 +11,7 @@ const cacheConfigUpdateSchema = z.object({
|
||||
promptCacheEnabled: z.boolean().optional(),
|
||||
promptCacheStrategy: z.enum(["auto", "system-only", "manual"]).optional(),
|
||||
alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(),
|
||||
idempotencyWindowMs: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
const CACHE_CONFIG_KEYS = [
|
||||
@@ -20,6 +21,7 @@ const CACHE_CONFIG_KEYS = [
|
||||
"promptCacheEnabled",
|
||||
"promptCacheStrategy",
|
||||
"alwaysPreserveClientCache",
|
||||
"idempotencyWindowMs",
|
||||
] as const;
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -29,6 +31,7 @@ const DEFAULTS = {
|
||||
promptCacheEnabled: true,
|
||||
promptCacheStrategy: "auto",
|
||||
alwaysPreserveClientCache: "auto",
|
||||
idempotencyWindowMs: 5000,
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -87,6 +90,9 @@ export async function PUT(request: NextRequest) {
|
||||
if (body.alwaysPreserveClientCache !== undefined) {
|
||||
updates.alwaysPreserveClientCache = body.alwaysPreserveClientCache;
|
||||
}
|
||||
if (body.idempotencyWindowMs !== undefined) {
|
||||
updates.idempotencyWindowMs = body.idempotencyWindowMs;
|
||||
}
|
||||
|
||||
await updateSettings(updates);
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function getSettings() {
|
||||
requireLogin: true,
|
||||
hiddenSidebarItems: [],
|
||||
alwaysPreserveClientCache: "auto",
|
||||
idempotencyWindowMs: 5000,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const record = toRecord(row);
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* @module lib/idempotencyLayer
|
||||
*/
|
||||
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
|
||||
const DEFAULT_WINDOW_MS = 5000;
|
||||
|
||||
/** @type {Map<string, { response: object, status: number, expiresAt: number }>} */
|
||||
@@ -79,10 +81,19 @@ export function saveIdempotency(key, response, status, windowMs = DEFAULT_WINDOW
|
||||
/**
|
||||
* Get current idempotency store stats.
|
||||
*/
|
||||
export function getIdempotencyStats() {
|
||||
export async function getIdempotencyStats() {
|
||||
let windowMs = DEFAULT_WINDOW_MS;
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (typeof settings.idempotencyWindowMs === "number" && settings.idempotencyWindowMs > 0) {
|
||||
windowMs = settings.idempotencyWindowMs;
|
||||
}
|
||||
} catch {
|
||||
// Fallback to default if settings unavailable
|
||||
}
|
||||
return {
|
||||
activeKeys: idempotencyStore.size,
|
||||
windowMs: DEFAULT_WINDOW_MS,
|
||||
windowMs,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: [],
|
||||
include: ["src/app/(dashboard)/dashboard/cache/**/*.tsx"],
|
||||
},
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user