Просмотр исходного кода

feat(providers): extract vendor endpoint CRUD table

ding113 2 недель назад
Родитель
Сommit
3d59a0db2c

+ 704 - 0
src/app/[locale]/settings/providers/_components/provider-endpoints-table.tsx

@@ -0,0 +1,704 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { formatDistanceToNow } from "date-fns";
+import { Edit2, Loader2, MoreHorizontal, Play, Plus, Trash2 } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import {
+  addProviderEndpoint,
+  editProviderEndpoint,
+  getProviderEndpoints,
+  getProviderEndpointsByVendor,
+  probeProviderEndpoint,
+  removeProviderEndpoint,
+} from "@/actions/provider-endpoints";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import {
+  getAllProviderTypes,
+  getProviderTypeConfig,
+  getProviderTypeTranslationKey,
+} from "@/lib/provider-type-utils";
+import type { ProviderEndpoint, ProviderType } from "@/types/provider";
+import { EndpointLatencySparkline } from "./endpoint-latency-sparkline";
+import { UrlPreview } from "./forms/url-preview";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export interface ProviderEndpointsTableProps {
+  /** Vendor ID to fetch endpoints for */
+  vendorId: number;
+  /** Optional: filter endpoints by providerType. If undefined, shows all types. */
+  providerType?: ProviderType;
+  /** If true, hides add/edit/delete actions (view-only mode) */
+  readOnly?: boolean;
+  /** If true, hides the type column (useful when filtering by single type) */
+  hideTypeColumn?: boolean;
+  /** Custom query key suffix for cache isolation */
+  queryKeySuffix?: string;
+}
+
+// ============================================================================
+// Main Component
+// ============================================================================
+
+/**
+ * Reusable endpoint CRUD table component.
+ * Supports filtering by providerType and read-only mode for ProviderForm reuse.
+ */
+export function ProviderEndpointsTable({
+  vendorId,
+  providerType,
+  readOnly = false,
+  hideTypeColumn = false,
+  queryKeySuffix,
+}: ProviderEndpointsTableProps) {
+  const t = useTranslations("settings.providers");
+  const tTypes = useTranslations("settings.providers.types");
+
+  // Build query key based on whether we filter by type
+  const queryKey = providerType
+    ? ["provider-endpoints", vendorId, providerType, queryKeySuffix].filter(Boolean)
+    : ["provider-endpoints", vendorId, queryKeySuffix].filter(Boolean);
+
+  const { data: rawEndpoints = [], isLoading } = useQuery({
+    queryKey,
+    queryFn: async () => {
+      if (providerType) {
+        return await getProviderEndpoints({ vendorId, providerType });
+      }
+      return await getProviderEndpointsByVendor({ vendorId });
+    },
+  });
+
+  // Sort endpoints by type order (from getAllProviderTypes) then by sortOrder
+  const endpoints = useMemo(() => {
+    const typeOrder = getAllProviderTypes();
+    const typeIndexMap = new Map(typeOrder.map((t, i) => [t, i]));
+
+    return [...rawEndpoints].sort((a, b) => {
+      const aTypeIndex = typeIndexMap.get(a.providerType) ?? 999;
+      const bTypeIndex = typeIndexMap.get(b.providerType) ?? 999;
+      if (aTypeIndex !== bTypeIndex) {
+        return aTypeIndex - bTypeIndex;
+      }
+      return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
+    });
+  }, [rawEndpoints]);
+
+  if (isLoading) {
+    return <div className="text-center py-4 text-sm text-muted-foreground">{t("keyLoading")}</div>;
+  }
+
+  if (endpoints.length === 0) {
+    return (
+      <div className="text-center py-8 border rounded-md border-dashed">
+        <p className="text-sm text-muted-foreground">{t("noEndpoints")}</p>
+        <p className="text-xs text-muted-foreground mt-1">{t("noEndpointsDesc")}</p>
+      </div>
+    );
+  }
+
+  return (
+    <div className="border rounded-md">
+      <Table>
+        <TableHeader>
+          <TableRow>
+            {!hideTypeColumn && <TableHead className="w-[60px]">{t("columnType")}</TableHead>}
+            <TableHead>{t("columnUrl")}</TableHead>
+            <TableHead>{t("status")}</TableHead>
+            <TableHead className="w-[220px]">{t("latency")}</TableHead>
+            {!readOnly && <TableHead className="text-right">{t("columnActions")}</TableHead>}
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {endpoints.map((endpoint) => (
+            <EndpointRow
+              key={endpoint.id}
+              endpoint={endpoint}
+              tTypes={tTypes}
+              readOnly={readOnly}
+              hideTypeColumn={hideTypeColumn}
+            />
+          ))}
+        </TableBody>
+      </Table>
+    </div>
+  );
+}
+
+// ============================================================================
+// EndpointRow
+// ============================================================================
+
+function EndpointRow({
+  endpoint,
+  tTypes,
+  readOnly,
+  hideTypeColumn,
+}: {
+  endpoint: ProviderEndpoint;
+  tTypes: ReturnType<typeof useTranslations>;
+  readOnly: boolean;
+  hideTypeColumn: boolean;
+}) {
+  const t = useTranslations("settings.providers");
+  const tCommon = useTranslations("settings.common");
+  const queryClient = useQueryClient();
+  const [isProbing, setIsProbing] = useState(false);
+  const [isToggling, setIsToggling] = useState(false);
+
+  const typeConfig = getProviderTypeConfig(endpoint.providerType);
+  const TypeIcon = typeConfig.icon;
+  const typeKey = getProviderTypeTranslationKey(endpoint.providerType);
+  const typeLabel = tTypes(`${typeKey}.label`);
+
+  const probeMutation = useMutation({
+    mutationFn: async () => {
+      const res = await probeProviderEndpoint({ endpointId: endpoint.id });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onMutate: () => setIsProbing(true),
+    onSettled: () => setIsProbing(false),
+    onSuccess: (data) => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      if (data?.result.ok) {
+        toast.success(t("probeSuccess"));
+      } else {
+        toast.error(
+          data?.result.errorMessage
+            ? `${t("probeFailed")}: ${data.result.errorMessage}`
+            : t("probeFailed")
+        );
+      }
+    },
+    onError: () => {
+      toast.error(t("probeFailed"));
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: async () => {
+      const res = await removeProviderEndpoint({ endpointId: endpoint.id });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
+      toast.success(t("endpointDeleteSuccess"));
+    },
+    onError: () => {
+      toast.error(t("endpointDeleteFailed"));
+    },
+  });
+
+  const toggleMutation = useMutation({
+    mutationFn: async (nextEnabled: boolean) => {
+      const res = await editProviderEndpoint({
+        endpointId: endpoint.id,
+        isEnabled: nextEnabled,
+      });
+      if (!res.ok) throw new Error(res.error);
+      return res.data;
+    },
+    onMutate: () => setIsToggling(true),
+    onSettled: () => setIsToggling(false),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      toast.success(t("endpointUpdateSuccess"));
+    },
+    onError: () => {
+      toast.error(t("endpointUpdateFailed"));
+    },
+  });
+
+  return (
+    <TableRow>
+      {!hideTypeColumn && (
+        <TableCell>
+          <TooltipProvider>
+            <Tooltip delayDuration={200}>
+              <TooltipTrigger asChild>
+                <span
+                  className={`inline-flex h-6 w-6 items-center justify-center rounded ${typeConfig.bgColor}`}
+                >
+                  <TypeIcon className={`h-4 w-4 ${typeConfig.iconColor}`} />
+                </span>
+              </TooltipTrigger>
+              <TooltipContent>{typeLabel}</TooltipContent>
+            </Tooltip>
+          </TooltipProvider>
+        </TableCell>
+      )}
+      <TableCell className="font-mono text-xs max-w-[200px] truncate" title={endpoint.url}>
+        {endpoint.url}
+      </TableCell>
+      <TableCell>
+        <div className="flex items-center gap-2">
+          {endpoint.isEnabled ? (
+            <Badge
+              variant="secondary"
+              className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
+            >
+              {t("enabledStatus")}
+            </Badge>
+          ) : (
+            <Badge variant="outline">{t("disabledStatus")}</Badge>
+          )}
+          {!readOnly && (
+            <Switch
+              checked={endpoint.isEnabled}
+              onCheckedChange={(checked) => toggleMutation.mutate(checked)}
+              disabled={isToggling}
+              aria-label={t("enabledStatus")}
+            />
+          )}
+        </div>
+      </TableCell>
+      <TableCell>
+        <div className="flex items-center gap-3">
+          <EndpointLatencySparkline endpointId={endpoint.id} limit={12} />
+          {endpoint.lastProbedAt ? (
+            <span className="text-muted-foreground text-[10px] whitespace-nowrap">
+              {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })}
+            </span>
+          ) : (
+            <span className="text-muted-foreground text-[10px]">-</span>
+          )}
+        </div>
+      </TableCell>
+      {!readOnly && (
+        <TableCell className="text-right">
+          <div className="flex justify-end gap-2">
+            <Button
+              variant="ghost"
+              size="icon"
+              className="h-8 w-8"
+              onClick={() => probeMutation.mutate()}
+              disabled={isProbing}
+            >
+              {isProbing ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : (
+                <Play className="h-4 w-4" />
+              )}
+            </Button>
+
+            <EditEndpointDialog endpoint={endpoint} />
+
+            <DropdownMenu>
+              <DropdownMenuTrigger asChild>
+                <Button variant="ghost" size="icon" className="h-8 w-8">
+                  <MoreHorizontal className="h-4 w-4" />
+                </Button>
+              </DropdownMenuTrigger>
+              <DropdownMenuContent align="end">
+                <DropdownMenuItem
+                  className="text-destructive focus:text-destructive"
+                  onClick={() => {
+                    if (confirm(t("confirmDeleteEndpoint"))) {
+                      deleteMutation.mutate();
+                    }
+                  }}
+                >
+                  <Trash2 className="mr-2 h-4 w-4" />
+                  {tCommon("delete")}
+                </DropdownMenuItem>
+              </DropdownMenuContent>
+            </DropdownMenu>
+          </div>
+        </TableCell>
+      )}
+    </TableRow>
+  );
+}
+
+// ============================================================================
+// AddEndpointButton
+// ============================================================================
+
+export interface AddEndpointButtonProps {
+  vendorId: number;
+  /** If provided, locks the type selector to this value */
+  providerType?: ProviderType;
+  /** Custom query key suffix for cache invalidation */
+  queryKeySuffix?: string;
+}
+
+export function AddEndpointButton({
+  vendorId,
+  providerType: fixedProviderType,
+  queryKeySuffix,
+}: AddEndpointButtonProps) {
+  const t = useTranslations("settings.providers");
+  const tTypes = useTranslations("settings.providers.types");
+  const tCommon = useTranslations("settings.common");
+  const [open, setOpen] = useState(false);
+  const queryClient = useQueryClient();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [url, setUrl] = useState("");
+  const [label, setLabel] = useState("");
+  const [sortOrder, setSortOrder] = useState(0);
+  const [isEnabled, setIsEnabled] = useState(true);
+  const [providerType, setProviderType] = useState<ProviderType>(fixedProviderType ?? "claude");
+
+  const selectableTypes: ProviderType[] = getAllProviderTypes().filter(
+    (type) => !["claude-auth", "gemini-cli"].includes(type)
+  );
+
+  useEffect(() => {
+    if (!open) {
+      setUrl("");
+      setLabel("");
+      setSortOrder(0);
+      setIsEnabled(true);
+      setProviderType(fixedProviderType ?? "claude");
+    }
+  }, [open, fixedProviderType]);
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setIsSubmitting(true);
+    const formData = new FormData(e.currentTarget);
+    const endpointUrl = formData.get("url") as string;
+    const endpointLabel = formData.get("label") as string;
+    const endpointSortOrder = Number.parseInt(formData.get("sortOrder") as string, 10) || 0;
+
+    try {
+      const res = await addProviderEndpoint({
+        vendorId,
+        providerType,
+        url: endpointUrl,
+        label: endpointLabel.trim() || null,
+        sortOrder: endpointSortOrder,
+        isEnabled,
+      });
+
+      if (res.ok) {
+        toast.success(t("endpointAddSuccess"));
+        setOpen(false);
+        // Invalidate both specific and general queries
+        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId] });
+        if (fixedProviderType) {
+          queryClient.invalidateQueries({
+            queryKey: ["provider-endpoints", vendorId, fixedProviderType, queryKeySuffix].filter(
+              Boolean
+            ),
+          });
+        }
+      } else {
+        toast.error(res.error || t("endpointAddFailed"));
+      }
+    } catch (_err) {
+      toast.error(t("endpointAddFailed"));
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  const showTypeSelector = !fixedProviderType;
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button size="sm" className="h-7 gap-1">
+          <Plus className="h-3.5 w-3.5" />
+          {t("addEndpoint")}
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t("addEndpoint")}</DialogTitle>
+          <DialogDescription>{t("addEndpointDescGeneric")}</DialogDescription>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          {showTypeSelector && (
+            <div className="space-y-2">
+              <Label htmlFor="providerType">{t("columnType")}</Label>
+              <Select
+                value={providerType}
+                onValueChange={(value) => setProviderType(value as ProviderType)}
+              >
+                <SelectTrigger id="providerType">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {selectableTypes.map((type) => {
+                    const typeConfig = getProviderTypeConfig(type);
+                    const TypeIcon = typeConfig.icon;
+                    const typeKey = getProviderTypeTranslationKey(type);
+                    const label = tTypes(`${typeKey}.label`);
+                    return (
+                      <SelectItem key={type} value={type}>
+                        <div className="flex items-center gap-2">
+                          <span
+                            className={`inline-flex h-5 w-5 items-center justify-center rounded ${typeConfig.bgColor}`}
+                          >
+                            <TypeIcon className={`h-3.5 w-3.5 ${typeConfig.iconColor}`} />
+                          </span>
+                          {label}
+                        </div>
+                      </SelectItem>
+                    );
+                  })}
+                </SelectContent>
+              </Select>
+            </div>
+          )}
+
+          <div className="space-y-2">
+            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
+            <Input
+              id="url"
+              name="url"
+              placeholder={t("endpointUrlPlaceholder")}
+              required
+              onChange={(e) => setUrl(e.target.value)}
+            />
+          </div>
+
+          <div className="space-y-2">
+            <Label htmlFor="label">{t("endpointLabelOptional")}</Label>
+            <Input
+              id="label"
+              name="label"
+              placeholder={t("endpointLabelPlaceholder")}
+              value={label}
+              onChange={(e) => setLabel(e.target.value)}
+            />
+          </div>
+
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="sortOrder">{t("sortOrder")}</Label>
+              <Input
+                id="sortOrder"
+                name="sortOrder"
+                type="number"
+                min={0}
+                value={sortOrder}
+                onChange={(e) => setSortOrder(Number.parseInt(e.target.value, 10) || 0)}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>{t("enabledStatus")}</Label>
+              <div className="flex items-center h-9">
+                <Switch id="isEnabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
+              </div>
+            </div>
+          </div>
+
+          <UrlPreview baseUrl={url} providerType={providerType} />
+
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+              {tCommon("cancel")}
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {tCommon("create")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// ============================================================================
+// EditEndpointDialog
+// ============================================================================
+
+function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) {
+  const t = useTranslations("settings.providers");
+  const tCommon = useTranslations("settings.common");
+  const [open, setOpen] = useState(false);
+  const queryClient = useQueryClient();
+  const [isSubmitting, setIsSubmitting] = useState(false);
+  const [isEnabled, setIsEnabled] = useState(endpoint.isEnabled);
+
+  useEffect(() => {
+    if (open) {
+      setIsEnabled(endpoint.isEnabled);
+    }
+  }, [open, endpoint.isEnabled]);
+
+  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+    setIsSubmitting(true);
+    const formData = new FormData(e.currentTarget);
+    const url = formData.get("url") as string;
+    const label = formData.get("label") as string;
+    const sortOrder = Number.parseInt(formData.get("sortOrder") as string, 10) || 0;
+
+    try {
+      const res = await editProviderEndpoint({
+        endpointId: endpoint.id,
+        url,
+        label: label.trim() || null,
+        sortOrder,
+        isEnabled,
+      });
+
+      if (res.ok) {
+        toast.success(t("endpointUpdateSuccess"));
+        setOpen(false);
+        queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
+      } else {
+        toast.error(res.error || t("endpointUpdateFailed"));
+      }
+    } catch (_err) {
+      toast.error(t("endpointUpdateFailed"));
+    } finally {
+      setIsSubmitting(false);
+    }
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button variant="ghost" size="icon" className="h-8 w-8">
+          <Edit2 className="h-4 w-4" />
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-md">
+        <DialogHeader>
+          <DialogTitle>{t("editEndpoint")}</DialogTitle>
+        </DialogHeader>
+        <form onSubmit={handleSubmit} className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
+            <Input id="url" name="url" defaultValue={endpoint.url} required />
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="label">{t("endpointLabelOptional")}</Label>
+            <Input
+              id="label"
+              name="label"
+              placeholder={t("endpointLabelPlaceholder")}
+              defaultValue={endpoint.label ?? ""}
+            />
+          </div>
+          <div className="grid grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <Label htmlFor="sortOrder">{t("sortOrder")}</Label>
+              <Input
+                id="sortOrder"
+                name="sortOrder"
+                type="number"
+                min={0}
+                defaultValue={endpoint.sortOrder ?? 0}
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>{t("enabledStatus")}</Label>
+              <div className="flex items-center h-9">
+                <Switch id="isEnabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
+              </div>
+            </div>
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+              {tCommon("cancel")}
+            </Button>
+            <Button type="submit" disabled={isSubmitting}>
+              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {tCommon("save")}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// ============================================================================
+// ProviderEndpointsSection (convenience wrapper)
+// ============================================================================
+
+export interface ProviderEndpointsSectionProps {
+  vendorId: number;
+  providerType?: ProviderType;
+  readOnly?: boolean;
+  hideTypeColumn?: boolean;
+  queryKeySuffix?: string;
+}
+
+/**
+ * Section wrapper that includes header with Add button and the table.
+ * Use this for full section rendering (like in VendorCard).
+ */
+export function ProviderEndpointsSection({
+  vendorId,
+  providerType,
+  readOnly = false,
+  hideTypeColumn = false,
+  queryKeySuffix,
+}: ProviderEndpointsSectionProps) {
+  const t = useTranslations("settings.providers");
+
+  return (
+    <div>
+      <div className="px-6 py-3 bg-muted/10 border-b font-medium text-sm text-muted-foreground flex items-center justify-between">
+        <span>{t("endpoints")}</span>
+        {!readOnly && (
+          <AddEndpointButton
+            vendorId={vendorId}
+            providerType={providerType}
+            queryKeySuffix={queryKeySuffix}
+          />
+        )}
+      </div>
+
+      <div className="p-6">
+        <ProviderEndpointsTable
+          vendorId={vendorId}
+          providerType={providerType}
+          readOnly={readOnly}
+          hideTypeColumn={hideTypeColumn}
+          queryKeySuffix={queryKeySuffix}
+        />
+      </div>
+    </div>
+  );
+}

+ 9 - 519
src/app/[locale]/settings/providers/_components/provider-vendor-view.tsx

@@ -1,29 +1,11 @@
 "use client";
 
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { formatDistanceToNow } from "date-fns";
-import {
-  Edit2,
-  ExternalLink,
-  InfoIcon,
-  Loader2,
-  MoreHorizontal,
-  Play,
-  Plus,
-  Trash2,
-} from "lucide-react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { ExternalLink, InfoIcon, Loader2, Trash2 } from "lucide-react";
 import { useTranslations } from "next-intl";
-import { useEffect, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
 import { toast } from "sonner";
-import {
-  addProviderEndpoint,
-  editProviderEndpoint,
-  getProviderEndpointsByVendor,
-  getProviderVendors,
-  probeProviderEndpoint,
-  removeProviderEndpoint,
-  removeProviderVendor,
-} from "@/actions/provider-endpoints";
+import { getProviderVendors, removeProviderVendor } from "@/actions/provider-endpoints";
 import {
   AlertDialog,
   AlertDialogAction,
@@ -36,59 +18,14 @@ import {
   AlertDialogTrigger,
 } from "@/components/ui/alert-dialog";
 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import {
-  Dialog,
-  DialogContent,
-  DialogDescription,
-  DialogFooter,
-  DialogHeader,
-  DialogTitle,
-  DialogTrigger,
-} from "@/components/ui/dialog";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
-  Select,
-  SelectContent,
-  SelectItem,
-  SelectTrigger,
-  SelectValue,
-} from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeader,
-  TableRow,
-} from "@/components/ui/table";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import {
-  getAllProviderTypes,
-  getProviderTypeConfig,
-  getProviderTypeTranslationKey,
-} from "@/lib/provider-type-utils";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import { getErrorMessage } from "@/lib/utils/error-messages";
-import type {
-  ProviderDisplay,
-  ProviderEndpoint,
-  ProviderType,
-  ProviderVendor,
-} from "@/types/provider";
+import type { ProviderDisplay, ProviderVendor } from "@/types/provider";
 import type { User } from "@/types/user";
-import { EndpointLatencySparkline } from "./endpoint-latency-sparkline";
-import { UrlPreview } from "./forms/url-preview";
+import { ProviderEndpointsSection } from "./provider-endpoints-table";
 import { VendorKeysCompactList } from "./vendor-keys-compact-list";
 
 interface ProviderVendorViewProps {
@@ -270,461 +207,14 @@ function VendorCard({
           currencyCode={currencyCode}
         />
 
-        {enableMultiProviderTypes && vendorId > 0 && <VendorEndpointsSection vendorId={vendorId} />}
+        {enableMultiProviderTypes && vendorId > 0 && (
+          <ProviderEndpointsSection vendorId={vendorId} />
+        )}
       </CardContent>
     </Card>
   );
 }
 
-function VendorEndpointsSection({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-
-  return (
-    <div>
-      <div className="px-6 py-3 bg-muted/10 border-b font-medium text-sm text-muted-foreground flex items-center justify-between">
-        <span>{t("endpoints")}</span>
-        <AddEndpointButton vendorId={vendorId} />
-      </div>
-
-      <div className="p-6">
-        <EndpointsTable vendorId={vendorId} />
-      </div>
-    </div>
-  );
-}
-
-function EndpointsTable({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-  const tTypes = useTranslations("settings.providers.types");
-
-  const { data: rawEndpoints = [], isLoading } = useQuery({
-    queryKey: ["provider-endpoints", vendorId],
-    queryFn: async () => {
-      const endpoints = await getProviderEndpointsByVendor({ vendorId });
-      return endpoints;
-    },
-  });
-
-  // Sort endpoints by type order (from getAllProviderTypes) then by sortOrder
-  const endpoints = useMemo(() => {
-    const typeOrder = getAllProviderTypes();
-    const typeIndexMap = new Map(typeOrder.map((t, i) => [t, i]));
-
-    return [...rawEndpoints].sort((a, b) => {
-      const aTypeIndex = typeIndexMap.get(a.providerType) ?? 999;
-      const bTypeIndex = typeIndexMap.get(b.providerType) ?? 999;
-      if (aTypeIndex !== bTypeIndex) {
-        return aTypeIndex - bTypeIndex;
-      }
-      return (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
-    });
-  }, [rawEndpoints]);
-
-  if (isLoading) {
-    return <div className="text-center py-4 text-sm text-muted-foreground">{t("keyLoading")}</div>;
-  }
-
-  if (endpoints.length === 0) {
-    return (
-      <div className="text-center py-8 border rounded-md border-dashed">
-        <p className="text-sm text-muted-foreground">{t("noEndpoints")}</p>
-        <p className="text-xs text-muted-foreground mt-1">{t("noEndpointsDesc")}</p>
-      </div>
-    );
-  }
-
-  return (
-    <div className="border rounded-md">
-      <Table>
-        <TableHeader>
-          <TableRow>
-            <TableHead className="w-[60px]">{t("columnType")}</TableHead>
-            <TableHead>{t("columnUrl")}</TableHead>
-            <TableHead>{t("status")}</TableHead>
-            <TableHead className="w-[220px]">{t("latency")}</TableHead>
-            <TableHead className="text-right">{t("columnActions")}</TableHead>
-          </TableRow>
-        </TableHeader>
-        <TableBody>
-          {endpoints.map((endpoint) => (
-            <EndpointRow key={endpoint.id} endpoint={endpoint} tTypes={tTypes} />
-          ))}
-        </TableBody>
-      </Table>
-    </div>
-  );
-}
-
-function EndpointRow({
-  endpoint,
-  tTypes,
-}: {
-  endpoint: ProviderEndpoint;
-  tTypes: ReturnType<typeof useTranslations>;
-}) {
-  const t = useTranslations("settings.providers");
-  const tCommon = useTranslations("settings.common");
-  const queryClient = useQueryClient();
-  const [isProbing, setIsProbing] = useState(false);
-  const [isToggling, setIsToggling] = useState(false);
-
-  const typeConfig = getProviderTypeConfig(endpoint.providerType);
-  const TypeIcon = typeConfig.icon;
-  const typeKey = getProviderTypeTranslationKey(endpoint.providerType);
-  const typeLabel = tTypes(`${typeKey}.label`);
-
-  const probeMutation = useMutation({
-    mutationFn: async () => {
-      const res = await probeProviderEndpoint({ endpointId: endpoint.id });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onMutate: () => setIsProbing(true),
-    onSettled: () => setIsProbing(false),
-    onSuccess: (data) => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      if (data?.result.ok) {
-        toast.success(t("probeSuccess"));
-      } else {
-        toast.error(
-          data?.result.errorMessage
-            ? `${t("probeFailed")}: ${data.result.errorMessage}`
-            : t("probeFailed")
-        );
-      }
-    },
-    onError: () => {
-      toast.error(t("probeFailed"));
-    },
-  });
-
-  const deleteMutation = useMutation({
-    mutationFn: async () => {
-      const res = await removeProviderEndpoint({ endpointId: endpoint.id });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      queryClient.invalidateQueries({ queryKey: ["provider-vendors"] });
-      toast.success(t("endpointDeleteSuccess"));
-    },
-    onError: () => {
-      toast.error(t("endpointDeleteFailed"));
-    },
-  });
-
-  const toggleMutation = useMutation({
-    mutationFn: async (nextEnabled: boolean) => {
-      const res = await editProviderEndpoint({
-        endpointId: endpoint.id,
-        isEnabled: nextEnabled,
-      });
-      if (!res.ok) throw new Error(res.error);
-      return res.data;
-    },
-    onMutate: () => setIsToggling(true),
-    onSettled: () => setIsToggling(false),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      toast.success(t("endpointUpdateSuccess"));
-    },
-    onError: () => {
-      toast.error(t("endpointUpdateFailed"));
-    },
-  });
-
-  return (
-    <TableRow>
-      <TableCell>
-        <TooltipProvider>
-          <Tooltip delayDuration={200}>
-            <TooltipTrigger asChild>
-              <span
-                className={`inline-flex h-6 w-6 items-center justify-center rounded ${typeConfig.bgColor}`}
-              >
-                <TypeIcon className={`h-4 w-4 ${typeConfig.iconColor}`} />
-              </span>
-            </TooltipTrigger>
-            <TooltipContent>{typeLabel}</TooltipContent>
-          </Tooltip>
-        </TooltipProvider>
-      </TableCell>
-      <TableCell className="font-mono text-xs max-w-[200px] truncate" title={endpoint.url}>
-        {endpoint.url}
-      </TableCell>
-      <TableCell>
-        <div className="flex items-center gap-2">
-          {endpoint.isEnabled ? (
-            <Badge
-              variant="secondary"
-              className="text-green-600 bg-green-500/10 hover:bg-green-500/20"
-            >
-              {t("enabledStatus")}
-            </Badge>
-          ) : (
-            <Badge variant="outline">{t("disabledStatus")}</Badge>
-          )}
-          <Switch
-            checked={endpoint.isEnabled}
-            onCheckedChange={(checked) => toggleMutation.mutate(checked)}
-            disabled={isToggling}
-            aria-label={t("enabledStatus")}
-          />
-        </div>
-      </TableCell>
-      <TableCell>
-        <div className="flex items-center gap-3">
-          <EndpointLatencySparkline endpointId={endpoint.id} limit={12} />
-          {endpoint.lastProbedAt ? (
-            <span className="text-muted-foreground text-[10px] whitespace-nowrap">
-              {formatDistanceToNow(new Date(endpoint.lastProbedAt), { addSuffix: true })}
-            </span>
-          ) : (
-            <span className="text-muted-foreground text-[10px]">-</span>
-          )}
-        </div>
-      </TableCell>
-      <TableCell className="text-right">
-        <div className="flex justify-end gap-2">
-          <Button
-            variant="ghost"
-            size="icon"
-            className="h-8 w-8"
-            onClick={() => probeMutation.mutate()}
-            disabled={isProbing}
-          >
-            {isProbing ? (
-              <Loader2 className="h-4 w-4 animate-spin" />
-            ) : (
-              <Play className="h-4 w-4" />
-            )}
-          </Button>
-
-          <EditEndpointDialog endpoint={endpoint} />
-
-          <DropdownMenu>
-            <DropdownMenuTrigger asChild>
-              <Button variant="ghost" size="icon" className="h-8 w-8">
-                <MoreHorizontal className="h-4 w-4" />
-              </Button>
-            </DropdownMenuTrigger>
-            <DropdownMenuContent align="end">
-              <DropdownMenuItem
-                className="text-destructive focus:text-destructive"
-                onClick={() => {
-                  if (confirm(t("confirmDeleteEndpoint"))) {
-                    deleteMutation.mutate();
-                  }
-                }}
-              >
-                <Trash2 className="mr-2 h-4 w-4" />
-                {tCommon("delete")}
-              </DropdownMenuItem>
-            </DropdownMenuContent>
-          </DropdownMenu>
-        </div>
-      </TableCell>
-    </TableRow>
-  );
-}
-
-function AddEndpointButton({ vendorId }: { vendorId: number }) {
-  const t = useTranslations("settings.providers");
-  const tTypes = useTranslations("settings.providers.types");
-  const tCommon = useTranslations("settings.common");
-  const [open, setOpen] = useState(false);
-  const queryClient = useQueryClient();
-  const [isSubmitting, setIsSubmitting] = useState(false);
-  const [url, setUrl] = useState("");
-  const [providerType, setProviderType] = useState<ProviderType>("claude");
-
-  // Get provider types for the selector (exclude claude-auth and gemini-cli which are internal)
-  const selectableTypes: ProviderType[] = getAllProviderTypes().filter(
-    (type) => !["claude-auth", "gemini-cli"].includes(type)
-  );
-
-  useEffect(() => {
-    if (!open) {
-      setUrl("");
-      setProviderType("claude");
-    }
-  }, [open]);
-
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    setIsSubmitting(true);
-    const formData = new FormData(e.currentTarget);
-    const endpointUrl = formData.get("url") as string;
-
-    try {
-      const res = await addProviderEndpoint({
-        vendorId,
-        providerType,
-        url: endpointUrl,
-        label: null,
-        sortOrder: 0,
-        isEnabled: true,
-      });
-
-      if (res.ok) {
-        toast.success(t("endpointAddSuccess"));
-        setOpen(false);
-        queryClient.invalidateQueries({ queryKey: ["provider-endpoints", vendorId] });
-      } else {
-        toast.error(res.error || t("endpointAddFailed"));
-      }
-    } catch (_err) {
-      toast.error(t("endpointAddFailed"));
-    } finally {
-      setIsSubmitting(false);
-    }
-  };
-
-  return (
-    <Dialog open={open} onOpenChange={setOpen}>
-      <DialogTrigger asChild>
-        <Button size="sm" className="h-7 gap-1">
-          <Plus className="h-3.5 w-3.5" />
-          {t("addEndpoint")}
-        </Button>
-      </DialogTrigger>
-      <DialogContent className="sm:max-w-md">
-        <DialogHeader>
-          <DialogTitle>{t("addEndpoint")}</DialogTitle>
-          <DialogDescription>{t("addEndpointDescGeneric")}</DialogDescription>
-        </DialogHeader>
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="providerType">{t("columnType")}</Label>
-            <Select
-              value={providerType}
-              onValueChange={(value) => setProviderType(value as ProviderType)}
-            >
-              <SelectTrigger id="providerType">
-                <SelectValue />
-              </SelectTrigger>
-              <SelectContent>
-                {selectableTypes.map((type) => {
-                  const typeConfig = getProviderTypeConfig(type);
-                  const TypeIcon = typeConfig.icon;
-                  const typeKey = getProviderTypeTranslationKey(type);
-                  const label = tTypes(`${typeKey}.label`);
-                  return (
-                    <SelectItem key={type} value={type}>
-                      <div className="flex items-center gap-2">
-                        <span
-                          className={`inline-flex h-5 w-5 items-center justify-center rounded ${typeConfig.bgColor}`}
-                        >
-                          <TypeIcon className={`h-3.5 w-3.5 ${typeConfig.iconColor}`} />
-                        </span>
-                        {label}
-                      </div>
-                    </SelectItem>
-                  );
-                })}
-              </SelectContent>
-            </Select>
-          </div>
-
-          <div className="space-y-2">
-            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
-            <Input
-              id="url"
-              name="url"
-              placeholder={t("endpointUrlPlaceholder")}
-              required
-              onChange={(e) => setUrl(e.target.value)}
-            />
-          </div>
-
-          <UrlPreview baseUrl={url} providerType={providerType} />
-
-          <DialogFooter>
-            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
-              {tCommon("cancel")}
-            </Button>
-            <Button type="submit" disabled={isSubmitting}>
-              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {tCommon("create")}
-            </Button>
-          </DialogFooter>
-        </form>
-      </DialogContent>
-    </Dialog>
-  );
-}
-
-function EditEndpointDialog({ endpoint }: { endpoint: ProviderEndpoint }) {
-  const t = useTranslations("settings.providers");
-  const tCommon = useTranslations("settings.common");
-  const [open, setOpen] = useState(false);
-  const queryClient = useQueryClient();
-  const [isSubmitting, setIsSubmitting] = useState(false);
-
-  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    setIsSubmitting(true);
-    const formData = new FormData(e.currentTarget);
-    const url = formData.get("url") as string;
-    const isEnabled = formData.get("isEnabled") === "on";
-
-    try {
-      const res = await editProviderEndpoint({
-        endpointId: endpoint.id,
-        url,
-        isEnabled,
-      });
-
-      if (res.ok) {
-        toast.success(t("endpointUpdateSuccess"));
-        setOpen(false);
-        queryClient.invalidateQueries({ queryKey: ["provider-endpoints"] });
-      } else {
-        toast.error(res.error || t("endpointUpdateFailed"));
-      }
-    } catch (_err) {
-      toast.error(t("endpointUpdateFailed"));
-    } finally {
-      setIsSubmitting(false);
-    }
-  };
-
-  return (
-    <Dialog open={open} onOpenChange={setOpen}>
-      <DialogTrigger asChild>
-        <Button variant="ghost" size="icon" className="h-8 w-8">
-          <Edit2 className="h-4 w-4" />
-        </Button>
-      </DialogTrigger>
-      <DialogContent className="sm:max-w-md">
-        <DialogHeader>
-          <DialogTitle>{t("editEndpoint")}</DialogTitle>
-        </DialogHeader>
-        <form onSubmit={handleSubmit} className="space-y-4">
-          <div className="space-y-2">
-            <Label htmlFor="url">{t("endpointUrlLabel")}</Label>
-            <Input id="url" name="url" defaultValue={endpoint.url} required />
-          </div>
-          <div className="flex items-center space-x-2">
-            <Switch id="isEnabled" name="isEnabled" defaultChecked={endpoint.isEnabled} />
-            <Label htmlFor="isEnabled">{t("enabledStatus")}</Label>
-          </div>
-          <DialogFooter>
-            <Button type="button" variant="outline" onClick={() => setOpen(false)}>
-              {tCommon("cancel")}
-            </Button>
-            <Button type="submit" disabled={isSubmitting}>
-              {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
-              {tCommon("save")}
-            </Button>
-          </DialogFooter>
-        </form>
-      </DialogContent>
-    </Dialog>
-  );
-}
-
 function DeleteVendorDialog({ vendor, vendorId }: { vendor?: ProviderVendor; vendorId: number }) {
   const t = useTranslations("settings.providers");
   const tCommon = useTranslations("settings.common");

+ 567 - 0
tests/unit/settings/providers/provider-endpoints-table.test.tsx

@@ -0,0 +1,567 @@
+/**
+ * @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 {
+  ProviderEndpointsTable,
+  AddEndpointButton,
+  ProviderEndpointsSection,
+} from "@/app/[locale]/settings/providers/_components/provider-endpoints-table";
+import enMessages from "../../../../messages/en";
+
+vi.mock("next/navigation", () => ({
+  useRouter: () => ({ refresh: vi.fn() }),
+}));
+
+const sonnerMocks = vi.hoisted(() => ({
+  toast: {
+    success: vi.fn(),
+    error: vi.fn(),
+  },
+}));
+vi.mock("sonner", () => sonnerMocks);
+
+vi.mock("@/components/ui/tooltip", () => ({
+  Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
+  TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
+  TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
+}));
+
+const providerEndpointsActionMocks = vi.hoisted(() => ({
+  addProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  editProviderEndpoint: vi.fn(async () => ({ ok: true, data: { endpoint: {} } })),
+  getProviderEndpointProbeLogs: vi.fn(async () => ({ ok: true, data: { logs: [] } })),
+  getProviderEndpoints: vi.fn(async () => [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.claude.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderEndpointsByVendor: vi.fn(async () => [
+    {
+      id: 1,
+      vendorId: 1,
+      providerType: "claude",
+      url: "https://api.claude.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: true,
+      lastProbedAt: null,
+      lastProbeOk: null,
+      lastProbeLatencyMs: null,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+    {
+      id: 2,
+      vendorId: 1,
+      providerType: "openai-compatible",
+      url: "https://api.openai.example.com/v1",
+      label: null as string | null,
+      sortOrder: 0,
+      isEnabled: false,
+      lastProbedAt: "2026-01-01T12:00:00Z",
+      lastProbeOk: true,
+      lastProbeLatencyMs: 150,
+      createdAt: "2026-01-01",
+      updatedAt: "2026-01-01",
+    },
+  ]),
+  getProviderVendors: vi.fn(async () => []),
+  probeProviderEndpoint: vi.fn(async () => ({ ok: true, data: { result: { ok: true } } })),
+  removeProviderEndpoint: vi.fn(async () => ({ ok: true })),
+  removeProviderVendor: vi.fn(async () => ({ ok: true })),
+}));
+vi.mock("@/actions/provider-endpoints", () => providerEndpointsActionMocks);
+
+function loadMessages() {
+  return {
+    common: enMessages.common,
+    errors: enMessages.errors,
+    ui: enMessages.ui,
+    forms: enMessages.forms,
+    settings: enMessages.settings,
+  };
+}
+
+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 {
+    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("ProviderEndpointsTable", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders endpoints from getProviderEndpointsByVendor when no providerType filter", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpointsByVendor).toHaveBeenCalledWith({
+      vendorId: 1,
+    });
+    expect(document.body.textContent || "").toContain("https://api.claude.example.com/v1");
+    expect(document.body.textContent || "").toContain("https://api.openai.example.com/v1");
+
+    unmount();
+  });
+
+  test("renders endpoints from getProviderEndpoints when providerType filter is set", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({
+      vendorId: 1,
+      providerType: "claude",
+    });
+
+    unmount();
+  });
+
+  test("hides type column when hideTypeColumn is true", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} hideTypeColumn={true} />
+    );
+
+    await flushTicks(6);
+
+    const headers = Array.from(document.querySelectorAll("th")).map((th) => th.textContent);
+    expect(headers).not.toContain("Type");
+
+    unmount();
+  });
+
+  test("shows type column by default", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Type");
+
+    unmount();
+  });
+
+  test("hides actions column in readOnly mode", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsTable vendorId={1} readOnly={true} />
+    );
+
+    await flushTicks(6);
+
+    const headers = Array.from(document.querySelectorAll("th")).map((th) => th.textContent);
+    expect(headers).not.toContain("Actions");
+
+    const switchElements = document.querySelectorAll("[data-slot='switch']");
+    expect(switchElements.length).toBe(0);
+
+    unmount();
+  });
+
+  test("shows actions column by default", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Actions");
+
+    unmount();
+  });
+
+  test("toggle switch calls editProviderEndpoint", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const endpointRow = Array.from(document.querySelectorAll("tr")).find((row) =>
+      row.textContent?.includes("https://api.claude.example.com/v1")
+    );
+    expect(endpointRow).toBeDefined();
+
+    const switchEl = endpointRow?.querySelector<HTMLElement>("[data-slot='switch']");
+    expect(switchEl).not.toBeNull();
+    switchEl?.click();
+
+    await flushTicks(2);
+
+    expect(providerEndpointsActionMocks.editProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({ endpointId: 1, isEnabled: false })
+    );
+
+    unmount();
+  });
+
+  test("probe button calls probeProviderEndpoint", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const probeButtons = document.querySelectorAll("button");
+    const probeButton = Array.from(probeButtons).find((btn) =>
+      btn.querySelector("svg.lucide-play")
+    );
+    expect(probeButton).toBeDefined();
+
+    probeButton?.click();
+    await flushTicks(2);
+
+    expect(providerEndpointsActionMocks.probeProviderEndpoint).toHaveBeenCalledWith({
+      endpointId: 1,
+    });
+
+    unmount();
+  });
+
+  test("shows empty state when no endpoints", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([]);
+
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("No endpoints");
+
+    unmount();
+  });
+
+  test("displays enabled/disabled badge correctly", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("enabled");
+    expect(document.body.textContent || "").toContain("disabled");
+
+    unmount();
+  });
+
+  test("edit dialog submits with label, sortOrder, and isEnabled", async () => {
+    providerEndpointsActionMocks.getProviderEndpointsByVendor.mockResolvedValueOnce([
+      {
+        id: 10,
+        vendorId: 1,
+        providerType: "claude",
+        url: "https://original.example.com/v1",
+        label: "Original Label",
+        sortOrder: 3,
+        isEnabled: true,
+        lastProbedAt: null,
+        lastProbeOk: null,
+        lastProbeLatencyMs: null,
+        createdAt: "2026-01-01",
+        updatedAt: "2026-01-01",
+      },
+    ]);
+
+    const { unmount } = renderWithProviders(<ProviderEndpointsTable vendorId={1} />);
+
+    await flushTicks(6);
+
+    const editButtons = document.querySelectorAll("button");
+    const editButton = Array.from(editButtons).find((btn) => btn.querySelector("svg.lucide-pen"));
+    expect(editButton).toBeDefined();
+
+    act(() => {
+      editButton?.click();
+    });
+
+    await flushTicks(4);
+
+    const urlInput = document.querySelector<HTMLInputElement>('input[name="url"]');
+    const labelInput = document.querySelector<HTMLInputElement>('input[name="label"]');
+    const sortOrderInput = document.querySelector<HTMLInputElement>('input[name="sortOrder"]');
+
+    expect(urlInput?.value).toBe("https://original.example.com/v1");
+    expect(labelInput?.value).toBe("Original Label");
+    expect(sortOrderInput?.value).toBe("3");
+
+    act(() => {
+      if (urlInput) {
+        urlInput.value = "https://updated.example.com/v1";
+        urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (labelInput) {
+        labelInput.value = "Updated Label";
+        labelInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (sortOrderInput) {
+        sortOrderInput.value = "10";
+        sortOrderInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+    });
+
+    const form = document.querySelector("form");
+    act(() => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(4);
+
+    expect(providerEndpointsActionMocks.editProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({
+        endpointId: 10,
+        url: "https://updated.example.com/v1",
+        label: "Updated Label",
+        sortOrder: 10,
+        isEnabled: true,
+      })
+    );
+
+    unmount();
+  });
+});
+
+describe("AddEndpointButton", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders add button", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("opens dialog on click", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("URL");
+
+    unmount();
+  });
+
+  test("shows type selector when no fixed providerType", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    expect(document.body.textContent || "").toContain("Type");
+
+    unmount();
+  });
+
+  test("hides type selector when providerType is fixed", async () => {
+    const { unmount } = renderWithProviders(
+      <AddEndpointButton vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    addButton?.click();
+
+    await flushTicks(2);
+
+    const labels = Array.from(document.querySelectorAll("label")).map((l) => l.textContent);
+    const hasTypeLabel = labels.some((l) => l === "Type");
+    expect(hasTypeLabel).toBe(false);
+
+    unmount();
+  });
+
+  test("submits with label, sortOrder, and isEnabled fields", async () => {
+    const { unmount } = renderWithProviders(<AddEndpointButton vendorId={1} />);
+
+    await flushTicks(2);
+
+    const addButton = document.querySelector("button");
+    act(() => {
+      addButton?.click();
+    });
+
+    await flushTicks(2);
+
+    const urlInput = document.querySelector<HTMLInputElement>('input[name="url"]');
+    const labelInput = document.querySelector<HTMLInputElement>('input[name="label"]');
+    const sortOrderInput = document.querySelector<HTMLInputElement>('input[name="sortOrder"]');
+
+    act(() => {
+      if (urlInput) {
+        urlInput.value = "https://test.example.com/v1";
+        urlInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (labelInput) {
+        labelInput.value = "Test Label";
+        labelInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+      if (sortOrderInput) {
+        sortOrderInput.value = "5";
+        sortOrderInput.dispatchEvent(new Event("input", { bubbles: true }));
+      }
+    });
+
+    const form = document.querySelector("form");
+    act(() => {
+      form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+    });
+
+    await flushTicks(4);
+
+    expect(providerEndpointsActionMocks.addProviderEndpoint).toHaveBeenCalledWith(
+      expect.objectContaining({
+        vendorId: 1,
+        url: "https://test.example.com/v1",
+        label: "Test Label",
+        sortOrder: 5,
+        isEnabled: true,
+      })
+    );
+
+    unmount();
+  });
+});
+
+describe("ProviderEndpointsSection", () => {
+  beforeEach(() => {
+    queryClient = new QueryClient({
+      defaultOptions: {
+        queries: { retry: false },
+        mutations: { retry: false },
+      },
+    });
+    vi.clearAllMocks();
+    while (document.body.firstChild) {
+      document.body.removeChild(document.body.firstChild);
+    }
+  });
+
+  test("renders section header with endpoints label", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Endpoints");
+
+    unmount();
+  });
+
+  test("renders add button in section header", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("hides add button in readOnly mode", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsSection vendorId={1} readOnly={true} />
+    );
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").not.toContain("Add Endpoint");
+
+    unmount();
+  });
+
+  test("renders table with endpoints", async () => {
+    const { unmount } = renderWithProviders(<ProviderEndpointsSection vendorId={1} />);
+
+    await flushTicks(6);
+
+    expect(document.body.textContent || "").toContain("https://api.claude.example.com/v1");
+
+    unmount();
+  });
+
+  test("passes providerType filter to table", async () => {
+    const { unmount } = renderWithProviders(
+      <ProviderEndpointsSection vendorId={1} providerType="claude" />
+    );
+
+    await flushTicks(6);
+
+    expect(providerEndpointsActionMocks.getProviderEndpoints).toHaveBeenCalledWith({
+      vendorId: 1,
+      providerType: "claude",
+    });
+
+    unmount();
+  });
+});