mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-10 17:22:17 +03:00
Compare commits
4 Commits
fix/9981-i
...
maint/cher
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b8faab94 | ||
|
|
074dccdc8e | ||
|
|
8cd3fd86b0 | ||
|
|
88e7699074 |
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user