feat: add connection pagination, health filter, batch delete confirmation, and custom banned keywords (#3454)

Integrated into release/v3.8.17
This commit is contained in:
sdfsdfw2
2026-06-09 07:41:06 +08:00
committed by GitHub
parent 1a98dfe8ed
commit 42887b65b2
9 changed files with 359 additions and 67 deletions

View File

@@ -125,6 +125,19 @@ export const ACCOUNT_DEACTIVATED_SIGNALS = [
"this service has been disabled in this account",
];
// Custom banned signals — loaded from DB settings at runtime.
// Combined with ACCOUNT_DEACTIVATED_SIGNALS in isAccountDeactivated().
let _customBannedSignals: string[] = [];
export function setCustomBannedSignals(signals: string[]): void {
_customBannedSignals = signals;
}
export function getMergedBannedSignals(): string[] {
if (_customBannedSignals.length === 0) return ACCOUNT_DEACTIVATED_SIGNALS;
return [...ACCOUNT_DEACTIVATED_SIGNALS, ..._customBannedSignals];
}
// T10 (sub2api PR #1169): Signals that indicate billing credits are exhausted.
// Distinct from rate-limit 429 — the account won't recover until credits are added.
export const CREDITS_EXHAUSTED_SIGNALS = [
@@ -240,7 +253,7 @@ const MALFORMED_REQUEST_PATTERNS = [
*/
export function isAccountDeactivated(errorText: string): boolean {
const lower = String(errorText || "").toLowerCase();
return ACCOUNT_DEACTIVATED_SIGNALS.some((sig) => lower.includes(sig));
return getMergedBannedSignals().some((sig) => lower.includes(sig));
}
/**

View File

@@ -23,6 +23,7 @@ import {
Badge,
Input,
Modal,
ConfirmModal,
CardSkeleton,
OAuthModal,
KiroOAuthWrapper,
@@ -1508,6 +1509,10 @@ export default function ProviderDetailPage() {
const [batchDeleting, setBatchDeleting] = useState(false);
const [batchUpdating, setBatchUpdating] = useState<"activate" | "deactivate" | null>(null);
const [batchRetesting, setBatchRetesting] = useState(false);
const [healthFilter, setHealthFilter] = useState<string>("all");
const [page, setPage] = useState(0);
const PAGE_SIZE = 50;
const [batchDeleteConfirmOpen, setBatchDeleteConfirmOpen] = useState(false);
const commandCodeAuthWindowRef = useRef<Window | null>(null);
const commandCodeAuthTimerRef = useRef<number | null>(null);
const pendingRiskActionRef = useRef<(() => void) | null>(null);
@@ -2077,10 +2082,13 @@ export default function ProviderDetailPage() {
});
}, [connections]);
const handleBatchDelete = async () => {
const handleBatchDeleteOpenModal = () => {
if (selectedIds.size === 0) return;
if (!confirm(t("batchDeleteConfirm", { count: selectedIds.size }))) return;
setBatchDeleteConfirmOpen(true);
};
const handleBatchDeleteConfirm = async () => {
setBatchDeleteConfirmOpen(false);
setBatchDeleting(true);
try {
const res = await fetch("/api/providers", {
@@ -4778,50 +4786,149 @@ export default function ProviderDetailPage() {
icon="delete"
loading={batchDeleting}
disabled={bulkBusy && !batchDeleting}
onClick={handleBatchDelete}
onClick={handleBatchDeleteOpenModal}
>
{t("batchDeleteSelected", { count: selectedIds.size })}
</Button>
</div>
);
const isHealthy = (c: ConnectionRowConnection): boolean => {
const s = c.testStatus;
return c.isActive !== false && (!s || s === "active" || s === "success");
};
const STATUS_FILTER_OPTIONS = [
{ value: "all", label: t("filterAll", "All") },
{ value: "active", label: t("filterActive", "Active") },
{ value: "error", label: t("filterError", "Error") },
{ value: "banned", label: t("filterBanned", "Banned") },
{ value: "credits_exhausted", label: t("filterCreditsExhausted", "Credits Exhausted") },
];
const filtered = healthFilter === "all"
? sorted
: sorted.filter((c) => {
if (healthFilter === "active") return isHealthy(c);
if (healthFilter === "error") return !isHealthy(c) && c.testStatus !== "banned" && c.testStatus !== "credits_exhausted";
return c.testStatus === healthFilter;
});
const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const clampedPage = Math.min(page, totalFilteredPages - 1);
const pageStart = clampedPage * PAGE_SIZE;
const pageEnd = pageStart + PAGE_SIZE;
const filterPills = (
<div className="flex items-center gap-1.5 flex-wrap">
{STATUS_FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => {
setHealthFilter(opt.value);
setPage(0);
setSelectedIds(new Set());
}}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
healthFilter === opt.value
? "bg-primary text-white"
: "bg-muted/60 text-text-muted hover:bg-muted"
}`}
>
{opt.label}
</button>
))}
</div>
);
const paginationBar = totalFilteredPages > 1 ? (
<div className="flex items-center justify-between px-3 py-2 border-t border-border">
<span className="text-xs text-text-muted">
{pageStart + 1}{Math.min(pageEnd, filtered.length)} / {filtered.length}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon="chevron_left"
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
/>
<span className="text-xs text-text-muted min-w-[4rem] text-center">
{clampedPage + 1} / {totalFilteredPages}
</span>
<Button
variant="ghost"
size="sm"
icon="chevron_right"
disabled={clampedPage >= totalFilteredPages - 1}
onClick={() => setPage((p) => Math.min(totalFilteredPages - 1, p + 1))}
/>
</div>
</div>
) : null;
if (!hasAnyTag) {
const pageConnections = filtered.slice(pageStart, pageEnd);
const allSelected = pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id));
const someSelected = pageConnections.some((c) => selectedIds.has(c.id));
return (
<>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={handleToggleSelectAll}
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
/>
<span className="text-sm font-medium text-text-muted">
{selectedIds.size > 0
? providerCountText(
t,
"selectedCount",
selectedIds.size,
"{count} selected",
"{count} selected"
)
: providerCountText(
t,
"accountsCount",
connections.length,
"{count} account",
"{count} accounts"
)}
</span>
</label>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<div className="flex items-center gap-2 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={() => {
if (allSelected) {
const toRemove = new Set(pageConnections.map((c) => c.id));
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of toRemove) next.delete(id);
return next;
});
} else {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const c of pageConnections) next.add(c.id);
return next;
});
}
}}
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
/>
<span className="text-sm font-medium text-text-muted">
{selectedIds.size > 0
? providerCountText(
t,
"selectedCount",
selectedIds.size,
"{count} selected",
"{count} selected"
)
: providerCountText(
t,
"accountsCount",
filtered.length,
"{count} account",
"{count} accounts"
)}
</span>
</label>
{filterPills}
</div>
{bulkActions}
</div>
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] border border-t-0 border-border rounded-b-lg overflow-hidden">
{sorted.map((conn, index) => (
{pageConnections.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-text-muted">
{t("noFilteredConnections", "No connections match the current filter.")}
</div>
) : (
pageConnections.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
@@ -4829,7 +4936,7 @@ export default function ProviderDetailPage() {
isClaude={providerId === "claude"}
codexGlobalServiceMode={codexGlobalServiceMode}
isFirst={index === 0}
isLast={index === sorted.length - 1}
isLast={index === pageConnections.length - 1}
isSelected={selectedIds.has(conn.id)}
onToggleSelect={() => handleToggleSelectOne(conn.id)}
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
@@ -4927,15 +5034,17 @@ export default function ProviderDetailPage() {
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
onTogglePerKeyProxyEnabled={(enabled) => handleTogglePerKeyProxyEnabled(conn.id, enabled)}
/>
))}
)))
}
</div>
{paginationBar}
</>
);
}
// Build ordered tag groups: untagged first, then alphabetically
const groupMap = new Map<string, ConnectionRowConnection[]>();
for (const conn of sorted) {
for (const conn of filtered) {
const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || "";
if (!groupMap.has(tag)) groupMap.set(tag, []);
groupMap.get(tag)!.push(conn);
@@ -4949,35 +5058,38 @@ export default function ProviderDetailPage() {
return (
<>
{selectedIds.size > 0 || connections.length > 0 ? (
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={handleToggleSelectAll}
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
/>
<span className="text-sm font-medium text-text-muted">
{selectedIds.size > 0
? providerCountText(
t,
"selectedCount",
selectedIds.size,
"{count} selected",
"{count} selected"
)
: providerCountText(
t,
"accountsCount",
connections.length,
"{count} account",
"{count} accounts"
)}
</span>
</label>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<div className="flex items-center gap-2 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={handleToggleSelectAll}
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
/>
<span className="text-sm font-medium text-text-muted">
{selectedIds.size > 0
? providerCountText(
t,
"selectedCount",
selectedIds.size,
"{count} selected",
"{count} selected"
)
: providerCountText(
t,
"accountsCount",
filtered.length,
"{count} account",
"{count} accounts"
)}
</span>
</label>
{filterPills}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Distribute Proxies lives in the provider toolbar (top action bar);
@@ -5313,6 +5425,16 @@ export default function ProviderDetailPage() {
onClose={handleCloseAddApiKeyModal}
/>
)}
<ConfirmModal
isOpen={batchDeleteConfirmOpen}
onClose={() => setBatchDeleteConfirmOpen(false)}
onConfirm={handleBatchDeleteConfirm}
title={t("batchDeleteConfirmTitle", "Delete connections")}
message={t("batchDeleteConfirm", { count: selectedIds.size })}
confirmText={t("batchDeleteConfirmButton", "Delete")}
cancelText={t("cancel", "Cancel")}
loading={batchDeleting}
/>
{providerId === "codex" && applyCodexModalConnectionId && (
<ApplyCodexAuthModal
key={applyCodexModalConnectionId}

View File

@@ -20,6 +20,7 @@ export default function SecurityTab() {
const [requireLoginPassword, setRequireLoginPassword] = useState("");
const [requireLoginError, setRequireLoginError] = useState("");
const [requireLoginLoading, setRequireLoginLoading] = useState(false);
const [newBannedKeyword, setNewBannedKeyword] = useState("");
const t = useTranslations("settings");
const tc = useTranslations("common");
@@ -112,6 +113,20 @@ export default function SecurityTab() {
updateSetting("blockedProviders", updated);
};
const customBannedSignals: string[] = settings.customBannedSignals || [];
const addBannedKeyword = () => {
const keyword = newBannedKeyword.trim().toLowerCase();
if (!keyword || customBannedSignals.includes(keyword)) return;
updateSetting("customBannedSignals", [...customBannedSignals, keyword]);
setNewBannedKeyword("");
};
const removeBannedKeyword = (index: number) => {
const updated = customBannedSignals.filter((_, i) => i !== index);
updateSetting("customBannedSignals", updated);
};
const handlePasswordChange = async (e) => {
e.preventDefault();
if (passwords.new !== passwords.confirm) {
@@ -368,6 +383,59 @@ export default function SecurityTab() {
<SessionInfoCard />
<IPFilterSection />
<AuthzSection />
{/* Custom Banned Keywords */}
<Card>
<div className="flex flex-col gap-4">
<div>
<p className="font-medium">{t("customBannedSignals", "Banned Keywords")}</p>
<p className="text-sm text-text-muted">
{t("customBannedSignalsDesc", "Additional keywords that trigger permanent account ban detection. Built-in keywords always apply.")}
</p>
</div>
<div className="flex gap-2">
<Input
placeholder={t("customBannedSignalsPlaceholder", "e.g. api key revoked")}
value={newBannedKeyword}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewBannedKeyword(e.target.value)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter") addBannedKeyword();
}}
/>
<Button
variant="secondary"
size="sm"
icon="add"
onClick={addBannedKeyword}
disabled={!newBannedKeyword.trim()}
>
{t("add", "Add")}
</Button>
</div>
{customBannedSignals.length > 0 ? (
<div className="flex flex-wrap gap-2">
{customBannedSignals.map((keyword, index) => (
<div
key={index}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border bg-red-500/10 border-red-500/30 text-red-600 dark:text-red-400"
>
{keyword}
<button
onClick={() => removeBannedKeyword(index)}
className="material-symbols-outlined text-[12px] hover:opacity-70"
>
close
</button>
</div>
))}
</div>
) : (
<p className="text-xs text-text-muted">
{t("noCustomBannedSignals", "No custom keywords. Only built-in keywords are active.")}
</p>
)}
</div>
</Card>
</div>
);
}

