Bladeren bron

fix(dashboard): restore availability chart colors by removing hsl() wrapper on OKLCH variables

The chart CSS variables (--chart-*, --destructive, --background) are defined in OKLCH
format in globals.css, but the latency charts wrapped them with hsl() causing color
parsing to fail. This fix uses var(--chart-*) directly in ChartConfig and var(--color-<key>)
for stroke/fill, matching the pattern used by other working charts.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
ding113 3 weken geleden
bovenliggende
commit
0f05b314f7

+ 8 - 8
src/app/[locale]/dashboard/availability/_components/endpoint/latency-curve.tsx

@@ -20,7 +20,7 @@ interface LatencyCurveProps {
 const chartConfig = {
   latency: {
     label: "Latency",
-    color: "hsl(var(--primary))",
+    color: "var(--chart-1)",
   },
 } satisfies ChartConfig;
 
@@ -98,8 +98,8 @@ export function LatencyCurve({ logs, className }: LatencyCurveProps) {
         <LineChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
           <defs>
             <linearGradient id="latencyGradient" x1="0" y1="0" x2="0" y2="1">
-              <stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
-              <stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
+              <stop offset="5%" stopColor="var(--color-latency)" stopOpacity={0.3} />
+              <stop offset="95%" stopColor="var(--color-latency)" stopOpacity={0} />
             </linearGradient>
           </defs>
           <CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-border/30" />
@@ -139,7 +139,7 @@ export function LatencyCurve({ logs, className }: LatencyCurveProps) {
           <Line
             type="monotone"
             dataKey="latency"
-            stroke="hsl(var(--primary))"
+            stroke="var(--color-latency)"
             strokeWidth={2}
             dot={(props) => {
               const { cx, cy, payload } = props;
@@ -150,8 +150,8 @@ export function LatencyCurve({ logs, className }: LatencyCurveProps) {
                     cx={cx}
                     cy={cy}
                     r={4}
-                    fill="hsl(var(--destructive))"
-                    stroke="hsl(var(--destructive))"
+                    fill="var(--destructive)"
+                    stroke="var(--destructive)"
                   />
                 );
               }
@@ -159,8 +159,8 @@ export function LatencyCurve({ logs, className }: LatencyCurveProps) {
             }}
             activeDot={{
               r: 6,
-              fill: "hsl(var(--primary))",
-              stroke: "hsl(var(--background))",
+              fill: "var(--color-latency)",
+              stroke: "var(--background)",
               strokeWidth: 2,
             }}
           />

+ 15 - 15
src/app/[locale]/dashboard/availability/_components/provider/latency-chart.tsx

@@ -20,15 +20,15 @@ interface LatencyChartProps {
 const chartConfig = {
   p50: {
     label: "P50",
-    color: "hsl(var(--chart-2))",
+    color: "var(--chart-2)",
   },
   p95: {
     label: "P95",
-    color: "hsl(var(--chart-4))",
+    color: "var(--chart-4)",
   },
   p99: {
     label: "P99",
-    color: "hsl(var(--chart-1))",
+    color: "var(--chart-1)",
   },
 } satisfies ChartConfig;
 
@@ -102,15 +102,15 @@ export function LatencyChart({ providers, className }: LatencyChartProps) {
         <h3 className="text-sm font-medium text-muted-foreground">{t("title")}</h3>
         <div className="flex items-center gap-4 text-xs">
           <div className="flex items-center gap-1.5">
-            <div className="w-3 h-0.5 rounded bg-[hsl(var(--chart-2))]" />
+            <div className="w-3 h-0.5 rounded" style={{ backgroundColor: "var(--chart-2)" }} />
             <span className="text-muted-foreground">{t("p50")}</span>
           </div>
           <div className="flex items-center gap-1.5">
-            <div className="w-3 h-0.5 rounded bg-[hsl(var(--chart-4))]" />
+            <div className="w-3 h-0.5 rounded" style={{ backgroundColor: "var(--chart-4)" }} />
             <span className="text-muted-foreground">{t("p95")}</span>
           </div>
           <div className="flex items-center gap-1.5">
-            <div className="w-3 h-0.5 rounded bg-[hsl(var(--chart-1))]" />
+            <div className="w-3 h-0.5 rounded" style={{ backgroundColor: "var(--chart-1)" }} />
             <span className="text-muted-foreground">{t("p99")}</span>
           </div>
         </div>
@@ -120,16 +120,16 @@ export function LatencyChart({ providers, className }: LatencyChartProps) {
         <AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
           <defs>
             <linearGradient id="fillP50" x1="0" y1="0" x2="0" y2="1">
-              <stop offset="5%" stopColor="hsl(var(--chart-2))" stopOpacity={0.3} />
-              <stop offset="95%" stopColor="hsl(var(--chart-2))" stopOpacity={0} />
+              <stop offset="5%" stopColor="var(--color-p50)" stopOpacity={0.3} />
+              <stop offset="95%" stopColor="var(--color-p50)" stopOpacity={0} />
             </linearGradient>
             <linearGradient id="fillP95" x1="0" y1="0" x2="0" y2="1">
-              <stop offset="5%" stopColor="hsl(var(--chart-4))" stopOpacity={0.3} />
-              <stop offset="95%" stopColor="hsl(var(--chart-4))" stopOpacity={0} />
+              <stop offset="5%" stopColor="var(--color-p95)" stopOpacity={0.3} />
+              <stop offset="95%" stopColor="var(--color-p95)" stopOpacity={0} />
             </linearGradient>
             <linearGradient id="fillP99" x1="0" y1="0" x2="0" y2="1">
-              <stop offset="5%" stopColor="hsl(var(--chart-1))" stopOpacity={0.3} />
-              <stop offset="95%" stopColor="hsl(var(--chart-1))" stopOpacity={0} />
+              <stop offset="5%" stopColor="var(--color-p99)" stopOpacity={0.3} />
+              <stop offset="95%" stopColor="var(--color-p99)" stopOpacity={0} />
             </linearGradient>
           </defs>
           <CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-border/30" />
@@ -178,21 +178,21 @@ export function LatencyChart({ providers, className }: LatencyChartProps) {
           <Area
             type="monotone"
             dataKey="p50"
-            stroke="hsl(var(--chart-2))"
+            stroke="var(--color-p50)"
             fill="url(#fillP50)"
             strokeWidth={2}
           />
           <Area
             type="monotone"
             dataKey="p95"
-            stroke="hsl(var(--chart-4))"
+            stroke="var(--color-p95)"
             fill="url(#fillP95)"
             strokeWidth={2}
           />
           <Area
             type="monotone"
             dataKey="p99"
-            stroke="hsl(var(--chart-1))"
+            stroke="var(--color-p99)"
             fill="url(#fillP99)"
             strokeWidth={2}
           />

+ 220 - 0
tests/unit/dashboard/availability/latency-chart.test.tsx

@@ -0,0 +1,220 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+// Mock next-intl
+vi.mock("next-intl", () => ({
+  useTranslations: () => (key: string) => key,
+}));
+
+// Mock recharts to expose color props via data-* attributes
+vi.mock("recharts", async (importOriginal) => {
+  const React = await import("react");
+  const actual = await importOriginal<typeof import("recharts")>();
+  return {
+    ...actual,
+    ResponsiveContainer: ({ children }: { children: ReactNode }) =>
+      React.createElement("div", { "data-testid": "recharts-responsive" }, children),
+    AreaChart: ({ children, data }: { children: ReactNode; data: unknown[] }) =>
+      React.createElement(
+        "div",
+        { "data-testid": "recharts-areachart", "data-points": data?.length || 0 },
+        children
+      ),
+    Area: ({ stroke, fill, dataKey }: { stroke: string; fill: string; dataKey: string }) =>
+      React.createElement("div", {
+        "data-testid": `recharts-area-${dataKey}`,
+        "data-stroke": stroke,
+        "data-fill": fill,
+      }),
+    CartesianGrid: () => null,
+    XAxis: () => null,
+    YAxis: () => null,
+  };
+});
+
+// Mock chart.tsx to expose ChartStyle for testing
+vi.mock("@/components/ui/chart", async () => {
+  const React = await import("react");
+  const actual = await vi.importActual<typeof import("@/components/ui/chart")>(
+    "@/components/ui/chart"
+  );
+  return {
+    ...actual,
+    ChartContainer: ({
+      children,
+      config,
+      className,
+    }: {
+      children: ReactNode;
+      config: Record<string, { color?: string; label?: string }>;
+      className?: string;
+    }) =>
+      React.createElement(
+        "div",
+        {
+          "data-testid": "chart-container",
+          "data-config": JSON.stringify(config),
+          className,
+        },
+        children
+      ),
+    ChartTooltip: () => null,
+  };
+});
+
+import { LatencyChart } from "@/app/[locale]/dashboard/availability/_components/provider/latency-chart";
+import type { ProviderAvailabilitySummary } from "@/lib/availability";
+
+const mockProviders: ProviderAvailabilitySummary[] = [
+  {
+    provider: "test-provider",
+    uptime: 99.5,
+    totalRequests: 100,
+    successRequests: 99,
+    failedRequests: 1,
+    avgLatencyMs: 150,
+    p50LatencyMs: 120,
+    p95LatencyMs: 200,
+    p99LatencyMs: 350,
+    timeBuckets: [
+      {
+        bucketStart: "2024-01-01T10:00:00Z",
+        totalRequests: 50,
+        successRequests: 49,
+        failedRequests: 1,
+        avgLatencyMs: 140,
+        p50LatencyMs: 110,
+        p95LatencyMs: 190,
+        p99LatencyMs: 340,
+      },
+      {
+        bucketStart: "2024-01-01T11:00:00Z",
+        totalRequests: 50,
+        successRequests: 50,
+        failedRequests: 0,
+        avgLatencyMs: 160,
+        p50LatencyMs: 130,
+        p95LatencyMs: 210,
+        p99LatencyMs: 360,
+      },
+    ],
+  },
+];
+
+function renderComponent(providers: ProviderAvailabilitySummary[]) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(<LatencyChart providers={providers} />);
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+describe("LatencyChart color bindings", () => {
+  beforeEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  test("ChartConfig uses var(--chart-*) without hsl wrapper", () => {
+    const { container, unmount } = renderComponent(mockProviders);
+
+    const chartContainer = container.querySelector('[data-testid="chart-container"]');
+    expect(chartContainer).toBeTruthy();
+
+    const configStr = chartContainer?.getAttribute("data-config");
+    expect(configStr).toBeTruthy();
+
+    const config = JSON.parse(configStr!);
+
+    // Colors should use var(--chart-*) directly, NOT hsl(var(--chart-*))
+    expect(config.p50.color).toBe("var(--chart-2)");
+    expect(config.p95.color).toBe("var(--chart-4)");
+    expect(config.p99.color).toBe("var(--chart-1)");
+
+    // Ensure no hsl wrapper
+    expect(config.p50.color).not.toMatch(/^hsl\(/);
+    expect(config.p95.color).not.toMatch(/^hsl\(/);
+    expect(config.p99.color).not.toMatch(/^hsl\(/);
+
+    unmount();
+  });
+
+  test("Area stroke uses var(--color-<key>) CSS variable", () => {
+    const { container, unmount } = renderComponent(mockProviders);
+
+    const areaP50 = container.querySelector('[data-testid="recharts-area-p50"]');
+    const areaP95 = container.querySelector('[data-testid="recharts-area-p95"]');
+    const areaP99 = container.querySelector('[data-testid="recharts-area-p99"]');
+
+    expect(areaP50).toBeTruthy();
+    expect(areaP95).toBeTruthy();
+    expect(areaP99).toBeTruthy();
+
+    // Stroke should use var(--color-<key>) injected by ChartContainer
+    expect(areaP50?.getAttribute("data-stroke")).toBe("var(--color-p50)");
+    expect(areaP95?.getAttribute("data-stroke")).toBe("var(--color-p95)");
+    expect(areaP99?.getAttribute("data-stroke")).toBe("var(--color-p99)");
+
+    unmount();
+  });
+
+  test("Area fill references gradient with correct ID pattern", () => {
+    const { container, unmount } = renderComponent(mockProviders);
+
+    const areaP50 = container.querySelector('[data-testid="recharts-area-p50"]');
+    const areaP95 = container.querySelector('[data-testid="recharts-area-p95"]');
+    const areaP99 = container.querySelector('[data-testid="recharts-area-p99"]');
+
+    // Fill should reference gradient URL
+    expect(areaP50?.getAttribute("data-fill")).toMatch(/url\(#fillP50\)/);
+    expect(areaP95?.getAttribute("data-fill")).toMatch(/url\(#fillP95\)/);
+    expect(areaP99?.getAttribute("data-fill")).toMatch(/url\(#fillP99\)/);
+
+    unmount();
+  });
+
+  test("renders no data message when providers have no time buckets with requests", () => {
+    const emptyProviders: ProviderAvailabilitySummary[] = [
+      {
+        provider: "empty-provider",
+        uptime: 0,
+        totalRequests: 0,
+        successRequests: 0,
+        failedRequests: 0,
+        avgLatencyMs: 0,
+        p50LatencyMs: 0,
+        p95LatencyMs: 0,
+        p99LatencyMs: 0,
+        timeBuckets: [],
+      },
+    ];
+
+    const { container, unmount } = renderComponent(emptyProviders);
+
+    // Should show no data message
+    expect(container.textContent).toContain("noData");
+    // Should not render chart
+    expect(container.querySelector('[data-testid="chart-container"]')).toBeNull();
+
+    unmount();
+  });
+});

+ 207 - 0
tests/unit/dashboard/availability/latency-curve.test.tsx

@@ -0,0 +1,207 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+
+// Mock next-intl
+vi.mock("next-intl", () => ({
+  useTranslations: () => (key: string) => key,
+}));
+
+// Mock recharts to expose color props via data-* attributes
+vi.mock("recharts", async (importOriginal) => {
+  const React = await import("react");
+  const actual = await importOriginal<typeof import("recharts")>();
+  return {
+    ...actual,
+    ResponsiveContainer: ({ children }: { children: ReactNode }) =>
+      React.createElement("div", { "data-testid": "recharts-responsive" }, children),
+    LineChart: ({ children, data }: { children: ReactNode; data: unknown[] }) =>
+      React.createElement(
+        "div",
+        { "data-testid": "recharts-linechart", "data-points": data?.length || 0 },
+        children
+      ),
+    Line: ({
+      stroke,
+      dataKey,
+      dot,
+      activeDot,
+    }: {
+      stroke: string;
+      dataKey: string;
+      dot: unknown;
+      activeDot: unknown;
+    }) =>
+      React.createElement("div", {
+        "data-testid": `recharts-line-${dataKey}`,
+        "data-stroke": stroke,
+        "data-has-dot": dot != null ? "true" : "false",
+        "data-activedot": JSON.stringify(activeDot),
+      }),
+    CartesianGrid: () => null,
+    XAxis: () => null,
+    YAxis: () => null,
+  };
+});
+
+// Mock chart.tsx to expose ChartStyle for testing
+vi.mock("@/components/ui/chart", async () => {
+  const React = await import("react");
+  const actual = await vi.importActual<typeof import("@/components/ui/chart")>(
+    "@/components/ui/chart"
+  );
+  return {
+    ...actual,
+    ChartContainer: ({
+      children,
+      config,
+      className,
+    }: {
+      children: ReactNode;
+      config: Record<string, { color?: string; label?: string }>;
+      className?: string;
+    }) =>
+      React.createElement(
+        "div",
+        {
+          "data-testid": "chart-container",
+          "data-config": JSON.stringify(config),
+          className,
+        },
+        children
+      ),
+    ChartTooltip: () => null,
+  };
+});
+
+import { LatencyCurve } from "@/app/[locale]/dashboard/availability/_components/endpoint/latency-curve";
+import type { ProviderEndpointProbeLog } from "@/types/provider";
+
+const mockLogs: ProviderEndpointProbeLog[] = [
+  {
+    id: 1,
+    endpointId: 1,
+    ok: true,
+    statusCode: 200,
+    latencyMs: 120,
+    createdAt: "2024-01-01T10:00:00Z",
+    errorMessage: null,
+  },
+  {
+    id: 2,
+    endpointId: 1,
+    ok: true,
+    statusCode: 200,
+    latencyMs: 150,
+    createdAt: "2024-01-01T10:05:00Z",
+    errorMessage: null,
+  },
+  {
+    id: 3,
+    endpointId: 1,
+    ok: false,
+    statusCode: 500,
+    latencyMs: 200,
+    createdAt: "2024-01-01T10:10:00Z",
+    errorMessage: "Internal Server Error",
+  },
+];
+
+function renderComponent(logs: ProviderEndpointProbeLog[]) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(<LatencyCurve logs={logs} />);
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+describe("LatencyCurve color bindings", () => {
+  beforeEach(() => {
+    document.body.innerHTML = "";
+  });
+
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  test("ChartConfig uses var(--chart-*) without hsl wrapper", () => {
+    const { container, unmount } = renderComponent(mockLogs);
+
+    const chartContainer = container.querySelector('[data-testid="chart-container"]');
+    expect(chartContainer).toBeTruthy();
+
+    const configStr = chartContainer?.getAttribute("data-config");
+    expect(configStr).toBeTruthy();
+
+    const config = JSON.parse(configStr!);
+
+    // Color should use var(--chart-*) directly, NOT hsl(var(--primary)) or hsl(var(--chart-*))
+    expect(config.latency.color).toBe("var(--chart-1)");
+
+    // Ensure no hsl wrapper
+    expect(config.latency.color).not.toMatch(/^hsl\(/);
+
+    unmount();
+  });
+
+  test("Line stroke uses var(--color-latency) CSS variable", () => {
+    const { container, unmount } = renderComponent(mockLogs);
+
+    const line = container.querySelector('[data-testid="recharts-line-latency"]');
+    expect(line).toBeTruthy();
+
+    // Stroke should use var(--color-latency) injected by ChartContainer
+    expect(line?.getAttribute("data-stroke")).toBe("var(--color-latency)");
+
+    unmount();
+  });
+
+  test("renders no data message when logs are empty", () => {
+    const { container, unmount } = renderComponent([]);
+
+    // Should show no data message
+    expect(container.textContent).toContain("noData");
+    // Should not render chart
+    expect(container.querySelector('[data-testid="chart-container"]')).toBeNull();
+
+    unmount();
+  });
+
+  test("renders no data message when all logs have null latency", () => {
+    const logsWithNullLatency: ProviderEndpointProbeLog[] = [
+      {
+        id: 1,
+        endpointId: 1,
+        ok: false,
+        statusCode: null,
+        latencyMs: null,
+        createdAt: "2024-01-01T10:00:00Z",
+        errorMessage: "Timeout",
+      },
+    ];
+
+    const { container, unmount } = renderComponent(logsWithNullLatency);
+
+    // Should show no data message
+    expect(container.textContent).toContain("noData");
+    // Should not render chart
+    expect(container.querySelector('[data-testid="chart-container"]')).toBeNull();
+
+    unmount();
+  });
+});