blob: 28ca79f1c3dfa46c0f61dbb2d3a6380d26c7df87 [file]
// Copyright 2026 The casbin Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {CircleX, Pencil, Trash2} from "lucide-react";
import i18next from "i18next";
import {ProviderIcon} from "@/components/ProviderIcon";
import {QuotaBadge} from "@/components/ProviderQuota";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {Card, CardContent} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {SimpleTooltip} from "@/components/ui/tooltip";
import {agentSpeaks} from "@/lib/agents";
import {providerIdOf, providerProtocol, usesClientAuth} from "@/lib/providers";
import {cn} from "@/lib/utils";
import type {Agent, Provider, ProviderHealth, ProviderQuota} from "@/types";
/** How many model badges fit before the rest become a count. */
const visibleModels = 3;
/** The one mark that says whether the proxy is happy with this provider. */
function HealthDot({health}: {health: ProviderHealth | undefined}) {
if (!health) {
return (
<SimpleTooltip title={i18next.t("provider:Not used yet")}>
<span className="bg-muted-foreground/40 size-2 shrink-0 rounded-full" />
</SimpleTooltip>
);
}
const healthy = health.healthy && health.consecutive === 0;
return (
<SimpleTooltip
title={
health.healthy
? `${health.successes} / ${health.successes + health.failures}`
: `${health.lastError} ยท ${i18next.t("provider:Retried at")} ${health.retryTime}`
}
>
<span
className={cn(
"size-2 shrink-0 rounded-full",
healthy ? "bg-success" : health.healthy ? "bg-muted-foreground" : "bg-warning",
)}
/>
</SimpleTooltip>
);
}
/**
* One provider, as a card rather than a row: the name, where it points, what it
* serves, and the agents it answers for. Switching an agent over is what people
* come to this page to do, so it is one click from here.
*/
export function ProviderGridCard({
provider,
agents,
health,
quota,
busy,
onEdit,
onDelete,
onBind,
}: {
provider: Provider;
agents: Agent[];
health: ProviderHealth | undefined;
quota: ProviderQuota | undefined;
busy: boolean;
onEdit: () => void;
onDelete: () => void;
onBind: (agent: Agent) => void;
}) {
const id = providerIdOf(provider);
const protocol = providerProtocol(provider.type);
const models = provider.models ?? [];
const users = agents.filter(agent => agent.provider === id);
// An agent bound directly to a provider reads its answers itself, so only one
// serving the API that agent speaks is offered; through the gateway they all
// are, since the proxy translates.
const others = agents.filter(agent => agent.provider !== id && agentSpeaks(agent, protocol));
return (
<Card className="gap-0 py-0">
<CardContent className="flex h-full flex-col gap-3 p-4">
<div className="flex items-start gap-2">
<ProviderIcon icon={provider.icon} baseUrl={provider.baseUrl} alt={provider.name} size={22} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={onEdit}
className="min-w-0 truncate text-left font-medium hover:underline"
>
{provider.displayName || provider.name}
</button>
<HealthDot health={health} />
</div>
<SimpleTooltip title={provider.baseUrl}>
<p className="text-muted-foreground truncate font-mono text-xs">{provider.baseUrl || "-"}</p>
</SimpleTooltip>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={protocol === "anthropic" ? "info" : "success"}>{protocol}</Badge>
{usesClientAuth(provider) ? (
<Badge variant="muted">{i18next.t("provider:Caller's own login")}</Badge>
) : null}
{provider.status === "enabled" ? null : (
<Badge variant="muted">
<CircleX />
{i18next.t("provider:Disabled")}
</Badge>
)}
{/* A card has no column to fill, so a balance nobody can read is left out. */}
{quota && (quota.error !== "" || quota.supported) ? <QuotaBadge quota={quota} /> : null}
</div>
<div className="flex flex-wrap gap-1">
{models.length === 0 ? (
<span className="text-muted-foreground text-xs">
{usesClientAuth(provider) ? i18next.t("provider:Any model") : "-"}
</span>
) : (
<>
{models.slice(0, visibleModels).map(model => (
<Badge key={model} variant="muted">
{model}
</Badge>
))}
{models.length > visibleModels ? (
<SimpleTooltip title={models.slice(visibleModels).join(", ")}>
<Badge variant="muted">{`+${models.length - visibleModels}`}</Badge>
</SimpleTooltip>
) : null}
</>
)}
</div>
<p className="text-muted-foreground mt-auto truncate text-xs">
{users.length === 0
? i18next.t("provider:Used by no agent")
: `${i18next.t("provider:Used by")} ${users.map(agent => agent.name).join(", ")}`}
</p>
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
{others.length === 0 ? null : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" disabled={busy}>
{i18next.t("provider:Switch an agent over")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>{i18next.t("provider:Switch hint")}</DropdownMenuLabel>
{others.map(agent => (
<DropdownMenuItem key={`${agent.agentId}/${agent.path}`} onSelect={() => onBind(agent)}>
{agent.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<div className="ml-auto flex gap-2">
<SimpleTooltip title={i18next.t("general:Edit")}>
<Button size="icon-sm" variant="ghost" aria-label={i18next.t("general:Edit")} onClick={onEdit}>
<Pencil />
</Button>
</SimpleTooltip>
<ConfirmDialog
title={i18next
.t("general:Sure to delete {name} ?")
.replace("{name}", provider.displayName || provider.name)}
confirmText={i18next.t("general:Delete")}
onConfirm={onDelete}
>
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label={i18next.t("general:Delete")}
>
<Trash2 />
</Button>
</ConfirmDialog>
</div>
</div>
</CardContent>
</Card>
);
}