+4
-23
@@ -37,7 +37,6 @@ import {
|
||||
type CommandError,
|
||||
type LocalSingBoxStatusResponse,
|
||||
type PingServerResponse,
|
||||
type ProxyTargetCheckResponse,
|
||||
type ProxiFyreSetupStatus,
|
||||
type SingBoxSetupStatus,
|
||||
} from "../api/tauriCommands";
|
||||
@@ -105,9 +104,7 @@ import {
|
||||
routeChainSegments,
|
||||
safeProxyError,
|
||||
pingTone,
|
||||
proxyCheckNoticeKind,
|
||||
proxyCheckNoticeTitle,
|
||||
proxyCheckText,
|
||||
type ProxyCheckResult,
|
||||
changesApplyButtonLabel,
|
||||
routeProxyCheckTarget,
|
||||
profileItemInput,
|
||||
@@ -230,9 +227,7 @@ export function App() {
|
||||
const [serverPings, setServerPings] = useState<
|
||||
Record<string, PingServerResponse>
|
||||
>({});
|
||||
const [proxyCheck, setProxyCheck] = useState<ProxyTargetCheckResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [proxyCheck, setProxyCheck] = useState<ProxyCheckResult | null>(null);
|
||||
const [adminStatus, setAdminStatus] = useState<AdminStatusResponse | null>(
|
||||
null,
|
||||
);
|
||||
@@ -374,7 +369,6 @@ export function App() {
|
||||
singbox,
|
||||
routeMode: savedSnapshot?.routeMode ?? routeMode,
|
||||
singBoxStatus,
|
||||
proxyCheck,
|
||||
artifacts,
|
||||
});
|
||||
|
||||
@@ -1202,11 +1196,7 @@ export function App() {
|
||||
try {
|
||||
target = routeProxyCheckTarget(routeMode, proxyInput, singBoxStatus);
|
||||
} catch (error) {
|
||||
showNotice({
|
||||
kind: "error",
|
||||
title: "Маршрут не проверен",
|
||||
text: errorMessage(error),
|
||||
});
|
||||
setProxyCheck({ failure: errorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1223,18 +1213,9 @@ export function App() {
|
||||
const result = await pingProxyTarget(target.host, target.port);
|
||||
if (!current()) return;
|
||||
setProxyCheck(result);
|
||||
showNotice({
|
||||
kind: proxyCheckNoticeKind(result),
|
||||
title: proxyCheckNoticeTitle(result),
|
||||
text: proxyCheckText(result),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
showNotice({
|
||||
kind: "error",
|
||||
title: "Маршрут не проверен",
|
||||
text: errorMessage(error),
|
||||
});
|
||||
setProxyCheck({ failure: errorMessage(error) });
|
||||
} finally {
|
||||
if (request === probeRequest.current) setIsProxyChecking(false);
|
||||
}
|
||||
|
||||
+123
-5
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
ApplyConfigurationResult,
|
||||
PingServerResponse,
|
||||
ProxyTargetCheckResponse,
|
||||
} from "../api/tauriCommands";
|
||||
import type { ComponentStatus } from "../domain/types";
|
||||
import {
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
pingSummary,
|
||||
routeChainSegments,
|
||||
summaryRouteChainSegments,
|
||||
systemSummaryState,
|
||||
type SummaryStateInput,
|
||||
} from "./viewModel";
|
||||
|
||||
const runningProxiFyre: ComponentStatus = {
|
||||
@@ -129,9 +132,9 @@ describe("App view helpers", () => {
|
||||
ip: "203.0.113.10",
|
||||
},
|
||||
{
|
||||
id: "ipify",
|
||||
name: "ipify",
|
||||
url: "https://api.ipify.org",
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
},
|
||||
@@ -147,12 +150,13 @@ describe("App view helpers", () => {
|
||||
expect(view).toMatchObject({
|
||||
checked: true,
|
||||
endpoint: "192.168.50.111:8080",
|
||||
tone: "warning",
|
||||
tone: "muted",
|
||||
title: "Ответили 1 из 2 контрольных точек",
|
||||
});
|
||||
expect(view.probes.map((probe) => probe.id)).toEqual([
|
||||
"tcp",
|
||||
"cloudflare-trace",
|
||||
"ipify",
|
||||
"cloudflare-speed",
|
||||
]);
|
||||
expect(view.details).toContainEqual({
|
||||
label: "Cloudflare Trace",
|
||||
@@ -163,6 +167,120 @@ describe("App view helpers", () => {
|
||||
latency: "21 ms",
|
||||
status: "Доступна",
|
||||
});
|
||||
expect(view.probes[2]).toMatchObject({
|
||||
label: "CF speed",
|
||||
tone: "error",
|
||||
status: "Ошибка",
|
||||
detail: "https://speed.cloudflare.com/meta · timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps diagnostic failures separate from service and configuration status", () => {
|
||||
const ready: SummaryStateInput = {
|
||||
isLoading: false,
|
||||
isDetectingComponents: false,
|
||||
proxyfier: runningProxiFyre,
|
||||
singbox: undefined,
|
||||
routeMode: "external",
|
||||
singBoxStatus: null,
|
||||
artifacts: [
|
||||
{
|
||||
component: "proxyfier",
|
||||
sourceMatchesPrepared: true,
|
||||
generatedExists: true,
|
||||
activation: "confirmed",
|
||||
},
|
||||
],
|
||||
};
|
||||
const failed: ProxyTargetCheckResponse = {
|
||||
tag: "external",
|
||||
server: "proxy.example.test",
|
||||
serverPort: 1080,
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
probes: [],
|
||||
};
|
||||
|
||||
for (const proxyCheck of [
|
||||
null,
|
||||
failed,
|
||||
{ failure: "Command failed" },
|
||||
{ ...failed, ok: true },
|
||||
]) {
|
||||
const input = { ...ready, proxyCheck };
|
||||
expect(systemSummaryState(input)).toMatchObject({ tone: "ok" });
|
||||
expect(
|
||||
systemSummaryState({
|
||||
...input,
|
||||
proxyfier: { ...runningProxiFyre, state: "stopped", running: false },
|
||||
}),
|
||||
).toMatchObject({ tone: "warning", title: "Требует внимания" });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the TCP success when all HTTPS probes fail", () => {
|
||||
const view = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyCheck: {
|
||||
tag: "external",
|
||||
server: "proxy.example.test",
|
||||
serverPort: 1080,
|
||||
ok: false,
|
||||
latency: 14,
|
||||
error: "HTTPS probes failed",
|
||||
probes: [
|
||||
{
|
||||
id: "cloudflare-speed",
|
||||
name: "Cloudflare Speed",
|
||||
url: "https://speed.cloudflare.com/meta",
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
},
|
||||
],
|
||||
},
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: false,
|
||||
});
|
||||
|
||||
expect(view).toMatchObject({
|
||||
tone: "muted",
|
||||
title: "Ответили 0 из 1 контрольных точек",
|
||||
});
|
||||
expect(view.probes[0]).toMatchObject({
|
||||
id: "tcp",
|
||||
tone: "ok",
|
||||
status: "Доступен",
|
||||
latency: "14 ms",
|
||||
detail: "TCP-соединение с SOCKS5 endpoint",
|
||||
});
|
||||
expect(view.probes[1]).toMatchObject({ tone: "error", status: "Ошибка" });
|
||||
});
|
||||
|
||||
it("reports a diagnostic command failure without inventing failed probes", () => {
|
||||
const view = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyCheck: { failure: "Проверка прервана: timeout" },
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: false,
|
||||
});
|
||||
|
||||
expect(view).toMatchObject({
|
||||
tone: "muted",
|
||||
title: "Проверка не выполнена",
|
||||
text: "Проверка прервана: timeout",
|
||||
checked: true,
|
||||
loading: false,
|
||||
probes: [],
|
||||
});
|
||||
expect(view.disabledReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows the result surface while the route check is running", () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectionCheckView } from "../viewModel";
|
||||
import { connectionCheckView, 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: "Частичный ответ",
|
||||
tone: "muted",
|
||||
title: "Ответили 2 из 3 контрольных точек",
|
||||
text: "Доступны 2 из 3 контрольных точек.",
|
||||
endpoint: "192.168.50.111:8080",
|
||||
endpointLabel: "Внешний SOCKS5",
|
||||
@@ -64,4 +64,27 @@ describe("ConnectionCheckPanel", () => {
|
||||
expect(markup).toContain("Технические детали");
|
||||
expect(markup).toContain('aria-live="polite"');
|
||||
});
|
||||
|
||||
it("shows a command failure inline and allows retry without an empty tooltip", () => {
|
||||
const check = connectionCheckView({
|
||||
routeMode: "external",
|
||||
proxyInput: "proxy.example.test:1080",
|
||||
proxyCheck: { failure: "Проверка прервана: timeout" },
|
||||
singbox: undefined,
|
||||
singBoxStatus: null,
|
||||
selectedServer: null,
|
||||
isDetectingComponents: false,
|
||||
isProxyChecking: false,
|
||||
});
|
||||
const markup = renderToStaticMarkup(
|
||||
<ConnectionCheckPanel check={check} onCheck={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Проверка не выполнена");
|
||||
expect(markup).toContain("Проверка прервана: timeout");
|
||||
expect(markup).toContain("Проверить");
|
||||
expect(markup).not.toContain("disabled=");
|
||||
expect(markup).not.toContain('role="tooltip"');
|
||||
expect(markup).not.toContain("Требует внимания");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export function ConnectionCheckPanel({
|
||||
const detailsId = useId();
|
||||
const buttonDisabled = Boolean(check.disabledReason);
|
||||
const showResult = check.loading || check.checked;
|
||||
const hasDetails = check.details.length > 0 || check.probes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="connection-check" aria-label="Проверка соединения">
|
||||
@@ -61,7 +62,9 @@ export function ConnectionCheckPanel({
|
||||
<div
|
||||
className={`connection-result ${check.loading ? "checking" : check.tone}`}
|
||||
tabIndex={check.loading ? undefined : 0}
|
||||
aria-describedby={check.loading ? undefined : detailsId}
|
||||
aria-describedby={
|
||||
check.loading || !hasDetails ? undefined : detailsId
|
||||
}
|
||||
>
|
||||
<span className="connection-result-indicator" aria-hidden="true">
|
||||
{check.loading ? (
|
||||
@@ -84,7 +87,7 @@ export function ConnectionCheckPanel({
|
||||
: check.text}
|
||||
</p>
|
||||
</div>
|
||||
{!check.loading ? (
|
||||
{!check.loading && hasDetails ? (
|
||||
<span className="connection-detail-hint">
|
||||
Подробнее при наведении
|
||||
</span>
|
||||
@@ -125,7 +128,7 @@ export function ConnectionCheckPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!check.loading ? (
|
||||
{!check.loading && hasDetails ? (
|
||||
<div
|
||||
className="connection-result-details"
|
||||
id={detailsId}
|
||||
|
||||
+33
-34
@@ -83,10 +83,12 @@ export interface RouteChainSegment {
|
||||
tone: StatusTone;
|
||||
details: string[];
|
||||
}
|
||||
export type ProxyCheckResult = ProxyTargetCheckResponse | { failure: string };
|
||||
|
||||
export interface ConnectionCheckInput {
|
||||
routeMode: RouteMode;
|
||||
proxyInput: string;
|
||||
proxyCheck: ProxyTargetCheckResponse | null;
|
||||
proxyCheck: ProxyCheckResult | null;
|
||||
singbox: ComponentStatus | undefined;
|
||||
singBoxStatus: LocalSingBoxStatusResponse | null;
|
||||
selectedServer: SubscriptionServer | null;
|
||||
@@ -125,7 +127,6 @@ export interface SummaryStateInput {
|
||||
singbox: ComponentStatus | undefined;
|
||||
routeMode: RouteMode;
|
||||
singBoxStatus: LocalSingBoxStatusResponse | null;
|
||||
proxyCheck: ProxyTargetCheckResponse | null;
|
||||
}
|
||||
|
||||
export interface SummaryState {
|
||||
@@ -209,14 +210,6 @@ export function systemSummaryState(input: SummaryStateInput): SummaryState {
|
||||
text: "Службы работают, но конфигурация этого запуска не подтверждена. Проверка доступности прокси не проверяет маршрутизацию приложений.",
|
||||
};
|
||||
|
||||
if (input.proxyCheck && !input.proxyCheck.ok) {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Маршрут не прошел проверку",
|
||||
text: proxyCheckText(input.proxyCheck),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "ok",
|
||||
title: "Конфиг передан службам",
|
||||
@@ -247,6 +240,26 @@ export function routeEndpointLabel(
|
||||
export function connectionCheckView(
|
||||
input: ConnectionCheckInput,
|
||||
): ConnectionCheckView {
|
||||
if (input.proxyCheck && "failure" in input.proxyCheck) {
|
||||
return {
|
||||
tone: "muted",
|
||||
title: "Проверка не выполнена",
|
||||
text: input.proxyCheck.failure,
|
||||
endpoint: routeEndpointLabel(
|
||||
input.routeMode,
|
||||
input.proxyInput,
|
||||
input.singBoxStatus,
|
||||
),
|
||||
endpointLabel:
|
||||
input.routeMode === "external"
|
||||
? "Внешний SOCKS5"
|
||||
: "Local SOCKS listener",
|
||||
details: [],
|
||||
probes: [],
|
||||
checked: true,
|
||||
loading: input.isProxyChecking,
|
||||
};
|
||||
}
|
||||
if (input.routeMode === "external") {
|
||||
const proxyValidation = input.proxyInput.trim()
|
||||
? safeProxyError(input.proxyInput)
|
||||
@@ -551,32 +564,15 @@ export function pingTone(ping: PingServerResponse): StatusTone {
|
||||
}
|
||||
|
||||
export function proxyCheckTone(check: ProxyTargetCheckResponse): StatusTone {
|
||||
if (!check.ok) return "error";
|
||||
if (check.probes.some((probe) => !probe.ok)) return "warning";
|
||||
if (!check.ok || check.probes.some((probe) => !probe.ok)) return "muted";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
export function proxyCheckNoticeKind(
|
||||
check: ProxyTargetCheckResponse,
|
||||
): Notice["kind"] {
|
||||
if (!check.ok) return "error";
|
||||
return check.probes.some((probe) => !probe.ok) ? "info" : "success";
|
||||
}
|
||||
|
||||
export function proxyCheckTitle(check: ProxyTargetCheckResponse) {
|
||||
if (!check.ok) return "Маршрут не прошел";
|
||||
if (!check.probes.length)
|
||||
return check.ok ? "Прокси ответил" : "Не удалось подключиться к прокси";
|
||||
const okCount = check.probes.filter((probe) => probe.ok).length;
|
||||
if (check.probes.length && okCount < check.probes.length)
|
||||
return "Маршрут частично отвечает";
|
||||
return "Маршрут отвечает";
|
||||
}
|
||||
|
||||
export function proxyCheckNoticeTitle(check: ProxyTargetCheckResponse) {
|
||||
if (!check.ok) return "Проверка маршрута не прошла";
|
||||
const okCount = check.probes.filter((probe) => probe.ok).length;
|
||||
if (check.probes.length && okCount < check.probes.length)
|
||||
return "Проверка частично прошла";
|
||||
return "Проверка маршрута прошла";
|
||||
return `Ответили ${okCount} из ${check.probes.length} контрольных точек`;
|
||||
}
|
||||
|
||||
export function proxyCheckText(check: ProxyTargetCheckResponse) {
|
||||
@@ -635,11 +631,14 @@ export function proxyCheckProbes(
|
||||
{
|
||||
id: "tcp",
|
||||
label: "SOCKS5",
|
||||
tone: check.error && !check.ok ? "error" : "ok",
|
||||
status: check.ok ? "Доступен" : "Ошибка",
|
||||
tone: check.ok || check.probes.length > 0 ? "ok" : "error",
|
||||
status: check.ok || check.probes.length > 0 ? "Доступен" : "Нет ответа",
|
||||
ip: null,
|
||||
latency: check.latency != null ? `${check.latency} ms` : null,
|
||||
detail: check.error ?? "TCP-соединение с SOCKS5 endpoint",
|
||||
detail:
|
||||
check.probes.length > 0
|
||||
? "TCP-соединение с SOCKS5 endpoint"
|
||||
: (check.error ?? "TCP-соединение с SOCKS5 endpoint"),
|
||||
},
|
||||
...check.probes.map((probe) => ({
|
||||
id: probe.id,
|
||||
|
||||
Reference in New Issue
Block a user