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
+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>