Refine ProxyWarden routing workflow
This commit is contained in:
+37
-104
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user