Add device inventory refresh endpoint and auto-refresh UI
This commit is contained in:
@@ -617,6 +617,11 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, deviceInventory.snapshot());
|
return sendJson(res, 200, deviceInventory.snapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestUrl.pathname === '/api/devices/refresh') {
|
||||||
|
if (!deviceInventory || req.method !== 'POST') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
return sendJson(res, 200, await deviceInventory.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
const deviceMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})$/);
|
const deviceMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})$/);
|
||||||
if (deviceMatch && req.method === 'PUT') {
|
if (deviceMatch && req.method === 'PUT') {
|
||||||
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ function deviceStatus(lastSeenAt, now) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDeviceInventoryService({ store, observe, vendor = () => null, now = () => new Date() }) {
|
export function createDeviceInventoryService({ store, observe, vendor = () => null, now = () => new Date() }) {
|
||||||
|
let refreshPromise = null;
|
||||||
|
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
const state = migrate(store.read());
|
const state = migrate(store.read());
|
||||||
const current = now();
|
const current = now();
|
||||||
@@ -89,7 +91,7 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
async function performRefresh() {
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await observe();
|
result = await observe();
|
||||||
@@ -138,6 +140,15 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu
|
|||||||
return snapshot();
|
return snapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = performRefresh().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function update(id, patch, expectedRevision) {
|
function update(id, patch, expectedRevision) {
|
||||||
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||||
throw new HarborError('REQUEST_INVALID');
|
throw new HarborError('REQUEST_INVALID');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.9.3',
|
macClient: '0.9.4',
|
||||||
gatewayClient: '0.9.3',
|
gatewayClient: '0.9.4',
|
||||||
gatewayBackend: '0.9.0',
|
gatewayBackend: '0.9.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
list: () => request('/api/devices'),
|
list: () => request('/api/devices'),
|
||||||
|
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
|
||||||
update: (id, patch, expectedRevision) => request(`/api/devices/${id}`, {
|
update: (id, patch, expectedRevision) => request(`/api/devices/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...patch, expectedRevision }),
|
body: JSON.stringify({ ...patch, expectedRevision }),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { formatLastSeen } from '../utils/format.js';
|
import { formatLastSeen } from '../utils/format.js';
|
||||||
|
|
||||||
@@ -7,6 +7,8 @@ const STATUS_LABELS = {
|
|||||||
recent: 'Недавно',
|
recent: 'Недавно',
|
||||||
offline: 'Не в сети',
|
offline: 'Не в сети',
|
||||||
};
|
};
|
||||||
|
const AUTO_REFRESH_MS = 15_000;
|
||||||
|
const DEVICE_MOVE_MS = 520;
|
||||||
|
|
||||||
function Tooltip({ children }) {
|
function Tooltip({ children }) {
|
||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||||
@@ -28,27 +30,65 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
const [editingId, setEditingId] = useState('');
|
const [editingId, setEditingId] = useState('');
|
||||||
const [alias, setAlias] = useState('');
|
const [alias, setAlias] = useState('');
|
||||||
const [savingId, setSavingId] = useState('');
|
const [savingId, setSavingId] = useState('');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [refreshCycle, setRefreshCycle] = useState(0);
|
||||||
|
const deviceNodes = useRef(new Map());
|
||||||
|
const previousPositions = useRef(new Map());
|
||||||
|
const devices = snapshot?.devices || [];
|
||||||
|
|
||||||
async function load(quiet = false) {
|
async function load(quiet = false, discover = false) {
|
||||||
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
||||||
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const next = await api.devices.list();
|
const next = await (discover ? api.devices.refresh() : api.devices.list());
|
||||||
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
|
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
|
||||||
setError(null);
|
setError(null);
|
||||||
setStatus('ready');
|
setStatus('ready');
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(requestError);
|
setError(requestError);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
setRefreshCycle((cycle) => cycle + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
load();
|
load();
|
||||||
const timer = setInterval(() => load(true), 15_000);
|
return undefined;
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || refreshing || status === 'loading') return undefined;
|
||||||
|
const timer = setTimeout(() => load(true), AUTO_REFRESH_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [open, refreshCycle, refreshing, status]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
previousPositions.current.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const positions = new Map();
|
||||||
|
for (const [id, node] of deviceNodes.current) {
|
||||||
|
node.getAnimations().forEach((animation) => animation.cancel());
|
||||||
|
positions.set(id, node.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
for (const [id, after] of positions) {
|
||||||
|
const before = previousPositions.current.get(id);
|
||||||
|
const deltaY = before ? before.top - after.top : 0;
|
||||||
|
if (Math.abs(deltaY) < 1) continue;
|
||||||
|
deviceNodes.current.get(id)?.animate([
|
||||||
|
{ transform: `translateY(${deltaY}px)` },
|
||||||
|
{ transform: 'translateY(0)' },
|
||||||
|
], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
previousPositions.current = positions;
|
||||||
|
}, [devices, open]);
|
||||||
|
|
||||||
async function updateDevice(device, patch) {
|
async function updateDevice(device, patch) {
|
||||||
setSavingId(device.id);
|
setSavingId(device.id);
|
||||||
try {
|
try {
|
||||||
@@ -71,7 +111,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
setEditingId('');
|
setEditingId('');
|
||||||
}
|
}
|
||||||
|
|
||||||
const devices = snapshot?.devices || [];
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
@@ -90,7 +129,27 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>×</button>
|
>×</button>
|
||||||
<header className="client-instructions-header client-devices-header">
|
<header className="client-instructions-header client-devices-header">
|
||||||
|
<div className="client-devices-kicker">
|
||||||
<span>Gateway · {devices.length}</span>
|
<span>Gateway · {devices.length}</span>
|
||||||
|
<span className="client-devices-refresh-wrap client-tooltip-anchor">
|
||||||
|
<button
|
||||||
|
className={`client-devices-refresh${refreshing ? ' is-refreshing' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={refreshing ? 'Обновляем устройства' : 'Обновить устройства сейчас'}
|
||||||
|
aria-busy={refreshing}
|
||||||
|
disabled={refreshing}
|
||||||
|
onClick={() => load(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" />
|
||||||
|
</svg>
|
||||||
|
<svg className="client-devices-refresh-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Tooltip>{refreshing ? 'Обновляем устройства…' : 'Обновить сейчас · автоматически каждые 15 с'}</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<h2 id="client-devices-title">Устройства</h2>
|
<h2 id="client-devices-title">Устройства</h2>
|
||||||
<div className="client-instructions-intro">
|
<div className="client-instructions-intro">
|
||||||
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
|
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
|
||||||
@@ -120,7 +179,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
const saving = savingId === device.id;
|
const saving = savingId === device.id;
|
||||||
const seen = formatLastSeen(device.lastSeenAt);
|
const seen = formatLastSeen(device.lastSeenAt);
|
||||||
const uncertainIdentity = device.confidence !== 'high';
|
const uncertainIdentity = device.confidence !== 'high';
|
||||||
return <article className={`client-device is-${device.status}`} key={device.id}>
|
return <article
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) deviceNodes.current.set(device.id, node);
|
||||||
|
else deviceNodes.current.delete(device.id);
|
||||||
|
}}
|
||||||
|
className={`client-device is-${device.status}`}
|
||||||
|
key={device.id}
|
||||||
|
>
|
||||||
<div className="client-device-heading">
|
<div className="client-device-heading">
|
||||||
<span className="client-device-status">{STATUS_LABELS[device.status]}</span>
|
<span className="client-device-status">{STATUS_LABELS[device.status]}</span>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
|
|||||||
+102
-3
@@ -723,6 +723,98 @@ p {
|
|||||||
margin-bottom: 28px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-devices-kicker {
|
||||||
|
width: max-content;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh-wrap {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh {
|
||||||
|
position: relative;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 220ms ease, filter 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh:hover:not(:disabled),
|
||||||
|
.client-devices-refresh:focus-visible,
|
||||||
|
.client-devices-refresh.is-refreshing {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh-ring,
|
||||||
|
.client-devices-refresh-icon {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh-ring {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh-ring circle {
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
opacity: 0.68;
|
||||||
|
animation: client-devices-refresh-progress 15s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh.is-refreshing .client-devices-refresh-ring circle {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh.is-refreshing .client-devices-refresh-icon {
|
||||||
|
animation: client-spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-header .client-devices-refresh-wrap > .client-tooltip {
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-devices-refresh-progress {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.client-devices-source,
|
.client-devices-source,
|
||||||
.client-devices-error,
|
.client-devices-error,
|
||||||
.client-devices-empty {
|
.client-devices-empty {
|
||||||
@@ -922,14 +1014,14 @@ p {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-pin-wrap > .client-tooltip {
|
.client-device-pin-wrap.client-tooltip-anchor > .client-tooltip {
|
||||||
right: 0;
|
right: 0;
|
||||||
left: auto;
|
left: auto;
|
||||||
transform: translate(0, 2px);
|
transform: translate(0, 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-pin-wrap:hover > .client-tooltip,
|
.client-device-pin-wrap.client-tooltip-anchor:hover > .client-tooltip,
|
||||||
.client-device-pin-wrap:has(> :focus-visible) > .client-tooltip {
|
.client-device-pin-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
|
||||||
transform: translate(0, 0);
|
transform: translate(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3987,6 +4079,9 @@ p {
|
|||||||
.client-device-edit,
|
.client-device-edit,
|
||||||
.client-device-edit svg,
|
.client-device-edit svg,
|
||||||
.client-device-edit-wrap,
|
.client-device-edit-wrap,
|
||||||
|
.client-devices-refresh,
|
||||||
|
.client-devices-refresh-ring circle,
|
||||||
|
.client-devices-refresh-icon,
|
||||||
.client-text-morph-value,
|
.client-text-morph-value,
|
||||||
.client-subscription-edit,
|
.client-subscription-edit,
|
||||||
.client-subscription-edit::after,
|
.client-subscription-edit::after,
|
||||||
@@ -3998,6 +4093,10 @@ p {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-devices-refresh-ring circle {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.client-local-rules,
|
.client-local-rules,
|
||||||
.client-local-rules-toggle,
|
.client-local-rules-toggle,
|
||||||
.client-local-rules-toggle svg,
|
.client-local-rules-toggle svg,
|
||||||
|
|||||||
@@ -30,14 +30,21 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
|
|||||||
], current.toISOString()),
|
], current.toISOString()),
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
let observeCalls = 0;
|
||||||
const service = createDeviceInventoryService({
|
const service = createDeviceInventoryService({
|
||||||
store,
|
store,
|
||||||
observe: async () => observation,
|
observe: async () => {
|
||||||
|
observeCalls += 1;
|
||||||
|
return observation;
|
||||||
|
},
|
||||||
vendor,
|
vendor,
|
||||||
now: () => current,
|
now: () => current,
|
||||||
});
|
});
|
||||||
|
|
||||||
let snapshot = await service.refresh();
|
const [firstRefresh, sharedRefresh] = await Promise.all([service.refresh(), service.refresh()]);
|
||||||
|
let snapshot = firstRefresh;
|
||||||
|
assert.strictEqual(sharedRefresh, firstRefresh);
|
||||||
|
assert.equal(observeCalls, 1);
|
||||||
assert.equal(snapshot.devices.length, 1);
|
assert.equal(snapshot.devices.length, 1);
|
||||||
assert.equal(snapshot.devices[0].manufacturer, 'Example Devices');
|
assert.equal(snapshot.devices[0].manufacturer, 'Example Devices');
|
||||||
assert.equal(snapshot.devices[0].status, 'online');
|
assert.equal(snapshot.devices[0].status, 'online');
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ import { formatLastSeen } from '../../src/web/utils/format.js';
|
|||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||||
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||||
const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8');
|
const panel = fs.readFileSync(path.join(root, 'src/web/components/DevicesPanel.jsx'), 'utf8');
|
||||||
|
const api = fs.readFileSync(path.join(root, 'src/web/api.js'), 'utf8');
|
||||||
|
const server = fs.readFileSync(path.join(root, 'src/server/index.js'), 'utf8');
|
||||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||||
|
|
||||||
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
|
test('Gateway device inventory uses the existing accessible responsive drawer', () => {
|
||||||
assert.match(overview, /isGateway && <button[\s\S]*client-devices-toggle/);
|
assert.match(overview, /isGateway && <button[\s\S]*client-devices-toggle/);
|
||||||
assert.match(panel, /api\.devices\.list\(\)/);
|
assert.match(panel, /api\.devices\.list\(\)/);
|
||||||
|
assert.match(panel, /api\.devices\.refresh\(\)/);
|
||||||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||||||
|
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
|
||||||
|
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||||
|
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||||
|
assert.match(server, /requestUrl\.pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||||||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||||||
assert.match(panel, /<TextMorph from=\{seen\.label\} to=\{seen\.relative\} \/>/);
|
assert.match(panel, /<TextMorph from=\{seen\.label\} to=\{seen\.relative\} \/>/);
|
||||||
assert.doesNotMatch(panel, /client-text-morph-goo/);
|
assert.doesNotMatch(panel, /client-text-morph-goo/);
|
||||||
@@ -24,6 +31,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/);
|
assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/);
|
||||||
assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/);
|
assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/);
|
||||||
assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/);
|
assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/);
|
||||||
|
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
|
||||||
|
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user