Allow device routing without pinning
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 17:27:13 +03:00
parent 608f8cfcf2
commit dfa9f09695
13 changed files with 114 additions and 61 deletions
@@ -314,7 +314,7 @@ export function createDeviceInventoryService({
}
function policyIdentity(device) {
return device?.pinned && device.confidence !== 'ambiguous'
return Boolean(device) && device.confidence !== 'ambiguous'
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
&& isDeviceInterface(device.interface);
}
@@ -783,11 +783,6 @@ export function createDeviceInventoryService({
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
const index = state.devices.findIndex((device) => device.id === id);
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
const currentPolicy = policyFor(state, state.devices[index].mac);
if (pinProvided && patch.pinned === false
&& (currentPolicy.desired === 'direct' || currentPolicy.applied === 'direct')) {
throw new HarborError('REQUEST_INVALID');
}
const devices = [...state.devices];
devices[index] = {
...devices[index],
@@ -809,7 +804,6 @@ export function createDeviceInventoryService({
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
const device = state.devices.find((candidate) => candidate.id === id);
if (!device) throw new HarborError('DEVICE_NOT_FOUND');
if (mode === 'direct' && !device.pinned) throw new HarborError('DEVICE_POLICY_REQUIRES_PIN');
if (mode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
const current = policyFor(state, device.mac);
if (current.desired === mode && current.status === 'applied') return state;
-1
View File
@@ -11,7 +11,6 @@ export const ERROR_DEFINITIONS = Object.freeze({
STATE_CONFLICT: { status: 409, message: 'Данные изменились во время операции.', retryable: true },
SERVER_NOT_FOUND: { status: 404, message: 'Выбранный сервер больше недоступен.', retryable: false },
DEVICE_NOT_FOUND: { status: 404, message: 'Устройство больше недоступно.', retryable: false },
DEVICE_POLICY_REQUIRES_PIN: { status: 409, message: 'Сначала закрепите устройство.', retryable: false },
DEVICE_IDENTITY_AMBIGUOUS: { status: 409, message: 'Gateway не может безопасно применить маршрут к этому устройству.', retryable: true },
DEVICE_POLICY_APPLY_FAILED: { status: 503, message: 'Не удалось применить маршрут устройства.', retryable: true },
CONFIG_INVALID: { status: 422, message: 'Конфигурация VPN недействительна.', retryable: false },
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.12.3',
gatewayClient: '0.13.0',
gatewayBackend: '0.13.1',
macClient: '0.13.0',
gatewayClient: '0.14.0',
gatewayBackend: '0.14.0',
});
export function parseVersion(value) {
+21 -22
View File
@@ -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">
+1 -1
View File
@@ -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
View File
@@ -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,
+2
View File
@@ -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)