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
@@ -0,0 +1,32 @@
---
name: design-proxywarden-ui
description: Design, implement, review, or refine ProxyWarden UI using the shared calm monospace VPN-client language: centered state control, green-tinted neutrals, route-aware accents, stable geometry, and smooth state-driven motion. Use for React components, CSS, service controls, routing views, tooltips, status transitions, and responsive polish in this repository.
---
# Design ProxyWarden UI
Keep ProxyWarden a compact Windows utility while matching the visual language of the sibling VPN client.
## Workflow
1. Read `AGENTS.md`, `.agent/skills/react-typescript-ui/SKILL.md`, and the complete component and CSS being changed.
2. Read [visual-language.md](references/visual-language.md) for composition, typography, color, and surfaces.
3. Read [motion-and-interaction.md](references/motion-and-interaction.md) for state and interaction animation.
4. Reuse `src/ui/*`, existing state, CSS tokens, and typed Tauri boundaries. Prefer CSS and narrow markup changes over dependencies or new abstractions.
5. Keep geometry stable across loading, success, error, copy, refresh, and route changes.
6. Add `prefers-reduced-motion` behavior with every new animation.
7. Run `npm test`, `npm run build`, and a visual desktop/narrow smoke when the environment allows it.
## Non-negotiable decisions
- Preserve explicit install, start, stop, uninstall, and apply actions. Styling must not blur operational meaning.
- Keep the summary read-only except for its existing service power action; do not add configuration mutations there.
- Render the primary power action as a generous invisible hit target around the icon, not a large filled accent circle.
- Use the blue-green accent for ready/active routing and orange only for direct/local-route distinction. Keep warnings and errors semantic.
- Prefer open composition, quiet surface shifts, and localized light over dashboard cards, thick borders, and decorative chrome.
- Animate opacity, blur, glow, color, filter, and transform; never animate layout properties or use `transition: all`.
- Keep labels, paths, status copy, spinners, and feedback in reserved geometry so neighboring content does not move.
- Keep tooltips independent from transformed, rotating, glowing, or filtered controls.
- Keep secrets and credential-bearing URLs redacted in every visual state.
- Keep narrow layouts single-column and keyboard focus visible.
@@ -0,0 +1,42 @@
# Motion and interaction
## Character
Use fluid, slightly viscous motion that makes work and state legible without moving layout. Avoid bounce, elastic easing, abrupt unmounts, and decorative page choreography.
Use `cubic-bezier(0.16, 1, 0.3, 1)` for arrivals and interaction feedback.
- hover and press: 180-300ms;
- popover/tooltip: 90-180ms;
- panel reveal: 420-600ms;
- state color and glow: 600-900ms;
- progress or numeric tween: about 900ms.
## State controls
- Transition inactive gray to the route accent slowly when a service becomes active, and back to gray when stopped.
- Animate icon color, localized light, and SVG shadow together while keeping the hit target fixed.
- Use a short `scale(0.97)` press followed by a slower release.
- Show checking and running work with restrained motion that finishes cleanly; do not stop spinners or cycles at arbitrary coordinates.
## Changing content
- Crossfade alternate labels inside one fixed slot. Do not replace text in normal flow when its length can move the interface.
- Animate only what changed. Unchanged labels, icons, surrounding rows, and route nodes stay fixed.
- Update data immediately when it arrives; finishing a decorative cycle must not delay the result.
- Repeated background polling updates quietly and does not replay entrance choreography.
- Keep mode selectors outside the keyed content they replace. Let the new content enter with a short directional fade and blur while focus remains on the selected mode.
- For user-triggered sorting, fade and lightly blur the reordered list as one surface; row stagger stays bounded and saved data order does not change.
## Lists and disclosures
- Reveal dynamic rows with opacity, light blur, and a small transform.
- On hover, let a row lift one or two pixels and reveal a restrained local surface/light; keep resting rows visually flat.
- Animate status dots through color, light, and a small scale change instead of animating a surrounding badge or border.
- Keep departing rows mounted until their exit animation completes; remove immediately under reduced motion.
- Bound list stagger to 60-100ms and never make interaction latency grow with list length.
- Tooltips appear quickly above the trigger as independent translucent surfaces and never inherit trigger transforms or filters.
## Reduced motion
Under `prefers-reduced-motion: reduce`, remove transforms, filters, transitions, and keyframes while preserving final state, focus, contrast, status wording, and all functionality.
@@ -0,0 +1,58 @@
# Visual language
## Character
Design for a Windows user opening a small control surface to check routing, recover a service, or apply one deliberate configuration change. The UI should feel soft, precise, dependable, and slightly terminal-like, not like a network administration dashboard.
## Composition
- Make current system state and the next safe action dominant.
- Keep the summary power control visually centered and pair it with a compact vertical route chain.
- Use open space, typography, subtle surface shifts, localized light, and state color before frames or dividers.
- Keep service rows compact: status, human-readable detail, one primary action, then secondary actions.
- Preserve the existing tabs and operational grouping; visual consistency does not justify moving ownership or hiding actions.
## Typography and geometry
- Use JetBrains Mono with uppercase tracked micro-labels only for metadata.
- Use weight and color before large size jumps. Use tabular numerals for changing values.
- Reserve equal space for mutually exclusive labels and feedback.
- Use 8px controls, 10px surfaces, and pills only for status tokens.
- Keep icon-only hit areas at least 40px and align icons in flex/grid rather than guessed offsets.
## Color and light
- Base dark surfaces on green-tinted OKLCH neutrals around hue 145.
- Use blue-green `oklch(0.68 0.11 185)` as the primary active/focus accent.
- Use orange `oklch(0.71 0.12 72)` for direct/local-route distinction, never as general decoration.
- Keep warning/error colors semantic. Do not recolor destructive actions with the route accent.
- Prefer localized `drop-shadow`, text glow, or a soft radial light layer over filled accent containers.
- Keep inactive power neutral even on hover; color communicates state, not clickability alone.
## Surfaces and controls
- Use quiet translucent cloud surfaces for tooltips and transient overlays.
- Inputs are inset and slightly darker than surrounding surfaces.
- Avoid nested cards. Group related controls with spacing and one subtle surface shift.
- Keep persistent work surfaces borderless by default. Use a border only when it communicates input focus, destructive confirmation, or another essential state.
- Render statuses and counters as a glowing dot or quiet value plus text, not as bordered badge capsules.
- Let service rows, route nodes, app rows, and server rows float on the shared canvas; reveal their surface only on hover, focus, selection, or active work.
- Prefer a short luminous underline or localized glow for selection and keyboard focus over a rectangular focus frame.
- Use shared `src/ui` primitives and preserve their default, hover, active, focus, disabled, loading, empty, and error states.
## Route checks
- Keep the route description, endpoint, and check action in a stable three-part row. Reserve the action width so mode changes and endpoint length never move the button.
- Present the endpoint as the named route target, not as a detached badge or a second result.
- Reveal a borderless result surface only while a check is running or after it completes. Show every returned probe in a structured table with separate status, external IP, and latency columns; do not compress unlike values into mixed badges or hardcode a fixed probe count.
- Keep the summary short. Put verbose URLs, request methods, status codes, and errors in a calm structured detail cloud opened by hovering or focusing the result surface.
- Animate result arrival and status light, while preserving the same geometry and honoring reduced motion.
## Route modes and managed lists
- Present external and local proxy routes as two peer choices above the content they replace. Keep the chooser mounted while the mode body crossfades in from the selected direction.
- Reserve the same configuration-stage height for both routes and place it before route diagnostics, so mode-specific labels and controls remain aligned even when check results expand.
- Use the blue-green accent for the external route and the warm route accent for Local sing-box. A small status light and quiet surface shift are enough; do not add a long selection rule.
- A green service light means running, not merely installed. Installed-without-service, stopped, and missing states remain warning-colored.
- Hovering service and application rows reveals a neutral side marker and slight positional response. Do not place a green radial wash behind the entire row.
- Application grouping is display-only. Preserve saved order as the default, provide explicit Processes, EXE files, and Folders sections with counts, and keep alphabetical sorting as a separate option. Remount only the visible list surface so changes can fade into place.
+2356
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: set this to true or false
+33 -100
View File
@@ -67,13 +67,11 @@ import {
} from "../ui"; } from "../ui";
import { ProxiFyreSetupStrip } from "./components/ProxiFyreSetupStrip"; import { ProxiFyreSetupStrip } from "./components/ProxiFyreSetupStrip";
import { SummaryStatusControl } from "./components/SummaryStatusControl"; import { SummaryStatusControl } from "./components/SummaryStatusControl";
import { ConnectionCheckPanel } from "./components/ConnectionCheckPanel";
import { AppList } from "./components/AppList";
import { useNoticeLog } from "./hooks/useNoticeLog"; import { useNoticeLog } from "./hooks/useNoticeLog";
import { parseProxy, type ParsedProxy } from "./lib/parseProxy"; import { parseProxy, type ParsedProxy } from "./lib/parseProxy";
import { import { normalizeItemValue, type DraftItemType } from "./lib/profileItems";
itemTypeLabel,
normalizeItemValue,
type DraftItemType,
} from "./lib/profileItems";
import { import {
configChangeRows, configChangeRows,
configSnapshotFromUi, configSnapshotFromUi,
@@ -115,7 +113,6 @@ import {
serverTooltip, serverTooltip,
pingSummary, pingSummary,
errorMessage, errorMessage,
type ConnectionCheckView,
type DraftItem, type DraftItem,
type RouteChainInput, type RouteChainInput,
} from "./viewModel"; } from "./viewModel";
@@ -928,6 +925,7 @@ export function App() {
return; return;
} }
setProxyCheck(null);
setIsProxyChecking(true); setIsProxyChecking(true);
try { try {
const result = await pingProxyTarget(target.host, target.port); const result = await pingProxyTarget(target.host, target.port);
@@ -1228,40 +1226,7 @@ export function App() {
</div> </div>
)} )}
<div className="app-list"> <AppList items={items} loading={isLoading} onRemove={removeItem} />
{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>
</section> </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() { function renderSingBoxCard() {
const state = serviceControlState(singbox, isDetectingComponents); const state = serviceControlState(singbox, isDetectingComponents);
const setupSummary = singBoxSetupStatus const setupSummary = singBoxSetupStatus
@@ -1637,7 +1554,10 @@ export function App() {
aria-label="Состояние прокси" aria-label="Состояние прокси"
> >
{check.tone === "checking" ? <BusyRing /> : null} {check.tone === "checking" ? <BusyRing /> : null}
{renderConnectionCheck(check)} <ConnectionCheckPanel
check={check}
onCheck={() => void checkRouteProxy()}
/>
</section> </section>
); );
} }
@@ -1650,32 +1570,51 @@ export function App() {
id="panel-proxy" id="panel-proxy"
aria-labelledby="tab-proxy" aria-labelledby="tab-proxy"
> >
{renderProxyOverview()} <section className="route-mode-selector" aria-label="Режим прокси">
<span>Режим маршрута</span>
<section className="route-panel" aria-label="Маршрут приложений">
<div className="route-switch"> <div className="route-switch">
<Button <Button
type="button" type="button"
variant={routeMode === "external" ? "primary" : "neutral"} variant={routeMode === "external" ? "primary" : "neutral"}
className="route-mode-option external"
onClick={() => changeRouteMode("external")} onClick={() => changeRouteMode("external")}
aria-pressed={routeMode === "external"} aria-pressed={routeMode === "external"}
aria-controls="route-mode-content"
> >
Внешний прокси <span className="route-mode-label">
<strong>Внешний прокси</strong>
<small>Готовый SOCKS5 endpoint</small>
</span>
</Button> </Button>
<Button <Button
type="button" type="button"
variant={routeMode === "local-singbox" ? "primary" : "neutral"} variant={routeMode === "local-singbox" ? "primary" : "neutral"}
className="route-mode-option local"
onClick={() => changeRouteMode("local-singbox")} onClick={() => changeRouteMode("local-singbox")}
aria-pressed={routeMode === "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> </Button>
</div> </div>
</section>
<div
className={`route-mode-content ${routeMode === "external" ? "external" : "local"}`}
id="route-mode-content"
key={routeMode}
>
<section className="route-panel" aria-label="Маршрут приложений">
{routeMode === "external" {routeMode === "external"
? renderExternalProxyControls() ? renderExternalProxyControls()
: renderSingBoxCard()} : renderSingBoxCard()}
</section> </section>
{renderProxyOverview()}
</div>
</section> </section>
); );
} }
@@ -1803,9 +1742,3 @@ function nextFrame() {
window.requestAnimationFrame(() => resolve()); 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"; } from "../api/tauriCommands";
import type { ComponentStatus } from "../domain/types"; import type { ComponentStatus } from "../domain/types";
import { import {
connectionCheckView,
noticeFromConfigurationApply, noticeFromConfigurationApply,
pingSummary, pingSummary,
routeChainSegments, routeChainSegments,
@@ -59,6 +60,78 @@ describe("App view helpers", () => {
expect(pingSummary(results)).toBe("Ответили 2/3; быстрее fast: 20 ms."); 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", () => { it("distinguishes rolled-back and partial apply failures", () => {
const base: ApplyConfigurationResult = { const base: ApplyConfigurationResult = {
success: false, 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, ApplyConfigurationResult,
LocalSingBoxStatusResponse, LocalSingBoxStatusResponse,
PingServerResponse, PingServerResponse,
ProxyProbeResponse,
ProxyTargetCheckResponse, ProxyTargetCheckResponse,
ProxiFyreSetupProgress, ProxiFyreSetupProgress,
ProxiFyreSetupStatus, ProxiFyreSetupStatus,
@@ -55,16 +54,25 @@ export interface ConnectionCheckView {
title: string; title: string;
text: string; text: string;
endpoint: string; endpoint: string;
details: string[]; endpointLabel: string;
details: ConnectionDetailView[];
probes: ConnectionProbeView[]; probes: ConnectionProbeView[];
checked: boolean;
disabledReason?: string; disabledReason?: string;
loading: boolean; loading: boolean;
} }
export interface ConnectionDetailView {
label: string;
value: string;
}
export interface ConnectionProbeView { export interface ConnectionProbeView {
id: string; id: string;
label: string; label: string;
value: string;
tone: StatusTone; tone: StatusTone;
status: string;
ip: string | null;
latency: string | null;
detail: string;
} }
export interface RouteChainSegment { export interface RouteChainSegment {
id: string; id: string;
@@ -219,11 +227,15 @@ export function connectionCheckView(
input.proxyInput, input.proxyInput,
input.singBoxStatus, input.singBoxStatus,
); );
const endpointLabel = "Внешний SOCKS5";
const details = [ const details = [
`Endpoint: ${endpoint}`, { label: "Прокси", value: endpoint },
"Проверка: TCP доступность SOCKS5 и HTTP-запросы через него.", { label: "Метод", value: "TCP SOCKS5 и HTTPS через прокси" },
"Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.", {
"Local sing-box для этого маршрута не требуется.", label: "Точки",
value: "Cloudflare Trace, Cloudflare Speed, ipify",
},
{ label: "Local sing-box", value: "Не используется" },
]; ];
if (proxyValidation) { if (proxyValidation) {
@@ -232,8 +244,10 @@ export function connectionCheckView(
title: "Нужен endpoint", title: "Нужен endpoint",
text: proxyValidation, text: proxyValidation,
endpoint, endpoint,
endpointLabel,
details, details,
probes: [], probes: [],
checked: false,
disabledReason: proxyValidation, disabledReason: proxyValidation,
loading: input.isProxyChecking, loading: input.isProxyChecking,
}; };
@@ -245,8 +259,10 @@ export function connectionCheckView(
title: proxyCheckTitle(input.proxyCheck), title: proxyCheckTitle(input.proxyCheck),
text: proxyCheckText(input.proxyCheck), text: proxyCheckText(input.proxyCheck),
endpoint, endpoint,
endpointLabel,
details: [...details, ...proxyCheckDetails(input.proxyCheck)], details: [...details, ...proxyCheckDetails(input.proxyCheck)],
probes: proxyCheckProbes(input.proxyCheck), probes: proxyCheckProbes(input.proxyCheck),
checked: true,
loading: input.isProxyChecking, loading: input.isProxyChecking,
}; };
} }
@@ -256,22 +272,31 @@ export function connectionCheckView(
title: "Готово к проверке", title: "Готово к проверке",
text: "Проверит TCP, внешний IP и задержку через тестовые endpoints.", text: "Проверит TCP, внешний IP и задержку через тестовые endpoints.",
endpoint, endpoint,
endpointLabel,
details, details,
probes: [], probes: [],
checked: false,
loading: input.isProxyChecking, loading: input.isProxyChecking,
}; };
} }
const localAddress = localSingBoxAddress(input.singBoxStatus); const localAddress = localSingBoxAddress(input.singBoxStatus);
const endpointLabel = "Local SOCKS listener";
const selectedEndpoint = input.selectedServer const selectedEndpoint = input.selectedServer
? `${displayServerTag(input.selectedServer.tag)} · ${formatHostPort(input.selectedServer.server, input.selectedServer.serverPort)}` ? `${displayServerTag(input.selectedServer.tag)} · ${formatHostPort(input.selectedServer.server, input.selectedServer.serverPort)}`
: "сервер не выбран"; : "сервер не выбран";
const details = [ const details = [
`Endpoint: ${localAddress}`, { label: "Listener", value: localAddress },
`LAN: ${lanSingBoxAddress(input.singBoxStatus) ?? "недоступен"}`, {
`Сервер: ${selectedEndpoint}`, label: "LAN",
"Проверка: HTTP-запросы через local SOCKS listener.", value: lanSingBoxAddress(input.singBoxStatus) ?? "Недоступен",
"Контрольные точки: Cloudflare Trace, Cloudflare Speed, ipify.", },
{ label: "Сервер", value: selectedEndpoint },
{ label: "Метод", value: "HTTPS через Local SOCKS listener" },
{
label: "Точки",
value: "Cloudflare Trace, Cloudflare Speed, ipify",
},
]; ];
if (input.isDetectingComponents) { if (input.isDetectingComponents) {
@@ -280,8 +305,10 @@ export function connectionCheckView(
title: "Проверяю компоненты", title: "Проверяю компоненты",
text: "Обновляю состояние.", text: "Обновляю состояние.",
endpoint: localAddress, endpoint: localAddress,
endpointLabel,
details, details,
probes: [], probes: [],
checked: false,
disabledReason: "Дождись завершения проверки компонентов.", disabledReason: "Дождись завершения проверки компонентов.",
loading: input.isProxyChecking, loading: input.isProxyChecking,
}; };
@@ -293,8 +320,10 @@ export function connectionCheckView(
title: "sing-box не готов", title: "sing-box не готов",
text: "Установи sing-box и выбери сервер.", text: "Установи sing-box и выбери сервер.",
endpoint: localAddress, endpoint: localAddress,
endpointLabel,
details, details,
probes: [], probes: [],
checked: false,
disabledReason: "Local sing-box не установлен.", disabledReason: "Local sing-box не установлен.",
loading: false, loading: false,
}; };
@@ -306,8 +335,10 @@ export function connectionCheckView(
title: "Служба остановлена", title: "Служба остановлена",
text: "Запусти sing-box.", text: "Запусти sing-box.",
endpoint: localAddress, endpoint: localAddress,
endpointLabel,
details, details,
probes: [], probes: [],
checked: false,
disabledReason: "Local sing-box остановлен.", disabledReason: "Local sing-box остановлен.",
loading: false, loading: false,
}; };
@@ -319,8 +350,10 @@ export function connectionCheckView(
title: "Сервер не выбран", title: "Сервер не выбран",
text: "Выбери сервер подписки.", text: "Выбери сервер подписки.",
endpoint: selectedEndpoint, endpoint: selectedEndpoint,
endpointLabel: "Выбранный сервер",
details, details,
probes: [], probes: [],
checked: false,
disabledReason: "Сервер Local sing-box не выбран.", disabledReason: "Сервер Local sing-box не выбран.",
loading: false, loading: false,
}; };
@@ -332,8 +365,10 @@ export function connectionCheckView(
title: proxyCheckTitle(input.proxyCheck), title: proxyCheckTitle(input.proxyCheck),
text: proxyCheckText(input.proxyCheck), text: proxyCheckText(input.proxyCheck),
endpoint: localAddress, endpoint: localAddress,
endpointLabel,
details: [...details, ...proxyCheckDetails(input.proxyCheck)], details: [...details, ...proxyCheckDetails(input.proxyCheck)],
probes: proxyCheckProbes(input.proxyCheck), probes: proxyCheckProbes(input.proxyCheck),
checked: true,
loading: input.isProxyChecking, loading: input.isProxyChecking,
}; };
} }
@@ -343,8 +378,10 @@ export function connectionCheckView(
title: "Готово к проверке", title: "Готово к проверке",
text: "Проверит внешний IP и задержку через local SOCKS listener.", text: "Проверит внешний IP и задержку через local SOCKS listener.",
endpoint: localAddress, endpoint: localAddress,
endpointLabel,
details, details,
probes: [], probes: [],
checked: false,
loading: input.isProxyChecking, loading: input.isProxyChecking,
}; };
} }
@@ -579,25 +616,9 @@ export function proxyCheckText(check: ProxyTargetCheckResponse) {
if (!check.probes.length) if (!check.probes.length)
return `${formatHostPort(check.server, check.serverPort)} доступен за ${check.latency ?? 0} ms.`; return `${formatHostPort(check.server, check.serverPort)} доступен за ${check.latency ?? 0} ms.`;
const fastest = okProbes.reduce<ProxyProbeResponse | null>( if (okProbes.length === check.probes.length)
(current, probe) => { return `Доступны все ${check.probes.length} контрольные точки.`;
if (!current) return probe; return `Доступны ${okProbes.length} из ${check.probes.length} контрольных точек.`;
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}.`;
} }
export function proxyProbeErrorText(check: ProxyTargetCheckResponse) { export function proxyProbeErrorText(check: ProxyTargetCheckResponse) {
@@ -609,18 +630,29 @@ export function proxyProbeErrorText(check: ProxyTargetCheckResponse) {
} }
export function proxyCheckDetails(check: ProxyTargetCheckResponse) { export function proxyCheckDetails(check: ProxyTargetCheckResponse) {
const details = [ const details: ConnectionDetailView[] = [
`SOCKS5: ${formatHostPort(check.server, check.serverPort)}${check.latency != null ? ` · ${check.latency} ms` : ""}`, {
label: "SOCKS5",
value: `${formatHostPort(check.server, check.serverPort)}${check.latency != null ? ` · ${check.latency} ms` : ""}`,
},
]; ];
for (const probe of check.probes) { for (const probe of check.probes) {
if (probe.ok) { if (probe.ok) {
const ip = probe.ip ? ` · IP ${probe.ip}` : ""; const facts = [
const status = probe.status ? ` · HTTP ${probe.status}` : ""; probe.ip ? `IP ${probe.ip}` : null,
const latency = probe.latency != null ? ` · ${probe.latency} ms` : ""; probe.status ? `HTTP ${probe.status}` : null,
details.push(`${probe.name}:${ip}${status}${latency}`); probe.latency != null ? `${probe.latency} ms` : null,
].filter((fact): fact is string => Boolean(fact));
details.push({
label: probe.name,
value: facts.join(" · ") || "Доступна",
});
} else { } else {
details.push(`${probe.name}: ${probe.error ?? "нет ответа"}`); details.push({
label: probe.name,
value: probe.error ?? "Нет ответа",
});
} }
} }
@@ -633,37 +665,27 @@ export function proxyCheckProbes(
return [ return [
{ {
id: "tcp", id: "tcp",
label: "SOCKS", label: "SOCKS5",
value:
check.latency != null
? `${check.latency} ms`
: check.ok
? "ok"
: "fail",
tone: check.error && !check.ok ? "error" : "ok", 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) => ({ ...check.probes.map((probe) => ({
id: probe.id, id: probe.id,
label: shortProbeName(probe.name), label: shortProbeName(probe.name),
value: probe.ok ? probeValue(probe) : "fail",
tone: probe.ok ? ("ok" as const) : ("error" as const), 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) { export function compactIp(ip: string) {
return ip.length > 20 ? `${ip.slice(0, 10)}...${ip.slice(-6)}` : ip; 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) { export function BusyRing({ className }: BusyRingProps) {
const classes = ["ui-busy-ring", className ?? ""].filter(Boolean).join(" "); const classes = ["ui-busy-ring", className ?? ""].filter(Boolean).join(" ");
return ( return <span className={classes} aria-hidden="true" />;
<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>
);
} }
+1 -6
View File
@@ -54,12 +54,7 @@ export function ServiceControlRow({
return ( return (
<div className={classes}> <div className={classes}>
<span className="ui-service-border-glow" aria-hidden="true"> <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-dot" aria-hidden="true" /> <span className="ui-service-dot" aria-hidden="true" />
<div className="ui-service-text"> <div className="ui-service-text">
<div className="ui-service-title-line"> <div className="ui-service-title-line">