Allow device routing without pinning
This commit is contained in:
@@ -203,7 +203,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
onClick={() => setSortDirection((direction) => direction === 'desc' ? 'asc' : 'desc')}
|
||||
>
|
||||
<span>Трафик</span>
|
||||
<span aria-hidden="true">{sortDirection === 'desc' ? '↓' : '↑'}</span>
|
||||
<span className="client-devices-sort-icon" aria-hidden="true">
|
||||
{sortDirection === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
|
||||
</span>
|
||||
@@ -274,16 +276,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
: device.appliedPolicy;
|
||||
const cannotEnableDirect = device.policyStatus === 'applied'
|
||||
&& device.appliedPolicy !== 'direct'
|
||||
&& (!device.pinned || device.confidence === 'ambiguous');
|
||||
&& device.confidence === 'ambiguous';
|
||||
const policyTooltip = policyBusy
|
||||
? `Применяем: ${device.desiredPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}`
|
||||
: policyFailed
|
||||
? `${device.policyError || 'Маршрут не применён'}. Сейчас: ${device.appliedPolicy === 'direct' ? 'напрямую' : 'через Gateway'}. Нажмите, чтобы оставить текущий маршрут`
|
||||
: policyPending
|
||||
? 'Gateway должен однозначно распознать устройство. Нажмите, чтобы отменить ожидание'
|
||||
: !device.pinned
|
||||
? 'Закрепите устройство, чтобы изменить маршрут'
|
||||
: device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct'
|
||||
: device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct'
|
||||
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
|
||||
: displayPolicy === 'direct'
|
||||
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
|
||||
@@ -296,6 +296,22 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
className={`client-device is-${device.status}`}
|
||||
key={device.id}
|
||||
>
|
||||
<span className="client-device-pin-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className="client-device-pin"
|
||||
type="button"
|
||||
aria-pressed={device.pinned}
|
||||
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
|
||||
disabled={saving}
|
||||
onClick={() => updateDevice(device, { pinned: !device.pinned })}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
||||
</span>
|
||||
|
||||
<div className="client-device-heading">
|
||||
{editing ? (
|
||||
<form className="client-device-alias" onSubmit={(event) => saveAlias(event, device)}>
|
||||
@@ -343,23 +359,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
<Tooltip>{trafficLabel}</Tooltip>
|
||||
</span>}
|
||||
</span>
|
||||
<span className="client-device-pin-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className="client-device-pin"
|
||||
type="button"
|
||||
aria-pressed={device.pinned}
|
||||
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
|
||||
disabled={saving || device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'}
|
||||
onClick={() => updateDevice(device, { pinned: !device.pinned })}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>{device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'
|
||||
? 'Сначала верните маршрут через Gateway'
|
||||
: device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="client-device-meta">
|
||||
|
||||
@@ -185,7 +185,7 @@ export function ServerPicker({
|
||||
...Object.fromEntries(ids.map((id) => [id, { error: true, checking: true, checkedAt: new Date().toISOString() }])),
|
||||
}));
|
||||
} finally {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 700 - (performance.now() - startedAt))));
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt))));
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
|
||||
|
||||
+54
-11
@@ -761,6 +761,17 @@ p {
|
||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent));
|
||||
}
|
||||
|
||||
.client-devices-sort-icon {
|
||||
display: inline-block;
|
||||
transform-origin: center;
|
||||
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-devices-sort:hover .client-devices-sort-icon,
|
||||
.client-devices-sort:focus-visible .client-devices-sort-icon {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
.client-devices-refresh {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
@@ -878,14 +889,18 @@ p {
|
||||
|
||||
.client-device {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
grid-template-columns: 32px minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
gap: 3px 4px;
|
||||
padding: 10px 8px;
|
||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
||||
}
|
||||
|
||||
.client-device-heading {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto 32px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
@@ -927,6 +942,9 @@ p {
|
||||
}
|
||||
|
||||
.client-device-pin-wrap {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
align-self: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -949,7 +967,7 @@ p {
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: color 180ms ease, filter 220ms ease;
|
||||
transition: color 180ms ease, filter 220ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-device-edit:hover,
|
||||
@@ -959,11 +977,33 @@ p {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-edit:hover svg,
|
||||
.client-device-edit:focus-visible svg {
|
||||
transform: translate(1px, -1px) rotate(-4deg);
|
||||
}
|
||||
|
||||
.client-device-pin[aria-pressed="true"] {
|
||||
color: var(--client-accent);
|
||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 42%, transparent));
|
||||
}
|
||||
|
||||
.client-device-pin svg {
|
||||
transform-origin: 50% 70%;
|
||||
}
|
||||
|
||||
.client-device-pin:hover:not(:disabled) svg,
|
||||
.client-device-pin:focus-visible svg,
|
||||
.client-device-pin[aria-pressed="true"] svg {
|
||||
transform: translateY(-2px) rotate(-12deg);
|
||||
}
|
||||
|
||||
.client-device-pin:active:not(:disabled) svg {
|
||||
transform: translateY(-1px) rotate(-8deg) scale(0.94);
|
||||
}
|
||||
|
||||
.client-device-meta {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 72px auto;
|
||||
@@ -1003,8 +1043,8 @@ p {
|
||||
}
|
||||
|
||||
.client-device-pin-wrap.client-tooltip-anchor > .client-tooltip {
|
||||
right: 0;
|
||||
left: auto;
|
||||
right: auto;
|
||||
left: 0;
|
||||
transform: translate(0, 2px);
|
||||
}
|
||||
|
||||
@@ -3215,7 +3255,7 @@ p {
|
||||
.client-server-health.is-checking .client-server-health-checking {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
animation: client-server-check-spin 700ms linear infinite;
|
||||
animation: client-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.client-servers.is-scalable {
|
||||
@@ -3295,6 +3335,7 @@ p {
|
||||
stroke-width: 1.6;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-server-check.is-checking {
|
||||
@@ -3304,11 +3345,7 @@ p {
|
||||
}
|
||||
|
||||
.client-server-check.is-checking svg {
|
||||
animation: client-server-check-spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes client-server-check-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
animation: client-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.client-server-check:hover:not(:disabled),
|
||||
@@ -3316,6 +3353,11 @@ p {
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-server-check:hover:not(:disabled) svg,
|
||||
.client-server-check:focus-visible:not(.is-checking) svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.client-server-check:focus-visible {
|
||||
outline: 2px solid var(--client-accent);
|
||||
outline-offset: 1px;
|
||||
@@ -4150,6 +4192,7 @@ p {
|
||||
.client-device-edit-wrap,
|
||||
.client-devices-refresh,
|
||||
.client-devices-sort,
|
||||
.client-devices-sort-icon,
|
||||
.client-devices-refresh-ring circle,
|
||||
.client-devices-refresh-icon,
|
||||
.client-text-morph-value,
|
||||
|
||||
@@ -36,6 +36,8 @@ export function sortDevicesByTraffic(devices, direction = 'desc') {
|
||||
return (Array.isArray(devices) ? devices : [])
|
||||
.map((device, index) => ({ device, index }))
|
||||
.sort((left, right) => {
|
||||
const pinned = Number(right.device.pinned === true) - Number(left.device.pinned === true);
|
||||
if (pinned) return pinned;
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user