Refine ProxyWarden routing workflow
This commit is contained in:
@@ -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.
|
||||||
Generated
+2356
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: set this to true or false
|
||||||
+33
-100
@@ -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} />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
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
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user