diff --git a/.codex/skills/design-vpn-client-ui/SKILL.md b/.codex/skills/design-vpn-client-ui/SKILL.md
index d2bf230..9c52afa 100644
--- a/.codex/skills/design-vpn-client-ui/SKILL.md
+++ b/.codex/skills/design-vpn-client-ui/SKILL.md
@@ -26,6 +26,7 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
- Never let labels, timers, feedback, icons, progress, or server rows shift neighboring content.
- Animate state, opacity, blur, glow, color, filter, and transform. Do not animate layout properties.
- Make live behavior visibly alive: running processes, changing values, mode changes, and interactive affordances should communicate through restrained motion instead of abrupt static replacement.
+- Give every actionable icon a semantic hover/focus response; rotate cyclic actions, move the physical part of object-like controls, and keep their hit targets fixed.
- Let every visible cycle finish and return to its resting coordinates before stopping. Never cancel a hover animation, spinner, or list exit at an arbitrary frame.
- Animate dynamic rows through complete enter and exit phases; keep a departing row mounted until its exit finishes, with immediate removal under reduced motion.
- Animate only what changed. Keep unchanged digits, labels, icons, and surrounding geometry stable.
@@ -51,5 +52,6 @@ Before handing off, verify:
- Server separators are compact and only slightly wider than their content.
- Repeated polling does not replay decorative list animations.
- Manual refresh has an obvious but non-jarring response.
+- Icon-only controls respond on hover and focus, active cyclic work spins, and durable states such as pinned remain legible at rest.
- Keyboard focus remains visible even when the text caret is intentionally hidden.
- Narrow screens return to a simple single-column layout.
diff --git a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md
index b61c231..ec942dd 100644
--- a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md
+++ b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md
@@ -61,6 +61,15 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri
- On updated traffic, tween the number, advance the bar, and emit a visible but brief mode-accent flare.
- Keep refresh tooltip outside the rotating button so it remains upright and unfiltered.
+## Icon controls
+
+- Give every actionable icon a small semantic response on hover and keyboard focus; leave decorative icons still.
+- Rotate cyclic actions such as refresh, ping, and traffic sorting on hover, then use the shared continuous spin while work is running.
+- Move the physical part of object-like controls: lift and tilt a pin, pencil, or trash lid instead of moving its fixed hit target.
+- Keep durable state on the icon wrapper and transient motion on the SVG child. A pinned icon stays lifted and tilted while its row moves to the pinned group.
+- Keep the hit target, tooltip, and surrounding layout fixed. Tooltips remain outside the transformed SVG.
+- Under reduced motion, preserve color, focus, and final state without animated travel or rotation.
+
## Server cascade
- On initial display, reveal rows from top to bottom with a small negative Y offset, opacity, and blur.
diff --git a/README.md b/README.md
index 11b5bc4..f95dfb0 100644
--- a/README.md
+++ b/README.md
@@ -76,9 +76,9 @@ http://АДРЕС-GATEWAY:3456
### Устройства Gateway
-После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённый интернет-трафик. В строке отдельно отмечаются ненулевые источники `Gateway` и `Прокси`; одно устройство может использовать оба, а подробное получено/отдано доступно при наведении или фокусе. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; название, закрепление и накопленные totals сохраняются в volume Gateway. Кнопка «Трафик ↓/↑» сортирует список по сумме обоих источников от большего объёма к меньшему или наоборот.
+После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённый интернет-трафик. В строке отдельно отмечаются ненулевые источники `Gateway` и `Прокси`; одно устройство может использовать оба, а подробное получено/отдано доступно при наведении или фокусе. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
-У закреплённого и однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую`. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут; перед откреплением устройство нужно вернуть в `VPN`.
+У однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую` независимо от закрепления. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут.
Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится.
diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js
index 2013ed7..8e78539 100644
--- a/src/server/services/deviceInventoryService.js
+++ b/src/server/services/deviceInventoryService.js
@@ -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;
diff --git a/src/shared/errors.js b/src/shared/errors.js
index c5873fe..b6d36a8 100644
--- a/src/shared/errors.js
+++ b/src/shared/errors.js
@@ -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 },
diff --git a/src/shared/versions.js b/src/shared/versions.js
index 11bf082..5dde818 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -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) {
diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx
index 367d255..53edae6 100644
--- a/src/web/components/DevicesPanel.jsx
+++ b/src/web/components/DevicesPanel.jsx
@@ -203,7 +203,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
onClick={() => setSortDirection((direction) => direction === 'desc' ? 'asc' : 'desc')}
>
Трафик
- {sortDirection === 'desc' ? '↓' : '↑'}
+
+ {sortDirection === 'desc' ? '↓' : '↑'}
+ Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика
@@ -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}
>
+
+
+ {device.pinned ? 'Открепить' : 'Закрепить'}
+
+