Răsfoiți Sursa

feat(providers): add endpoint pool hover

ding113 2 săptămâni în urmă
părinte
comite
a312060ed8

+ 154 - 0
src/app/[locale]/settings/providers/_components/provider-endpoint-hover.tsx

@@ -0,0 +1,154 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { Server } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { getEndpointCircuitInfo, getProviderEndpointsByVendor } from "@/actions/provider-endpoints";
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ProviderEndpoint, ProviderType } from "@/types/provider";
+import { getEndpointStatusModel } from "./endpoint-status";
+
+interface ProviderEndpointHoverProps {
+  vendorId: number;
+  providerType: ProviderType;
+}
+
+export function ProviderEndpointHover({ vendorId, providerType }: ProviderEndpointHoverProps) {
+  const t = useTranslations("settings.providers");
+  const [isOpen, setIsOpen] = useState(false);
+
+  const { data: allEndpoints = [] } = useQuery({
+    queryKey: ["provider-endpoints", vendorId],
+    queryFn: async () => getProviderEndpointsByVendor({ vendorId }),
+    staleTime: 1000 * 30,
+  });
+
+  const endpoints = useMemo(() => {
+    return allEndpoints
+      .filter(
+        (ep) => ep.providerType === providerType && ep.isEnabled === true && ep.deletedAt === null
+      )
+      .sort((a, b) => {
+        const getStatusScore = (ok: boolean | null) => {
+          if (ok === true) return 0;
+          if (ok === null) return 1;
+          return 2;
+        };
+        const scoreA = getStatusScore(a.lastProbeOk);
+        const scoreB = getStatusScore(b.lastProbeOk);
+        if (scoreA !== scoreB) return scoreA - scoreB;
+
+        const sortA = a.sortOrder ?? 0;
+        const sortB = b.sortOrder ?? 0;
+        if (sortA !== sortB) return sortA - sortB;
+
+        const latA = a.lastProbeLatencyMs ?? Number.MAX_SAFE_INTEGER;
+        const latB = b.lastProbeLatencyMs ?? Number.MAX_SAFE_INTEGER;
+        if (latA !== latB) return latA - latB;
+
+        return a.id - b.id;
+      });
+  }, [allEndpoints, providerType]);
+
+  const count = endpoints.length;
+
+  return (
+    <TooltipProvider>
+      <Tooltip open={isOpen} onOpenChange={setIsOpen} delayDuration={200}>
+        <TooltipTrigger asChild>
+          <div
+            className="flex items-center gap-1.5 cursor-help opacity-80 hover:opacity-100 transition-opacity focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 rounded px-1"
+            tabIndex={0}
+            role="button"
+            aria-label={t("endpointStatus.viewDetails", { count })}
+            data-testid="endpoint-hover-trigger"
+          >
+            <Server className="h-3.5 w-3.5 text-muted-foreground" />
+            <span className="text-xs font-medium text-muted-foreground tabular-nums">{count}</span>
+          </div>
+        </TooltipTrigger>
+        <TooltipContent
+          side="right"
+          className="p-0 border shadow-lg rounded-lg overflow-hidden min-w-[280px] max-w-[320px]"
+        >
+          <div className="bg-muted/40 px-3 py-2 border-b">
+            <h4 className="text-xs font-semibold text-foreground">
+              {t("endpointStatus.activeEndpoints")} ({count})
+            </h4>
+          </div>
+          <div className="max-h-[300px] overflow-y-auto py-1">
+            {count === 0 ? (
+              <div className="px-3 py-4 text-center text-xs text-muted-foreground">
+                {t("endpointStatus.noEndpoints")}
+              </div>
+            ) : (
+              <div className="flex flex-col gap-0.5">
+                {endpoints.map((endpoint) => (
+                  <EndpointRow key={endpoint.id} endpoint={endpoint} isOpen={isOpen} />
+                ))}
+              </div>
+            )}
+          </div>
+        </TooltipContent>
+      </Tooltip>
+    </TooltipProvider>
+  );
+}
+
+function EndpointRow({ endpoint, isOpen }: { endpoint: ProviderEndpoint; isOpen: boolean }) {
+  const t = useTranslations("settings.providers");
+
+  const { data: circuitResult } = useQuery({
+    queryKey: ["endpoint-circuit", endpoint.id],
+    queryFn: async () => getEndpointCircuitInfo({ endpointId: endpoint.id }),
+    enabled: isOpen,
+    staleTime: 1000 * 10,
+  });
+
+  const circuitState =
+    circuitResult?.ok && circuitResult.data ? circuitResult.data.health.circuitState : undefined;
+
+  const statusModel = getEndpointStatusModel(endpoint, circuitState);
+  const Icon = statusModel.icon;
+
+  return (
+    <div className="px-3 py-2 hover:bg-muted/50 transition-colors flex items-start gap-3 group">
+      <div className="mt-0.5 shrink-0">
+        <Icon className={cn("h-3.5 w-3.5", statusModel.color)} />
+      </div>
+      <div className="flex-1 min-w-0 space-y-1">
+        <div className="flex items-center justify-between gap-2">
+          <span className="text-xs font-medium truncate text-foreground/90">
+            {endpoint.label || endpoint.url}
+          </span>
+          {endpoint.lastProbeLatencyMs && (
+            <span className="text-[10px] text-muted-foreground tabular-nums shrink-0">
+              {endpoint.lastProbeLatencyMs}ms
+            </span>
+          )}
+        </div>
+
+        <div className="flex items-center gap-2 text-[10px] text-muted-foreground">
+          <span className={cn("font-medium", statusModel.color)}>
+            {t(statusModel.labelKey.replace("settings.providers.", ""))}
+          </span>
+
+          {(circuitState === "open" || circuitState === "half-open") && (
+            <Badge
+              variant="outline"
+              className={cn(
+                "h-4 px-1 text-[9px] uppercase tracking-wider border-current opacity-80",
+                statusModel.color
+              )}
+            >
+              {circuitState}
+            </Badge>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 244 - 0
tests/unit/settings/providers/provider-endpoint-hover.test.tsx

@@ -0,0 +1,244 @@
+/**
+ * @vitest-environment happy-dom
+ */
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { NextIntlClientProvider } from "next-intl";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { ProviderEndpointHover } from "@/app/[locale]/settings/providers/_components/provider-endpoint-hover";
+import type { ProviderEndpoint } from "@/types/provider";
+import enMessages from "../../../../messages/en";
+
+vi.mock("@/components/ui/tooltip", () => ({
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+  Tooltip: ({ children, open }: { children: ReactNode; open: boolean }) => (
+    <div data-testid="tooltip" data-state={open ? "open" : "closed"}>
+      {children}
+    </div>
+  ),
+  TooltipTrigger: ({ children }: { children: ReactNode }) => (
+    <div data-testid="tooltip-trigger">{children}</div>
+  ),
+  TooltipContent: ({ children }: { children: ReactNode }) => (
+    <div data-testid="tooltip-content">{children}</div>
+  ),
+}));
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  getProviderEndpointsByVendor: vi.fn(),
+  getEndpointCircuitInfo: vi.fn(),
+}));
+
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  const endpointStatus = {
+    viewDetails: "View Details",
+    activeEndpoints: "Active Endpoints",
+    noEndpoints: "No Endpoints",
+    healthy: "Healthy",
+    unhealthy: "Unhealthy",
+    unknown: "Unknown",
+    circuitOpen: "Circuit Open",
+    circuitHalfOpen: "Circuit Half-Open",
+  };
+
+  return {
+    settings: {
+      ...enMessages.settings,
+      providers: {
+        ...(enMessages.settings.providers || {}),
+        endpointStatus,
+      },
+    },
+  };
+}
+
+let queryClient: QueryClient;
+
+function renderWithProviders(node: ReactNode) {
+  const container = document.createElement("div");
+  document.body.appendChild(container);
+  const root = createRoot(container);
+
+  act(() => {
+    root.render(
+      <QueryClientProvider client={queryClient}>
+        <NextIntlClientProvider locale="en" messages={loadMessages()} timeZone="UTC">
+          {node}
+        </NextIntlClientProvider>
+      </QueryClientProvider>
+    );
+  });
+
+  return {
+    container,
+    unmount: () => {
+      act(() => root.unmount());
+      container.remove();
+    },
+  };
+}
+
+async function flushTicks(times = 3) {
+  for (let i = 0; i < times; i++) {
+    await act(async () => {
+      await new Promise((r) => setTimeout(r, 0));
+    });
+  }
+}
+
+describe("ProviderEndpointHover", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  const mockEndpoints: ProviderEndpoint[] = [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v1",
+      label: "Healthy Endpoint",
+      sortOrder: 10,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 100,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 2,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v2",
+      label: "Unhealthy Endpoint",
+      sortOrder: 20,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: false,
+      lastProbeLatencyMs: null,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 3,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v3",
+      label: "Unknown Endpoint",
+      sortOrder: 5,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 4,
+      vendorId: 1,
+      providerType: "openai-compatible",
+      url: "https://api.openai.com",
+      label: "Wrong Type",
+      sortOrder: 0,
+      isEnabled: true,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 50,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+    {
+      id: 5,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.anthropic.com/v4",
+      label: "Disabled Endpoint",
+      sortOrder: 0,
+      isEnabled: false,
+      deletedAt: null,
+      lastProbeOk: true,
+      lastProbeLatencyMs: 50,
+      createdAt: "2024-01-01",
+      updatedAt: "2024-01-01",
+      lastProbedAt: null,
+      lastOk: null,
+      lastLatencyMs: null,
+    },
+  ];
+
+  test("renders trigger with correct count and filters correctly", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount, container } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    const triggerText = container.textContent;
+    expect(triggerText).toContain("3");
+
+    unmount();
+  });
+
+  test("sorts endpoints correctly: Healthy > Unknown > Unhealthy", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    const tooltipContent = document.querySelector("[data-testid='tooltip-content']");
+    expect(tooltipContent).not.toBeNull();
+
+    const labels = Array.from(
+      document.querySelectorAll("[data-testid='tooltip-content'] span.truncate")
+    ).map((el) => el.textContent);
+
+    expect(labels).toEqual(["Healthy Endpoint", "Unknown Endpoint", "Unhealthy Endpoint"]);
+
+    unmount();
+  });
+
+  test("does not fetch circuit info initially (when closed)", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValue(mockEndpoints);
+
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointHover vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks();
+
+    expect(providerEndpointsActionMocks.getEndpointCircuitInfo).not.toHaveBeenCalled();
+
+    unmount();
+  });
+});