View File

@@ -4000,6 +4000,14 @@
"batchRetestSelected": "Retest",
"batchActivateSuccess": "Activated {count} connection(s)",
"batchDeactivateSuccess": "Deactivated {count} connection(s)",
"batchDeleteConfirmTitle": "Delete connections",
"batchDeleteConfirmButton": "Delete",
"filterAll": "All",
"filterActive": "Active",
"filterError": "Error",
"filterBanned": "Banned",
"filterCreditsExhausted": "Credits Exhausted",
"noFilteredConnections": "No connections match the current filter.",
"failedSetAlias": "Failed to set alias",
"setAliasSuccess": "Alias {alias} set",
"deleteAliasSuccess": "Alias {alias} deleted",

View File

@@ -3966,6 +3966,14 @@
"batchRetestSelected": "__MISSING__:Retest",
"batchActivateSuccess": "__MISSING__:Activated {count} connection(s)",
"batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)",
"batchDeleteConfirmTitle": "删除连接",
"batchDeleteConfirmButton": "删除",
"filterAll": "全部",
"filterActive": "健康",
"filterError": "错误",
"filterBanned": "封禁",
"filterCreditsExhausted": "额度耗尽",
"noFilteredConnections": "没有符合当前筛选条件的连接。",
"failedSetAlias": "设置别名失败",
"failedSaveConnection": "保存连接失败",
"failedSaveConnectionRetry": "无法保存连接。请再试一次。",

