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)
+74 -7
View File
@@ -191,6 +191,7 @@
.client-devices-error button,
.client-device-pin,
.client-device-deprioritize,
.client-device-edit {
padding: 0;
border: 0;
@@ -213,12 +214,42 @@
row-gap: 6px;
padding: 10px 8px;
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
position: relative;
}
.client-device.is-group-start {
margin-top: 22px;
}
.client-device-group-heading {
position: absolute;
top: -17px;
left: 8px;
color: var(--client-muted);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-device:not(.is-group-start) > .client-device-group-heading {
display: none;
}
.client-device.is-pinned {
--client-device-chart-height: 72px;
}
.client-device.is-deprioritized {
grid-template-rows: 34px;
row-gap: 0;
padding-block: 6px;
}
.client-device.is-deprioritized .client-device-main {
grid-column: 1 / 4;
}
.client-device-main {
grid-column: 2;
grid-row: 1;
@@ -231,7 +262,7 @@
padding: 11px 0 0;
}
.client-device-main > h3 {
.client-device-main > h4 {
height: 23px;
flex: 1 1 auto;
min-width: 0;
@@ -246,7 +277,7 @@
white-space: nowrap;
}
.client-device-main > h3.is-address-only {
.client-device-main > h4.is-address-only {
flex: 0 1 auto;
}
@@ -370,7 +401,8 @@
}
.client-device-edit,
.client-device-pin {
.client-device-pin,
.client-device-deprioritize {
width: 32px;
height: 32px;
display: grid;
@@ -384,7 +416,8 @@
}
.client-device-edit svg,
.client-device-pin svg {
.client-device-pin svg,
.client-device-deprioritize svg {
width: 17px;
height: 17px;
fill: none;
@@ -398,7 +431,9 @@
.client-device-edit:hover,
.client-device-edit:focus-visible,
.client-device-pin:hover,
.client-device-pin:focus-visible {
.client-device-pin:focus-visible,
.client-device-deprioritize:hover,
.client-device-deprioritize:focus-visible {
color: var(--client-accent);
}
@@ -988,7 +1023,8 @@
cursor: wait;
}
.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip {
.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip,
.client-device-deprioritize-wrap.client-tooltip-anchor > .client-tooltip {
right: 0;
left: auto;
text-transform: none;
@@ -996,10 +1032,41 @@
}
.client-device-policy-wrap.client-tooltip-anchor:hover > .client-tooltip,
.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip,
.client-device-deprioritize-wrap.client-tooltip-anchor:hover > .client-tooltip,
.client-device-deprioritize-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
transform: translate(0, 0);
}
.client-device-deprioritize-wrap {
grid-column: 4;
grid-row: 2;
width: 34px;
height: 34px;
position: relative;
z-index: 5;
display: grid;
place-items: center;
}
.client-device.is-deprioritized .client-device-deprioritize-wrap {
grid-row: 1;
}
.client-device-deprioritize[aria-pressed="true"] {
color: var(--client-accent);
}
.client-device-deprioritize:hover:not(:disabled) svg,
.client-device-deprioritize:focus-visible svg {
transform: translateY(2px);
}
.client-device-deprioritize[aria-pressed="true"]:hover:not(:disabled) svg,
.client-device-deprioritize[aria-pressed="true"]:focus-visible svg {
transform: translateY(-2px);
}
.client-devices-error button {
justify-self: start;
font-size: 10px;
+2
View File
@@ -67,6 +67,8 @@
.client-device,
.client-device-pin,
.client-device-pin svg,
.client-device-deprioritize,
.client-device-deprioritize svg,
.client-device-policy,
.client-device-policy svg,
.client-device-alias-trigger,
+7 -1
View File
@@ -69,6 +69,8 @@ export function sortDevicesByTraffic(devices: Device[] | undefined, direction: '
.sort((left, right) => {
const pinned = Number(right.device.pinned === true) - Number(left.device.pinned === true);
if (pinned) return pinned;
const deprioritized = Number(left.device.deprioritized === true) - Number(right.device.deprioritized === true);
if (deprioritized) return deprioritized;
const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes)
+ byteString(left.device.proxyUploadBytes) + byteString(left.device.proxyDownloadBytes);
const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes)
@@ -96,7 +98,11 @@ export function stabilizeDevicesByTraffic(
seen.add(id);
}
const ordered = ids.map((id) => byId.get(id)).filter((device): device is Device => Boolean(device));
const stable = [...ordered.filter(({ pinned }) => pinned), ...ordered.filter(({ pinned }) => !pinned)];
const stable = [
...ordered.filter(({ pinned }) => pinned),
...ordered.filter(({ pinned, deprioritized }) => !pinned && deprioritized !== true),
...ordered.filter(({ pinned, deprioritized }) => !pinned && deprioritized === true),
];
return { devices: stable, ids: stable.map(({ id }) => id) };
}