Update VPN proxy client behavior
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-09 11:55:02 +03:00
parent 71ede44be0
commit 90433d7cd8
27 changed files with 770 additions and 254 deletions
+2 -2
View File
@@ -77,13 +77,13 @@ http://АДРЕС-GATEWAY:3456
### Устройства Gateway
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
Home показывает фактически применённый VPN-сервер и общий график тех же счётчиков. `Учтено Harbor` накопленная сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткий график последних 15-секундных интервалов после перезапуска начинает заполняться заново.
Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново.
## Установка Harbor Connect на macOS
+16 -2
View File
@@ -127,12 +127,16 @@ interface TrafficSample {
observedAt: string | null | undefined;
gatewayBytes: string;
proxyBytes: string;
uploadBytes: string;
downloadBytes: string;
}
interface TrafficCursor {
signature: string;
gateway: bigint;
proxy: bigint;
upload: bigint;
download: bigint;
}
interface DeviceObservation {
@@ -674,10 +678,12 @@ export function createDeviceInventoryService({
const proxy = state.traffic.proxy.totalsByMac[device.mac];
const signature = `${traffic?.observedAt || ''}|${proxy?.observedAt || ''}`;
if (signature === '|') continue;
const upload = BigInt(traffic?.uploadBytes || '0') + BigInt(proxy?.uploadBytes || '0');
const download = BigInt(traffic?.downloadBytes || '0') + BigInt(proxy?.downloadBytes || '0');
const gateway = BigInt(traffic?.uploadBytes || '0') + BigInt(traffic?.downloadBytes || '0');
const proxyTotal = BigInt(proxy?.uploadBytes || '0') + BigInt(proxy?.downloadBytes || '0');
const previous = trafficCursorByMac.get(device.mac);
trafficCursorByMac.set(device.mac, { signature, gateway, proxy: proxyTotal });
trafficCursorByMac.set(device.mac, { signature, gateway, proxy: proxyTotal, upload, download });
if (!previous || previous.signature === signature) continue;
const observedAt = [traffic?.observedAt, proxy?.observedAt].filter(Boolean).sort().at(-1);
const samples = trafficHistoryByMac.get(device.mac) || [];
@@ -685,6 +691,8 @@ export function createDeviceInventoryService({
observedAt,
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
proxyBytes: proxyTotal > previous.proxy ? (proxyTotal - previous.proxy).toString() : '0',
uploadBytes: upload > previous.upload ? (upload - previous.upload).toString() : '0',
downloadBytes: download > previous.download ? (download - previous.download).toString() : '0',
}].slice(-TRAFFIC_HISTORY_LIMIT));
}
for (const mac of trafficCursorByMac.keys()) {
@@ -695,16 +703,20 @@ export function createDeviceInventoryService({
}
const gatewaySource = state.traffic.global.gateway;
const proxySource = state.traffic.global.proxy;
const upload = BigInt(gatewaySource.uploadBytes) + BigInt(proxySource.uploadBytes);
const download = BigInt(gatewaySource.downloadBytes) + BigInt(proxySource.downloadBytes);
const gateway = BigInt(gatewaySource.uploadBytes) + BigInt(gatewaySource.downloadBytes);
const proxy = BigInt(proxySource.uploadBytes) + BigInt(proxySource.downloadBytes);
const signature = `${gatewaySource.lastObservedAt || ''}|${proxySource.lastObservedAt || ''}`;
const previous = globalTrafficCursor;
globalTrafficCursor = { signature, gateway, proxy };
globalTrafficCursor = { signature, gateway, proxy, upload, download };
if (previous && previous.signature !== signature) {
globalTrafficHistory = [...globalTrafficHistory, {
observedAt: [gatewaySource.lastObservedAt, proxySource.lastObservedAt].filter(Boolean).sort().at(-1),
gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
proxyBytes: proxy > previous.proxy ? (proxy - previous.proxy).toString() : '0',
uploadBytes: upload > previous.upload ? (upload - previous.upload).toString() : '0',
downloadBytes: download > previous.download ? (download - previous.download).toString() : '0',
}].slice(-TRAFFIC_HISTORY_LIMIT);
}
}
@@ -800,6 +812,8 @@ export function createDeviceInventoryService({
traffic: {
gatewayBytes: gatewayBytes.toString(),
proxyBytes: proxyBytes.toString(),
uploadBytes: (BigInt(gatewayTraffic.uploadBytes) + BigInt(proxyTraffic.uploadBytes)).toString(),
downloadBytes: (BigInt(gatewayTraffic.downloadBytes) + BigInt(proxyTraffic.downloadBytes)).toString(),
totalBytes: (gatewayBytes + proxyBytes).toString(),
gatewayObservedAt: gatewayTraffic.lastObservedAt,
proxyObservedAt: proxyTraffic.lastObservedAt,
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.20.36',
gatewayClient: '0.21.21',
gatewayBackend: '0.21.22',
macClient: '0.21.0',
gatewayClient: '0.22.0',
gatewayBackend: '0.22.0',
});
export interface ParsedVersion {
+40 -10
View File
@@ -119,6 +119,9 @@ interface ClientOverviewPageProps {
}
type CopyKind = 'gateway' | 'socks5' | 'http';
type CopyFeedback = { failed: boolean; cycle: number };
type CopyFeedbackMap = Partial<Record<CopyKind, CopyFeedback>>;
type CopyAnnouncement = { text: string; cycle: number };
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
@@ -263,8 +266,9 @@ function InlineProgress({ operations, context }: {
);
}
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
function HarborBrand({ isGateway, connected, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
isGateway: boolean;
connected: boolean;
gatewayAvailable: boolean;
gatewayDirect: boolean;
blocked: boolean;
@@ -333,7 +337,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSe
</div>;
return (
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
<div className={`harbor-brand is-${product.toLowerCase()}${connected ? ' is-connected' : ''}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
{switchable ? <button
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
type="button"
@@ -392,8 +396,10 @@ export function ClientOverviewPage({
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const [now, setNow] = useState(Date.now());
const [showIntro, setShowIntro] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<{ kind: CopyKind; failed: boolean } | null>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({});
const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({});
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const controlHost = window.location.host || `${gatewayAddress}:3456`;
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
@@ -472,7 +478,8 @@ export function ClientOverviewPage({
}, [diagnosticsAvailable]);
useEffect(() => () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
for (const timer of Object.values(copyTimersRef.current)) clearTimeout(timer);
copyAttemptsRef.current = {};
}, []);
function selectServer(serverId: string) {
@@ -482,14 +489,36 @@ export function ClientOverviewPage({
async function copyProxy(kind: CopyKind) {
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
const activeTimer = copyTimersRef.current[kind];
if (activeTimer) clearTimeout(activeTimer);
const attempt = {};
copyAttemptsRef.current[kind] = attempt;
let failed = false;
try {
await copyText(value);
setCopyFeedback({ kind, failed: false });
} catch {
setCopyFeedback({ kind, failed: true });
failed = true;
}
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
if (copyAttemptsRef.current[kind] !== attempt) return;
const pendingTimer = copyTimersRef.current[kind];
if (pendingTimer) clearTimeout(pendingTimer);
setCopyFeedback((current) => ({
...current,
[kind]: { failed, cycle: (current[kind]?.cycle || 0) + 1 },
}));
setCopyAnnouncement((current) => ({
text: failed ? 'Не удалось скопировать' : 'Скопировано',
cycle: current.cycle + 1,
}));
copyTimersRef.current[kind] = setTimeout(() => {
setCopyFeedback((current) => {
const next = { ...current };
delete next[kind];
return next;
});
delete copyTimersRef.current[kind];
delete copyAttemptsRef.current[kind];
}, 800);
}
function openRouting() {
@@ -506,10 +535,11 @@ export function ClientOverviewPage({
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div>
<HarborBrand
isGateway={isGateway}
connected={connected}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
+62 -61
View File
@@ -11,8 +11,8 @@ const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
type CopyKind = 'gateway' | 'socks5' | 'http';
interface CopyFeedback {
kind: CopyKind;
failed: boolean;
cycle: number;
}
interface DurationUnit {
@@ -34,7 +34,7 @@ interface ConnectionPanelProps {
proxyPort?: number;
now: number;
blocked: boolean;
copyFeedback?: CopyFeedback | null;
copyFeedback?: Partial<Record<CopyKind, CopyFeedback>>;
routingSlot?: ReactNode;
serverSlot?: ReactNode;
statusSlot?: ReactNode;
@@ -161,7 +161,7 @@ export function ConnectionPanel({
</button>;
return <>
{visible && <section className="client-power-section" aria-labelledby="connection-title">
{visible && <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title">
{isGateway ? <span
className="client-power-control client-tooltip-anchor"
tabIndex={powerUnavailable ? 0 : undefined}
@@ -173,6 +173,58 @@ export function ConnectionPanel({
Сначала добавьте подписку и выберите сервер
</span>}
</span> : powerButton}
<div className="client-state-detail">
{connected ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
key="duration"
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
onClick={toggleDurationMode}
>
<span className="client-duration-stack">
<time
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'digital'}
>
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
</time>
<time
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'words'}
>
{duration.days.value > 0 && (
<span className="client-duration-word-row is-calendar">
<span className="client-duration-unit" data-unit="days">
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
<DurationPart name="days-label">{duration.days.label}</DurationPart>
</span>
</span>
)}
<span className="client-duration-word-row is-clock">
{wordClockDuration.map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{name === 'seconds'
? <AnimatedSeconds value={part.value} padded={false} />
: part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
</span>
))}
</span>
</time>
</span>
<span className="client-tooltip" role="tooltip">
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
</span>
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
{routingSlot}
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
@@ -181,58 +233,6 @@ export function ConnectionPanel({
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{serverSlot}
<div className="client-state-detail">
{connected ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
key="duration"
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
onClick={toggleDurationMode}
>
<span className="client-duration-stack">
<time
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'digital'}
>
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
</time>
<time
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'words'}
>
{duration.days.value > 0 && (
<span className="client-duration-word-row is-calendar">
<span className="client-duration-unit" data-unit="days">
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
<DurationPart name="days-label">{duration.days.label}</DurationPart>
</span>
</span>
)}
<span className="client-duration-word-row is-clock">
{wordClockDuration.map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{name === 'seconds'
? <AnimatedSeconds value={part.value} padded={false} />
: part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
</span>
))}
</span>
</time>
</span>
<span className="client-tooltip" role="tooltip">
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
</span>
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
</div>
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
@@ -249,18 +249,19 @@ export function ConnectionPanel({
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{proxyKinds.map(([kind, label]) => (
<button
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
{proxyKinds.map(([kind, label]) => {
const feedback = copyFeedback?.[kind];
return <button
className={`client-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
onClick={() => onCopyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
</button>
))}
{feedback && <span className="client-copy-feedback" aria-hidden="true" key={feedback.cycle}>{feedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
</button>;
})}
</div>
</div>
</section>
+27 -4
View File
@@ -1,5 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { formatByteString, formatLastSeen } from '../../utils/format.js';
import {
formatByteString,
formatLastSeen,
trafficBytesPerSecond,
} from '../../utils/format.js';
import { TrafficChart } from './TrafficChart.js';
import {
parseDeviceSnapshot,
@@ -207,6 +211,18 @@ export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature;
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
const globalTraffic = feature.snapshot?.traffic;
const history = globalTraffic?.history || [];
const latest = history.at(-1);
const previous = history.at(-2);
const hasDirectionalRate = latest && previous
&& typeof latest.downloadBytes === 'string'
&& typeof latest.uploadBytes === 'string';
const downloadRate = hasDirectionalRate
? trafficBytesPerSecond(latest.downloadBytes, previous.observedAt, latest.observedAt)
: null;
const uploadRate = hasDirectionalRate
? trafficBytesPerSecond(latest.uploadBytes, previous.observedAt, latest.observedAt)
: null;
const trafficSourceError = feature.snapshot?.source?.traffic?.error
|| feature.snapshot?.source?.traffic?.proxy?.error
|| (feature.status === 'error' ? feature.error : null);
@@ -216,14 +232,21 @@ export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeatur
return <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
<div className="client-gateway-traffic-heading">
<span>Учтено Harbor</span>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
<span className="client-gateway-traffic-total">
<small>Учтено Harbor</small>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
</span>
<span className="client-gateway-traffic-speed" aria-label="Текущая средняя скорость">
<span className="is-download"> {downloadRate === null ? '—' : `${formatByteString(downloadRate)}/с`}</span>
<span className="is-upload"> {uploadRate === null ? '—' : `${formatByteString(uploadRate)}/с`}</span>
</span>
</div>
<div className="client-gateway-traffic-chart">
<TrafficChart
samples={globalTraffic?.history || []}
samples={history}
capacity={feature.snapshot?.trafficHistoryCapacity || 120}
routeLabel="Gateway"
series="speed"
/>
</div>
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
+37 -10
View File
@@ -75,7 +75,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [alias, setAlias] = useState('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const [copyFeedback, setCopyFeedback] = useState<Record<string, { failed: boolean }>>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
const deviceNodes = useRef(new Map<string, HTMLElement>());
@@ -85,7 +86,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const movementAnimations = useRef(new Map<string, Animation>());
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
const aliasBaseline = useRef({ id: '', value: '' });
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const copyAttempts = useRef(new Map<string, object>());
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
const devices = useMemo(
@@ -104,7 +106,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
for (const timer of copyTimers.current.values()) clearTimeout(timer);
copyTimers.current.clear();
copyAttempts.current.clear();
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
}, []);
@@ -190,14 +194,36 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
async function copyDeviceIp(device: Device) {
if (!device.ip) return;
if (copyTimer.current) clearTimeout(copyTimer.current);
const activeTimer = copyTimers.current.get(device.id);
if (activeTimer) clearTimeout(activeTimer);
const attempt = {};
copyAttempts.current.set(device.id, attempt);
let feedback: { failed: boolean };
try {
await copyText(device.ip);
setCopyFeedback({ id: device.id, failed: false });
feedback = { failed: false };
} catch {
setCopyFeedback({ id: device.id, failed: true });
feedback = { failed: true };
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
if (copyAttempts.current.get(device.id) !== attempt) return;
const announcement = {
id: device.id,
message: feedback.failed ? `Не удалось скопировать IP ${device.ip}` : `IP ${device.ip} скопирован`,
};
const pendingTimer = copyTimers.current.get(device.id);
if (pendingTimer) clearTimeout(pendingTimer);
setCopyFeedback((current) => ({ ...current, [device.id]: feedback }));
setCopyAnnouncement(announcement);
copyTimers.current.set(device.id, setTimeout(() => {
setCopyFeedback((current) => {
const next = { ...current };
delete next[device.id];
return next;
});
setCopyAnnouncement((current) => current?.id === device.id ? null : current);
copyTimers.current.delete(device.id);
copyAttempts.current.delete(device.id);
}, COPY_FEEDBACK_MS));
}
function startEditing(device: Device) {
@@ -303,7 +329,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
)}
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''}
{copyAnnouncement?.message || ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
@@ -320,7 +346,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
const hasProxyTraffic = proxyTotal > 0n;
const trafficDelta = trafficDeltas[device.id] || {};
const copied = copyFeedback?.id === device.id;
const feedback = copyFeedback[device.id];
const copied = Boolean(feedback);
const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed';
const policyPending = device.policyStatus === 'pending';
@@ -392,7 +419,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
>{title}</button>}
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
{device.ip ? <button
className={`client-device-ip${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
className={`client-device-ip${copied ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
onClick={() => copyDeviceIp(device)}
+65 -32
View File
@@ -4,6 +4,7 @@ import {
byteString,
formatByteString,
trafficAxisMid,
trafficBytesPerSecond,
trafficScaleRatio,
} from '../../utils/format.js';
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js';
@@ -40,38 +41,60 @@ function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY'
}, `M ${points[0].x},${points[0][valueKey]}`);
}
function trafficSeriesMax(samples: TrafficSample[]) {
return samples.reduce((largest, sample) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
function trafficSeriesMax(values: Array<{ gateway: bigint; proxy: bigint }>) {
return values.reduce((largest, { gateway, proxy }) => {
return gateway > largest
? (proxy > gateway ? proxy : gateway)
: (proxy > largest ? proxy : largest);
}, 0n);
}
function formatRate(value: bigint) {
return `${formatByteString(value)}/с`;
}
export function TrafficChart({
samples,
scale = 'linear',
capacity,
routeLabel,
pinned = true,
series = 'routes',
}: {
samples: TrafficSample[];
scale?: TrafficScale;
capacity: number;
routeLabel: string;
pinned?: boolean;
series?: 'routes' | 'speed';
}) {
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
const previousPoints = useRef<ChartPoint[]>([]);
const previousScale = useRef<TrafficScale>(scale);
const max = trafficSeriesMax(samples);
const previousSeries = useRef(series);
const speedAvailable = samples.length > 1 && samples.every((sample) => (
typeof sample.downloadBytes === 'string' && typeof sample.uploadBytes === 'string'
));
const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples;
const values = visibleSamples.map((sample, index) => {
if (series === 'routes') {
return {
sample,
gateway: byteString(sample.gatewayBytes),
proxy: byteString(sample.proxyBytes),
};
}
const previous = samples[index];
return {
sample,
gateway: trafficBytesPerSecond(sample.downloadBytes, previous.observedAt, sample.observedAt),
proxy: trafficBytesPerSecond(sample.uploadBytes, previous.observedAt, sample.observedAt),
};
});
const max = trafficSeriesMax(values);
const mid = trafficAxisMid(max, scale);
const firstSlot = capacity - samples.length;
const points = samples.map((sample, index) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
const firstSlot = capacity - visibleSamples.length;
const points = values.map(({ sample, gateway, proxy }, index) => {
return {
sample,
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
@@ -84,16 +107,18 @@ export function TrafficChart({
const previous = points.slice(0, -1);
const penultimate = points.at(-2);
const newest = points.at(-1);
const hasProxy = points.some(({ proxy }) => proxy > 0n);
const hasSecondary = points.some(({ proxy }) => proxy > 0n);
const scaleFrom = previousPoints.current;
const animateScale = previousScale.current !== scale
&& previousSeries.current === series
&& scaleFrom.length === points.length
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
useLayoutEffect(() => {
previousPoints.current = points;
previousScale.current = scale;
}, [points, scale]);
previousSeries.current = series;
}, [points, scale, series]);
function trackPointer(event: PointerEvent<HTMLSpanElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
@@ -116,9 +141,17 @@ export function TrafficChart({
}}
>
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
{series === 'speed' ? <>
<strong> Download {formatRate(hovered.gateway)}</strong>
<span className="is-upload"> Upload {formatRate(hovered.proxy)}</span>
<span>За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))}</span>
<span>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</span>
{byteString(hovered.sample.proxyBytes) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>}
</> : <>
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
</>}
</span>,
document.body,
);
@@ -126,15 +159,15 @@ export function TrafficChart({
return <span
className="client-device-traffic-chart"
role="img"
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
aria-label={`${series === 'speed' ? 'История скорости' : 'История трафика'}, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${series === 'speed' ? formatRate(max) : formatByteString(max)}`}
style={{
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
} as CSSProperties}
>
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
<span className="is-max">{formatByteString(max)}</span>
<span className="is-mid">{formatByteString(mid)}</span>
<span className="is-max">{series === 'speed' ? formatRate(max) : formatByteString(max)}</span>
<span className="is-mid">{series === 'speed' ? formatRate(mid) : formatByteString(mid)}</span>
<span className="is-zero">0</span>
</span>}
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
@@ -145,31 +178,31 @@ export function TrafficChart({
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) } as CSSProperties}>
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
{animateScale && <animate key={`gateway-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
{previous.length > 0 && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath(previous, 'gatewayY')}>
{animateScale && <animate key={`gateway-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>}
{hasProxy && previous.length > 0 && <path className="is-proxy" d={smoothTrafficPath(previous, 'proxyY')}>
{animateScale && <animate key={`proxy-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
{hasSecondary && previous.length > 0 && <path className={series === 'speed' ? 'is-upload' : 'is-proxy'} d={smoothTrafficPath(previous, 'proxyY')}>
{animateScale && <animate key={`proxy-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>}
{penultimate && newest && <path className="is-gateway is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
{animateScale && <animate key={`gateway-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
{penultimate && newest && <path className={`${series === 'speed' ? 'is-download' : 'is-gateway'} is-new`} pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
{animateScale && <animate key={`gateway-new-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
</path>}
{hasProxy && penultimate && newest && <path className="is-proxy is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
{animateScale && <animate key={`proxy-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
{hasSecondary && penultimate && newest && <path className={`${series === 'speed' ? 'is-upload' : 'is-proxy'} is-new`} pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
{animateScale && <animate key={`proxy-new-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
</path>}
{!penultimate && newest && <line className="is-gateway is-point" x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
{!penultimate && newest && <line className={`${series === 'speed' ? 'is-download' : 'is-gateway'} is-point`} x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
</g>
{hovered && <g className="client-device-traffic-cursor">
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
<line className="is-point is-gateway" x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
{hovered.proxy > 0n && <line className="is-point is-proxy" x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
<line className={`is-point ${series === 'speed' ? 'is-download' : 'is-gateway'}`} x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
{hovered.proxy > 0n && <line className={`is-point ${series === 'speed' ? 'is-upload' : 'is-proxy'}`} x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
</g>}
</svg>
</span>
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
<span>15 с</span>
<time dateTime={samples[samples.length - 1].observedAt}>{chartTime(samples[samples.length - 1].observedAt)}</time>
{visibleSamples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
<span>{series === 'speed' ? '↓ / ↑' : '15 с'}</span>
<time dateTime={visibleSamples[visibleSamples.length - 1].observedAt}>{chartTime(visibleSamples[visibleSamples.length - 1].observedAt)}</time>
</span>}
{tooltip}
</span>;
+9 -1
View File
@@ -9,6 +9,8 @@ export interface TrafficSample extends Record<string, unknown> {
observedAt: string;
gatewayBytes: ByteValue;
proxyBytes: ByteValue;
uploadBytes?: ByteValue;
downloadBytes?: ByteValue;
}
export interface Device extends Record<string, unknown> {
@@ -60,6 +62,8 @@ export interface DeviceSnapshot extends Record<string, unknown> {
gatewayBytes: ByteValue;
proxyBytes: ByteValue;
totalBytes: ByteValue;
uploadBytes?: ByteValue;
downloadBytes?: ByteValue;
gatewayObservedAt: string | null;
proxyObservedAt: string | null;
observedAt: string | null;
@@ -93,7 +97,9 @@ function validTrafficSample(value: unknown): value is TrafficSample {
return record(value)
&& timestamp(value.observedAt)
&& bytes(value.gatewayBytes)
&& bytes(value.proxyBytes);
&& bytes(value.proxyBytes)
&& (value.uploadBytes === undefined || bytes(value.uploadBytes))
&& (value.downloadBytes === undefined || bytes(value.downloadBytes));
}
function validHistory(value: unknown): value is TrafficSample[] {
@@ -144,6 +150,8 @@ function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
&& bytes(value.gatewayBytes)
&& bytes(value.proxyBytes)
&& bytes(value.totalBytes)
&& (value.uploadBytes === undefined || bytes(value.uploadBytes))
&& (value.downloadBytes === undefined || bytes(value.downloadBytes))
&& nullableTimestamp(value.gatewayObservedAt)
&& nullableTimestamp(value.proxyObservedAt)
&& nullableTimestamp(value.observedAt)
@@ -55,22 +55,48 @@ function InstructionBlock({
open: boolean;
onToggle: () => void;
}) {
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [copyFeedback, setCopyFeedback] = useState<Record<string, { failed: boolean }>>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const copyAttempts = useRef(new Map<string, object>());
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
for (const timer of copyTimers.current.values()) clearTimeout(timer);
copyTimers.current.clear();
copyAttempts.current.clear();
}, []);
async function copyInstruction(action: InstructionCopyAction) {
if (copyTimer.current) clearTimeout(copyTimer.current);
const activeTimer = copyTimers.current.get(action.id);
if (activeTimer) clearTimeout(activeTimer);
const attempt = {};
copyAttempts.current.set(action.id, attempt);
let feedback: { failed: boolean };
try {
await copyText(action.text);
setCopyFeedback({ id: action.id, failed: false });
feedback = { failed: false };
} catch {
setCopyFeedback({ id: action.id, failed: true });
feedback = { failed: true };
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
if (copyAttempts.current.get(action.id) !== attempt) return;
const announcement = {
id: action.id,
message: feedback.failed ? `Не удалось скопировать ${action.label}` : `${action.label} скопировано`,
};
const pendingTimer = copyTimers.current.get(action.id);
if (pendingTimer) clearTimeout(pendingTimer);
setCopyFeedback((current) => ({ ...current, [action.id]: feedback }));
setCopyAnnouncement(announcement);
copyTimers.current.set(action.id, setTimeout(() => {
setCopyFeedback((current) => {
const next = { ...current };
delete next[action.id];
return next;
});
setCopyAnnouncement((current) => current?.id === action.id ? null : current);
copyTimers.current.delete(action.id);
copyAttempts.current.delete(action.id);
}, 800));
}
return (
@@ -106,7 +132,7 @@ function InstructionBlock({
: <code>{block.code}</code>)}
{block.copies && <div className="client-instruction-copies">
{block.copies.map((action) => {
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
const feedback = copyFeedback[action.id];
return <div className="client-instruction-copy" key={action.id}>
<span>{action.label}</span>
<button
@@ -122,7 +148,7 @@ function InstructionBlock({
</div>;
})}
<span className="client-live-region" role="status" aria-live="polite">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
{copyAnnouncement?.message || ''}
</span>
</div>}
{block.note && <p className="client-instruction-note">{block.note}</p>}
@@ -413,35 +413,40 @@ export function SubscriptionPanel({ feature, statusSlot, serverSlot }: Subscript
inert={editing ? true : undefined}
>
<div className="client-subscription-heading">
<span className="client-subscription-label">Ваша подписка</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-refresh"
type="button"
aria-label="Обновить подписку"
disabled={refreshing || refreshBlocked}
onClick={feature.refresh}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
</button>
<CloudTooltip>Обновить подписку</CloudTooltip>
<span className="client-subscription-status">
<i aria-hidden="true" />
Сохранена
</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
disabled={deleteBlocked}
onClick={feature.requestDelete}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
</svg>
</button>
<CloudTooltip>Удалить подписку</CloudTooltip>
<span className="client-subscription-actions">
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-refresh"
type="button"
aria-label="Обновить подписку"
disabled={refreshing || refreshBlocked}
onClick={feature.refresh}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
</button>
<CloudTooltip>Обновить подписку</CloudTooltip>
</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
disabled={deleteBlocked}
onClick={feature.requestDelete}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
</svg>
</button>
<CloudTooltip>Удалить подписку</CloudTooltip>
</span>
</span>
</div>
<button
@@ -450,6 +455,7 @@ export function SubscriptionPanel({ feature, statusSlot, serverSlot }: Subscript
tabIndex={editing ? -1 : 0}
onClick={feature.edit}
>
<span className="client-subscription-label">Подписка</span>
<strong>{subscriptionDomain(subscription?.host)}</strong>
</button>
</div>
@@ -508,11 +514,13 @@ export function SubscriptionPanel({ feature, statusSlot, serverSlot }: Subscript
{hasSubscription && contentReady && hasUsage && (
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
<span>Использовано</span>
<strong>
{formatBytes(displayedUsed)}
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
</strong>
<div className="client-usage-summary">
<span>Использовано</span>
<strong>
{formatBytes(displayedUsed)}
<small> из {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
</strong>
</div>
{usage.percent !== null && (
<div
className="client-usage-bar"
@@ -527,10 +535,11 @@ export function SubscriptionPanel({ feature, statusSlot, serverSlot }: Subscript
)}
<div className="client-usage-details">
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
<span>
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
</span>
<>
<span>Действует до</span>
<strong>{usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}</strong>
<small>{subscriptionDaysLeft(usage.expiresAt)}</small>
</>
)}
</div>
</section>
+23 -3
View File
@@ -8,7 +8,7 @@
grid-column: 2;
display: grid;
justify-items: center;
gap: 18px;
gap: 12px;
text-align: center;
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@@ -36,6 +36,22 @@
border-radius: 50%;
}
.client-power-section.is-gateway .client-power-control,
.client-power-section.is-gateway .client-power {
width: 68px;
height: 68px;
}
.client-power-section.is-gateway .client-power svg {
width: 30px;
height: 30px;
}
.client-power-section.is-gateway .client-power::before {
width: 44px;
height: 44px;
}
.client-power-control:focus-visible {
outline: 1px solid color-mix(in oklch, var(--client-accent) 64%, transparent);
outline-offset: 5px;
@@ -188,7 +204,7 @@
min-height: 44px;
display: grid;
place-items: center;
margin-top: 8px;
margin-top: 0;
}
.client-state-detail > * {
@@ -209,6 +225,10 @@
transition: opacity 260ms ease, filter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-power-section.is-gateway .client-duration {
font-size: 15px;
}
.client-duration.is-active {
opacity: 1;
filter: blur(0);
@@ -324,7 +344,7 @@
justify-items: center;
gap: 5px;
width: 240px;
margin-top: 6px;
margin-top: 10px;
}
.client-proxies.is-gateway {
+90 -10
View File
@@ -746,12 +746,27 @@
stroke: var(--client-accent);
}
.client-device-traffic-lines .is-download {
stroke: var(--client-accent);
}
.client-device-traffic-lines .is-upload {
stroke: color-mix(in oklch, var(--client-accent) 42%, var(--client-text));
stroke-dasharray: 5 4;
}
.client-device-traffic-lines path.is-new {
stroke-dasharray: 1;
stroke-dashoffset: 1;
animation: client-device-traffic-line-draw 620ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.client-device-traffic-lines path.is-new.is-upload {
stroke-dasharray: 0.08 0.05;
stroke-dashoffset: 0;
animation: none;
}
.client-device-traffic-lines .is-point {
stroke-width: 3;
stroke-linecap: round;
@@ -775,6 +790,14 @@
stroke: var(--client-accent);
}
.client-device-traffic-cursor .is-point.is-download {
stroke: var(--client-accent);
}
.client-device-traffic-cursor .is-point.is-upload {
stroke: color-mix(in oklch, var(--client-accent) 42%, var(--client-text));
}
.client-device-traffic-time {
grid-column: 2;
grid-row: 2;
@@ -817,6 +840,10 @@
color: var(--client-accent);
}
.client-device-traffic-point-tooltip .is-upload {
color: color-mix(in oklch, var(--client-accent) 56%, oklch(0.92 0.008 145));
}
@keyframes client-device-traffic-shift {
from { opacity: 0.72; transform: translateX(calc(100% / var(--sample-count))); }
to { opacity: 1; transform: translateX(0); }
@@ -951,14 +978,14 @@
grid-column: 1 / -1;
grid-row: 2;
justify-self: center;
width: min(480px, calc(100% - 24px));
width: min(860px, calc(100% - 48px));
display: grid;
gap: 6px;
gap: 10px;
text-align: center;
}
.client-gateway-summary-kicker,
.client-gateway-traffic-heading > span {
.client-gateway-traffic-total > small {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
@@ -967,29 +994,59 @@
}
.client-gateway-traffic-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: end;
gap: 20px;
text-align: left;
}
.client-gateway-traffic-heading strong {
.client-gateway-traffic-total {
display: flex;
align-items: baseline;
gap: 10px;
white-space: nowrap;
}
.client-gateway-traffic-total > small {
display: block;
}
.client-gateway-traffic-total > strong {
color: var(--client-text);
font-size: 15px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.client-gateway-traffic-speed {
min-width: 0;
display: flex;
justify-self: end;
gap: 18px;
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.client-gateway-traffic-speed .is-download {
color: var(--client-accent);
}
.client-gateway-traffic-speed .is-upload {
color: color-mix(in oklch, var(--client-accent) 48%, var(--client-text));
}
.client-gateway-traffic-chart {
width: 100%;
min-height: 88px;
min-height: 180px;
}
.client-gateway-traffic-chart .client-device-traffic-chart {
grid-column: auto;
grid-row: auto;
width: 100%;
height: 88px;
height: 180px;
}
.client-gateway-traffic-freshness {
@@ -1002,3 +1059,26 @@
.client-gateway-traffic-freshness.is-stale {
color: oklch(0.68 0.14 72);
}
@media (max-width: 560px) {
.client-gateway-summary {
width: min(100%, calc(100vw - 52px));
}
.client-gateway-traffic-heading {
grid-template-columns: 1fr;
gap: 6px;
}
.client-gateway-traffic-speed {
justify-self: start;
gap: 14px;
font-size: 10px;
}
.client-gateway-traffic-chart,
.client-gateway-traffic-chart .client-device-traffic-chart {
min-height: 130px;
height: 130px;
}
}
+94 -25
View File
@@ -1,6 +1,6 @@
.client-icon-tooltip {
width: 16px;
height: 16px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
}
@@ -23,8 +23,8 @@
.client-subscription-refresh,
.client-subscription-delete {
width: 16px;
height: 16px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
@@ -247,9 +247,37 @@
}
.client-subscription-heading {
display: flex;
width: 100%;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 64px;
align-items: center;
gap: 8px;
}
.client-subscription-status {
grid-column: 2;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--client-accent);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-subscription-status i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.client-subscription-actions {
grid-column: 3;
display: flex;
justify-content: flex-end;
}
.client-subscription-domain-button {
@@ -264,6 +292,11 @@
cursor: pointer;
}
.client-subscription-domain-button .client-subscription-label {
display: block;
margin-bottom: 3px;
}
.client-subscription-summary {
min-width: 0;
max-width: 100%;
@@ -272,11 +305,11 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
gap: 5px;
padding: 0 2px;
color: var(--client-muted);
text-align: center;
cursor: pointer;
cursor: default;
overflow: visible;
}
@@ -288,19 +321,21 @@
}
.client-subscription-label {
font-size: 12px;
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.1em;
}
.client-subscription-domain-button strong {
display: block;
overflow: hidden;
color: var(--client-text);
font-size: 28px;
font-weight: 600;
letter-spacing: -0.055em;
opacity: 0.88;
font-size: 30px;
font-weight: 650;
letter-spacing: -0.05em;
opacity: 0.96;
text-shadow: 0 0 20px color-mix(in oklch, var(--client-accent) 24%, transparent);
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
text-overflow: ellipsis;
@@ -314,24 +349,32 @@
}
.client-usage {
width: min(100%, 250px);
width: min(100%, 280px);
min-height: 76px;
display: grid;
gap: 7px;
margin: -10px auto 0;
align-content: start;
gap: 9px;
margin: -6px auto 0;
color: var(--client-muted);
text-align: center;
}
.client-usage > span {
.client-usage-summary {
display: grid;
gap: 3px;
}
.client-usage-summary > span,
.client-usage-details > span {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-usage > strong {
.client-usage-summary > strong {
color: var(--client-text);
font-size: 14px;
font-size: 16px;
font-weight: 600;
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
}
@@ -340,7 +383,7 @@
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-usage.is-updated > strong {
.client-usage.is-updated .client-usage-summary > strong {
color: var(--client-accent);
filter: brightness(1.25);
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
@@ -358,7 +401,7 @@
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
}
.client-usage > strong small {
.client-usage-summary > strong small {
color: var(--client-muted);
font-size: 10px;
font-weight: 500;
@@ -379,13 +422,33 @@
}
.client-usage-details {
display: flex;
min-height: 24px;
display: grid;
grid-template-columns: auto auto;
justify-content: center;
gap: 12px;
align-items: baseline;
column-gap: 6px;
font-size: 9px;
white-space: nowrap;
}
.client-usage-details > span {
grid-column: 1 / -1;
font-size: 8px;
opacity: 0.72;
}
.client-usage-details > strong {
color: var(--client-text);
font-size: 10px;
font-weight: 600;
}
.client-usage-details > small {
color: var(--client-muted);
font-size: 9px;
}
.client-inline-error.is-subscription {
top: calc(100% + 2px);
}
@@ -419,3 +482,9 @@
transform: translateY(-2px);
}
}
@media (prefers-reduced-motion: reduce) {
.client-usage-summary > strong {
transition: none;
}
}
+44 -3
View File
@@ -189,6 +189,36 @@
gap: 9px;
}
.harbor-brand-content > svg,
.harbor-brand-name > strong,
.harbor-brand-name > em,
.harbor-mode-stack {
transition: filter 760ms ease, text-shadow 760ms ease, transform 760ms cubic-bezier(0.16, 1, 0.3, 1);
transform: translateY(0);
}
.harbor-brand-name > strong,
.harbor-brand-name > em {
display: inline-block;
}
.harbor-brand.is-connected .harbor-brand-content > svg {
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent));
}
.harbor-brand.is-connected .harbor-brand-content > svg,
.harbor-brand.is-connected .harbor-brand-name > strong,
.harbor-brand.is-connected .harbor-brand-name > em,
.harbor-brand.is-connected .harbor-mode-stack {
transform: translateY(-4px);
}
.harbor-brand.is-connected .harbor-brand-name > strong,
.harbor-brand.is-connected .harbor-brand-name > em,
.harbor-brand.is-connected .harbor-mode-stack {
text-shadow: 0 0 12px color-mix(in oklch, var(--client-accent) 34%, transparent);
}
.client-shell.is-intro .harbor-brand-content {
animation: harbor-startup-brand 760ms cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -367,6 +397,10 @@
animation: harbor-mode-arrows-discovered 260ms 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.harbor-brand.is-connected .harbor-mode-swap {
transform: translateY(calc(-50% - 4px));
}
@keyframes harbor-mode-arrows-discovered {
from {
opacity: 0;
@@ -519,8 +553,9 @@
}
.client-panel.is-gateway-home {
grid-template-rows: minmax(0, 1fr) auto;
row-gap: 28px;
min-height: max(620px, calc(100dvh - 80px));
grid-template-rows: minmax(260px, 1fr) auto;
row-gap: 36px;
}
.client-gateway-route-summary {
@@ -558,7 +593,7 @@
}
.client-panel.is-gateway-home .client-state-copy {
min-height: 136px;
min-height: 84px;
}
@media (min-width: 921px) {
@@ -590,6 +625,12 @@
padding: 110px 6px 72px;
}
.client-panel.is-gateway-home {
min-height: calc(100dvh - 40px);
grid-template-rows: minmax(320px, 1fr) auto;
gap: 32px;
}
.client-power-section,
.client-form,
.client-panel.is-setup .client-form {
+12
View File
@@ -118,6 +118,11 @@
stroke-dashoffset: 0;
}
.client-device-traffic-lines path.is-new {
stroke-dashoffset: 0;
animation: none;
}
.client-local-rules,
.client-local-rules-toggle,
.client-local-rules-toggle svg,
@@ -151,6 +156,13 @@
animation: none;
}
.harbor-brand-content > svg,
.harbor-brand-name > strong,
.harbor-brand-name > em,
.harbor-mode-stack {
transition: none;
}
.client-shell.is-intro .client-panel,
.client-shell.is-intro .client-secondary-menu,
.client-shell.is-intro .harbor-versions {
+21 -13
View File
@@ -94,21 +94,29 @@ export async function copyText(text: string, options: {
const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
const documentRef = options.documentRef ?? globalThis.document;
if (documentRef?.execCommand) {
const textarea = documentRef.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
documentRef.body.append(textarea);
textarea.select();
const copied = documentRef.execCommand('copy');
textarea.remove();
if (copied) return;
if (clipboard?.writeText) {
try {
await clipboard.writeText(text);
return;
} catch {
// Fall through for Gateway pages served over HTTP.
}
}
if (!clipboard?.writeText) throw new Error('Copy failed');
await clipboard.writeText(text);
if (!documentRef?.execCommand) throw new Error('Copy failed');
const textarea = documentRef.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
try {
documentRef.body.append(textarea);
textarea.select();
if (documentRef.execCommand('copy')) return;
} finally {
textarea.remove();
}
throw new Error('Copy failed');
}
export function subscriptionUsage(userInfo: unknown = {}) {
+7
View File
@@ -39,6 +39,13 @@ export function positiveByteDelta(previous: unknown, current: unknown) {
return after > before ? formatByteString((after - before).toString()) : '';
}
export function trafficBytesPerSecond(value: unknown, previousObservedAt: unknown, observedAt: unknown) {
const elapsed = Date.parse(String(observedAt || '')) - Date.parse(String(previousObservedAt || ''));
return Number.isFinite(elapsed) && elapsed > 0
? byteString(value) * 1000n / BigInt(elapsed)
: 0n;
}
export function trafficScaleRatio(value: unknown, maxValue: unknown, scale: 'linear' | 'log' = 'linear') {
const current = byteString(value);
const max = byteString(maxValue);
+8
View File
@@ -267,6 +267,8 @@ test('device traffic totals persist exact deltas across polls and process epochs
assert.deepEqual(snapshot.traffic, {
gatewayBytes: '9007199254741093',
proxyBytes: '3000',
uploadBytes: '9007199254741993',
downloadBytes: '2100',
totalBytes: '9007199254744093',
gatewayObservedAt: observedAt,
proxyObservedAt: observedAt,
@@ -295,6 +297,8 @@ test('device traffic totals persist exact deltas across polls and process epochs
assert.equal(snapshot.devices[0].proxyDownloadBytes, '2050');
assert.equal(snapshot.traffic.gatewayBytes, '9007199254741145');
assert.equal(snapshot.traffic.proxyBytes, '3060');
assert.equal(snapshot.traffic.uploadBytes, '9007199254742005');
assert.equal(snapshot.traffic.downloadBytes, '2200');
service = createService();
snapshot = await service.refresh();
@@ -398,11 +402,15 @@ test('device traffic history stays in bounded service memory and survives client
observedAt,
gatewayBytes: '30',
proxyBytes: '5',
uploadBytes: '30',
downloadBytes: '5',
}]);
assert.deepEqual(snapshot.traffic.history, [{
observedAt,
gatewayBytes: '30',
proxyBytes: '5',
uploadBytes: '30',
downloadBytes: '5',
}]);
assert.deepEqual(service.snapshot().devices[0].trafficHistory, snapshot.devices[0].trafficHistory);
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /trafficHistory/);
+68
View File
@@ -93,6 +93,74 @@ test('copy uses the synchronous native path available on gateway HTTP', async ()
assert.equal(textarea.removed, true);
});
test('copy prefers the Clipboard API when it is available', async () => {
const writes = [];
let legacyCalls = 0;
await copyText('socks5://127.0.0.1:8082', {
clipboard: { writeText: async (text) => writes.push(text) },
documentRef: { execCommand: () => { legacyCalls += 1; } },
});
assert.deepEqual(writes, ['socks5://127.0.0.1:8082']);
assert.equal(legacyCalls, 0);
});
test('copy falls back after the Clipboard API rejects', async () => {
const textarea = {
style: {},
setAttribute() {},
select() {},
remove() { this.removed = true; },
};
const documentRef = {
body: { append() {} },
createElement: () => textarea,
execCommand: () => true,
};
await copyText('192.168.50.111', {
clipboard: { writeText: async () => { throw new Error('denied'); } },
documentRef,
});
assert.equal(textarea.removed, true);
});
test('copy rejects and removes the temporary textarea when the fallback fails', async () => {
const textarea = {
style: {},
setAttribute() {},
select() {},
remove() { this.removed = true; },
};
const documentRef = {
body: { append() {} },
createElement: () => textarea,
execCommand: () => false,
};
await assert.rejects(copyText('192.168.50.111', { documentRef }), /Copy failed/);
assert.equal(textarea.removed, true);
});
test('copy removes the temporary textarea when the fallback throws', async () => {
const textarea = {
style: {},
setAttribute() {},
select() {},
remove() { this.removed = true; },
};
const documentRef = {
body: { append() {} },
createElement: () => textarea,
execCommand: () => { throw new Error('legacy failed'); },
};
await assert.rejects(copyText('192.168.50.111', { documentRef }), /legacy failed/);
assert.equal(textarea.removed, true);
});
test('subscription usage combines traffic and caps progress', () => {
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
upload: 30,
+5 -2
View File
@@ -15,7 +15,7 @@ test('connection feature is the sole always-mounted power panel owner', () => {
assert.equal((page.match(/<ConnectionPanel/g) || []).length, 1);
assert.match(page, /<main[\s\S]*<ConnectionPanel[\s\S]*<GatewayTrafficSummary/);
assert.doesNotMatch(page, /client-power-section|const powerButton|function toggleConnection|confirmingStop|id="stop-connection"|DURATION_MODE_STORAGE_KEY/);
assert.match(panel, /\{visible && <section className="client-power-section"/);
assert.match(panel, /\{visible && <section className=\{`client-power-section\$\{isGateway \? ' is-gateway' : ''\}`\}/);
assert.match(panel, /<ConfirmationDialog[\s\S]*open=\{confirmingStop\}/);
assert.ok(panel.indexOf('<ConfirmationDialog') > panel.indexOf('{visible && <section'), 'dialog remains mounted outside the visible section');
});
@@ -27,7 +27,7 @@ test('connection feature preserves actions, local preference and opaque neighbor
assert.match(panel, /action\?\.type === 'stop'[\s\S]*action\?\.type === 'apply'[\s\S]*action\?\.type === 'restart'/);
assert.match(panel, /if \(!await onStop\(\)\) return;[\s\S]*setConfirmingStop\(false\)/);
assert.match(panel, /localStorage\.getItem\(DURATION_MODE_STORAGE_KEY\) === 'words'[\s\S]*localStorage\.setItem\(DURATION_MODE_STORAGE_KEY, nextMode\)/);
assert.match(panel, /\{routingSlot\}[\s\S]*client-state-copy[\s\S]*\{serverSlot\}[\s\S]*client-state-detail[\s\S]*client-proxies[\s\S]*\{statusSlot\}/);
assert.match(panel, /client-state-detail[\s\S]*\{routingSlot\}[\s\S]*client-state-copy[\s\S]*\{serverSlot\}[\s\S]*client-proxies[\s\S]*\{statusSlot\}/);
assert.match(page, /routingSlot=\{<RoutingPendingStatus[\s\S]*blocked=\{connectionBlocked\}[\s\S]*onRestart=\{onRestart\}/);
assert.match(routing, /client-route-rules-pending[\s\S]*Перезапустить VPN/);
assert.match(page, /serverSlot=\{isGateway && <div className="client-gateway-route-summary"/);
@@ -41,6 +41,9 @@ test('shared clock, copy feedback and live announcement stay single-owned by the
assert.doesNotMatch(panel, /const \[copyFeedback, setCopyFeedback\]/);
assert.equal((pageBody.match(/className="client-live-region"/g) || []).length, 1);
assert.match(page, /onCopyProxy=\{copyProxy\}/);
assert.match(page, /copyTimersRef = useRef<Partial<Record<CopyKind[\s\S]*copyTimersRef\.current\[kind\]/);
assert.match(page, /copyAttemptsRef = useRef<Partial<Record<CopyKind[\s\S]*copyAttemptsRef\.current\[kind\] = attempt[\s\S]*await copyText\(value\)[\s\S]*copyAttemptsRef\.current\[kind\] !== attempt[\s\S]*const pendingTimer = copyTimersRef\.current\[kind\][\s\S]*setCopyFeedback/);
assert.match(panel, /const feedback = copyFeedback\?\.\[kind\][\s\S]*key=\{feedback\.cycle\}/);
assert.match(panel, /localProxyUrls\(proxyPort, gatewayAddress\)/);
assert.match(panel, /onClick=\{\(\) => onCopyProxy\(kind\)\}/);
});
+20 -8
View File
@@ -10,6 +10,7 @@ import {
sortDevicesByTraffic,
stabilizeDevicesByTraffic,
trafficAxisMid,
trafficBytesPerSecond,
trafficScaleRatio,
} from '../../.test-dist/src/web/utils/format.js';
import { readStyleSource } from './style-source.js';
@@ -63,7 +64,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /pencilAnimationId === device\.id \? ' is-writing'/);
assert.match(panel, /onAnimationEnd=\{\(\) => setPencilAnimationId/);
assert.match(panel, /COPY_FEEDBACK_MS = 800/);
assert.match(panel, /IP скопирован/);
assert.match(panel, /IP \$\{device\.ip\} скопирован/);
assert.doesNotMatch(panel, /client-device-name-feedback|client-device-name-primary/);
assert.doesNotMatch(panel, /client-device-title/);
assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/);
@@ -95,11 +96,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(chart, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
assert.match(chart, /onPointerMove=\{trackPointer\}/);
assert.match(chart, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
assert.match(chart, /client-device-traffic-grid[\s\S]*path className="is-gateway"[\s\S]*path className="is-proxy"/);
assert.match(chart, /client-device-traffic-cursor[\s\S]*className="is-point is-gateway"[\s\S]*className="is-point is-proxy"/);
assert.match(chart, /client-device-traffic-lines[\s\S]*series === 'speed' \? 'is-download' : 'is-gateway'[\s\S]*series === 'speed' \? 'is-upload' : 'is-proxy'/);
assert.match(chart, /client-device-traffic-cursor[\s\S]*`is-point \$\{series === 'speed' \? 'is-download' : 'is-gateway'\}`[\s\S]*`is-point \$\{series === 'speed' \? 'is-upload' : 'is-proxy'\}`/);
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
assert.match(chart, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
assert.match(chart, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
assert.match(chart, /<time dateTime=\{visibleSamples\[0\]\.observedAt\}>[\s\S]*series === 'speed' \? '↓ \/ ↑' : '15 с'/);
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
@@ -136,6 +137,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(calc\(100% \/ var\(--sample-count\)\)\)[\s\S]*translateX\(0\)/);
assert.match(styles, /@keyframes client-device-traffic-line-draw[\s\S]*stroke-dashoffset: 0/);
assert.match(styles, /\.client-device-traffic-lines \.is-upload \{[\s\S]*stroke-dasharray: 5 4/);
assert.match(styles, /\.client-device-traffic-lines path\.is-new\.is-upload \{[\s\S]*stroke-dashoffset: 0;[\s\S]*animation: none/);
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: 700 14px\/1\.2/);
@@ -165,13 +168,20 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*\.client-device-traffic-lines path\.is-new \{[\s\S]*stroke-dashoffset: 0;[\s\S]*animation: none/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
});
test('device copy feedback stays keyed per device', () => {
assert.match(panel, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(device\.id\)[\s\S]*copyTimers\.current\.set\(device\.id/);
assert.match(panel, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(device\.id, attempt\)[\s\S]*await copyText\(device\.ip\)[\s\S]*copyAttempts\.current\.get\(device\.id\) !== attempt/);
assert.match(panel, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[device\.id\]: feedback \}\)\)/);
assert.match(panel, /const feedback = copyFeedback\[device\.id\]/);
});
test('Gateway Home reuses the canonical device snapshot for applied route and global traffic', () => {
const powerStart = connection.indexOf('<section className="client-power-section"');
const powerStart = connection.indexOf('<section className={`client-power-section');
const trafficStart = overview.indexOf('<GatewayTrafficSummary');
const connectionPanelStart = overview.indexOf('<ConnectionPanel');
@@ -179,8 +189,8 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.match(overview, /appliedServer\?\.label \|\| 'VPN-сервер не используется'/);
assert.match(overview, /selectedServerId !== appliedServerId[\s\S]*Переключаем на \{desiredServer\.label\}/);
assert.match(feature, /formatByteString\(globalTraffic\?\.totalBytes \|\| '0'\)/);
assert.match(feature, /samples=\{globalTraffic\?\.history \|\| \[\]\}[\s\S]*capacity=\{feature\.snapshot\?\.trafficHistoryCapacity \|\| 120\}[\s\S]*routeLabel="Gateway"/);
assert.match(connection, /<section className="client-power-section"[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-state-detail[\s\S]*client-proxies/);
assert.match(feature, /const history = globalTraffic\?\.history \|\| \[\][\s\S]*samples=\{history\}[\s\S]*routeLabel="Gateway"[\s\S]*series="speed"/);
assert.match(connection, /<section className=\{`client-power-section[\s\S]*client-state-detail[\s\S]*client-connection-title[\s\S]*\{serverSlot\}[\s\S]*client-proxies/);
assert.ok(powerStart >= 0 && connectionPanelStart >= 0 && trafficStart > connectionPanelStart, 'traffic summary follows the connection panel');
assert.equal((feature.match(/className="client-gateway-summary"/g) || []).length, 1);
assert.doesNotMatch(feature, /<TrafficChart[\s\S]{0,240}scale=/);
@@ -225,6 +235,8 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
assert.equal(Math.round(trafficScaleRatio('9', '99', 'log') * 100), 50);
assert.equal(trafficAxisMid('100', 'linear'), 50n);
assert.equal(trafficAxisMid('99', 'log'), 9n);
assert.equal(trafficBytesPerSecond('15000', '2026-08-07T13:00:00.000Z', '2026-08-07T13:00:15.000Z'), 1000n);
assert.equal(trafficBytesPerSecond('15000', 'invalid', '2026-08-07T13:00:15.000Z'), 0n);
const devices = [
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },
@@ -30,7 +30,9 @@ test('unconditional controller and conditional panel preserve lifecycle and rese
assert.doesNotMatch(page, /if \(!instructionsAvailable\)|instructionsAvailable/);
assert.match(feature, /const \[openInstructionId, setOpenInstructionId\] = useState\(''\)/);
assert.match(feature, /function InstructionBlock[\s\S]*const \[copyFeedback, setCopyFeedback\] = useState/);
assert.match(feature, /clearTimeout\(copyTimer\.current\)[\s\S]*setTimeout\(\(\) => setCopyFeedback\(null\), 800\)/);
assert.match(feature, /copyTimers = useRef\(new Map[\s\S]*copyTimers\.current\.get\(action\.id\)[\s\S]*copyTimers\.current\.set\(action\.id/);
assert.match(feature, /copyAttempts = useRef\(new Map[\s\S]*copyAttempts\.current\.set\(action\.id, attempt\)[\s\S]*await copyText\(action\.text\)[\s\S]*copyAttempts\.current\.get\(action\.id\) !== attempt/);
assert.match(feature, /setCopyFeedback\(\(current\) => \(\{ \.\.\.current, \[action\.id\]: feedback \}\)\)/);
assert.match(feature, /requestAnimationFrame\(\(\) => closeRef\.current\?\.focus\(\)\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
assert.match(feature, /addEventListener\('pointerdown', closeOutside\)/);
assert.match(feature, /flushSync[\s\S]*prefers-reduced-motion: reduce[\s\S]*document\.startViewTransition\(update\)/);
+5 -2
View File
@@ -39,8 +39,11 @@ test('desktop layout keeps the power control on a symmetric center axis', () =>
assert.match(styles, /\.client-panel\.has-subscription \{[\s\S]*transform:\s*translateY\(-9vh\)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-power-section \{[\s\S]*height:\s*var\(--client-work-height\);[\s\S]*padding-top:\s*var\(--client-power-top\)/);
assert.match(styles, /\.client-panel\.has-subscription \.client-form-content \{[\s\S]*padding-top:\s*var\(--client-power-top\)/);
assert.match(rule('.client-panel.is-gateway-home'), /grid-template-rows:\s*minmax\(0, 1fr\) auto[\s\S]*row-gap:\s*28px/);
assert.match(rule('.client-gateway-summary'), /grid-column:\s*1 \/ -1[\s\S]*grid-row:\s*2[\s\S]*justify-self:\s*center[\s\S]*width:\s*min\(480px, calc\(100% - 24px\)\)/);
assert.match(rule('.client-panel.is-gateway-home'), /min-height:\s*max\(620px, calc\(100dvh - 80px\)\)[\s\S]*grid-template-rows:\s*minmax\(260px, 1fr\) auto[\s\S]*row-gap:\s*36px/);
assert.match(rule('.client-gateway-summary'), /grid-column:\s*1 \/ -1[\s\S]*grid-row:\s*2[\s\S]*justify-self:\s*center[\s\S]*width:\s*min\(860px, calc\(100% - 48px\)\)/);
assert.match(styles, /\.client-power-section\.is-gateway \.client-power-control,[\s\S]*width:\s*68px;[\s\S]*height:\s*68px/);
assert.match(rule('.client-gateway-traffic-chart'), /min-height:\s*180px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-gateway-traffic-chart,[\s\S]*min-height:\s*130px;[\s\S]*height:\s*130px/);
assert.doesNotMatch(rule('.client-gateway-summary'), /background|border|box-shadow/);
assert.match(rule('.client-gateway-route-slot'), /min-height:\s*16px[\s\S]*overflow:\s*hidden[\s\S]*text-overflow:\s*ellipsis[\s\S]*white-space:\s*nowrap/);
assert.match(component, /client-panel\$\{showPower \? '' : ' is-setup'\}[\s\S]*is-gateway-home/);
+1 -1
View File
@@ -84,7 +84,7 @@ test('copy feedback, drawers and Gateway access actions expose complete semantic
assert.match(component, /className="client-live-region" role="status" aria-live="polite" aria-atomic="true"/);
assert.match(component, /Не удалось скопировать/);
assert.match(component, /Скопировано/);
assert.match(connection, /client-copy-feedback[^\n]*\{copyFeedback\.failed \? 'Ошибка' : 'Скопировано'\}/);
assert.match(connection, /client-copy-feedback[^\n]*\{feedback\.failed \? 'Ошибка' : 'Скопировано'\}/);
assert.doesNotMatch(component, /ГОТОВО/);
assert.doesNotMatch(component, />Error<|>Copied</);
assert.match(instructions, /aria-label="Закрыть инструкции"/);
+15 -15
View File
@@ -36,26 +36,26 @@ const expectedImports = [
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
const acceptedLedger = {
counts: {
cascadeEdges: 742,
cascadeEdges: 780,
customProperties: 31,
declarations: 2793,
declarations: 2885,
important: 0,
keyframes: 48,
media: 8,
rules: 824,
variableReferences: 323,
media: 10,
rules: 861,
variableReferences: 339,
},
hashes: {
cascadeEdges: '9e85cea58c179358dac8767ed987433e3e1f4974324999dba1803a49cbd6f4f6',
cascadeEdges: '7d47b1c26e06748e4532ce9ba7229e9a9bab4058b9c615129ce668aa7e906714',
customProperties: 'c7dd331e4bad898c450568999d8c9c6837e275a79c365c7680e143026fde4545',
declarations: '2e733c57dc0d89369e46a0eb44c1d67d09d1d40c28f7c36813c5fec607539be7',
declarations: 'ea140d4e00166daba7dc92fabb2ef63afd2ed3cb9d6c0dadeaf346bbf1eddc32',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '1565bf06e07fd7cbf601d24846bb3e1059d06720ace1544f2ac7f6ecf36cab47',
keyframes: '853c54c05759d9db27bea891254913e1651b1f601059ad9e3e2baa2c55ef1b2b',
ruleDeclarationSequences: '4a741c5ee09f9cd694e058609b4362cf796c97009d83886c5c8b4b4fa4d73b52',
selectors: '2da3202f84a79a8cf68e0cf262d36aa4b7756268133135469c086317dcbb82ff',
variableReferences: 'e8dc6951d717dbd6a9aa51a495798a56568b00ce0bbd7fb6edc7c758a81ddb6e',
witnesses: '68723a5909eb1a0972a70cde2be75e63e90abda61babe98db713414bda6a0c23',
ruleDeclarationSequences: '1a231e3068345d08f1670b64e5b2db91abbdb7083f164d3e96f073d4ae561bdb',
selectors: '824d578e36c3ae25a39bc686494635da353ea45996633eed01f93132dfb4dabd',
variableReferences: 'da4298e47c6249c3cfa12168d3670b0ec9c92c0fca7033ba968ea70598753a65',
witnesses: '6d498098d34d0b9fe399e457ece265456d99b4351a46a534e1b4fe5e4620e90f',
},
};
@@ -110,7 +110,7 @@ test('tokens, shared primitives, and feature styles have one explicit owner', ()
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
const witnesses = readStyleWitnesses(root);
assert.equal(witnesses.length, 686);
assert.equal(witnesses.length, 707);
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
assert.deepEqual(ledger.counts, acceptedLedger.counts);
@@ -293,8 +293,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
assert.deepEqual(assets, ['index-jheYW1hW.css']);
assert.deepEqual(assets, ['index-ZmHjygJv.css']);
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
assert.equal(built.byteLength, 103299);
assert.equal(sha256(built), '3acfedf526a1d6e867e825692b1dbdf55896d481a3ec19d97b513dd7704ae291');
assert.equal(built.byteLength, 107497);
assert.equal(sha256(built), '40412db73b9c6cd2f2f49f4a2b6cdf2130253a12f4d0d0e4a37277017933421c');
});
@@ -9,6 +9,7 @@ const root = path.resolve(import.meta.dirname, '../..');
const page = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.tsx'), 'utf8');
const app = fs.readFileSync(path.join(root, 'src/web/App.tsx'), 'utf8');
const feature = fs.readFileSync(path.join(root, 'src/web/features/subscription/SubscriptionFeature.tsx'), 'utf8');
const styles = fs.readFileSync(path.join(root, 'src/web/styles/features/subscription.css'), 'utf8');
const boundary = fs.readFileSync(path.join(root, 'src/web/features/subscription/index.ts'), 'utf8');
test('subscription feature is the sole always-mounted lifecycle and view owner', () => {
@@ -86,3 +87,14 @@ test('feature keeps exact slots, truthy closes and subscription DOM order', () =
assert.match(page, /subscriptionFeature\.close\(\)[\s\S]*devicesFeature\.close\(\)[\s\S]*diagnosticsFeature\.close\(\)/);
assert.doesNotMatch(feature, /setInterval|copyText|client-live-region/);
});
test('saved subscription stays one clear shared presentation', () => {
assert.equal((feature.match(/client-subscription-summary/g) || []).length, 1);
assert.match(feature, /client-subscription-heading[\s\S]*client-subscription-status[\s\S]*Сохранена[\s\S]*client-subscription-actions[\s\S]*client-subscription-refresh[\s\S]*client-subscription-delete/);
assert.match(feature, /client-subscription-domain-button[\s\S]*client-subscription-label">Подписка[\s\S]*subscriptionDomain\(subscription\?\.host\)/);
assert.match(feature, /client-usage-summary[\s\S]*Использовано[\s\S]* из [\s\S]*client-usage-bar[\s\S]*client-usage-details[\s\S]*Действует до[\s\S]*subscriptionDaysLeft/);
assert.doesNotMatch(feature, /role="tab"|client-subscription-card|subscriptions\.map\(/);
assert.match(styles, /\.client-subscription-heading\s*\{[\s\S]*grid-template-columns:\s*64px minmax\(0, 1fr\) 64px/);
assert.match(styles, /\.client-usage\s*\{[\s\S]*min-height:\s*76px/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-usage-summary > strong[\s\S]*transition:\s*none/);
});