providers table, create, and edit first pass

This commit is contained in:
miloschwartz
2026-08-03 10:36:42 -04:00
committed by Owen
parent 1a644b131d
commit b0edc6302e
16 changed files with 2141 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { aiProviderTypeValues } from "@app/lib/aiProviderFormSchema";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
const typeLabelMap = {
openai: "aiProviderTypeOpenai",
anthropic: "aiProviderTypeAnthropic",
googleGemini: "aiProviderTypeGoogleGemini",
vertexAi: "aiProviderTypeVertexAi",
bedrock: "aiProviderTypeBedrock",
microsoftFoundry: "aiProviderTypeMicrosoftFoundry",
openRouter: "aiProviderTypeOpenRouter",
vercelAiGateway: "aiProviderTypeVercelAiGateway",
custom: "aiProviderTypeCustom"
} as const;
const typeDescriptionMap = {
openai: "aiProviderTypeOpenaiDescription",
anthropic: "aiProviderTypeAnthropicDescription",
googleGemini: "aiProviderTypeGoogleGeminiDescription",
vertexAi: "aiProviderTypeVertexAiDescription",
bedrock: "aiProviderTypeBedrockDescription",
microsoftFoundry: "aiProviderTypeMicrosoftFoundryDescription",
openRouter: "aiProviderTypeOpenRouterDescription",
vercelAiGateway: "aiProviderTypeVercelAiGatewayDescription",
custom: "aiProviderTypeCustomDescription"
} as const;
type AiProviderTypeSelectProps = {
value: AiProviderType;
onChange: (value: AiProviderType) => void;
disabled?: boolean;
className?: string;
};
export function AiProviderTypeSelect({
value,
onChange,
disabled,
className
}: AiProviderTypeSelectProps) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const options = useMemo(
() =>
aiProviderTypeValues.map((type) => ({
type,
title: t(typeLabelMap[type]),
description: t(typeDescriptionMap[type])
})),
[t]
);
const selected = options.find((option) => option.type === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal h-auto min-h-10 py-2",
className
)}
>
<div className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left">
<span className="truncate">
{selected?.title ?? t("noneSelected")}
</span>
{selected?.description && (
<span className="text-muted-foreground text-xs leading-snug truncate w-full">
{selected.description}
</span>
)}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={t("aiProviderTypeSearch")} />
<CommandList>
<CommandEmpty>
{t("aiProviderTypeNotFound")}
</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.type}
value={`${option.type} ${option.title} ${option.description}`}
onSelect={() => {
onChange(option.type);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4 shrink-0",
option.type === value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">
{option.title}
</span>
<span className="text-muted-foreground text-xs leading-snug">
{option.description}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+317
View File
@@ -0,0 +1,317 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { PaginationState } from "@tanstack/react-table";
import { ArrowRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
export type AiProviderRow = {
providerId: number;
name: string;
type: string;
routingMode: string;
enabled: boolean;
effectiveUpstreamUrl: string | null;
apiKeyLastChars: string | null;
};
type AiProvidersTableProps = {
providers: AiProviderRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
};
export default function AiProvidersTable({
providers,
orgId,
pagination,
rowCount
}: AiProvidersTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [rows, setRows] = useState(providers);
const [selected, setSelected] = useState<AiProviderRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
useEffect(() => {
setRows(providers);
}, [providers]);
function refreshData() {
startTransition(() => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
function typeLabel(type: string) {
const key = `aiProviderType${type.charAt(0).toUpperCase()}${type.slice(1)}`;
const map: Record<string, string> = {
openai: "aiProviderTypeOpenai",
anthropic: "aiProviderTypeAnthropic",
googleGemini: "aiProviderTypeGoogleGemini",
vertexAi: "aiProviderTypeVertexAi",
bedrock: "aiProviderTypeBedrock",
microsoftFoundry: "aiProviderTypeMicrosoftFoundry",
openRouter: "aiProviderTypeOpenRouter",
vercelAiGateway: "aiProviderTypeVercelAiGateway",
custom: "aiProviderTypeCustom"
};
return t(map[type] ?? key);
}
function routingLabel(mode: string) {
return mode === "target"
? t("aiProviderRoutingModeTarget")
: t("aiProviderRoutingModeUrl");
}
async function toggleEnabled(row: AiProviderRow, enabled: boolean) {
setRows((prev) =>
prev.map((r) =>
r.providerId === row.providerId ? { ...r, enabled } : r
)
);
try {
await api.post(`/ai-provider/${row.providerId}`, { enabled });
toast({
title: t("success"),
description: t("aiProviderUpdated")
});
router.refresh();
} catch (e) {
setRows((prev) =>
prev.map((r) =>
r.providerId === row.providerId
? { ...r, enabled: row.enabled }
: r
)
);
toast({
variant: "destructive",
title: t("aiProviderErrorUpdate"),
description: formatAxiosError(e, t("aiProviderErrorUpdate"))
});
}
}
function deleteProvider(row: AiProviderRow) {
startTransition(async () => {
try {
await api.delete(`/ai-provider/${row.providerId}`);
setRows((prev) =>
prev.filter((r) => r.providerId !== row.providerId)
);
setIsDeleteModalOpen(false);
setSelected(null);
toast({
title: t("success"),
description: t("aiProviderDeleted")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("aiProviderErrorDelete"),
description: formatAxiosError(e, t("aiProviderErrorDelete"))
});
}
});
}
const columns = useMemo<ExtendedColumnDef<AiProviderRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
className="hover:underline"
>
{row.original.name}
</Link>
)
},
{
accessorKey: "type",
header: () => (
<span className="p-3">{t("aiProviderType")}</span>
),
cell: ({ row }) => typeLabel(row.original.type)
},
{
accessorKey: "routingMode",
header: () => (
<span className="p-3">{t("aiProviderRoutingMode")}</span>
),
cell: ({ row }) => routingLabel(row.original.routingMode)
},
{
accessorKey: "effectiveUpstreamUrl",
header: () => (
<span className="p-3">{t("aiProviderUpstreamUrl")}</span>
),
cell: ({ row }) => (
<span>{row.original.effectiveUpstreamUrl ?? "-"}</span>
)
},
{
accessorKey: "enabled",
header: () => (
<span className="p-3">{t("aiProviderEnabled")}</span>
),
cell: ({ row }) => (
<Switch
checked={row.original.enabled}
onCheckedChange={(checked) =>
toggleEnabled(row.original, checked)
}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
>
{t("edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/ai-providers/${row.original.providerId}`}
>
<Button variant="outline">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
)
}
],
[orgId, t]
);
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelected(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("aiProviderQuestionRemove")}</p>
<p>{t("aiProviderMessageRemove")}</p>
</div>
}
buttonText={t("aiProviderDeleteConfirm")}
onConfirm={async () => deleteProvider(selected)}
string={selected.name}
title={t("aiProviderDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={rows}
addButtonText={t("aiProvidersAdd")}
onAdd={() =>
router.push(`/${orgId}/settings/ai-providers/create`)
}
tableId="ai-providers-table"
searchPlaceholder={t("aiProvidersSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
stickyRightColumn="actions"
/>
</>
);
}
+1
View File
@@ -47,6 +47,7 @@ export function HorizontalTabs({
.replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string)
.replace("{apiKeyId}", params.apiKeyId as string)
.replace("{providerId}", params.providerId as string)
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
}