View File

@@ -1,4 +1,5 @@
import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck";
import { setCustomBannedSignals } from "@omniroute/open-sse/services/accountFallback.ts";
type JsonRecord = Record<string, unknown>;
@@ -15,7 +16,8 @@ export type RuntimeReloadSection =
| "corsOrigins"
| "ccBridgeTransforms"
| "systemTransforms"
| "authzBypass";
| "authzBypass"
| "bannedSignals";
export interface RuntimeReloadChange {
section: RuntimeReloadSection;
@@ -42,6 +44,7 @@ interface RuntimeSettingsSnapshot {
ccBridgeTransforms: unknown;
systemTransforms: unknown;
authzBypass: AuthzBypassSnapshot;
customBannedSignals: string[];
}
// Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the
@@ -67,6 +70,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = {
ccBridgeTransforms: null,
systemTransforms: null,
authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT,
customBannedSignals: [],
};
let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null;
@@ -246,6 +250,7 @@ export function buildRuntimeSettingsSnapshot(
ccBridgeTransforms: parseStoredJson(settings.ccBridgeTransforms, "ccBridgeTransforms"),
systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"),
authzBypass: normalizeAuthzBypass(settings),
customBannedSignals: normalizeStringArray(settings.customBannedSignals),
};
}
@@ -526,6 +531,11 @@ export async function applyRuntimeSettings(
markChanged("authzBypass");
}
if (force || hasChanged(currentSnapshot.customBannedSignals, previousSnapshot.customBannedSignals)) {
setCustomBannedSignals(currentSnapshot.customBannedSignals);
markChanged("bannedSignals");
}
lastAppliedSnapshot = currentSnapshot;
return changes;
}

