Add deprioritized device group
This commit is contained in:
@@ -67,6 +67,7 @@ interface InventoryDevice {
|
||||
id: string;
|
||||
alias: string;
|
||||
pinned: boolean;
|
||||
deprioritized: boolean;
|
||||
hostname: string | null;
|
||||
manufacturer: string | null;
|
||||
mac: string;
|
||||
@@ -265,6 +266,7 @@ function normalizeInventoryDevice(value: unknown): InventoryDevice | null {
|
||||
: deviceId(mac),
|
||||
alias: typeof device.alias === 'string' ? device.alias : '',
|
||||
pinned: device.pinned === true,
|
||||
deprioritized: device.deprioritized === true && device.pinned !== true,
|
||||
hostname: typeof device.hostname === 'string' ? device.hostname : null,
|
||||
manufacturer: typeof device.manufacturer === 'string' ? device.manufacturer : null,
|
||||
mac,
|
||||
@@ -792,6 +794,7 @@ export function createDeviceInventoryService({
|
||||
};
|
||||
}).sort((left, right) => (
|
||||
Number(right.pinned) - Number(left.pinned)
|
||||
|| Number(left.deprioritized) - Number(right.deprioritized)
|
||||
|| rank[left.status] - rank[right.status]
|
||||
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
|
||||
));
|
||||
@@ -1016,6 +1019,7 @@ export function createDeviceInventoryService({
|
||||
id: previous?.id || deviceId(mac),
|
||||
alias: previous?.alias || '',
|
||||
pinned: previous?.pinned === true,
|
||||
deprioritized: previous?.deprioritized === true && previous?.pinned !== true,
|
||||
hostname: previous?.hostname || null,
|
||||
manufacturer: previous?.manufacturer || vendor(mac),
|
||||
mac,
|
||||
@@ -1031,7 +1035,7 @@ export function createDeviceInventoryService({
|
||||
}
|
||||
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
||||
const devices = [...byMac.values()].filter((device) => (
|
||||
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||
device.pinned || device.deprioritized || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||
));
|
||||
let traffic = state.traffic;
|
||||
if (trafficResult) {
|
||||
@@ -1271,15 +1275,19 @@ export function createDeviceInventoryService({
|
||||
const value = record(patch);
|
||||
const aliasProvided = Object.hasOwn(value, 'alias');
|
||||
const pinProvided = Object.hasOwn(value, 'pinned');
|
||||
const deprioritizedProvided = Object.hasOwn(value, 'deprioritized');
|
||||
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0
|
||||
|| (!aliasProvided && !pinProvided)
|
||||
|| (!aliasProvided && !pinProvided && !deprioritizedProvided)
|
||||
|| (aliasProvided && (typeof value.alias !== 'string' || value.alias.length > 64))
|
||||
|| (pinProvided && typeof value.pinned !== 'boolean')) {
|
||||
|| (pinProvided && typeof value.pinned !== 'boolean')
|
||||
|| (deprioritizedProvided && typeof value.deprioritized !== 'boolean')
|
||||
|| (value.pinned === true && value.deprioritized === true)) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const revision = expectedRevision;
|
||||
const alias = typeof value.alias === 'string' ? value.alias : '';
|
||||
const pinned = value.pinned === true;
|
||||
const deprioritized = value.deprioritized === true;
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== revision) throw new HarborError('STATE_CONFLICT');
|
||||
@@ -1289,7 +1297,10 @@ export function createDeviceInventoryService({
|
||||
devices[index] = {
|
||||
...devices[index],
|
||||
...(aliasProvided ? { alias: alias.trim() } : {}),
|
||||
...(pinProvided ? { pinned } : {}),
|
||||
...(pinProvided ? { pinned, ...(pinned ? { deprioritized: false } : {}) } : {}),
|
||||
...(deprioritizedProvided
|
||||
? { deprioritized, ...(deprioritized ? { pinned: false } : {}) }
|
||||
: {}),
|
||||
};
|
||||
return { ...state, revision: state.revision + 1, devices };
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.21.8',
|
||||
gatewayClient: '0.22.3',
|
||||
gatewayBackend: '0.22.5',
|
||||
macClient: '0.22.0',
|
||||
gatewayClient: '0.23.0',
|
||||
gatewayBackend: '0.23.0',
|
||||
});
|
||||
|
||||
export interface ParsedVersion {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user