Refine ProxyWarden routing workflow

This commit is contained in:
2026-07-22 01:26:51 +03:00
parent 90b2eb507c
commit 67792f245f
15 changed files with 5058 additions and 448 deletions
+37 -104
View File
@@ -67,13 +67,11 @@ import {
} from "../ui";
import { ProxiFyreSetupStrip } from "./components/ProxiFyreSetupStrip";
import { SummaryStatusControl } from "./components/SummaryStatusControl";
import { ConnectionCheckPanel } from "./components/ConnectionCheckPanel";
import { AppList } from "./components/AppList";
import { useNoticeLog } from "./hooks/useNoticeLog";
import { parseProxy, type ParsedProxy } from "./lib/parseProxy";
import {
itemTypeLabel,
normalizeItemValue,
type DraftItemType,
} from "./lib/profileItems";
import { normalizeItemValue, type DraftItemType } from "./lib/profileItems";
import {
configChangeRows,
configSnapshotFromUi,
@@ -115,7 +113,6 @@ import {
serverTooltip,
pingSummary,
errorMessage,
type ConnectionCheckView,
type DraftItem,
type RouteChainInput,
} from "./viewModel";
@@ -928,6 +925,7 @@ export function App() {
return;
}
setProxyCheck(null);
setIsProxyChecking(true);
try {
const result = await pingProxyTarget(target.host, target.port);
@@ -1228,40 +1226,7 @@ export function App() {
</div>
)}
<div className="app-list">
{isLoading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : items.length ? (
items.map((item) => (
<div className="app-row" key={item.id}>
<div className="app-row-main">
<span className="item-icon" aria-hidden="true">
{itemIcon(item.type)}
</span>
<div>
<strong>{item.value}</strong>
<span>{itemTypeLabel(item.type)}</span>
</div>
</div>
<Button
type="button"
variant="danger"
onClick={() => removeItem(item.id)}
aria-label={`Удалить ${item.value}`}
>
Удалить
</Button>
</div>
))
) : (
<div className="empty-state">
Список пуст. Добавь первое приложение сверху.
</div>
)}
</div>
<AppList items={items} loading={isLoading} onRemove={removeItem} />
</section>
);
}
@@ -1373,54 +1338,6 @@ export function App() {
);
}
function renderConnectionCheck(check: ConnectionCheckView) {
const buttonDisabled = Boolean(check.disabledReason);
return (
<div className="connection-check" aria-label="Проверка соединения">
<div className="connection-check-status">
<span>Проверка маршрута</span>
<strong>{check.title}</strong>
<p>{check.text}</p>
</div>
{check.probes.length ? (
<div className="connection-probes" aria-label="Контрольные точки">
{check.probes.map((probe) => (
<span className={`connection-probe ${probe.tone}`} key={probe.id}>
<strong>{probe.label}</strong>
<span>{probe.value}</span>
</span>
))}
</div>
) : null}
<DetailsPopover
className="connection-endpoint"
details={check.details}
popoverLabel="Проверка маршрута"
align="end"
aria-label={`${check.endpoint}. ${check.details.join(". ")}`}
>
<strong>{check.endpoint}</strong>
</DetailsPopover>
<Button
type="button"
variant="neutral"
size="md"
onClick={() => void checkRouteProxy()}
disabled={buttonDisabled}
loading={check.loading}
loadingLabel="Проверяю"
>
Проверить
</Button>
<p className="network-disclosure">
Проверка отправит HTTPS-запросы через выбранный прокси в Cloudflare и
ipify, чтобы увидеть внешний IP.
</p>
</div>
);
}
function renderSingBoxCard() {
const state = serviceControlState(singbox, isDetectingComponents);
const setupSummary = singBoxSetupStatus
@@ -1637,7 +1554,10 @@ export function App() {
aria-label="Состояние прокси"
>
{check.tone === "checking" ? <BusyRing /> : null}
{renderConnectionCheck(check)}
<ConnectionCheckPanel
check={check}
onCheck={() => void checkRouteProxy()}
/>
</section>
);
}
@@ -1650,32 +1570,51 @@ export function App() {
id="panel-proxy"
aria-labelledby="tab-proxy"
>
{renderProxyOverview()}
<section className="route-panel" aria-label="Маршрут приложений">
<section className="route-mode-selector" aria-label="Режим прокси">
<span>Режим маршрута</span>
<div className="route-switch">
<Button
type="button"
variant={routeMode === "external" ? "primary" : "neutral"}
className="route-mode-option external"
onClick={() => changeRouteMode("external")}
aria-pressed={routeMode === "external"}
aria-controls="route-mode-content"
>
Внешний прокси
<span className="route-mode-label">
<strong>Внешний прокси</strong>
<small>Готовый SOCKS5 endpoint</small>
</span>
</Button>
<Button
type="button"
variant={routeMode === "local-singbox" ? "primary" : "neutral"}
className="route-mode-option local"
onClick={() => changeRouteMode("local-singbox")}
aria-pressed={routeMode === "local-singbox"}
aria-controls="route-mode-content"
>
Локальный прокси
<span className="route-mode-label">
<strong>Локальный прокси</strong>
<small>sing-box на этом компьютере</small>
</span>
</Button>
</div>
{routeMode === "external"
? renderExternalProxyControls()
: renderSingBoxCard()}
</section>
<div
className={`route-mode-content ${routeMode === "external" ? "external" : "local"}`}
id="route-mode-content"
key={routeMode}
>
<section className="route-panel" aria-label="Маршрут приложений">
{routeMode === "external"
? renderExternalProxyControls()
: renderSingBoxCard()}
</section>
{renderProxyOverview()}
</div>
</section>
);
}
@@ -1803,9 +1742,3 @@ function nextFrame() {
window.requestAnimationFrame(() => resolve());
});
}
function itemIcon(type: DraftItemType) {
if (type === "process") return <Cpu size={18} strokeWidth={1.9} />;
if (type === "folder") return <FolderOpen size={18} strokeWidth={1.9} />;
return <FileCode2 size={18} strokeWidth={1.9} />;
}
+73
View File
@@ -5,6 +5,7 @@ import type {
} from "../api/tauriCommands";
import type { ComponentStatus } from "../domain/types";
import {
connectionCheckView,
noticeFromConfigurationApply,
pingSummary,
routeChainSegments,
@@ -59,6 +60,78 @@ describe("App view helpers", () => {
expect(pingSummary(results)).toBe("Ответили 2/3; быстрее fast: 20 ms.");
});
it("builds an expandable route-check result from every backend probe", () => {
const view = connectionCheckView({
routeMode: "external",
proxyInput: "192.168.50.111:8080",
proxyCheck: {
tag: "external",
server: "192.168.50.111",
serverPort: 8080,
ok: true,
latency: 14,
probes: [
{
id: "cloudflare-trace",
name: "Cloudflare Trace",
url: "https://cloudflare.com/cdn-cgi/trace",
ok: true,
status: 200,
latency: 21,
ip: "203.0.113.10",
},
{
id: "ipify",
name: "ipify",
url: "https://api.ipify.org",
ok: false,
error: "timeout",
},
],
},
singbox: undefined,
singBoxStatus: null,
selectedServer: null,
isDetectingComponents: false,
isProxyChecking: false,
});
expect(view).toMatchObject({
checked: true,
endpoint: "192.168.50.111:8080",
tone: "warning",
});
expect(view.probes.map((probe) => probe.id)).toEqual([
"tcp",
"cloudflare-trace",
"ipify",
]);
expect(view.details).toContainEqual({
label: "Cloudflare Trace",
value: "IP 203.0.113.10 · HTTP 200 · 21 ms",
});
expect(view.probes[1]).toMatchObject({
ip: "203.0.113.10",
latency: "21 ms",
status: "Доступна",
});
});
it("shows the result surface while the route check is running", () => {
const view = connectionCheckView({
routeMode: "external",
proxyInput: "192.168.50.111:8080",
proxyCheck: null,
singbox: undefined,
singBoxStatus: null,
selectedServer: null,
isDetectingComponents: false,
isProxyChecking: true,
});
expect(view).toMatchObject({ checked: false, loading: true });
});
it("distinguishes rolled-back and partial apply failures", () => {
const base: ApplyConfigurationResult = {
success: false,
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { DraftItem } from "../viewModel";
import { groupDraftItems, sortDraftItems } from "./AppList";
const items: DraftItem[] = [
{ id: "folder", type: "folder", value: "C:\\Games" },
{ id: "process-z", type: "process", value: "Zoom" },
{ id: "exe", type: "exe", value: "C:\\Apps\\Browser.exe" },
{ id: "process-a", type: "process", value: "Discord" },
];
describe("sortDraftItems", () => {
it("keeps the saved order by default", () => {
expect(sortDraftItems(items, "added")).toBe(items);
});
it("groups by item type and sorts values within each group", () => {
const groups = groupDraftItems(items, "grouped");
expect(groups.map((group) => group.label)).toEqual([
"Процессы",
"EXE-файлы",
"Папки",
]);
expect(
groups.flatMap((group) => group.items.map((item) => item.id)),
).toEqual(["process-a", "process-z", "exe", "folder"]);
});
it("sorts all display values without mutating the source", () => {
expect(sortDraftItems(items, "name").map((item) => item.id)).toEqual([
"exe",
"folder",
"process-a",
"process-z",
]);
expect(items[0]?.id).toBe("folder");
});
});
+154
View File
@@ -0,0 +1,154 @@
import { useMemo, useState } from "react";
import { Cpu, FileCode2, FolderOpen } from "lucide-react";
import { Button } from "../../ui";
import { itemTypeLabel, type DraftItemType } from "../lib/profileItems";
import type { DraftItem } from "../viewModel";
export type ItemSortMode = "added" | "grouped" | "name";
const SORT_OPTIONS: Array<{ value: ItemSortMode; label: string }> = [
{ value: "added", label: "Добавлены" },
{ value: "grouped", label: "Группы" },
{ value: "name", label: "А–Я" },
];
const TYPE_ORDER: Record<DraftItemType, number> = {
process: 0,
exe: 1,
folder: 2,
};
interface AppListProps {
items: DraftItem[];
loading: boolean;
onRemove: (id: string) => void;
}
export function AppList({ items, loading, onRemove }: AppListProps) {
const [sortMode, setSortMode] = useState<ItemSortMode>("added");
const itemGroups = useMemo(
() => groupDraftItems(items, sortMode),
[items, sortMode],
);
return (
<div className="app-list-shell">
{!loading && items.length > 1 ? (
<div className="app-sort-row">
<span>Порядок</span>
<div
className="app-sort"
role="group"
aria-label="Сортировка приложений"
>
{SORT_OPTIONS.map((option) => (
<Button
type="button"
variant="neutral"
size="sm"
className="app-sort-option"
aria-pressed={sortMode === option.value}
key={option.value}
onClick={() => setSortMode(option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
) : null}
<div className="app-list" key={sortMode} aria-live="polite">
{loading ? (
<div className="list-skeleton" aria-label="Загрузка приложений">
<span />
<span />
</div>
) : itemGroups.length ? (
itemGroups.map((group) => (
<section className="app-item-group" key={group.id}>
{group.label ? (
<div className="app-item-group-heading">
<strong>{group.label}</strong>
<span>{group.items.length}</span>
</div>
) : null}
{group.items.map((item) => (
<div className="app-row" key={item.id}>
<div className="app-row-main">
<span className="item-icon" aria-hidden="true">
{itemIcon(item.type)}
</span>
<div>
<strong>{item.value}</strong>
<span>{itemTypeLabel(item.type)}</span>
</div>
</div>
<Button
type="button"
variant="danger"
onClick={() => onRemove(item.id)}
aria-label={`Удалить ${item.value}`}
>
Удалить
</Button>
</div>
))}
</section>
))
) : (
<div className="empty-state">
Список пуст. Добавь первое приложение сверху.
</div>
)}
</div>
</div>
);
}
export function sortDraftItems(items: DraftItem[], mode: ItemSortMode) {
if (mode === "added") return items;
return [...items].sort((left, right) => {
if (mode === "grouped") {
const byType = TYPE_ORDER[left.type] - TYPE_ORDER[right.type];
if (byType !== 0) return byType;
}
return left.value.localeCompare(right.value, "ru", {
numeric: true,
sensitivity: "base",
});
});
}
export function groupDraftItems(items: DraftItem[], mode: ItemSortMode) {
const sortedItems = sortDraftItems(items, mode);
if (mode !== "grouped")
return [{ id: mode, label: null, items: sortedItems }];
return (["process", "exe", "folder"] as const).flatMap((type) => {
const typeItems = sortedItems.filter((item) => item.type === type);
return typeItems.length
? [
{
id: type,
label: groupLabel(type),
items: typeItems,
},
]
: [];
});
}
function groupLabel(type: DraftItemType) {
if (type === "process") return "Процессы";
if (type === "folder") return "Папки";
return "EXE-файлы";
}
function itemIcon(type: DraftItemType) {
if (type === "process") return <Cpu size={18} strokeWidth={1.9} />;
if (type === "folder") return <FolderOpen size={18} strokeWidth={1.9} />;
return <FileCode2 size={18} strokeWidth={1.9} />;
}
@@ -0,0 +1,67 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { ConnectionCheckView } from "../viewModel";
import { ConnectionCheckPanel } from "./ConnectionCheckPanel";
describe("ConnectionCheckPanel", () => {
it("renders the result summary, every probe, and hover details", () => {
const check: ConnectionCheckView = {
tone: "warning",
title: "Частичный ответ",
text: "Доступны 2 из 3 контрольных точек.",
endpoint: "192.168.50.111:8080",
endpointLabel: "Внешний SOCKS5",
details: [
{ label: "SOCKS5", value: "192.168.50.111:8080 · 14 ms" },
{
label: "Cloudflare",
value: "IP 203.0.113.10 · HTTP 200 · 21 ms",
},
{ label: "ipify", value: "timeout" },
],
probes: [
{
id: "tcp",
label: "SOCKS5",
tone: "ok",
status: "Доступен",
ip: null,
latency: "14 ms",
detail: "TCP-соединение с SOCKS5 endpoint",
},
{
id: "cloudflare",
label: "Cloudflare",
tone: "ok",
status: "Доступна",
ip: "203.0.113.10",
latency: "21 ms",
detail: "https://cloudflare.com · HTTP 200",
},
{
id: "ipify",
label: "ipify",
tone: "error",
status: "Ошибка",
ip: null,
latency: null,
detail: "https://api.ipify.org · timeout",
},
],
checked: true,
loading: false,
};
const markup = renderToStaticMarkup(
<ConnectionCheckPanel check={check} onCheck={() => undefined} />,
);
expect(markup).toContain("Результат проверки");
expect(markup).toContain("Cloudflare");
expect(markup).toContain("ipify");
expect(markup).toContain("Внешний IP");
expect(markup).toContain("Задержка");
expect(markup).toContain("Технические детали");
expect(markup).toContain('aria-live="polite"');
});
});
+164
View File
@@ -0,0 +1,164 @@
import { useId } from "react";
import type { ConnectionCheckView } from "../viewModel";
import { BusyRing, Button } from "../../ui";
interface ConnectionCheckPanelProps {
check: ConnectionCheckView;
onCheck: () => void;
}
export function ConnectionCheckPanel({
check,
onCheck,
}: ConnectionCheckPanelProps) {
const detailsId = useId();
const buttonDisabled = Boolean(check.disabledReason);
const showResult = check.loading || check.checked;
return (
<div className="connection-check" aria-label="Проверка соединения">
<div className="connection-check-primary">
<div className="connection-check-status">
<span>Проверка маршрута</span>
<strong>
{buttonDisabled ? check.title : "Проверка через прокси"}
</strong>
<p>
{buttonDisabled
? check.text
: "Проверим прокси, внешний IP и контрольные сайты."}
</p>
</div>
<div
className="connection-target"
aria-label={`${check.endpointLabel}: ${check.endpoint}`}
>
<small>{check.endpointLabel}</small>
<strong>{check.endpoint}</strong>
</div>
<Button
type="button"
variant="neutral"
size="md"
className="connection-check-action"
onClick={onCheck}
disabled={buttonDisabled}
loading={check.loading}
loadingLabel="Проверяю"
>
Проверить
</Button>
</div>
<div
className={`connection-result-slot ${showResult ? "is-visible" : ""}`}
aria-live="polite"
aria-busy={check.loading}
>
{showResult ? (
<div
className={`connection-result ${check.loading ? "checking" : check.tone}`}
tabIndex={check.loading ? undefined : 0}
aria-describedby={check.loading ? undefined : detailsId}
>
<span className="connection-result-indicator" aria-hidden="true">
{check.loading ? (
<BusyRing />
) : (
<span className="connection-result-dot" />
)}
</span>
<div className="connection-result-summary">
<div className="connection-result-copy">
<span>
{check.loading ? "Идёт проверка" : "Результат проверки"}
</span>
<strong>
{check.loading ? "Проверяю маршрут" : check.title}
</strong>
<p>
{check.loading
? "Проверяю SOCKS5 и HTTPS-точки через выбранный маршрут."
: check.text}
</p>
</div>
{!check.loading ? (
<span className="connection-detail-hint">
Подробнее при наведении
</span>
) : null}
</div>
{!check.loading && check.probes.length ? (
<div
className="connection-probe-table"
role="table"
aria-label="Результаты контрольных точек"
>
<div className="connection-probe-row heading" role="row">
<span role="columnheader">Проверка</span>
<span role="columnheader">Статус</span>
<span role="columnheader">Внешний IP</span>
<span role="columnheader">Задержка</span>
</div>
{check.probes.map((probe) => (
<div
className={`connection-probe-row ${probe.tone}`}
key={probe.id}
role="row"
>
<strong role="cell">{probe.label}</strong>
<span className="connection-probe-status" role="cell">
<i aria-hidden="true" />
{probe.status}
</span>
<span className="connection-probe-ip" role="cell">
{probe.ip ?? "—"}
</span>
<span className="connection-probe-latency" role="cell">
{probe.latency ?? "—"}
</span>
</div>
))}
</div>
) : null}
{!check.loading ? (
<div
className="connection-result-details"
id={detailsId}
role="tooltip"
>
<div className="connection-details-head">
<strong>Технические детали</strong>
<span>Полные параметры маршрута и запросов</span>
</div>
<dl className="connection-detail-list">
{check.details.map((detail) => (
<div key={`${detail.label}-${detail.value}`}>
<dt>{detail.label}</dt>
<dd>{detail.value}</dd>
</div>
))}
</dl>
{check.probes.length ? (
<div className="connection-detail-probes">
<strong>Запросы</strong>
{check.probes.map((probe) => (
<div key={probe.id}>
<span>{probe.label}</span>
<span>{probe.detail}</span>
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
</div>
);
}
+81 -59
View File
@@ -2,7 +2,6 @@ import type {
ApplyConfigurationResult,
LocalSingBoxStatusResponse,
PingServerResponse,
ProxyProbeResponse,
ProxyTargetCheckResponse,
ProxiFyreSetupProgress,
ProxiFyreSetupStatus,
@@ -55,16 +54,25 @@ export interface ConnectionCheckView {
title: string;
text: string;
endpoint: string;
details: string[];
endpointLabel: string;
details: ConnectionDetailView[];
probes: ConnectionProbeView[];
checked: boolean;
disabledReason?: string;
loading: boolean;
}
export interface ConnectionDetailView {
label: string;
value: string;
}
export interface ConnectionProbeView {
id: string;
label: string;
value: string;
tone: StatusTone;
status: string;
ip: string | null;
latency: string | null;
detail: string;
}
export interface RouteChainSegment {
id: string;
@@ -219,11 +227,15 @@ export function connectionCheckView(
input.proxyInput,
input.singBoxStatus,
);
const endpointLabel = "Внешний SOCKS5";
const details = [
`Endpoint: ${endpoint}`,
"Проверка: TCP доступность SOCKS5 и HTTP-запросы через него.",
"Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.",
"Local sing-box для этого маршрута не требуется.",
{ label: "Прокси", value: endpoint },
{ label: "Метод", value: "TCP SOCKS5 и HTTPS через прокси" },
{
label: "Точки",
value: "Cloudflare Trace, Cloudflare Speed, ipify",
},
{ label: "Local sing-box", value: "Не используется" },
];
if (proxyValidation) {
@@ -232,8 +244,10 @@ export function connectionCheckView(
title: "Нужен endpoint",
text: proxyValidation,
endpoint,
endpointLabel,
details,
probes: [],
checked: false,
disabledReason: proxyValidation,
loading: input.isProxyChecking,
};
@@ -245,8 +259,10 @@ export function connectionCheckView(
title: proxyCheckTitle(input.proxyCheck),
text: proxyCheckText(input.proxyCheck),
endpoint,
endpointLabel,
details: [...details, ...proxyCheckDetails(input.proxyCheck)],
probes: proxyCheckProbes(input.proxyCheck),
checked: true,
loading: input.isProxyChecking,
};
}
@@ -256,22 +272,31 @@ export function connectionCheckView(
title: "Готово к проверке",
text: "Проверит TCP, внешний IP и задержку через тестовые endpoints.",
endpoint,
endpointLabel,
details,
probes: [],
checked: false,
loading: input.isProxyChecking,
};
}
const localAddress = localSingBoxAddress(input.singBoxStatus);
const endpointLabel = "Local SOCKS listener";
const selectedEndpoint = input.selectedServer
? `${displayServerTag(input.selectedServer.tag)} · ${formatHostPort(input.selectedServer.server, input.selectedServer.serverPort)}`
: "сервер не выбран";
const details = [
`Endpoint: ${localAddress}`,
`LAN: ${lanSingBoxAddress(input.singBoxStatus) ?? "недоступен"}`,
`Сервер: ${selectedEndpoint}`,
"Проверка: HTTP-запросы через local SOCKS listener.",
"Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.",
{ label: "Listener", value: localAddress },
{
label: "LAN",
value: lanSingBoxAddress(input.singBoxStatus) ?? "Недоступен",
},
{ label: "Сервер", value: selectedEndpoint },
{ label: "Метод", value: "HTTPS через Local SOCKS listener" },
{
label: "Точки",
value: "Cloudflare Trace, Cloudflare Speed, ipify",
},
];
if (input.isDetectingComponents) {
@@ -280,8 +305,10 @@ export function connectionCheckView(
title: "Проверяю компоненты",
text: "Обновляю состояние.",
endpoint: localAddress,
endpointLabel,
details,
probes: [],
checked: false,
disabledReason: "Дождись завершения проверки компонентов.",
loading: input.isProxyChecking,
};
@@ -293,8 +320,10 @@ export function connectionCheckView(
title: "sing-box не готов",
text: "Установи sing-box и выбери сервер.",
endpoint: localAddress,
endpointLabel,
details,
probes: [],
checked: false,
disabledReason: "Local sing-box не установлен.",
loading: false,
};
@@ -306,8 +335,10 @@ export function connectionCheckView(
title: "Служба остановлена",
text: "Запусти sing-box.",
endpoint: localAddress,
endpointLabel,
details,
probes: [],
checked: false,
disabledReason: "Local sing-box остановлен.",
loading: false,
};
@@ -319,8 +350,10 @@ export function connectionCheckView(
title: "Сервер не выбран",
text: "Выбери сервер подписки.",
endpoint: selectedEndpoint,
endpointLabel: "Выбранный сервер",
details,
probes: [],
checked: false,
disabledReason: "Сервер Local sing-box не выбран.",
loading: false,
};
@@ -332,8 +365,10 @@ export function connectionCheckView(
title: proxyCheckTitle(input.proxyCheck),
text: proxyCheckText(input.proxyCheck),
endpoint: localAddress,
endpointLabel,
details: [...details, ...proxyCheckDetails(input.proxyCheck)],
probes: proxyCheckProbes(input.proxyCheck),
checked: true,
loading: input.isProxyChecking,
};
}
@@ -343,8 +378,10 @@ export function connectionCheckView(
title: "Готово к проверке",
text: "Проверит внешний IP и задержку через local SOCKS listener.",
endpoint: localAddress,
endpointLabel,
details,
probes: [],
checked: false,
loading: input.isProxyChecking,
};
}
@@ -579,25 +616,9 @@ export function proxyCheckText(check: ProxyTargetCheckResponse) {
if (!check.probes.length)
return `${formatHostPort(check.server, check.serverPort)} доступен за ${check.latency ?? 0} ms.`;
const fastest = okProbes.reduce<ProxyProbeResponse | null>(
(current, probe) => {
if (!current) return probe;
if (
(probe.latency ?? Number.MAX_SAFE_INTEGER) <
(current.latency ?? Number.MAX_SAFE_INTEGER)
)
return probe;
return current;
},
null,
);
const ips = uniqueProbeIps(okProbes);
const ipText = ips.length ? `IP ${ips.join(", ")}` : "IP не распознан";
const speedText = fastest
? `быстрее ${fastest.name}: ${fastest.latency ?? 0} ms`
: `TCP ${check.latency ?? 0} ms`;
return `Ответили ${okProbes.length}/${check.probes.length}; ${ipText}; ${speedText}.`;
if (okProbes.length === check.probes.length)
return `Доступны все ${check.probes.length} контрольные точки.`;
return `Доступны ${okProbes.length} из ${check.probes.length} контрольных точек.`;
}
export function proxyProbeErrorText(check: ProxyTargetCheckResponse) {
@@ -609,18 +630,29 @@ export function proxyProbeErrorText(check: ProxyTargetCheckResponse) {
}
export function proxyCheckDetails(check: ProxyTargetCheckResponse) {
const details = [
`SOCKS5: ${formatHostPort(check.server, check.serverPort)}${check.latency != null ? ` · ${check.latency} ms` : ""}`,
const details: ConnectionDetailView[] = [
{
label: "SOCKS5",
value: `${formatHostPort(check.server, check.serverPort)}${check.latency != null ? ` · ${check.latency} ms` : ""}`,
},
];
for (const probe of check.probes) {
if (probe.ok) {
const ip = probe.ip ? ` · IP ${probe.ip}` : "";
const status = probe.status ? ` · HTTP ${probe.status}` : "";
const latency = probe.latency != null ? ` · ${probe.latency} ms` : "";
details.push(`${probe.name}:${ip}${status}${latency}`);
const facts = [
probe.ip ? `IP ${probe.ip}` : null,
probe.status ? `HTTP ${probe.status}` : null,
probe.latency != null ? `${probe.latency} ms` : null,
].filter((fact): fact is string => Boolean(fact));
details.push({
label: probe.name,
value: facts.join(" · ") || "Доступна",
});
} else {
details.push(`${probe.name}: ${probe.error ?? "нет ответа"}`);
details.push({
label: probe.name,
value: probe.error ?? "Нет ответа",
});
}
}
@@ -633,37 +665,27 @@ export function proxyCheckProbes(
return [
{
id: "tcp",
label: "SOCKS",
value:
check.latency != null
? `${check.latency} ms`
: check.ok
? "ok"
: "fail",
label: "SOCKS5",
tone: check.error && !check.ok ? "error" : "ok",
status: check.ok ? "Доступен" : "Ошибка",
ip: null,
latency: check.latency != null ? `${check.latency} ms` : null,
detail: check.error ?? "TCP-соединение с SOCKS5 endpoint",
},
...check.probes.map((probe) => ({
id: probe.id,
label: shortProbeName(probe.name),
value: probe.ok ? probeValue(probe) : "fail",
tone: probe.ok ? ("ok" as const) : ("error" as const),
status: probe.ok ? "Доступна" : "Ошибка",
ip: probe.ip ? compactIp(probe.ip) : null,
latency: probe.latency != null ? `${probe.latency} ms` : null,
detail: probe.ok
? `${probe.url}${probe.status ? ` · HTTP ${probe.status}` : ""}`
: `${probe.url} · ${probe.error ?? "Нет ответа"}`,
})),
];
}
export function probeValue(probe: ProxyProbeResponse) {
const latency = probe.latency != null ? `${probe.latency} ms` : "ok";
return probe.ip ? `${compactIp(probe.ip)} · ${latency}` : latency;
}
export function uniqueProbeIps(probes: ProxyProbeResponse[]) {
return [
...new Set(
probes.map((probe) => probe.ip).filter((ip): ip is string => Boolean(ip)),
),
];
}
export function compactIp(ip: string) {
return ip.length > 20 ? `${ip.slice(0, 10)}...${ip.slice(-6)}` : ip;
}
+1951 -271
View File
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -5,12 +5,5 @@ export interface BusyRingProps {
export function BusyRing({ className }: BusyRingProps) {
const classes = ["ui-busy-ring", className ?? ""].filter(Boolean).join(" ");
return (
<span className={classes} aria-hidden="true">
<span className="ui-busy-ring-segment top" />
<span className="ui-busy-ring-segment right" />
<span className="ui-busy-ring-segment bottom" />
<span className="ui-busy-ring-segment left" />
</span>
);
return <span className={classes} aria-hidden="true" />;
}
+1 -6
View File
@@ -54,12 +54,7 @@ export function ServiceControlRow({
return (
<div className={classes}>
<span className="ui-service-border-glow" aria-hidden="true">
<span className="ui-service-border-glow-segment top" />
<span className="ui-service-border-glow-segment right" />
<span className="ui-service-border-glow-segment bottom" />
<span className="ui-service-border-glow-segment left" />
</span>
<span className="ui-service-border-glow" aria-hidden="true" />
<span className="ui-service-dot" aria-hidden="true" />
<div className="ui-service-text">
<div className="ui-service-title-line">