View File

@@ -123,6 +123,7 @@ export async function getSettings() {
// `applyAuthzBypassSection` → `getAuthzBypassSnapshot()`.
localOnlyManageScopeBypassEnabled: true,
localOnlyManageScopeBypassPrefixes: ["/api/mcp/"],
customBannedSignals: [],
proxyEnabled: true,
perKeyProxyEnabled: false,
};

View File

@@ -42,6 +42,7 @@ export const updateSettingsSchema = z.object({
showProviderTopologyOnHome: z.boolean().optional(),
localOnlyManageScopeBypassEnabled: z.boolean().optional(),
localOnlyManageScopeBypassPrefixes: z.array(z.string().max(200)).optional(),
customBannedSignals: z.array(z.string().max(200)).optional(),
debugMode: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
sidebarSectionOrder: z

View File

@@ -1186,3 +1186,64 @@ test("G-02: five consecutive 503 service_not_running do NOT trip provider circui
);
clearProviderFailure("9router"); // cleanup
});
// ── Custom banned signals (PR #3454) ──────────────────────────────────────────
// Operators can extend ACCOUNT_DEACTIVATED_SIGNALS with provider-specific
// permanent-ban phrasing via Settings → Security. These persist in the
// key_value settings store and are applied at boot + on hot-reload through
// setCustomBannedSignals(). Regression guard for the merge/detection behavior.
const {
setCustomBannedSignals,
getMergedBannedSignals,
isAccountDeactivated,
ACCOUNT_DEACTIVATED_SIGNALS,
} = accountFallback;
test("getMergedBannedSignals returns built-in list unchanged when no custom signals", () => {
setCustomBannedSignals([]);
const merged = getMergedBannedSignals();
assert.deepEqual(merged, ACCOUNT_DEACTIVATED_SIGNALS);
});
test("getMergedBannedSignals appends custom signals to the built-in list", () => {
setCustomBannedSignals(["api key revoked", "tenant suspended"]);
const merged = getMergedBannedSignals();
// Built-ins still present
for (const sig of ACCOUNT_DEACTIVATED_SIGNALS) {
assert.ok(merged.includes(sig), `built-in signal "${sig}" must survive merge`);
}
// Custom appended
assert.ok(merged.includes("api key revoked"));
assert.ok(merged.includes("tenant suspended"));
setCustomBannedSignals([]); // cleanup
});
test("isAccountDeactivated still matches built-in signals when custom list is empty", () => {
setCustomBannedSignals([]);
assert.equal(isAccountDeactivated("Your account has been suspended"), true);
assert.equal(isAccountDeactivated("rate limit exceeded, retry later"), false);
});
test("isAccountDeactivated matches a custom signal after setCustomBannedSignals", () => {
setCustomBannedSignals([]);
// Before registration the custom phrase is not a ban signal
assert.equal(
isAccountDeactivated("Error: API key revoked by administrator"),
false,
"custom phrase must not match before it is registered"
);
setCustomBannedSignals(["api key revoked"]);
// Case-insensitive substring match against the merged list
assert.equal(
isAccountDeactivated("Error: API key revoked by administrator"),
true,
"custom phrase must match once registered (case-insensitive substring)"
);
// Built-ins remain matchable alongside custom signals
assert.equal(isAccountDeactivated("account_deactivated"), true);
setCustomBannedSignals([]); // cleanup — restore module state for other tests
});