Update VPN proxy client behavior
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user