Update VPN client connection flow
This commit is contained in:
+4
-1
@@ -92,7 +92,10 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
diagnostics: {
|
||||
connectivity: () => request('/api/diagnostics/connectivity', { method: 'POST' }),
|
||||
connectivity: (services = []) => request('/api/diagnostics/connectivity', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ services }),
|
||||
}),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
CONNECTIVITY_IP_SOURCES,
|
||||
CONNECTIVITY_SITES,
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
} from '../../shared/connectivityDiagnostics.js';
|
||||
|
||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||
const SUMMARY_COPY = {
|
||||
available: ['is-good', 'Оба маршрута работают, ограничений не видно.'],
|
||||
'likely-direct-restriction': ['is-warning', 'Похоже на ограничение прямого маршрута: через VPN сервис доступен.'],
|
||||
'vpn-problem': ['is-error', 'Прямой маршрут работает, а VPN требует проверки.'],
|
||||
'direct-offline': ['is-error', 'Прямой выход в интернет недоступен.'],
|
||||
offline: ['is-error', 'Интернет недоступен по обоим маршрутам.'],
|
||||
'same-ip': ['is-warning', 'Внешний IP не изменился — проверьте, что трафик действительно идёт через VPN.'],
|
||||
'vpn-off': ['is-muted', 'VPN выключен: прямой маршрут проверен отдельно.'],
|
||||
inconclusive: ['is-muted', 'Результата недостаточно для уверенного вывода.'],
|
||||
available: ['is-good', 'Оба маршрута работают.'],
|
||||
'likely-direct-restriction': ['is-warning', 'Прямой маршрут, похоже, ограничен.'],
|
||||
'vpn-problem': ['is-error', 'VPN-маршрут требует проверки.'],
|
||||
'direct-offline': ['is-error', 'Прямой маршрут недоступен.'],
|
||||
offline: ['is-error', 'Оба маршрута недоступны.'],
|
||||
'same-ip': ['is-warning', 'Внешний IP не изменился.'],
|
||||
'vpn-off': ['is-muted', 'VPN выключен.'],
|
||||
inconclusive: ['is-muted', 'Недостаточно данных.'],
|
||||
};
|
||||
|
||||
function ipText(path) {
|
||||
const addresses = [...(path?.ipv4?.addresses || []), path?.ipv6].filter(Boolean);
|
||||
return addresses.length ? addresses.join(' · ') : 'Не определён';
|
||||
function readCustomServices() {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]');
|
||||
return Array.isArray(value)
|
||||
? value.filter((service) => (
|
||||
service
|
||||
&& typeof service.id === 'string'
|
||||
&& service.id.startsWith('custom-')
|
||||
&& typeof service.label === 'string'
|
||||
&& typeof service.url === 'string'
|
||||
)).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function internetStatus(path) {
|
||||
if (!path?.available) return ['is-muted', 'Не проверено'];
|
||||
return path.internetAvailable ? ['is-good', 'Доступен'] : ['is-error', 'Нет доступа'];
|
||||
}
|
||||
|
||||
function siteStatus(site) {
|
||||
if (!site) return ['is-muted', 'Не проверено'];
|
||||
function resultStatus(site, pending, available = true) {
|
||||
if (pending && !site) return ['is-running', 'Проверяем'];
|
||||
if (!available || !site) return ['is-muted', '—'];
|
||||
if (site.status === 'unavailable') return ['is-error', 'Нет доступа'];
|
||||
if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`];
|
||||
return ['is-good', `Доступен${site.latencyMs === null ? '' : ` · ${site.latencyMs} мс`}`];
|
||||
return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`];
|
||||
}
|
||||
|
||||
function Status({ value, route }) {
|
||||
@@ -34,12 +48,25 @@ function Status({ value, route }) {
|
||||
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function ipResult(path, source) {
|
||||
if (!path?.available) return null;
|
||||
return source.family === 6
|
||||
? path.ipv6Source
|
||||
: path.ipv4?.sources?.find((item) => item.source === source.id);
|
||||
}
|
||||
|
||||
function IpCell({ path, source, pending, route }) {
|
||||
const value = ipResult(path, source);
|
||||
if (pending && !value) return <Status value={['is-running', 'Проверяем']} route={route} />;
|
||||
if (!path?.available) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
|
||||
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
||||
}
|
||||
|
||||
function PathDetails({ title, path }) {
|
||||
if (!path?.available) return <section><strong>{title}</strong><p>VPN не запущен.</p></section>;
|
||||
return <section>
|
||||
<strong>{title}</strong>
|
||||
<p>IPv4: {path.ipv4.addresses.join(', ') || 'нет'}</p>
|
||||
<p>IPv6: {path.ipv6 || 'нет'}</p>
|
||||
{path.sites.map((site) => (
|
||||
<p key={site.id}>
|
||||
{site.label}: {site.stage}{site.httpStatus ? ` · HTTP ${site.httpStatus}` : ''}
|
||||
@@ -53,12 +80,25 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
|
||||
const [result, setResult] = useState(null);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [error, setError] = useState(null);
|
||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [serviceName, setServiceName] = useState('');
|
||||
const [serviceUrl, setServiceUrl] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices));
|
||||
} catch {
|
||||
// The service still works for this session when browser storage is unavailable.
|
||||
}
|
||||
}, [customServices]);
|
||||
|
||||
async function run() {
|
||||
setStatus('running');
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.diagnostics.connectivity());
|
||||
setResult(await api.diagnostics.connectivity(customServices));
|
||||
setStatus('ready');
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
@@ -66,6 +106,28 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
|
||||
}
|
||||
}
|
||||
|
||||
function addService(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
||||
const parsed = new URL(serviceUrl.trim());
|
||||
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
|
||||
setCustomServices((services) => [...services, {
|
||||
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||
label: serviceName.trim() || parsed.hostname,
|
||||
url: parsed.href,
|
||||
}]);
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
setAdding(false);
|
||||
} catch (validationError) {
|
||||
setFormError(validationError.message || 'Проверьте адрес.');
|
||||
}
|
||||
}
|
||||
|
||||
const pending = status === 'running';
|
||||
const sites = [...CONNECTIVITY_SITES, ...customServices];
|
||||
const summary = SUMMARY_COPY[result?.assessment?.summary] || SUMMARY_COPY.inconclusive;
|
||||
const checkedAt = result?.checkedAt
|
||||
? new Date(result.checkedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
@@ -90,69 +152,127 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
|
||||
>×</button>
|
||||
<header className="client-instructions-header client-diagnostics-header">
|
||||
<span>Gateway · Direct ↔ VPN</span>
|
||||
<h2 id="client-diagnostics-title">Проверка маршрутов</h2>
|
||||
<div className="client-instructions-intro">
|
||||
<p>Сравнивает внешний IP и доступность сайтов напрямую и через выбранный VPN, не меняя правила.</p>
|
||||
<div className="client-diagnostics-title-row">
|
||||
<h2 id="client-diagnostics-title">Маршруты</h2>
|
||||
<span className="client-diagnostics-refresh-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-diagnostics-refresh${pending ? ' is-running' : ''}`}
|
||||
type="button"
|
||||
aria-label="Проверить маршруты"
|
||||
aria-busy={pending}
|
||||
disabled={pending}
|
||||
onClick={run}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="client-tooltip" role="tooltip">Проверить маршруты</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button
|
||||
className={`client-diagnostics-run${status === 'running' ? ' is-running' : ''}`}
|
||||
type="button"
|
||||
aria-busy={status === 'running'}
|
||||
disabled={status === 'running'}
|
||||
onClick={run}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
<span>{status === 'running' ? 'Проверяем маршруты…' : result ? 'Проверить ещё раз' : 'Проверить сейчас'}</span>
|
||||
</button>
|
||||
<div className="client-diagnostics-feedback" aria-live="polite">
|
||||
{error ? <div className="client-diagnostics-error" role="alert">
|
||||
<span>{error.message}</span>
|
||||
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||
</div> : result && <>
|
||||
<strong className={`client-diagnostics-summary ${summary[0]}`}>{summary[1]}</strong>
|
||||
<span className="client-diagnostics-time">{checkedAt}</span>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{error && <div className="client-diagnostics-error" role="alert">
|
||||
<span>{error.message}</span>
|
||||
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||
</div>}
|
||||
<section className="client-diagnostics-section" aria-labelledby="diagnostic-ip-title">
|
||||
<div className="client-diagnostics-section-title">
|
||||
<span id="diagnostic-ip-title">IP-адреса</span>
|
||||
</div>
|
||||
<table className="client-diagnostics-table" aria-busy={pending}>
|
||||
<thead><tr>
|
||||
<th>Источник</th>
|
||||
<th>Напрямую</th>
|
||||
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
|
||||
</tr></thead>
|
||||
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => <tr key={source.id}>
|
||||
<th scope="row">{source.label}</th>
|
||||
<td><IpCell path={result?.direct} source={source} pending={pending && !result} route={`Напрямую, ${source.label}`} /></td>
|
||||
<td><IpCell path={result?.vpn} source={source} pending={pending && !result} route={`VPN, ${source.label}`} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{!result && status !== 'running' && !error && (
|
||||
<p className="client-diagnostics-empty">Результаты появятся здесь после ручного запуска.</p>
|
||||
)}
|
||||
<section className="client-diagnostics-section" aria-labelledby="diagnostic-sites-title">
|
||||
<div className="client-diagnostics-section-title">
|
||||
<span id="diagnostic-sites-title">Сервисы</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||
onClick={() => setAdding((value) => !value)}
|
||||
>
|
||||
{customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES ? 'Лимит 5' : 'Добавить свой сервис'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && <div className="client-diagnostics-results" aria-live="polite" aria-busy={status === 'running'}>
|
||||
<div className="client-diagnostics-grid client-diagnostics-grid-head" aria-hidden="true">
|
||||
<span />
|
||||
<strong>Напрямую</strong>
|
||||
<strong>VPN{result.vpn.server?.label ? ` · ${result.vpn.server.label}` : ''}</strong>
|
||||
</div>
|
||||
<div className="client-diagnostics-grid">
|
||||
<strong>Внешний IP</strong>
|
||||
<code aria-label={`Напрямую: ${ipText(result.direct)}`}>{ipText(result.direct)}</code>
|
||||
<code aria-label={`VPN: ${result.vpn.available ? ipText(result.vpn) : 'VPN выключен'}`}>
|
||||
{result.vpn.available ? ipText(result.vpn) : 'VPN выключен'}
|
||||
</code>
|
||||
</div>
|
||||
<div className="client-diagnostics-grid">
|
||||
<strong>Интернет</strong>
|
||||
<Status value={internetStatus(result.direct)} route="Напрямую" />
|
||||
<Status value={internetStatus(result.vpn)} route="VPN" />
|
||||
</div>
|
||||
{result.assessment.comparisons.map((comparison) => (
|
||||
<div className="client-diagnostics-grid" key={comparison.id}>
|
||||
<strong>{comparison.label}</strong>
|
||||
<Status value={siteStatus(comparison.direct)} route={`Напрямую, ${comparison.label}`} />
|
||||
<Status value={siteStatus(comparison.vpn)} route={`VPN, ${comparison.label}`} />
|
||||
{adding && <form className="client-diagnostics-add" onSubmit={addService}>
|
||||
<input
|
||||
type="text"
|
||||
maxLength="40"
|
||||
placeholder="Название"
|
||||
aria-label="Название сервиса"
|
||||
value={serviceName}
|
||||
onChange={(event) => setServiceName(event.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
placeholder="https://example.com"
|
||||
aria-label="HTTPS-адрес сервиса"
|
||||
required
|
||||
value={serviceUrl}
|
||||
onChange={(event) => {
|
||||
setServiceUrl(event.target.value);
|
||||
setFormError('');
|
||||
}}
|
||||
/>
|
||||
<div className="client-diagnostics-add-action">
|
||||
<span role="alert">{formError}</span>
|
||||
<button type="submit">Добавить</button>
|
||||
</div>
|
||||
))}
|
||||
<p className={`client-diagnostics-summary ${summary[0]}`} role="status">{summary[1]}</p>
|
||||
<p className="client-diagnostics-time">Проверено в {checkedAt}</p>
|
||||
<details className="client-diagnostics-details">
|
||||
<summary>Технические детали</summary>
|
||||
<div>
|
||||
<PathDetails title="Напрямую" path={result.direct} />
|
||||
<PathDetails title="VPN" path={result.vpn} />
|
||||
</div>
|
||||
</details>
|
||||
</div>}
|
||||
</form>}
|
||||
|
||||
<table className="client-diagnostics-table" aria-busy={pending}>
|
||||
<thead><tr>
|
||||
<th>Сервис</th>
|
||||
<th>Напрямую</th>
|
||||
<th>VPN</th>
|
||||
</tr></thead>
|
||||
<tbody>{sites.map((site) => {
|
||||
const direct = result?.direct?.sites?.find((item) => item.id === site.id);
|
||||
const vpn = result?.vpn?.sites?.find((item) => item.id === site.id);
|
||||
const custom = site.id.startsWith('custom-');
|
||||
return <tr key={site.id}>
|
||||
<th scope="row">
|
||||
<span className="client-diagnostics-service-name">{site.label}</span>
|
||||
{custom && <button
|
||||
className="client-diagnostics-remove"
|
||||
type="button"
|
||||
aria-label={`Удалить сервис ${site.label}`}
|
||||
onClick={() => setCustomServices((items) => items.filter((item) => item.id !== site.id))}
|
||||
>×</button>}
|
||||
</th>
|
||||
<td><Status value={resultStatus(direct, pending && !result)} route={`Напрямую, ${site.label}`} /></td>
|
||||
<td><Status value={resultStatus(vpn, pending && !result, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></td>
|
||||
</tr>;
|
||||
})}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{result && <details className="client-diagnostics-details">
|
||||
<summary>Технические детали</summary>
|
||||
<div>
|
||||
<PathDetails title="Напрямую" path={result.direct} />
|
||||
<PathDetails title="VPN" path={result.vpn} />
|
||||
</div>
|
||||
</details>}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
|
||||
const AUTO_REFRESH_MS = 15_000;
|
||||
const DEVICE_MOVE_MS = 520;
|
||||
const COPY_FEEDBACK_MS = 5_000;
|
||||
const COPY_FEEDBACK_MS = 800;
|
||||
const TRAFFIC_DELTA_MS = 2_200;
|
||||
|
||||
function Tooltip({ children }) {
|
||||
@@ -45,13 +45,14 @@ function chartTime(value) {
|
||||
|
||||
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 latest = samples.at(-1)?.observedAt || 'empty';
|
||||
const firstSlot = capacity - samples.length;
|
||||
const points = samples.map((sample, index) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
@@ -69,6 +70,15 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||
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();
|
||||
@@ -111,12 +121,48 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||
<line x1="0" x2="100" y1="50" y2="50" />
|
||||
<line x1="0" x2="100" y1="100" y2="100" />
|
||||
</g>}
|
||||
<g key={latest} className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')} />}
|
||||
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')} />}
|
||||
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY} />}
|
||||
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY} />}
|
||||
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5" />}
|
||||
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}>
|
||||
{animateScale && <animate
|
||||
key={`gateway-${scale}`}
|
||||
attributeName="points"
|
||||
from={scaleFrom.slice(0, -1).map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
|
||||
to={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
|
||||
dur="520ms"
|
||||
calcMode="spline"
|
||||
keyTimes="0;1"
|
||||
keySplines="0.16 1 0.3 1"
|
||||
fill="freeze"
|
||||
/>}
|
||||
</polyline>}
|
||||
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}>
|
||||
{animateScale && <animate
|
||||
key={`proxy-${scale}`}
|
||||
attributeName="points"
|
||||
from={scaleFrom.slice(0, -1).map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
|
||||
to={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
|
||||
dur="520ms"
|
||||
calcMode="spline"
|
||||
keyTimes="0;1"
|
||||
keySplines="0.16 1 0.3 1"
|
||||
fill="freeze"
|
||||
/>}
|
||||
</polyline>}
|
||||
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY}>
|
||||
{animateScale && <>
|
||||
<animate key={`gateway-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).gatewayY} to={penultimate.gatewayY} dur="520ms" fill="freeze" />
|
||||
<animate key={`gateway-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />
|
||||
</>}
|
||||
</line>}
|
||||
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY}>
|
||||
{animateScale && <>
|
||||
<animate key={`proxy-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).proxyY} to={penultimate.proxyY} dur="520ms" fill="freeze" />
|
||||
<animate key={`proxy-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).proxyY} to={newest.proxyY} dur="520ms" fill="freeze" />
|
||||
</>}
|
||||
</line>}
|
||||
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5">
|
||||
{animateScale && <animate key={`gateway-cy-${scale}`} attributeName="cy" from={scaleFrom[0].gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />}
|
||||
</circle>}
|
||||
</g>
|
||||
{hovered && <g className="client-device-traffic-cursor">
|
||||
<line x1={hovered.x} x2={hovered.x} y1="0" y2="100" />
|
||||
@@ -335,6 +381,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
||||
}
|
||||
|
||||
function startEditing(device) {
|
||||
setEditingId(device.id);
|
||||
setAlias(device.alias || device.hostname || '');
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
@@ -431,7 +482,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
)}
|
||||
|
||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'copied!' : ''}
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''}
|
||||
</div>
|
||||
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
|
||||
{devices.map((device) => {
|
||||
@@ -509,36 +560,34 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{device.ip ? <h3 className="client-device-name-heading">
|
||||
<button
|
||||
className={`client-device-name${hasName ? '' : ' is-address-only'}${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
<h3 className="client-device-name-heading">
|
||||
{hasName && <button
|
||||
className="client-device-alias-trigger"
|
||||
type="button"
|
||||
aria-label={`Изменить название ${title}`}
|
||||
onClick={() => startEditing(device)}
|
||||
>{title}</button>}
|
||||
{hasName && 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' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
||||
onClick={() => copyDeviceIp(device)}
|
||||
>
|
||||
<span className="client-device-name-primary">{title}</span>
|
||||
{hasName && <span className="client-device-name-ip" aria-hidden="true">{device.ip}</span>}
|
||||
<span className="client-device-name-feedback" aria-hidden="true">
|
||||
{copied && copyFeedback.failed ? 'Ошибка' : 'copied!'}
|
||||
</span>
|
||||
</button>
|
||||
</h3> : <h3>{title}</h3>}
|
||||
<span className="client-device-edit-wrap client-tooltip-anchor">
|
||||
>{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>}
|
||||
</h3>
|
||||
{!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className="client-device-edit"
|
||||
type="button"
|
||||
aria-label={`Изменить название ${title}`}
|
||||
onClick={() => {
|
||||
setEditingId(device.id);
|
||||
setAlias(device.alias || '');
|
||||
}}
|
||||
onClick={() => startEditing(device)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>Изменить название</Tooltip>
|
||||
</span>
|
||||
</span>}
|
||||
</>
|
||||
)}
|
||||
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
|
||||
|
||||
+267
-153
@@ -951,107 +951,86 @@ p {
|
||||
|
||||
.client-device-main > h3 {
|
||||
height: 23px;
|
||||
flex: 0 1 auto;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
letter-spacing: -0.03em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-device-name {
|
||||
height: 23px;
|
||||
width: 100%;
|
||||
.client-device-alias-trigger,
|
||||
.client-device-ip {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: 700 14px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
letter-spacing: -0.03em;
|
||||
text-align: left;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.client-device-name-ip {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-device-name > span {
|
||||
grid-area: 1 / 1;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transform: translateY(0.18em) scale(0.96);
|
||||
transform-origin: left center;
|
||||
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1), transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: color 180ms ease, filter 300ms ease;
|
||||
}
|
||||
|
||||
.client-device-name > .client-device-name-primary {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scale(1);
|
||||
.client-device-alias-trigger {
|
||||
flex: 0 1 auto;
|
||||
font: 700 14px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
letter-spacing: -0.03em;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.client-device-name:not(.is-address-only):hover .client-device-name-primary,
|
||||
.client-device-name:not(.is-address-only):focus-visible .client-device-name-primary {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: translateY(-0.18em) scale(1.04);
|
||||
}
|
||||
|
||||
.client-device-name:not(.is-address-only):hover .client-device-name-ip,
|
||||
.client-device-name:not(.is-address-only):focus-visible .client-device-name-ip {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.client-device-name.is-copied .client-device-name-primary,
|
||||
.client-device-name.is-copied .client-device-name-ip,
|
||||
.client-device-name.is-copy-error .client-device-name-primary,
|
||||
.client-device-name.is-copy-error .client-device-name-ip {
|
||||
opacity: 0;
|
||||
filter: blur(8px);
|
||||
transform: translateY(-0.18em) scale(1.04);
|
||||
}
|
||||
|
||||
.client-device-name.is-copied .client-device-name-feedback,
|
||||
.client-device-name.is-copy-error .client-device-name-feedback {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.client-device-name-feedback {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.client-device-name.is-copied .client-device-name-feedback {
|
||||
.client-device-alias-trigger:hover,
|
||||
.client-device-alias-trigger:focus-visible {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-name.is-copy-error .client-device-name-feedback {
|
||||
color: oklch(0.68 0.15 28);
|
||||
.client-device-name-separator {
|
||||
flex: 0 0 auto;
|
||||
color: var(--client-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.client-device-name:focus-visible {
|
||||
.client-device-ip {
|
||||
flex: 0 0 auto;
|
||||
font: 600 12px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.client-device-ip:hover,
|
||||
.client-device-ip:focus-visible {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-ip.is-copied {
|
||||
--client-device-copy-color: var(--client-accent);
|
||||
animation: client-device-ip-copy 800ms ease-out;
|
||||
}
|
||||
|
||||
.client-device-ip.is-copy-error {
|
||||
--client-device-copy-color: oklch(0.68 0.15 28);
|
||||
animation: client-device-ip-copy 800ms ease-out;
|
||||
}
|
||||
|
||||
.client-device-alias-trigger:focus-visible,
|
||||
.client-device-ip:focus-visible {
|
||||
border-radius: 3px;
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes client-device-ip-copy {
|
||||
35% {
|
||||
color: var(--client-device-copy-color);
|
||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-device-copy-color) 48%, transparent));
|
||||
}
|
||||
}
|
||||
|
||||
.client-device-edit-wrap,
|
||||
.client-device-pin-wrap {
|
||||
width: 32px;
|
||||
@@ -1074,14 +1053,6 @@ p {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.client-device-name-heading:has(.client-device-name:not(.is-address-only):hover) + .client-device-edit-wrap,
|
||||
.client-device-name-heading:has(.client-device-name:not(.is-address-only):focus-visible) + .client-device-edit-wrap {
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
pointer-events: none;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.client-device-pin-wrap {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
@@ -4571,28 +4542,40 @@ p {
|
||||
}
|
||||
|
||||
.client-diagnostics-header {
|
||||
margin-bottom: 26px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.client-diagnostics-run {
|
||||
min-width: 190px;
|
||||
min-height: 42px;
|
||||
.client-diagnostics-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
margin: 0 8px 26px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-diagnostics-title-row h2 {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.client-diagnostics-refresh-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.client-diagnostics-refresh {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--client-muted);
|
||||
cursor: pointer;
|
||||
transition: color 220ms ease, filter 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: color 220ms ease, filter 320ms ease;
|
||||
}
|
||||
|
||||
.client-diagnostics-run svg {
|
||||
.client-diagnostics-refresh svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
fill: none;
|
||||
@@ -4600,30 +4583,52 @@ p {
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-diagnostics-run:hover:not(:disabled),
|
||||
.client-diagnostics-run:focus-visible {
|
||||
outline: 0;
|
||||
.client-diagnostics-refresh:hover:not(:disabled),
|
||||
.client-diagnostics-refresh:focus-visible,
|
||||
.client-diagnostics-refresh.is-running {
|
||||
color: var(--client-accent);
|
||||
filter: drop-shadow(0 0 8px color-mix(in oklch, var(--client-accent) 48%, transparent));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.client-diagnostics-run.is-running svg {
|
||||
.client-diagnostics-refresh:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.client-diagnostics-refresh:hover:not(:disabled) svg,
|
||||
.client-diagnostics-refresh:focus-visible:not(.is-running) svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.client-diagnostics-refresh.is-running svg {
|
||||
animation: client-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.client-diagnostics-run:disabled {
|
||||
.client-diagnostics-refresh:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.client-diagnostics-empty,
|
||||
.client-diagnostics-error {
|
||||
min-height: 56px;
|
||||
margin: 0 8px;
|
||||
color: var(--client-muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.65;
|
||||
.client-diagnostics-refresh-wrap > .client-tooltip {
|
||||
right: 0;
|
||||
left: auto;
|
||||
text-transform: none;
|
||||
transform: translate(0, 2px);
|
||||
}
|
||||
|
||||
.client-diagnostics-refresh-wrap:hover > .client-tooltip,
|
||||
.client-diagnostics-refresh-wrap:has(> :focus-visible) > .client-tooltip {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.client-diagnostics-feedback {
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 8px 10px;
|
||||
}
|
||||
|
||||
.client-diagnostics-error {
|
||||
@@ -4631,6 +4636,8 @@ p {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: oklch(0.68 0.15 28);
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.client-diagnostics-error button {
|
||||
@@ -4642,54 +4649,101 @@ p {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-diagnostics-results {
|
||||
.client-diagnostics-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 8px;
|
||||
opacity: 1;
|
||||
gap: 5px;
|
||||
margin: 0 8px 24px;
|
||||
}
|
||||
|
||||
.client-diagnostics-section-title {
|
||||
min-height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.client-diagnostics-section-title button,
|
||||
.client-diagnostics-add button,
|
||||
.client-diagnostics-remove {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-accent);
|
||||
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.client-diagnostics-section-title button:disabled {
|
||||
color: var(--client-muted);
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.client-diagnostics-section-title button:focus-visible,
|
||||
.client-diagnostics-add button:focus-visible,
|
||||
.client-diagnostics-error button:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.client-diagnostics-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
transition: opacity 220ms ease, filter 320ms ease;
|
||||
}
|
||||
|
||||
.client-diagnostics-results[aria-busy='true'] {
|
||||
opacity: 0.58;
|
||||
filter: saturate(0.7);
|
||||
.client-diagnostics-table[aria-busy='true'] {
|
||||
opacity: 0.68;
|
||||
filter: saturate(0.72);
|
||||
}
|
||||
|
||||
.client-diagnostics-grid {
|
||||
min-height: 52px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(96px, 0.75fr) repeat(2, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in oklch, var(--client-panel) 48%, transparent);
|
||||
}
|
||||
|
||||
.client-diagnostics-grid-head {
|
||||
min-height: 32px;
|
||||
padding-block: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.client-diagnostics-grid > strong,
|
||||
.client-diagnostics-grid-head > strong {
|
||||
.client-diagnostics-table th,
|
||||
.client-diagnostics-table td {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 9px 8px;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.client-diagnostics-table th:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.client-diagnostics-table thead th {
|
||||
padding-top: 2px;
|
||||
padding-bottom: 7px;
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-diagnostics-grid > code,
|
||||
.client-diagnostics-table tbody tr {
|
||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
|
||||
}
|
||||
|
||||
.client-diagnostics-table tbody th {
|
||||
color: var(--client-text);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.client-diagnostics-table code,
|
||||
.client-diagnostics-status {
|
||||
min-width: 0;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--client-text);
|
||||
font: 600 10px/1.45 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
font: 600 9px/1.4 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.client-diagnostics-status.is-good,
|
||||
@@ -4712,23 +4766,80 @@ p {
|
||||
color: var(--client-muted);
|
||||
}
|
||||
|
||||
.client-diagnostics-status.is-running {
|
||||
color: var(--client-accent);
|
||||
animation: client-operation-pulse 900ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.client-diagnostics-summary {
|
||||
min-height: 46px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
font-size: 11px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.client-diagnostics-time {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.client-diagnostics-add {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 0.7fr) minmax(0, 1.3fr);
|
||||
gap: 8px 14px;
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.client-diagnostics-add input {
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--client-border);
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--client-text);
|
||||
font: 500 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.client-diagnostics-add input:focus {
|
||||
border-color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-diagnostics-add-action {
|
||||
min-height: 20px;
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-diagnostics-add-action span {
|
||||
color: oklch(0.68 0.15 28);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.client-diagnostics-service-name {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.client-diagnostics-remove {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: 3px;
|
||||
color: var(--client-muted);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.client-diagnostics-remove:hover,
|
||||
.client-diagnostics-remove:focus-visible {
|
||||
outline: 0;
|
||||
color: oklch(0.68 0.15 28);
|
||||
}
|
||||
|
||||
.client-diagnostics-details {
|
||||
margin-top: 12px;
|
||||
margin: 0 8px;
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
@@ -4765,21 +4876,22 @@ p {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.client-diagnostics-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px 12px;
|
||||
.client-diagnostics-table th,
|
||||
.client-diagnostics-table td {
|
||||
padding-inline: 5px;
|
||||
}
|
||||
|
||||
.client-diagnostics-grid > strong:first-child,
|
||||
.client-diagnostics-grid-head > span:first-child {
|
||||
grid-column: 1 / -1;
|
||||
.client-diagnostics-table th:first-child {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
.client-diagnostics-grid-head {
|
||||
min-height: 42px;
|
||||
.client-diagnostics-add {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.client-diagnostics-add-action,
|
||||
.client-diagnostics-details > div {
|
||||
grid-column: 1;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -4835,7 +4947,8 @@ p {
|
||||
.client-device-pin svg,
|
||||
.client-device-policy,
|
||||
.client-device-policy svg,
|
||||
.client-device-name > span,
|
||||
.client-device-alias-trigger,
|
||||
.client-device-ip,
|
||||
.client-device-traffic-value > span,
|
||||
.client-device-traffic-breakdown,
|
||||
.client-device-traffic-breakdown > span,
|
||||
@@ -4861,9 +4974,10 @@ p {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.client-diagnostics-run,
|
||||
.client-diagnostics-run svg,
|
||||
.client-diagnostics-results {
|
||||
.client-diagnostics-refresh,
|
||||
.client-diagnostics-refresh svg,
|
||||
.client-diagnostics-table,
|
||||
.client-diagnostics-status.is-running {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user