Add deprioritized device group
Build and Deploy Gateway / build-and-push (push) Successful in 19s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-10 09:25:29 +03:00
parent ec68ba6a7b
commit f233660dc3
13 changed files with 262 additions and 60 deletions
+67 -20
View File
@@ -1,4 +1,4 @@
import React, {
import {
useEffect,
useLayoutEffect,
useMemo,
@@ -233,16 +233,12 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
setAlias(value);
}
async function togglePin(device: Device) {
if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
await updateDevice(device, { pinned: !device.pinned });
return;
}
async function collapsePinned(device: Device, patch: Record<string, unknown>) {
setPinCollapses((current) => ({
...current,
[device.id]: { animationDone: false, result: 'pending' },
}));
const saved = await updateDevice(device, { pinned: false });
const saved = await updateDevice(device, patch);
setPinCollapses((current) => {
const phase = current[device.id];
if (!phase) return current;
@@ -255,6 +251,23 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
});
}
async function togglePin(device: Device) {
if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
await updateDevice(device, { pinned: !device.pinned });
return;
}
await collapsePinned(device, { pinned: false });
}
async function toggleDeprioritized(device: Device) {
const deprioritized = device.deprioritized === true;
if (!device.pinned || deprioritized || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
await updateDevice(device, { deprioritized: !deprioritized });
return;
}
await collapsePinned(device, { deprioritized: true });
}
function finishPinCollapse(deviceId: string) {
setPinCollapses((current) => {
const phase = current[deviceId];
@@ -365,10 +378,21 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
{copyAnnouncement?.message || ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
{devices.map((device, index) => {
const collapsing = Boolean(pinCollapses[device.id]);
const deprioritized = device.deprioritized === true;
const compact = deprioritized && !collapsing;
const expanded = device.pinned && !collapsing;
const showDetails = device.pinned || collapsing;
const showDetails = !compact && (device.pinned || collapsing);
const group = device.pinned ? 'pinned' : deprioritized ? 'deprioritized' : 'default';
const previous = devices[index - 1];
const previousGroup = previous
? previous.pinned ? 'pinned' : previous.deprioritized === true ? 'deprioritized' : 'default'
: '';
const groupLabel = group === 'pinned'
? 'Закреплённые'
: group === 'deprioritized' ? 'Убраны вниз' : 'Остальные';
const groupStart = group !== previousGroup;
const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const editing = editingId === device.id;
@@ -406,14 +430,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
return <article
key={device.id}
ref={(node) => {
if (node) deviceNodes.current.set(device.id, node);
else deviceNodes.current.delete(device.id);
}}
className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}`}
key={device.id}
className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}${compact ? ' is-deprioritized' : ''}${groupStart ? ' is-group-start' : ''}`}
>
<span className="client-device-pin-wrap client-tooltip-anchor">
<span
className="client-device-group-heading"
role={groupStart ? 'heading' : undefined}
aria-level={groupStart ? 3 : undefined}
aria-hidden={!groupStart}
>{groupStart ? groupLabel : ''}</span>
{!compact && <span className="client-device-pin-wrap client-tooltip-anchor">
<button
className="client-device-pin"
type="button"
@@ -427,10 +457,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</svg>
</button>
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
</span>
</span>}
<div className="client-device-main">
<h3 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
<h4 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
{editing ? (
<input
className="client-device-alias-input"
@@ -460,7 +490,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
onClick={() => copyDeviceIp(device)}
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
</h3>
</h4>
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
<button
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
@@ -490,7 +520,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</span>
</div>
<span
{!compact && <span
className="client-device-traffic"
role="group"
tabIndex={0}
@@ -503,9 +533,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
</span>
</span>
</span>}
<span className="client-device-policy-wrap client-tooltip-anchor">
{!compact && <span className="client-device-policy-wrap client-tooltip-anchor">
<button
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
type="button"
@@ -523,8 +553,25 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</svg>}
</button>
<Tooltip>{policyTooltip}</Tooltip>
</span>}
<span className="client-device-deprioritize-wrap client-tooltip-anchor">
<button
className="client-device-deprioritize"
type="button"
aria-pressed={deprioritized}
aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} вниз`}
disabled={saving}
onClick={() => toggleDeprioritized(device)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
{deprioritized
? <path d="M12 20V8M7 13l5-5 5 5M5 4h14" />
: <path d="M12 4v12M7 11l5 5 5-5M5 20h14" />}
</svg>
</button>
<Tooltip>{deprioritized ? 'Вернуть в список' : 'Убрать вниз'}</Tooltip>
</span>
<TrafficChart
{!compact && <TrafficChart
samples={device.trafficHistory || []}
scale={trafficScale}
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
@@ -532,7 +579,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
pinned={showDetails}
collapsing={collapsing}
onCollapseEnd={() => finishPinCollapse(device.id)}
/>
/>}
</article>;
})}
</div>
@@ -21,6 +21,7 @@ export interface Device extends Record<string, unknown> {
lastSeenAt: string | null;
status: DeviceStatus;
pinned: boolean;
deprioritized?: boolean;
downloadBytes: ByteValue;
uploadBytes: ByteValue;
proxyDownloadBytes: ByteValue;
@@ -116,6 +117,8 @@ function validDevice(value: unknown): value is Device {
&& nullableTimestamp(value.lastSeenAt)
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
&& typeof value.pinned === 'boolean'
&& (value.deprioritized === undefined || typeof value.deprioritized === 'boolean')
&& !(value.pinned === true && value.deprioritized === true)
&& bytes(value.downloadBytes)
&& bytes(value.uploadBytes)
&& bytes(value.proxyDownloadBytes)