Compare commits

...

4 Commits

Author SHA1 Message Date
Agnes
b1b8faab94 refactor(settings): reduce proxy registry component size
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 08:43:01 -03:00
Agnes
074dccdc8e refactor(settings): extract proxy registry helpers
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-09 08:34:03 -03:00
Agnes
8cd3fd86b0 test(proxy-pool): cover toolbar layout 2026-08-09 01:38:58 -03:00
Agnes
88e7699074 feat(proxy-pool): streamline pool actions 2026-08-09 01:38:58 -03:00
5 changed files with 391 additions and 306 deletions

View File

@@ -1,8 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { z } from "zod";
import { Button, Card, Modal } from "@/shared/components";
import { useProxyBatchOperations } from "./useProxyBatchOperations";
import { ProxyStatusBadge } from "./ProxyStatusBadge";
@@ -16,90 +15,32 @@ import {
} from "./parseBulkProxyImport";
import { POOL_STRATEGY_OPTIONS, isPoolStrategy, type PoolStrategy } from "./proxyStrategyOptions";
import type { ProxyItem } from "./proxyRegistryTypes";
import {
BULK_IMPORT_PLACEHOLDER,
EMPTY_FORM,
type HealthInfo,
type ProxyRegistryManagerProps,
type TestResult,
type UsageInfo,
} from "./proxyRegistryConstants";
import {
loadAllProxyUsage,
loadProxyHealth,
loadProxyUsage,
repairRelayResponseSchema,
} from "./proxyRegistryData";
type UsageInfo = {
count: number;
assignments: Array<{ scope: string; scopeId: string | null }>;
};
type HealthInfo = {
proxyId: string;
totalRequests: number;
successRate: number | null;
avgLatencyMs: number | null;
lastSeenAt: string | null;
};
type TestResult = {
success: boolean;
publicIp?: string;
latencyMs?: number;
country?: string;
error?: string;
};
const EMPTY_FORM = {
id: "",
name: "",
type: "http",
host: "",
port: "8080",
username: "",
password: "",
region: "",
notes: "",
status: "active",
family: "auto",
};
const BULK_IMPORT_TEMPLATE = `# Proxy Bulk Import
# ─────────────────────────────────────────────────────────────────────────────
# FORMAT 1 — Pipe-delimited (full control):
# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
#
# FORMAT 2 — Shorthand (one proxy per line, no pipe needed):
# ip:port → no auth, type defaults to socks5
# ip:port:user:pass → with auth
# user:pass@ip:port → with auth (@-style)
# user:pass:ip:port → with auth (user-pass-first)
# protocol://ip:port → explicit protocol
# protocol://user:pass@ip:port → explicit protocol + auth
#
# FORMAT 3 — Protocol header mode:
# Put a bare protocol (http, https, socks5) on its own line to set
# the default type for all subsequent shorthand lines that don't
# include an explicit protocol:// prefix.
#
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# ─────────────────────────────────────────────────────────────────────────────
# Pipe-delimited examples:
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
#
# Shorthand examples:
# 138.99.147.218:50101
# 138.99.147.218:50101:myuser:mypass
# myuser:mypass@138.99.147.218:50101
# myuser:mypass:138.99.147.218:50101
# http://10.0.0.50:8080
# https://admin:secret123@proxy.example.com:443
#
# Protocol header mode example:
# socks5
# 138.99.147.218:50101:myuser:mypass
# 200.234.177.62:50101:otheruser:otherpass
#`;
export default function ProxyRegistryManager({
export default function ProxyRegistryManager({
onRedeployRelay,
}: {
onRedeployRelay?: (proxy: ProxyItem) => void;
} = {}) {
showVercelRelay = false,
showDenoRelay = false,
showCloudflareRelay = false,
onOpenVercelRelay,
onOpenDenoRelay,
onOpenCloudflareRelay,
}: ProxyRegistryManagerProps = {}) {
const t = useTranslations("proxyRegistry");
const settingsT = useTranslations("settings");
const [items, setItems] = useState<ProxyItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -135,7 +76,7 @@ export default function ProxyRegistryManager({
const [poolLoaded, setPoolLoaded] = useState(false);
const [poolSaving, setPoolSaving] = useState(false);
const [bulkImportOpen, setBulkImportOpen] = useState(false);
const [bulkImportText, setBulkImportText] = useState(BULK_IMPORT_TEMPLATE);
const [bulkImportText, setBulkImportText] = useState("");
const [bulkImportParsed, setBulkImportParsed] = useState<ParsedProxyEntry[]>([]);
const [bulkImportErrors, setBulkImportErrors] = useState<ParseError[]>([]);
const [bulkImportSkipped, setBulkImportSkipped] = useState(0);
@@ -146,53 +87,40 @@ export default function ProxyRegistryManager({
updated: number;
failed: number;
} | null>(null);
const [actionsOpen, setActionsOpen] = useState(false);
const [relayMenuOpen, setRelayMenuOpen] = useState(false);
const actionsRef = useRef<HTMLDivElement | null>(null);
const relayRef = useRef<HTMLDivElement | null>(null);
const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay;
useEffect(() => {
if (!actionsOpen && !relayMenuOpen) return;
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (actionsOpen && actionsRef.current && !actionsRef.current.contains(target)) {
setActionsOpen(false);
}
if (relayMenuOpen && relayRef.current && !relayRef.current.contains(target)) {
setRelayMenuOpen(false);
}
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [actionsOpen, relayMenuOpen]);
const closeActions = () => {
setActionsOpen(false);
setRelayMenuOpen(false);
};
const editingId = useMemo(() => form.id || "", [form.id]);
const loadHealth = useCallback(async () => {
try {
const res = await fetch("/api/settings/proxies/health?hours=24");
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
const entries = Array.isArray(data?.items) ? data.items : [];
const mapped = Object.fromEntries(
entries.map((entry: HealthInfo) => [entry.proxyId, entry])
) as Record<string, HealthInfo>;
setHealthById(mapped);
} catch {
// ignore health loading errors in UI
}
}, []);
const loadAllUsage = useCallback(async (proxyIds: string[]) => {
if (!proxyIds.length) return;
try {
const results = await Promise.all(
proxyIds.map((id) =>
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
const rawAssignments: Array<{ scope: string; scopeId: string | null }> =
Array.isArray(data?.items) ? data.items : [];
// Deduplicate by scope+scopeId — prevents double-counting when both
// a provider-scope and account-scope row exist for the same proxy
const seen = new Set<string>();
const assignments = rawAssignments.filter((a) => {
const key = `${a.scope}:${a.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
})
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
)
);
setUsageById(Object.fromEntries(results));
} catch {
// ignore
}
}, []);
const loadHealth = useCallback(() => loadProxyHealth(setHealthById), []);
const loadAllUsage = useCallback(
(proxyIds: string[]) => loadAllProxyUsage(proxyIds, setUsageById),
[]
);
const load = useCallback(async () => {
setLoading(true);
@@ -240,17 +168,9 @@ export default function ProxyRegistryManager({
const allSelected = items.length > 0 && items.every((item) => selectedIds.has(item.id));
const handleBatchDelete = useCallback(() => {
hookHandleBatchDelete(setError);
}, [hookHandleBatchDelete, setError]);
const handleBatchActivate = useCallback(() => {
hookHandleBatchActivate(setError, "active");
}, [hookHandleBatchActivate, setError]);
const handleAutoTestAll = useCallback(() => {
hookHandleAutoTestAll(setError, setTestById);
}, [hookHandleAutoTestAll, setError, setTestById]);
const handleBatchDelete = () => hookHandleBatchDelete(setError);
const handleBatchActivate = () => hookHandleBatchActivate(setError, "active");
const handleAutoTestAll = () => hookHandleAutoTestAll(setError, setTestById);
useEffect(() => {
void load();
@@ -284,33 +204,7 @@ export default function ProxyRegistryManager({
setModalOpen(true);
};
const loadUsage = async (proxyId: string) => {
try {
const res = await fetch(
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
);
const data = await res.json().catch(() => ({}));
if (!res.ok) return;
const rawAssignments: Array<{ scope: string; scopeId: string | null }> = Array.isArray(
data?.items
)
? data.items
: [];
const seen = new Set<string>();
const assignments = rawAssignments.filter((a) => {
const key = `${a.scope}:${a.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
setUsageById((prev) => ({
...prev,
[proxyId]: { count: assignments.length, assignments },
}));
} catch {
// ignore usage loading errors in UI
}
};
const loadUsage = (proxyId: string) => loadProxyUsage(proxyId, setUsageById);
const handleTestProxy = async (item: ProxyItem) => {
if (testingId) return;
@@ -345,12 +239,6 @@ export default function ProxyRegistryManager({
}
};
const repairRelayResponseSchema = z.object({
repaired: z.boolean().optional(),
mode: z.enum(["noop", "recovered", "redeploy"]).optional(),
error: z.object({ message: z.string() }).optional(),
});
const handleRepairRelay = async (item: ProxyItem) => {
if (repairingId || !item.relayInfo?.isRelay) return;
setRepairingId(item.id);
@@ -724,7 +612,7 @@ export default function ProxyRegistryManager({
};
const openBulkImport = () => {
setBulkImportText(BULK_IMPORT_TEMPLATE);
setBulkImportText("");
setBulkImportParsed([]);
setBulkImportErrors([]);
setBulkImportSkipped(0);
@@ -736,49 +624,13 @@ export default function ProxyRegistryManager({
return (
<>
<Card className="p-6">
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<div className="mb-4 flex flex-col gap-3">
<div className="w-full min-w-0">
<h3 className="text-lg font-semibold">{t("title")}</h3>
<p className="text-sm text-text-muted">{t("description")}</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
icon="upgrade"
onClick={handleMigrate}
loading={migrating}
data-testid="proxy-registry-import-legacy"
>
{t("importLegacy")}
</Button>
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={openBulkImport}
data-testid="proxy-registry-open-bulk-import"
>
{t("bulkImport")}
</Button>
<Button
size="sm"
variant="secondary"
icon="account_tree"
onClick={() => setBulkOpen(true)}
data-testid="proxy-registry-open-bulk"
>
{t("bulkAssign")}
</Button>
<Button
size="sm"
variant="secondary"
icon="hub"
onClick={openPool}
data-testid="proxy-registry-open-pool"
>
{t("managePool")}
</Button>
<div className="w-full border-t border-border" aria-hidden="true" />
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<ProxyBatchActions
selectedCount={selectedIds.size}
batchDeleting={batchDeleting}
@@ -788,6 +640,152 @@ export default function ProxyRegistryManager({
onBatchActivate={handleBatchActivate}
onAutoTestAll={handleAutoTestAll}
/>
<Button
size="sm"
variant="secondary"
icon="hub"
onClick={openPool}
data-testid="proxy-registry-open-pool"
>
{t("managePool")}
</Button>
{showAnyRelay && (
<div className="relative inline-flex items-center" ref={relayRef}>
<Button
size="sm"
variant="secondary"
icon="rocket_launch"
iconRight="expand_more"
onClick={() => {
setRelayMenuOpen((value) => !value);
setActionsOpen(false);
}}
aria-haspopup="menu"
aria-expanded={relayMenuOpen}
data-testid="proxy-registry-deploy-relay"
>
{settingsT("deployRelayButton")}
</Button>
{relayMenuOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-surface p-1 shadow-xl"
role="menu"
>
{showVercelRelay && (
<Button
size="sm"
variant="ghost"
icon="cloud_upload"
fullWidth
className="justify-start"
onClick={() => {
onOpenVercelRelay?.();
closeActions();
}}
>
{settingsT("vercelRelayButton")}
</Button>
)}
{showDenoRelay && (
<Button
size="sm"
variant="ghost"
icon="terminal"
fullWidth
className="justify-start"
onClick={() => {
onOpenDenoRelay?.();
closeActions();
}}
>
{settingsT("denoRelayButton")}
</Button>
)}
{showCloudflareRelay && (
<Button
size="sm"
variant="ghost"
icon="cloud"
fullWidth
className="justify-start"
onClick={() => {
onOpenCloudflareRelay?.();
closeActions();
}}
>
{settingsT("cloudflareRelayButton")}
</Button>
)}
</div>
)}
</div>
)}
<div className="relative inline-flex items-center" ref={actionsRef}>
<Button
size="sm"
variant="secondary"
onClick={() => {
setActionsOpen((value) => !value);
setRelayMenuOpen(false);
}}
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={actionsOpen}
data-testid="proxy-registry-more-actions"
>
</Button>
{actionsOpen && (
<div
className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-surface p-1 shadow-xl"
role="menu"
>
<Button
size="sm"
variant="ghost"
icon="upload_file"
fullWidth
className="justify-start"
onClick={() => {
openBulkImport();
closeActions();
}}
data-testid="proxy-registry-open-bulk-import"
>
{t("bulkImport")}
</Button>
<Button
size="sm"
variant="ghost"
icon="upload_file"
fullWidth
className="justify-start"
onClick={() => {
handleMigrate();
closeActions();
}}
loading={migrating}
data-testid="proxy-registry-import-legacy"
>
{t("importLegacy")}
</Button>
<Button
size="sm"
variant="ghost"
icon="account_tree"
fullWidth
className="justify-start"
onClick={() => {
setBulkOpen(true);
closeActions();
}}
data-testid="proxy-registry-open-bulk"
>
{t("bulkAssign")}
</Button>
</div>
)}
</div>
<Button
size="sm"
icon="add"
@@ -1328,9 +1326,10 @@ export default function ProxyRegistryManager({
<div>
<textarea
data-testid="proxy-registry-bulk-import-textarea"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed"
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border font-mono text-xs leading-relaxed placeholder:whitespace-pre-wrap placeholder:text-text-muted/70"
rows={14}
value={bulkImportText}
placeholder={BULK_IMPORT_PLACEHOLDER}
onChange={(e) => {
setBulkImportText(e.target.value);
setBulkImportParsedOnce(false);

View File

@@ -1,6 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/shared/components";
import { useState } from "react";
import { useTranslations } from "next-intl";
import ProxyRegistryManager from "../ProxyRegistryManager";
import VercelRelayModal from "./VercelRelayModal";
@@ -13,27 +12,10 @@ export default function ProxyPoolTab() {
const [vercelModalOpen, setVercelModalOpen] = useState(false);
const [denoModalOpen, setDenoModalOpen] = useState(false);
const [cloudflareModalOpen, setCloudflareModalOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const showVercelRelay = process.env.NEXT_PUBLIC_VERCEL_RELAY_ENABLED !== "false";
const showDenoRelay = process.env.NEXT_PUBLIC_DENO_RELAY_ENABLED !== "false";
const showCloudflareRelay = process.env.NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED !== "false";
const showAnyRelay = showVercelRelay || showDenoRelay || showCloudflareRelay;
// Close the dropdown on outside click — mirrors the upstream PR-1437
// grouped-button UX so adding more relay backends does not blow up the
// toolbar horizontally.
useEffect(() => {
if (!menuOpen) return;
const onMouseDown = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
}, [menuOpen]);
const handleVercelDeployed = (_poolProxyId: string, relayUrl: string) => {
alert(`${t("vercelRelaySuccess")}: ${relayUrl}`);
@@ -50,79 +32,15 @@ export default function ProxyPoolTab() {
return (
<div className="space-y-4">
{showAnyRelay && (
<div className="flex justify-end">
<div className="relative" ref={menuRef}>
<Button
size="sm"
variant="secondary"
icon="rocket_launch"
onClick={() => setMenuOpen((v) => !v)}
>
{t("deployRelayButton")}
</Button>
{menuOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-48 rounded-md border border-border bg-surface p-1 shadow-xl">
{showVercelRelay && (
<button
type="button"
onClick={() => {
setVercelModalOpen(true);
setMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
>
<span
className="material-symbols-outlined text-[20px] text-primary"
aria-hidden="true"
>
cloud_upload
</span>
{t("vercelRelayButton")}
</button>
)}
{showDenoRelay && (
<button
type="button"
onClick={() => {
setDenoModalOpen(true);
setMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
>
<span
className="material-symbols-outlined text-[20px] text-primary"
aria-hidden="true"
>
terminal
</span>
{t("denoRelayButton")}
</button>
)}
{showCloudflareRelay && (
<button
type="button"
onClick={() => {
setCloudflareModalOpen(true);
setMenuOpen(false);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-sm hover:bg-surface-alt"
>
<span
className="material-symbols-outlined text-[20px] text-primary"
aria-hidden="true"
>
cloud
</span>
{t("cloudflareRelayButton")}
</button>
)}
</div>
)}
</div>
</div>
)}
<ProxyRegistryManager onRedeployRelay={handleRedeployRelay} />
<ProxyRegistryManager
onRedeployRelay={handleRedeployRelay}
showVercelRelay={showVercelRelay}
showDenoRelay={showDenoRelay}
showCloudflareRelay={showCloudflareRelay}
onOpenVercelRelay={() => setVercelModalOpen(true)}
onOpenDenoRelay={() => setDenoModalOpen(true)}
onOpenCloudflareRelay={() => setCloudflareModalOpen(true)}
/>
<VercelRelayModal
isOpen={vercelModalOpen}
onClose={() => setVercelModalOpen(false)}

View File

@@ -0,0 +1,88 @@
import type { ProxyItem } from "./proxyRegistryTypes";
export type UsageInfo = {
count: number;
assignments: Array<{ scope: string; scopeId: string | null }>;
};
export type HealthInfo = {
proxyId: string;
totalRequests: number;
successRate: number | null;
avgLatencyMs: number | null;
lastSeenAt: string | null;
};
export type TestResult = {
success: boolean;
publicIp?: string;
latencyMs?: number;
country?: string;
error?: string;
};
export const EMPTY_FORM = {
id: "",
name: "",
type: "http",
host: "",
port: "8080",
username: "",
password: "",
region: "",
notes: "",
status: "active",
family: "auto",
};
export const BULK_IMPORT_PLACEHOLDER = `# Proxy Bulk Import
# ─────────────────────────────────────────────────────────────────────────────
# FORMAT 1 — Pipe-delimited (full control):
# NAME|HOST|PORT|USERNAME|PASSWORD|TYPE|REGION|STATUS|NOTES
# Required: NAME, HOST, PORT
# Optional: USERNAME, PASSWORD, TYPE (http|https|socks5, default: socks5), REGION, STATUS (active|inactive, default: active), NOTES
#
# FORMAT 2 — Shorthand (one proxy per line, no pipe needed):
# ip:port → no auth, type defaults to socks5
# ip:port:user:pass → with auth
# user:pass@ip:port → with auth (@-style)
# user:pass:ip:port → with auth (user-pass-first)
# protocol://ip:port → explicit protocol
# protocol://user:pass@ip:port → explicit protocol + auth
#
# FORMAT 3 — Protocol header mode:
# Put a bare protocol (http, https, socks5) on its own line to set
# the default type for all subsequent shorthand lines that don't
# include an explicit protocol:// prefix.
#
# Lines starting with # are ignored. Existing proxies (same host+port) will be updated.
#
# ─────────────────────────────────────────────────────────────────────────────
# Pipe-delimited examples:
# proxy-us|138.99.147.218|50101|myuser|mypass|socks5|US-East|active|US production proxy
# proxy-eu|200.234.177.62|50101|myuser|mypass|socks5|EU-West
# http-proxy|10.0.0.50|8080|||http||active|Internal HTTP proxy
#
# Shorthand examples:
# 138.99.147.218:50101
# 138.99.147.218:50101:myuser:mypass
# myuser:mypass@138.99.147.218:50101
# myuser:mypass:138.99.147.218:50101
# http://10.0.0.50:8080
# https://admin:secret123@proxy.example.com:443
#
# Protocol header mode example:
# socks5
# 138.99.147.218:50101:myuser:mypass
# 200.234.177.62:50101:otheruser:otherpass
#`;
export type ProxyRegistryManagerProps = {
onRedeployRelay?: (proxy: ProxyItem) => void;
showVercelRelay?: boolean;
showDenoRelay?: boolean;
showCloudflareRelay?: boolean;
onOpenVercelRelay?: () => void;
onOpenDenoRelay?: () => void;
onOpenCloudflareRelay?: () => void;
};

View File

@@ -0,0 +1,78 @@
import { z } from "zod";
import type { HealthInfo, UsageInfo } from "./proxyRegistryConstants";
type SetState<T> = (value: T | ((previous: T) => T)) => void;
type Assignment = { scope: string; scopeId: string | null };
function uniqueAssignments(assignments: Assignment[]) {
const seen = new Set<string>();
return assignments.filter((assignment) => {
const key = `${assignment.scope}:${assignment.scopeId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export async function loadProxyHealth(setHealthById: SetState<Record<string, HealthInfo>>) {
try {
const response = await fetch("/api/settings/proxies/health?hours=24");
const data = await response.json().catch(() => ({}));
if (!response.ok) return;
const entries = Array.isArray(data?.items) ? data.items : [];
setHealthById(Object.fromEntries(entries.map((entry: HealthInfo) => [entry.proxyId, entry])));
} catch {
// Ignore health-loading errors in the UI.
}
}
export async function loadAllProxyUsage(
proxyIds: string[],
setUsageById: SetState<Record<string, UsageInfo>>
) {
if (!proxyIds.length) return;
try {
const results = await Promise.all(
proxyIds.map((id) =>
fetch(`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(id)}`)
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
const assignments = uniqueAssignments(
Array.isArray(data?.items) ? data.items : []
);
return [id, { count: assignments.length, assignments }] as [string, UsageInfo];
})
.catch(() => [id, { count: 0, assignments: [] }] as [string, UsageInfo])
)
);
setUsageById(Object.fromEntries(results));
} catch {
// Ignore usage-loading errors in the UI.
}
}
export const repairRelayResponseSchema = z.object({
repaired: z.boolean().optional(),
mode: z.enum(["noop", "recovered", "redeploy"]).optional(),
error: z.object({ message: z.string() }).optional(),
});
export async function loadProxyUsage(
proxyId: string,
setUsageById: SetState<Record<string, UsageInfo>>
) {
try {
const response = await fetch(
`/api/settings/proxies/assignments?proxyId=${encodeURIComponent(proxyId)}`
);
const data = await response.json().catch(() => ({}));
if (!response.ok) return;
const assignments = uniqueAssignments(Array.isArray(data?.items) ? data.items : []);
setUsageById((previous) => ({
...previous,
[proxyId]: { count: assignments.length, assignments },
}));
} catch {
// Ignore usage-loading errors in the UI.
}
}

View File

@@ -23,5 +23,7 @@ describe("ProxyRegistryManager (TDZ regression #5918)", () => {
const html = renderToString(React.createElement(ProxyRegistryManager));
// The heading key is rendered via the mocked translator (key echo).
expect(html).toContain("title");
expect(html).toContain("w-full border-t border-border");
expect(html).toContain("flex w-full flex-wrap items-center justify-end gap-2");
});
});