Add Gateway traffic totals and dashboard chart
This commit is contained in:
@@ -11,13 +11,14 @@ import {
|
||||
subscriptionDaysLeft,
|
||||
subscriptionUsage,
|
||||
} from '../utils/clientControls.js';
|
||||
import { formatBytes } from '../utils/format.js';
|
||||
import { formatBytes, formatByteString, formatLastSeen } from '../utils/format.js';
|
||||
import { instructionBlocks } from '../instructions.js';
|
||||
import { operationBlocked } from '../state/operations.js';
|
||||
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
|
||||
import { DevicesPanel } from './DevicesPanel.jsx';
|
||||
import { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.jsx';
|
||||
import { ServerPicker } from './ServerPicker.jsx';
|
||||
import { TrafficChart } from './TrafficChart.jsx';
|
||||
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
|
||||
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
||||
import {
|
||||
@@ -27,10 +28,11 @@ import {
|
||||
} from '../../shared/versions.js';
|
||||
|
||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||
const DEVICE_AUTO_REFRESH_MS = 15_000;
|
||||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||||
|
||||
function CloudTooltip({ children }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
function CloudTooltip({ children, id }) {
|
||||
return <span className="client-tooltip" id={id} role="tooltip">{children}</span>;
|
||||
}
|
||||
|
||||
const VERSION_PARTS = [
|
||||
@@ -616,8 +618,12 @@ export function ClientOverviewPage({
|
||||
const connected = Boolean(state?.singboxRunning);
|
||||
const hasSubscription = Boolean(state?.hasSubscription);
|
||||
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
||||
const showPower = hasSubscription && Boolean(selectedServerId);
|
||||
const appliedServerId = state?.selection?.appliedServerId || '';
|
||||
const appliedServer = servers.find(({ id }) => id === appliedServerId);
|
||||
const desiredServer = servers.find(({ id }) => id === selectedServerId);
|
||||
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
|
||||
const canStart = Boolean(selectedServerId || state?.configExists);
|
||||
const powerUnavailable = isGateway && !connected && !canStart;
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [durationMode, setDurationMode] = useState(() => {
|
||||
try {
|
||||
@@ -641,9 +647,15 @@ export function ClientOverviewPage({
|
||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||
const [serversLeaving, setServersLeaving] = useState(false);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
const [subscriptionOpen, setSubscriptionOpen] = useState(false);
|
||||
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||||
const [devicesOpen, setDevicesOpen] = useState(false);
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [deviceSnapshot, setDeviceSnapshot] = useState(null);
|
||||
const [deviceStatus, setDeviceStatus] = useState('idle');
|
||||
const [deviceError, setDeviceError] = useState(null);
|
||||
const [devicesRefreshing, setDevicesRefreshing] = useState(false);
|
||||
const [deviceRefreshCycle, setDeviceRefreshCycle] = useState(0);
|
||||
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
||||
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
|
||||
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
|
||||
@@ -656,6 +668,9 @@ export function ClientOverviewPage({
|
||||
const instructionsPanelRef = useRef(null);
|
||||
const instructionsToggleRef = useRef(null);
|
||||
const instructionsCloseRef = useRef(null);
|
||||
const subscriptionPanelRef = useRef(null);
|
||||
const subscriptionToggleRef = useRef(null);
|
||||
const subscriptionCloseRef = useRef(null);
|
||||
const localRulesPanelRef = useRef(null);
|
||||
const localRulesToggleRef = useRef(null);
|
||||
const localRulesCloseRef = useRef(null);
|
||||
@@ -666,7 +681,9 @@ export function ClientOverviewPage({
|
||||
const diagnosticsToggleRef = useRef(null);
|
||||
const diagnosticsCloseRef = useRef(null);
|
||||
const localRulesBaselineRef = useRef('[]');
|
||||
const confirmingDeleteRef = useRef(confirmingDelete);
|
||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||
confirmingDeleteRef.current = confirmingDelete;
|
||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||
const usage = subscriptionUsage(state?.userInfo);
|
||||
@@ -724,14 +741,54 @@ export function ClientOverviewPage({
|
||||
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
|
||||
)).length
|
||||
: 0;
|
||||
const globalTraffic = deviceSnapshot?.traffic;
|
||||
const trafficSourceError = deviceSnapshot?.source?.traffic?.error
|
||||
|| deviceSnapshot?.source?.traffic?.proxy?.error
|
||||
|| (deviceStatus === 'error' ? deviceError : null);
|
||||
const trafficFreshness = globalTraffic?.observedAt
|
||||
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
|
||||
: 'Нет данных';
|
||||
const switchingServer = Boolean(
|
||||
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
|
||||
);
|
||||
|
||||
async function loadDevices(quiet = false, discover = false) {
|
||||
if (!isGateway) return;
|
||||
if (!quiet) setDeviceStatus(deviceSnapshot ? 'refreshing' : 'loading');
|
||||
setDevicesRefreshing(true);
|
||||
try {
|
||||
const next = await (discover ? api.devices.refresh() : api.devices.list());
|
||||
setDeviceSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||
setDeviceError(null);
|
||||
setDeviceStatus('ready');
|
||||
} catch (requestError) {
|
||||
setDeviceError(requestError);
|
||||
setDeviceStatus('error');
|
||||
} finally {
|
||||
setDevicesRefreshing(false);
|
||||
setDeviceRefreshCycle((cycle) => cycle + 1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
if (!connected || !state?.singboxStartedAt) return undefined;
|
||||
if (!isGateway && (!connected || !state?.singboxStartedAt)) return undefined;
|
||||
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [connected, state?.singboxStartedAt]);
|
||||
}, [isGateway, connected, state?.singboxStartedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGateway) return undefined;
|
||||
loadDevices();
|
||||
return undefined;
|
||||
}, [isGateway]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGateway || devicesRefreshing || deviceStatus === 'loading') return undefined;
|
||||
const timer = setTimeout(() => loadDevices(true), DEVICE_AUTO_REFRESH_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isGateway, deviceRefreshCycle, devicesRefreshing, deviceStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||||
@@ -797,12 +854,14 @@ export function ClientOverviewPage({
|
||||
useEffect(() => {
|
||||
if (!hasSubscription) {
|
||||
setEditingSubscription(true);
|
||||
setInstructionsOpen(false);
|
||||
setLocalRulesOpen(false);
|
||||
setDevicesOpen(false);
|
||||
setDiagnosticsOpen(false);
|
||||
if (!isGateway) {
|
||||
setInstructionsOpen(false);
|
||||
setDevicesOpen(false);
|
||||
setDiagnosticsOpen(false);
|
||||
}
|
||||
}
|
||||
}, [hasSubscription]);
|
||||
}, [hasSubscription, isGateway]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
||||
@@ -845,6 +904,29 @@ export function ClientOverviewPage({
|
||||
|
||||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscriptionOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => subscriptionCloseRef.current?.focus());
|
||||
const closeSubscription = (event) => {
|
||||
if (confirmingDeleteRef.current) return;
|
||||
if (event.type === 'keydown' && event.key !== 'Escape') return;
|
||||
if (event.type !== 'keydown' && (
|
||||
subscriptionPanelRef.current?.contains(event.target) || subscriptionToggleRef.current?.contains(event.target)
|
||||
)) return;
|
||||
setSubscriptionOpen(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeSubscription);
|
||||
document.addEventListener('keydown', closeSubscription);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', closeSubscription);
|
||||
document.removeEventListener('keydown', closeSubscription);
|
||||
requestAnimationFrame(() => {
|
||||
if (subscriptionPanelRef.current?.contains(document.activeElement)) subscriptionToggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [subscriptionOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instructionsOpen) return undefined;
|
||||
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
|
||||
@@ -1048,6 +1130,7 @@ export function ClientOverviewPage({
|
||||
|
||||
function openLocalRules() {
|
||||
const rules = state?.route?.localRules || [];
|
||||
setSubscriptionOpen(false);
|
||||
setInstructionsOpen(false);
|
||||
setDevicesOpen(false);
|
||||
setDiagnosticsOpen(false);
|
||||
@@ -1111,9 +1194,26 @@ export function ClientOverviewPage({
|
||||
document.startViewTransition(update);
|
||||
}
|
||||
|
||||
const powerButton = <button
|
||||
className="client-power"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={connected}
|
||||
aria-label={isGateway
|
||||
? connected ? 'Остановить VPN' : 'Запустить VPN'
|
||||
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||||
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
|
||||
disabled={connectionBlocked || (!connected && !canStart)}
|
||||
onClick={toggleConnection}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||
</svg>
|
||||
</button>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro ? ' is-intro' : ''}`}
|
||||
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
|
||||
>
|
||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||
@@ -1126,7 +1226,27 @@ export function ClientOverviewPage({
|
||||
blocked={gatewayAutoBlocked}
|
||||
onSetGatewayAuto={onSetGatewayAuto}
|
||||
/>
|
||||
{hasSubscription && subscriptionContentReady && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||||
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||||
{isGateway && <button
|
||||
ref={subscriptionToggleRef}
|
||||
className={`client-instructions-toggle client-subscription-toggle${subscriptionOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={subscriptionOpen}
|
||||
aria-controls="client-subscription-drawer"
|
||||
aria-label={subscriptionOpen ? 'Закрыть подписку' : 'Управление подпиской'}
|
||||
onClick={() => {
|
||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||
setInstructionsOpen(false);
|
||||
setDevicesOpen(false);
|
||||
setDiagnosticsOpen(false);
|
||||
setSubscriptionOpen((open) => !open);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
|
||||
</svg>
|
||||
<span>Подписка</span>
|
||||
</button>}
|
||||
<button
|
||||
ref={instructionsToggleRef}
|
||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||
@@ -1136,6 +1256,7 @@ export function ClientOverviewPage({
|
||||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
onClick={() => {
|
||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||
setSubscriptionOpen(false);
|
||||
setDevicesOpen(false);
|
||||
setDiagnosticsOpen(false);
|
||||
setInstructionsOpen((open) => !open);
|
||||
@@ -1156,6 +1277,7 @@ export function ClientOverviewPage({
|
||||
aria-label={devicesOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
|
||||
onClick={() => {
|
||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||
setSubscriptionOpen(false);
|
||||
setInstructionsOpen(false);
|
||||
setDiagnosticsOpen(false);
|
||||
setDevicesOpen((open) => !open);
|
||||
@@ -1177,6 +1299,7 @@ export function ClientOverviewPage({
|
||||
aria-label={diagnosticsOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
|
||||
onClick={() => {
|
||||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||
setSubscriptionOpen(false);
|
||||
setInstructionsOpen(false);
|
||||
setDevicesOpen(false);
|
||||
setDiagnosticsOpen((open) => !open);
|
||||
@@ -1192,11 +1315,13 @@ export function ClientOverviewPage({
|
||||
ref={localRulesToggleRef}
|
||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
|
||||
type="button"
|
||||
disabled={gatewayDirect}
|
||||
disabled={gatewayDirect || (isGateway && !hasSubscription)}
|
||||
aria-expanded={localRulesOpen}
|
||||
aria-controls="client-local-rules"
|
||||
aria-label={gatewayDirect
|
||||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||||
aria-label={gatewayDirect || (isGateway && !hasSubscription)
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||||
: 'Локальные правила недоступны: сначала добавьте подписку'
|
||||
: localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
|
||||
>
|
||||
@@ -1205,27 +1330,52 @@ export function ClientOverviewPage({
|
||||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||||
</svg>
|
||||
<span>{gatewayDirect
|
||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||
<span>{gatewayDirect || (isGateway && !hasSubscription)
|
||||
? hasSubscription
|
||||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||||
: 'Сначала добавьте подписку'
|
||||
: localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||
</button>
|
||||
</nav>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${hasSubscription ? ' has-subscription' : ''}`}>
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
|
||||
{isGateway && <section className="client-gateway-summary" aria-labelledby="gateway-summary-title">
|
||||
<span className="client-gateway-summary-kicker">Сейчас</span>
|
||||
<h2 id="gateway-summary-title">
|
||||
{appliedServer?.label || 'VPN-сервер не используется'}
|
||||
</h2>
|
||||
<div className="client-gateway-route-slot" role="status" aria-live="polite">
|
||||
{switchingServer && <span>Переключаем на {desiredServer.label}</span>}
|
||||
</div>
|
||||
<div className="client-gateway-traffic-heading">
|
||||
<span>Учтено Harbor</span>
|
||||
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
|
||||
</div>
|
||||
<div className="client-gateway-traffic-chart">
|
||||
<TrafficChart
|
||||
samples={globalTraffic?.history || []}
|
||||
capacity={deviceSnapshot?.trafficHistoryCapacity || 120}
|
||||
routeLabel="Gateway"
|
||||
/>
|
||||
</div>
|
||||
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
|
||||
{trafficSourceError
|
||||
? `Трафик не обновляется · последние данные ${trafficFreshness}`
|
||||
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
|
||||
</div>
|
||||
</section>}
|
||||
{showPower && (
|
||||
<section className="client-power-section" aria-labelledby="connection-title">
|
||||
<button
|
||||
className="client-power"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={connected}
|
||||
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||||
disabled={connectionBlocked || (!connected && !canStart)}
|
||||
onClick={toggleConnection}
|
||||
{isGateway ? <span
|
||||
className="client-power-control client-tooltip-anchor"
|
||||
tabIndex={powerUnavailable ? 0 : undefined}
|
||||
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
|
||||
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||
</svg>
|
||||
</button>
|
||||
{powerButton}
|
||||
{powerUnavailable && <CloudTooltip id="gateway-power-unavailable">
|
||||
Сначала добавьте подписку и выберите сервер
|
||||
</CloudTooltip>}
|
||||
</span> : powerButton}
|
||||
<div className={`client-route-rules-pending${pendingLocalRulesCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||||
{pendingLocalRulesCount > 0 && (
|
||||
<>
|
||||
@@ -1334,12 +1484,24 @@ export function ClientOverviewPage({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
|
||||
aria-hidden={subscriptionWaiting}
|
||||
ref={isGateway ? subscriptionPanelRef : undefined}
|
||||
id={isGateway ? 'client-subscription-drawer' : undefined}
|
||||
className={isGateway
|
||||
? `client-drawer client-subscription-drawer${subscriptionOpen ? ' is-open' : ''}`
|
||||
: `client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
|
||||
aria-label={isGateway ? 'Управление подпиской' : undefined}
|
||||
aria-hidden={isGateway ? !subscriptionOpen : subscriptionWaiting}
|
||||
aria-disabled={gatewayDirect}
|
||||
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
|
||||
inert={(isGateway && !subscriptionOpen) || subscriptionWaiting || gatewayDirect ? true : undefined}
|
||||
>
|
||||
<div className="client-form-content">
|
||||
{isGateway && <button
|
||||
ref={subscriptionCloseRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть подписку"
|
||||
onClick={() => setSubscriptionOpen(false)}
|
||||
>×</button>}
|
||||
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
|
||||
<div
|
||||
ref={subscriptionRef}
|
||||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||
@@ -1493,7 +1655,7 @@ export function ClientOverviewPage({
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{hasSubscription && subscriptionContentReady && <aside
|
||||
{(isGateway || (hasSubscription && subscriptionContentReady)) && <aside
|
||||
ref={instructionsPanelRef}
|
||||
id="client-instructions"
|
||||
className={`client-drawer client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||
@@ -1530,14 +1692,22 @@ export function ClientOverviewPage({
|
||||
</div>
|
||||
</aside>}
|
||||
|
||||
{hasSubscription && subscriptionContentReady && isGateway && <DevicesPanel
|
||||
{isGateway && <DevicesPanel
|
||||
open={devicesOpen}
|
||||
panelRef={devicesPanelRef}
|
||||
closeRef={devicesCloseRef}
|
||||
onClose={() => setDevicesOpen(false)}
|
||||
snapshot={deviceSnapshot}
|
||||
status={deviceStatus}
|
||||
error={deviceError}
|
||||
refreshing={devicesRefreshing}
|
||||
refreshCycle={deviceRefreshCycle}
|
||||
onLoad={loadDevices}
|
||||
onSnapshot={setDeviceSnapshot}
|
||||
onError={setDeviceError}
|
||||
/>}
|
||||
|
||||
{hasSubscription && subscriptionContentReady && <ConnectivityDiagnosticsPanel
|
||||
{(isGateway || (hasSubscription && subscriptionContentReady)) && <ConnectivityDiagnosticsPanel
|
||||
isGateway={isGateway}
|
||||
open={diagnosticsOpen}
|
||||
panelRef={diagnosticsPanelRef}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import { copyText } from '../utils/clientControls.js';
|
||||
import {
|
||||
@@ -8,16 +7,12 @@ import {
|
||||
formatLastSeen,
|
||||
positiveByteDelta,
|
||||
stabilizeDevicesByTraffic,
|
||||
trafficAxisMid,
|
||||
trafficScaleRatio,
|
||||
} from '../utils/format.js';
|
||||
import { TrafficChart } from './TrafficChart.jsx';
|
||||
|
||||
const AUTO_REFRESH_MS = 15_000;
|
||||
const DEVICE_MOVE_MS = 520;
|
||||
const COPY_FEEDBACK_MS = 800;
|
||||
const TRAFFIC_DELTA_MS = 2_200;
|
||||
const TRAFFIC_CHART_HEADROOM = 10;
|
||||
const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||
|
||||
function Tooltip({ children }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
@@ -39,167 +34,13 @@ function TrafficValue({ value, delta }) {
|
||||
</strong>;
|
||||
}
|
||||
|
||||
function chartTime(value) {
|
||||
return new Date(value).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function smoothTrafficPath(points, valueKey) {
|
||||
if (!points.length) return '';
|
||||
return points.slice(1).reduce((path, point, index) => {
|
||||
const previous = points[index];
|
||||
const midX = (previous.x + point.x) / 2;
|
||||
return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`;
|
||||
}, `M ${points[0].x},${points[0][valueKey]}`);
|
||||
}
|
||||
|
||||
function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const previousPoints = useRef([]);
|
||||
const previousScale = useRef(scale);
|
||||
const max = samples.reduce((largest, sample) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
const proxy = byteString(sample.proxyBytes);
|
||||
return gateway > largest ? gateway : proxy > largest ? proxy : largest;
|
||||
}, 0n);
|
||||
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);
|
||||
return {
|
||||
sample,
|
||||
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
||||
gateway,
|
||||
proxy,
|
||||
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
||||
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
||||
};
|
||||
});
|
||||
const previous = points.slice(0, -1);
|
||||
const penultimate = points.at(-2);
|
||||
const newest = points.at(-1);
|
||||
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
||||
const scaleFrom = previousPoints.current;
|
||||
const animateScale = previousScale.current !== scale
|
||||
&& scaleFrom.length === points.length
|
||||
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
previousPoints.current = points;
|
||||
previousScale.current = scale;
|
||||
}, [points, scale]);
|
||||
|
||||
function trackPointer(event) {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
|
||||
const index = slot - firstSlot;
|
||||
if (index < 0 || index >= points.length) {
|
||||
setHovered(null);
|
||||
return;
|
||||
}
|
||||
setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY });
|
||||
}
|
||||
|
||||
const tooltip = hovered && typeof document !== 'undefined' && createPortal(
|
||||
<span
|
||||
className="client-device-traffic-point-tooltip"
|
||||
style={{
|
||||
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
|
||||
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
|
||||
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
|
||||
}}
|
||||
>
|
||||
<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>}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
return <span
|
||||
className="client-device-traffic-chart"
|
||||
role="img"
|
||||
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
|
||||
style={{
|
||||
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
||||
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
||||
}}
|
||||
>
|
||||
{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-zero">0</span>
|
||||
</span>}
|
||||
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
{pinned && <g className="client-device-traffic-grid">
|
||||
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
|
||||
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
||||
<line x1="0" x2="100" y1="100" y2="100" />
|
||||
</g>}
|
||||
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||
{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"
|
||||
/>}
|
||||
</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"
|
||||
/>}
|
||||
</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" />}
|
||||
</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" />}
|
||||
</path>}
|
||||
{!penultimate && newest && <line className="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} />}
|
||||
</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.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
|
||||
</span>}
|
||||
{tooltip}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
const [snapshot, setSnapshot] = useState(null);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [error, setError] = useState(null);
|
||||
export function DevicesPanel({
|
||||
open, panelRef, closeRef, onClose, snapshot, status, error, refreshing, refreshCycle,
|
||||
onLoad, onSnapshot, onError,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const [alias, setAlias] = useState('');
|
||||
const [savingId, setSavingId] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||
const [sortDirection, setSortDirection] = useState('desc');
|
||||
const [trafficScale, setTrafficScale] = useState('linear');
|
||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||
@@ -227,35 +68,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
[snapshot?.devices, sortDirection],
|
||||
);
|
||||
|
||||
async function load(quiet = false, discover = false) {
|
||||
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const next = await (discover ? api.devices.refresh() : api.devices.list());
|
||||
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||
setError(null);
|
||||
setStatus('ready');
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setRefreshCycle((cycle) => cycle + 1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
load();
|
||||
return undefined;
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || refreshing || status === 'loading') return undefined;
|
||||
const timer = setTimeout(() => load(true), AUTO_REFRESH_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, refreshCycle, refreshing, status]);
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimeout(copyTimer.current);
|
||||
clearTimeout(trafficDeltaTimer.current);
|
||||
@@ -340,18 +152,18 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
} catch (requestError) {
|
||||
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
||||
const latest = await api.devices.list();
|
||||
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
|
||||
throw requestError;
|
||||
}
|
||||
next = await api.devices.update(device.id, patch, latest.revision);
|
||||
}
|
||||
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||
setError(null);
|
||||
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||
onError(null);
|
||||
return true;
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
onError(requestError);
|
||||
return false;
|
||||
} finally {
|
||||
setSavingId('');
|
||||
@@ -377,23 +189,23 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
} catch (requestError) {
|
||||
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
||||
const latest = await api.devices.list();
|
||||
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
|
||||
next = await api.devices.setPolicy(device.id, mode, latest.revision);
|
||||
}
|
||||
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||
setError(null);
|
||||
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||||
onError(null);
|
||||
} catch (requestError) {
|
||||
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
|
||||
try {
|
||||
const latest = await api.devices.list();
|
||||
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||||
} catch {
|
||||
// Keep the policy error as the actionable result.
|
||||
}
|
||||
}
|
||||
setError(requestError);
|
||||
onError(requestError);
|
||||
} finally {
|
||||
setSavingId('');
|
||||
}
|
||||
@@ -445,7 +257,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
aria-label={refreshing ? 'Обновляем устройства' : 'Обновить устройства сейчас'}
|
||||
aria-busy={refreshing}
|
||||
disabled={refreshing}
|
||||
onClick={() => load(true, true)}
|
||||
onClick={() => onLoad(true, true)}
|
||||
>
|
||||
<svg key={refreshCycle} className="client-devices-refresh-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" pathLength="1" />
|
||||
@@ -505,7 +317,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
{error && (
|
||||
<div className="client-devices-error" role="alert">
|
||||
<span>{error.message}</span>
|
||||
<button type="button" onClick={() => load()}>Повторить</button>
|
||||
<button type="button" onClick={() => onLoad()}>Повторить</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства…</p>}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
byteString,
|
||||
formatByteString,
|
||||
trafficAxisMid,
|
||||
trafficScaleRatio,
|
||||
} from '../utils/format.js';
|
||||
|
||||
const TRAFFIC_CHART_HEADROOM = 10;
|
||||
const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||
|
||||
function chartTime(value) {
|
||||
return new Date(value).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function smoothTrafficPath(points, valueKey) {
|
||||
if (!points.length) return '';
|
||||
return points.slice(1).reduce((path, point, index) => {
|
||||
const previous = points[index];
|
||||
const midX = (previous.x + point.x) / 2;
|
||||
return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`;
|
||||
}, `M ${points[0].x},${points[0][valueKey]}`);
|
||||
}
|
||||
|
||||
function trafficSeriesMax(samples) {
|
||||
return samples.reduce((largest, sample) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
const proxy = byteString(sample.proxyBytes);
|
||||
return gateway > largest
|
||||
? (proxy > gateway ? proxy : gateway)
|
||||
: (proxy > largest ? proxy : largest);
|
||||
}, 0n);
|
||||
}
|
||||
|
||||
export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel, pinned = true }) {
|
||||
const [hovered, setHovered] = useState(null);
|
||||
const previousPoints = useRef([]);
|
||||
const previousScale = useRef(scale);
|
||||
const max = trafficSeriesMax(samples);
|
||||
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);
|
||||
return {
|
||||
sample,
|
||||
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
||||
gateway,
|
||||
proxy,
|
||||
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
||||
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
||||
};
|
||||
});
|
||||
const previous = points.slice(0, -1);
|
||||
const penultimate = points.at(-2);
|
||||
const newest = points.at(-1);
|
||||
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
||||
const scaleFrom = previousPoints.current;
|
||||
const animateScale = previousScale.current !== scale
|
||||
&& scaleFrom.length === points.length
|
||||
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
previousPoints.current = points;
|
||||
previousScale.current = scale;
|
||||
}, [points, scale]);
|
||||
|
||||
function trackPointer(event) {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
|
||||
const index = slot - firstSlot;
|
||||
if (index < 0 || index >= points.length) {
|
||||
setHovered(null);
|
||||
return;
|
||||
}
|
||||
setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY });
|
||||
}
|
||||
|
||||
const tooltip = hovered && typeof document !== 'undefined' && createPortal(
|
||||
<span
|
||||
className="client-device-traffic-point-tooltip"
|
||||
style={{
|
||||
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
|
||||
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
|
||||
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
|
||||
}}
|
||||
>
|
||||
<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>}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
return <span
|
||||
className="client-device-traffic-chart"
|
||||
role="img"
|
||||
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
|
||||
style={{
|
||||
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
|
||||
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
|
||||
}}
|
||||
>
|
||||
{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-zero">0</span>
|
||||
</span>}
|
||||
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
{pinned && <g className="client-device-traffic-grid">
|
||||
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
|
||||
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
|
||||
<line x1="0" x2="100" y1="100" y2="100" />
|
||||
</g>}
|
||||
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||
{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" />}
|
||||
</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" />}
|
||||
</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" />}
|
||||
</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" />}
|
||||
</path>}
|
||||
{!penultimate && newest && <line className="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} />}
|
||||
</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.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
|
||||
</span>}
|
||||
{tooltip}
|
||||
</span>;
|
||||
}
|
||||
@@ -2615,6 +2615,83 @@ p {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.client-gateway-summary {
|
||||
grid-column: 1;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
width: min(360px, 100%);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-right: clamp(20px, 4vw, 64px);
|
||||
text-align: left;
|
||||
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.client-gateway-summary-kicker,
|
||||
.client-gateway-traffic-heading > span {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.client-gateway-summary h2 {
|
||||
min-height: 29px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--client-text);
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.045em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-gateway-route-slot {
|
||||
min-height: 18px;
|
||||
color: var(--client-accent);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.client-gateway-traffic-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.client-gateway-traffic-heading strong {
|
||||
color: var(--client-text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.client-gateway-traffic-chart {
|
||||
width: 100%;
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
.client-gateway-traffic-chart .client-device-traffic-chart {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
width: 100%;
|
||||
height: 118px;
|
||||
}
|
||||
|
||||
.client-gateway-traffic-freshness {
|
||||
min-height: 28px;
|
||||
color: var(--client-muted);
|
||||
font-size: 8.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.client-gateway-traffic-freshness.is-stale {
|
||||
color: oklch(0.68 0.14 72);
|
||||
}
|
||||
|
||||
.client-panel.has-subscription .client-form {
|
||||
height: var(--client-work-height);
|
||||
}
|
||||
@@ -2687,6 +2764,19 @@ p {
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
.client-power-control {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.client-power-control:focus-visible {
|
||||
outline: 1px solid color-mix(in oklch, var(--client-accent) 64%, transparent);
|
||||
outline-offset: 5px;
|
||||
}
|
||||
|
||||
.client-power {
|
||||
position: relative;
|
||||
width: 96px;
|
||||
@@ -3035,6 +3125,23 @@ p {
|
||||
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-subscription-drawer {
|
||||
width: min(480px, 100vw);
|
||||
}
|
||||
|
||||
.client-subscription-sheet {
|
||||
min-height: 100%;
|
||||
align-content: start;
|
||||
padding: 82px 72px 72px 42px;
|
||||
}
|
||||
|
||||
.client-subscription-drawer .client-subscription,
|
||||
.client-subscription-drawer .client-usage,
|
||||
.client-subscription-drawer .client-servers {
|
||||
width: min(100%, 300px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 921px) {
|
||||
.client-panel.has-subscription {
|
||||
--client-work-height: min(640px, calc(100vh - 120px));
|
||||
@@ -4425,6 +4532,7 @@ p {
|
||||
}
|
||||
|
||||
.client-power-section,
|
||||
.client-gateway-summary,
|
||||
.client-form,
|
||||
.client-panel.is-setup .client-form {
|
||||
grid-column: 1;
|
||||
@@ -4432,6 +4540,16 @@ p {
|
||||
width: min(100%, 380px);
|
||||
}
|
||||
|
||||
.client-power-section {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.client-gateway-summary {
|
||||
order: 2;
|
||||
justify-self: center;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.client-form {
|
||||
height: auto;
|
||||
max-height: none;
|
||||
@@ -4560,6 +4678,10 @@ p {
|
||||
padding: 40px 58px 60px 18px;
|
||||
}
|
||||
|
||||
.client-subscription-sheet {
|
||||
padding: 70px 58px 60px 18px;
|
||||
}
|
||||
|
||||
.client-local-rules-sheet {
|
||||
padding: 40px 58px 60px 18px;
|
||||
}
|
||||
@@ -5054,6 +5176,10 @@ p {
|
||||
.client-subscription-refresh,
|
||||
.client-subscription-delete,
|
||||
.client-power-section,
|
||||
.client-gateway-summary,
|
||||
.client-gateway-traffic-chart,
|
||||
.client-gateway-traffic-freshness,
|
||||
.client-subscription-drawer,
|
||||
.client-form,
|
||||
.client-usage,
|
||||
.client-usage > strong,
|
||||
|
||||
Reference in New Issue
Block a user