This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new d65dd26 feat(web): mark brand items in dropdowns with their icons
d65dd26 is described below
commit d65dd263c65c9749f9639fd3a60225b3ee778831
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 20:35:02 2026 +0800
feat(web): mark brand items in dropdowns with their icons
---
web/src/components/AgentGridCard.tsx | 1 +
web/src/components/AgentIcon.tsx | 13 +++-
web/src/components/ProbeCaseList.tsx | 7 +-
web/src/components/ProviderCard.tsx | 4 +-
web/src/components/ProviderQuota.tsx | 3 +-
web/src/components/settings/cloud-sync-panel.tsx | 3 +-
web/src/components/shared/brand-options.tsx | 90 ++++++++++++++++++++++++
web/src/components/shared/simple-select.tsx | 8 ++-
web/src/lib/providers.ts | 4 --
web/src/lib/quota.ts | 6 ++
web/src/pages/AgentRecordsPage.tsx | 3 +-
web/src/pages/AgentSessionsPage.tsx | 6 +-
web/src/pages/ProviderEditPage.tsx | 10 +--
web/src/pages/ProviderListPage.tsx | 12 ++--
web/src/pages/UsagePage.tsx | 3 +-
15 files changed, 140 insertions(+), 33 deletions(-)
diff --git a/web/src/components/AgentGridCard.tsx
b/web/src/components/AgentGridCard.tsx
index de9bcc5..757008a 100644
--- a/web/src/components/AgentGridCard.tsx
+++ b/web/src/components/AgentGridCard.tsx
@@ -286,6 +286,7 @@ export function AgentGridCard({
</SelectTrigger>
<SelectContent>
<SelectItem value={builtinProvider}>
+ <AgentIcon agent={agent.agentId || agent.name} size={16}
fallback={<Bot className="size-4" />} />
{agentBuiltin(agent)}
<span className="text-muted-foreground ml-2 text-xs">
{i18next.t("agent:Built-in")}
diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
index 5134a9b..d9a32bf 100644
--- a/web/src/components/AgentIcon.tsx
+++ b/web/src/components/AgentIcon.tsx
@@ -47,13 +47,22 @@ const agentSites: Record<string, string> = {
"CodeBuddy Code": "codebuddy.ai",
};
+// Records name an agent by id, and a few ids do not spell out the name above.
+const agentIdSites: Record<string, string> = {
+ codebuddy: "codebuddy.ai",
+ dsh: "deepseek.com",
+ "kimi-code": "kimi.com",
+};
+
const agentKey = (agent: unknown) =>
String(agent || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
-const agentSitesByKey = Object.keys(agentSites).reduce<Record<string,
string>>((sites, name) => {
- sites[agentKey(name)] = agentSites[name];
+const agentSitesByKey = [agentSites, agentIdSites].reduce<Record<string,
string>>((sites, source) => {
+ Object.keys(source).forEach(name => {
+ sites[agentKey(name)] = source[name];
+ });
return sites;
}, {});
diff --git a/web/src/components/ProbeCaseList.tsx
b/web/src/components/ProbeCaseList.tsx
index 0c985b8..36c4b98 100644
--- a/web/src/components/ProbeCaseList.tsx
+++ b/web/src/components/ProbeCaseList.tsx
@@ -23,6 +23,7 @@ import {EmptyState} from "@/components/shared/empty-state";
import {Field, FormDialog} from "@/components/shared/form-dialog";
import {Loading} from "@/components/shared/loading";
import {NumberInput} from "@/components/shared/number-input";
+import {protocolOptions} from "@/components/shared/brand-options";
import {SimpleSelect} from "@/components/shared/simple-select";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
@@ -442,11 +443,7 @@ function CaseDialog({
<SimpleSelect
value={draft.protocol}
onChange={value => set({protocol: value as ProbeCase["protocol"]})}
- options={[
- {label: i18next.t("audit:Both APIs"), value: ""},
- {label: "Anthropic", value: "anthropic"},
- {label: "OpenAI", value: "openai"},
- ]}
+ options={[{label: i18next.t("audit:Both APIs"), value: ""},
...protocolOptions]}
/>
</Field>
<Field label={i18next.t("audit:Only these vendors")}
hint={i18next.t("audit:Vendors hint")}>
diff --git a/web/src/components/ProviderCard.tsx
b/web/src/components/ProviderCard.tsx
index 40b6418..8fec4c4 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -13,12 +13,13 @@
// limitations under the License.
import * as React from "react";
-import {FileCog, RotateCcw} from "lucide-react";
+import {Bot, FileCog, RotateCcw} from "lucide-react";
import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
import {EnvSnippet} from "@/components/EnvSnippet";
import {EnvWarningBanner} from "@/components/EnvWarningBanner";
+import {AgentIcon} from "@/components/AgentIcon";
import {ProviderIcon} from "@/components/ProviderIcon";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
import {Loading} from "@/components/shared/loading";
@@ -260,6 +261,7 @@ export function ProviderCard({
</SelectTrigger>
<SelectContent>
<SelectItem value={builtinProvider}>
+ <AgentIcon agent={agent.agentId || agent.name} size={16}
fallback={<Bot className="size-4" />} />
{agentBuiltin(agent)}
<span className="ml-2 text-xs text-muted-foreground">
{i18next.t("agent:Built-in")}
diff --git a/web/src/components/ProviderQuota.tsx
b/web/src/components/ProviderQuota.tsx
index e4ff7d6..f1756d4 100644
--- a/web/src/components/ProviderQuota.tsx
+++ b/web/src/components/ProviderQuota.tsx
@@ -22,6 +22,7 @@ import {Field} from "@/components/shared/form-dialog";
import {NumberInput} from "@/components/shared/number-input";
import {PasswordInput} from "@/components/shared/password-input";
import {Section} from "@/components/shared/page-header";
+import {vendorOption} from "@/components/shared/brand-options";
import {SimpleSelect} from "@/components/shared/simple-select";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
@@ -222,7 +223,7 @@ export function ProviderQuotaSection({
onChange({...preset.config, headers:
{...preset.config.headers}});
}
}}
- options={quotaPresets.map(preset => ({label: preset.label,
value: preset.key}))}
+ options={quotaPresets.map(preset => vendorOption(preset.key,
preset.label, preset.icon))}
/>
</Field>
<Field label={i18next.t("provider:Balance URL")} htmlFor="quota-url"
hint={i18next.t("provider:Balance URL hint")}>
diff --git a/web/src/components/settings/cloud-sync-panel.tsx
b/web/src/components/settings/cloud-sync-panel.tsx
index 32de3a0..ea615a9 100644
--- a/web/src/components/settings/cloud-sync-panel.tsx
+++ b/web/src/components/settings/cloud-sync-panel.tsx
@@ -20,6 +20,7 @@ import * as CloudSyncBackend from
"@/backend/CloudSyncBackend";
import * as Setting from "@/Setting";
import {Field} from "@/components/shared/form-dialog";
import {PasswordInput} from "@/components/shared/password-input";
+import {storageOption} from "@/components/shared/brand-options";
import {SimpleSelect} from "@/components/shared/simple-select";
import {MessageAlert} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
@@ -216,7 +217,7 @@ export function CloudSyncPanel({onSynced}: {onSynced: () =>
void}) {
setKind(value);
setTested("");
}}
- options={kinds.map(item => ({label: text(item.displayName),
value: item.name}))}
+ options={kinds.map(item => storageOption(item.name,
text(item.displayName)))}
/>
</Field>
diff --git a/web/src/components/shared/brand-options.tsx
b/web/src/components/shared/brand-options.tsx
new file mode 100644
index 0000000..906137e
--- /dev/null
+++ b/web/src/components/shared/brand-options.tsx
@@ -0,0 +1,90 @@
+// 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 * as React from "react";
+import {Bot, Cloud, Folder, Globe, Settings2} from "lucide-react";
+
+import {AgentIcon} from "@/components/AgentIcon";
+import {ProviderIcon} from "@/components/ProviderIcon";
+import type {SelectOption} from "@/components/shared/simple-select";
+import {providerPresets} from "@/lib/providers";
+
+/** A dropdown item that names a brand is read by its mark first. */
+function brandOption(value: string, text: string, mark: React.ReactNode):
SelectOption {
+ return {
+ value,
+ text,
+ label: (
+ <span className="flex items-center gap-2 truncate">
+ {mark}
+ {text}
+ </span>
+ ),
+ };
+}
+
+/** An agent, named by the id its records carry or by its own display name. */
+export function agentOption(agent: string, label?: string): SelectOption {
+ return brandOption(
+ agent,
+ label ?? agent,
+ <AgentIcon agent={agent} size={16} fallback={<Bot className="size-4
opacity-50" />} />,
+ );
+}
+
+/** A vendor, named by the site its mark is served from. */
+export function vendorOption(value: string, label: string, site: string):
SelectOption {
+ return brandOption(value, label, <ProviderIcon icon={site} size={16} />);
+}
+
+/** The wire format a provider serves, which is a vendor's API either way. */
+export const protocolOptions: SelectOption[] = [
+ vendorOption("openai", "OpenAI", "openai.com"),
+ vendorOption("anthropic", "Anthropic", "anthropic.com"),
+];
+
+/** The Type field of both provider forms: two vendors and everything else. */
+export const providerTypeOptions: SelectOption[] = [
+ ...protocolOptions,
+ brandOption("custom", "Custom", <Settings2 className="size-4 opacity-50" />),
+];
+
+/**
+ * A storage cloud sync writes to. Only S3 is a brand: a folder is a folder,
and
+ * WebDAV is a protocol nobody draws a logo for, so those two are shown as what
+ * they are.
+ */
+const storageMarks: Record<string, React.ReactNode> = {
+ folder: <Folder className="size-4 opacity-50" />,
+ s3: <ProviderIcon icon="aws.amazon.com" size={16} />,
+ webdav: <Globe className="size-4 opacity-50" />,
+};
+
+export function storageOption(kind: string, label: string): SelectOption {
+ return brandOption(kind, label, storageMarks[kind] ?? <Cloud
className="size-4 opacity-50" />);
+}
+
+/** The base URLs a type's vendors answer on, each behind its own mark. The
+ * mark comes from the vendor's own site: an API host often has no icon. */
+export function baseUrlOptions(type: string): SelectOption[] {
+ return providerPresets
+ .filter(preset => preset.type === type)
+ .map(preset =>
+ brandOption(
+ preset.baseUrl,
+ preset.baseUrl,
+ <ProviderIcon icon={preset.website} baseUrl={preset.baseUrl} size={16}
/>,
+ ),
+ );
+}
diff --git a/web/src/components/shared/simple-select.tsx
b/web/src/components/shared/simple-select.tsx
index 61319e3..d8b2248 100644
--- a/web/src/components/shared/simple-select.tsx
+++ b/web/src/components/shared/simple-select.tsx
@@ -26,6 +26,8 @@ export interface SelectOption {
label: React.ReactNode;
value: string;
disabled?: boolean;
+ /** What a search matches against, for a label that is a brand mark and a
name. */
+ text?: string;
}
// Radix refuses an item whose value is the empty string, but "" is exactly how
@@ -165,7 +167,11 @@ export function SearchSelect({
</CommandItem>
) : null}
{items.map(option => (
- <CommandItem key={option.value} value={String(option.label)}
onSelect={() => pick(option.value)}>
+ <CommandItem
+ key={option.value}
+ value={option.text ?? String(option.label)}
+ onSelect={() => pick(option.value)}
+ >
<Check className={cn("size-4", option.value === value ?
"opacity-100" : "opacity-0")} />
{option.label}
</CommandItem>
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index 59df16c..5cc96d7 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -556,10 +556,6 @@ export function presetOfSource(source: ProviderSource) {
}
/** The base URLs and models offered for a provider type, from the vendors of
it. */
-export function baseUrlPresets(type: string) {
- return providerPresets.filter(preset => preset.type === type).map(preset =>
preset.baseUrl);
-}
-
export function modelPresets(type: string) {
return providerPresets.filter(preset => preset.type === type).flatMap(preset
=> preset.models);
}
diff --git a/web/src/lib/quota.ts b/web/src/lib/quota.ts
index e0a57a4..cbb40a0 100644
--- a/web/src/lib/quota.ts
+++ b/web/src/lib/quota.ts
@@ -48,6 +48,8 @@ export function emptyQuotaConfig(): QuotaConfig {
export interface QuotaPreset {
key: string;
label: string;
+ /** The site, or the image, the project's own mark comes from. */
+ icon: string;
config: QuotaConfig;
}
@@ -55,6 +57,7 @@ export const quotaPresets: QuotaPreset[] = [
{
key: "newapi",
label: "New API",
+ icon: "newapi.pro",
config: {
...emptyQuotaConfig(),
url: "/api/user/self",
@@ -68,6 +71,9 @@ export const quotaPresets: QuotaPreset[] = [
{
key: "oneapi",
label: "One API",
+ // One API has no site of its own, and the logo in its repository is
+ // OpenAI's, so it is marked by where the project lives instead.
+ icon: "github.com",
config: {
...emptyQuotaConfig(),
url: "/api/user/self",
diff --git a/web/src/pages/AgentRecordsPage.tsx
b/web/src/pages/AgentRecordsPage.tsx
index 4daf118..7ba1d1a 100644
--- a/web/src/pages/AgentRecordsPage.tsx
+++ b/web/src/pages/AgentRecordsPage.tsx
@@ -23,6 +23,7 @@ import {AgentIcon} from "@/components/AgentIcon";
import {DataTable, type Column} from "@/components/shared/data-table";
import {CodeBlock, CodeText, DescriptionList, UnauthorizedResult} from
"@/components/shared/misc";
import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {agentOption} from "@/components/shared/brand-options";
import {SimpleSelect} from "@/components/shared/simple-select";
import {getOutcomeVariant, monitorAgentId} from "@/lib/agents";
import {MessageAlert} from "@/components/ui/alert";
@@ -292,7 +293,7 @@ export default function AgentRecordsPage({account}:
{account: Account}) {
onChange={value => setFilter("agent", value)}
options={[
{label: i18next.t("agent:All agents"), value: ALL},
- ...agentOptions.map(value => ({label: value, value})),
+ ...agentOptions.map(value => agentOption(value)),
]}
/>
<SimpleSelect
diff --git a/web/src/pages/AgentSessionsPage.tsx
b/web/src/pages/AgentSessionsPage.tsx
index 9fccba7..d7f12c4 100644
--- a/web/src/pages/AgentSessionsPage.tsx
+++ b/web/src/pages/AgentSessionsPage.tsx
@@ -23,6 +23,7 @@ import {AgentIcon} from "@/components/AgentIcon";
import {DataTable, type Column} from "@/components/shared/data-table";
import {UnauthorizedResult} from "@/components/shared/misc";
import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {agentOption} from "@/components/shared/brand-options";
import {SimpleSelect} from "@/components/shared/simple-select";
import {StatCard} from "@/components/shared/stat-card";
import {SimpleTooltip} from "@/components/ui/tooltip";
@@ -350,7 +351,10 @@ export default function AgentSessionsPage({account}:
{account: Account}) {
value={agent}
onChange={setAgent}
aria-label={i18next.t("agent:Agent")}
- options={[{label: i18next.t("agent:All agents"), value: ""},
...agentOptions]}
+ options={[
+ {label: i18next.t("agent:All agents"), value: ""},
+ ...agentOptions.map(name => agentOption(name)),
+ ]}
/>
</div>
diff --git a/web/src/pages/ProviderEditPage.tsx
b/web/src/pages/ProviderEditPage.tsx
index 358f7da..3bb8e4e 100644
--- a/web/src/pages/ProviderEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -30,6 +30,7 @@ import {CodeText, ResultScreen} from
"@/components/shared/misc";
import {NumberInput} from "@/components/shared/number-input";
import {PageContainer, PageHeader, Section} from
"@/components/shared/page-header";
import {PasswordInput} from "@/components/shared/password-input";
+import {baseUrlOptions, providerTypeOptions} from
"@/components/shared/brand-options";
import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
@@ -37,7 +38,6 @@ import {Textarea} from "@/components/ui/textarea";
import {
authClient,
baseUrlPlaceholder,
- baseUrlPresets,
providerProtocol,
gatewayBaseUrl,
localShell,
@@ -195,11 +195,7 @@ export default function ProviderEditPage() {
<SimpleSelect
value={provider.type}
onChange={value => setField("type", value)}
- options={[
- {label: "OpenAI", value: "openai"},
- {label: "Anthropic", value: "anthropic"},
- {label: "Custom", value: "custom"},
- ]}
+ options={providerTypeOptions}
/>
</Field>
<Field
@@ -216,7 +212,7 @@ export default function ProviderEditPage() {
allowCustomValue
value={provider.baseUrl}
placeholder={baseUrlPlaceholder(provider.type)}
- options={baseUrlPresets(provider.type)}
+ options={baseUrlOptions(provider.type)}
onChange={value => setField("baseUrl", value)}
/>
</Field>
diff --git a/web/src/pages/ProviderListPage.tsx
b/web/src/pages/ProviderListPage.tsx
index aab1b56..621979d 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -39,6 +39,7 @@ import {ErrorState} from "@/components/shared/empty-state";
import {Field, FormDialog} from "@/components/shared/form-dialog";
import {PageContainer, PageHeader} from "@/components/shared/page-header";
import {PasswordInput} from "@/components/shared/password-input";
+import {baseUrlOptions, providerTypeOptions} from
"@/components/shared/brand-options";
import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
import {MessageAlert} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
@@ -51,7 +52,6 @@ import {
authProvider,
authClient,
baseUrlPlaceholder,
- baseUrlPresets,
customSource,
providerIdOf,
providerSlug,
@@ -589,7 +589,7 @@ export default function ProviderListPage({account}:
{account: Account}) {
id="provider-base-url"
value={form.baseUrl}
placeholder={baseUrlPlaceholder(form.type)}
- options={baseUrlPresets(form.type)}
+ options={baseUrlOptions(form.type)}
onChange={value => setFormField("baseUrl", value)}
/>
</Field>
@@ -670,11 +670,7 @@ export default function ProviderListPage({account}:
{account: Account}) {
<SimpleSelect
value={form.type}
onChange={value => setFormField("type", value)}
- options={[
- {label: "OpenAI", value: "openai"},
- {label: "Anthropic", value: "anthropic"},
- {label: "Custom", value: "custom"},
- ]}
+ options={providerTypeOptions}
/>
</Field>
{source.key === customSource ? null : (
@@ -684,7 +680,7 @@ export default function ProviderListPage({account}:
{account: Account}) {
id="provider-base-url"
value={form.baseUrl}
placeholder={baseUrlPlaceholder(form.type)}
- options={baseUrlPresets(form.type)}
+ options={baseUrlOptions(form.type)}
onChange={value => setFormField("baseUrl", value)}
/>
</Field>
diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx
index 56f80e6..fc36cbc 100644
--- a/web/src/pages/UsagePage.tsx
+++ b/web/src/pages/UsagePage.tsx
@@ -24,6 +24,7 @@ import {UsageBreakdown} from
"@/components/usage/usage-breakdown";
import {UsageHero} from "@/components/usage/usage-hero";
import {UsageTrendChart} from "@/components/usage/usage-trend-chart";
import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {agentOption} from "@/components/shared/brand-options";
import {SimpleSelect} from "@/components/shared/simple-select";
import {UnauthorizedResult} from "@/components/shared/misc";
import {MessageAlert} from "@/components/ui/alert";
@@ -285,7 +286,7 @@ export default function UsagePage({account}: {account:
Account}) {
onChange={setAgent}
options={[
{label: i18next.t("usage:Every agent"), value: ""},
- ...knownAgents.map(name => ({label: name, value: name})),
+ ...knownAgents.map(name => agentOption(name)),
]}
/>
<SimpleSelect
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]