Add per-device VPN and direct routing policies
This commit is contained in:
@@ -86,6 +86,10 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ...patch, expectedRevision }),
|
||||
}),
|
||||
setPolicy: (id, mode, expectedRevision) => request(`/api/devices/${id}/policy`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ mode, expectedRevision }),
|
||||
}),
|
||||
},
|
||||
singbox: {
|
||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||
|
||||
@@ -130,6 +130,37 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
setEditingId('');
|
||||
}
|
||||
|
||||
async function updatePolicy(device, mode) {
|
||||
setSavingId(device.id);
|
||||
try {
|
||||
let next;
|
||||
try {
|
||||
next = await api.devices.setPolicy(device.id, mode, snapshot.revision);
|
||||
} catch (requestError) {
|
||||
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
|
||||
const latest = await api.devices.list();
|
||||
setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
|
||||
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
|
||||
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
|
||||
next = await api.devices.setPolicy(device.id, mode, latest.revision);
|
||||
}
|
||||
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
|
||||
setError(null);
|
||||
} catch (requestError) {
|
||||
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
|
||||
try {
|
||||
const latest = await api.devices.list();
|
||||
setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
|
||||
} catch {
|
||||
// Keep the policy error as the actionable result.
|
||||
}
|
||||
}
|
||||
setError(requestError);
|
||||
} finally {
|
||||
setSavingId('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
@@ -197,6 +228,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
Трафик временно не обновляется. Показаны последние сохранённые значения.
|
||||
</p>
|
||||
)}
|
||||
{snapshot?.source?.policy?.error && (
|
||||
<p className="client-devices-source" role="status">
|
||||
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="client-devices-error" role="alert">
|
||||
<span>{error.message}</span>
|
||||
@@ -217,6 +253,29 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
const uncertainIdentity = device.confidence !== 'high';
|
||||
const download = formatByteString(device.downloadBytes);
|
||||
const upload = formatByteString(device.uploadBytes);
|
||||
const policyBusy = device.policyStatus === 'applying';
|
||||
const policyFailed = device.policyStatus === 'failed';
|
||||
const policyPending = device.policyStatus === 'pending';
|
||||
const displayPolicy = device.appliedPolicy;
|
||||
const policyTarget = device.policyStatus === 'applied'
|
||||
? device.appliedPolicy === 'direct' ? 'vpn' : 'direct'
|
||||
: device.appliedPolicy;
|
||||
const cannotEnableDirect = device.policyStatus === 'applied'
|
||||
&& device.appliedPolicy !== 'direct'
|
||||
&& (!device.pinned || 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'
|
||||
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
|
||||
: displayPolicy === 'direct'
|
||||
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
|
||||
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
|
||||
return <article
|
||||
ref={(node) => {
|
||||
if (node) deviceNodes.current.set(device.id, node);
|
||||
@@ -260,53 +319,66 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="client-device-traffic-slot">
|
||||
{device.trafficObservedAt && <span
|
||||
className="client-device-traffic"
|
||||
aria-label={`Получено ${download}, отдано ${upload}`}
|
||||
>
|
||||
<span aria-hidden="true">↓ {download} · ↑ {upload}</span>
|
||||
</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}
|
||||
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.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
||||
<Tooltip>{device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'
|
||||
? 'Сначала верните маршрут через Gateway'
|
||||
: device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="client-device-meta">
|
||||
<div className="client-device-addresses">
|
||||
{title !== device.ip && device.ip && <span>{device.ip}</span>}
|
||||
{device.mac && <span className="client-device-mac">
|
||||
{device.mac}
|
||||
{uncertainIdentity && <span className="client-device-identity client-tooltip-anchor" tabIndex="0" aria-label="Пояснение идентификации устройства">
|
||||
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
|
||||
{uncertainIdentity && <span className="client-device-identity client-tooltip-anchor" tabIndex="0" aria-label="Пояснение идентификации устройства">
|
||||
ⓘ
|
||||
<Tooltip>{device.confidence === 'medium'
|
||||
? 'Устройство использует приватный MAC, производитель может не определиться'
|
||||
: device.confidence === 'ambiguous'
|
||||
? 'Один MAC наблюдается у нескольких IP, индивидуальные правила могут быть неточными'
|
||||
? 'Gateway видит это устройство с несколькими IP или интерфейсами, поэтому индивидуальное правило небезопасно'
|
||||
: 'Устройство определено приблизительно'}</Tooltip>
|
||||
</span>}
|
||||
</span>}
|
||||
</div>
|
||||
<span className="client-device-policy-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Маршрут устройства: ${displayPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}. ${policyTooltip}`}
|
||||
aria-pressed={displayPolicy === 'direct'}
|
||||
aria-busy={policyBusy}
|
||||
disabled={saving || policyBusy || cannotEnableDirect}
|
||||
onClick={() => updatePolicy(device, policyTarget)}
|
||||
>
|
||||
{displayPolicy === 'direct' ? 'Напрямую' : 'VPN'}
|
||||
</button>
|
||||
<Tooltip>{policyTooltip}</Tooltip>
|
||||
</span>
|
||||
<span className="client-device-last-seen" tabIndex="0">
|
||||
<time dateTime={device.lastSeenAt} aria-label={seen.tooltip}>
|
||||
<TextMorph from={seen.label} to={seen.relative} />
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
{(device.manufacturer || device.trafficObservedAt) && <div className="client-device-details">
|
||||
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
|
||||
{device.trafficObservedAt && <span
|
||||
className="client-device-traffic"
|
||||
aria-label={`Получено ${download}, отдано ${upload}`}
|
||||
>
|
||||
<span aria-hidden="true">↓ {download} · ↑ {upload}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
+68
-31
@@ -878,17 +878,17 @@ p {
|
||||
|
||||
.client-device {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 14px 8px;
|
||||
gap: 3px;
|
||||
padding: 10px 8px;
|
||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
|
||||
}
|
||||
|
||||
.client-device-heading {
|
||||
display: grid;
|
||||
grid-template-columns: 68px minmax(0, 1fr) 32px;
|
||||
grid-template-columns: 60px minmax(0, 1fr) auto 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.client-device-title {
|
||||
@@ -910,7 +910,7 @@ p {
|
||||
.client-device-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
gap: 6px;
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
@@ -992,11 +992,11 @@ p {
|
||||
|
||||
.client-device-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 10px 16px;
|
||||
margin-left: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 72px auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 66px;
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
line-height: 1.5;
|
||||
@@ -1005,7 +1005,7 @@ p {
|
||||
.client-device-addresses {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow: hidden;
|
||||
gap: 3px 0;
|
||||
}
|
||||
|
||||
@@ -1019,11 +1019,6 @@ p {
|
||||
content: '·';
|
||||
}
|
||||
|
||||
.client-device-mac {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.client-device-identity {
|
||||
position: relative;
|
||||
margin-left: 4px;
|
||||
@@ -1032,8 +1027,6 @@ p {
|
||||
}
|
||||
|
||||
.client-device-last-seen {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
color: var(--client-text);
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
@@ -1100,17 +1093,6 @@ p {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.client-device-details {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 4px 12px;
|
||||
margin-left: 76px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.client-device-manufacturer {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -1120,12 +1102,66 @@ p {
|
||||
}
|
||||
|
||||
.client-device-traffic {
|
||||
margin-left: auto;
|
||||
color: var(--client-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-device-traffic-slot {
|
||||
min-width: 0;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.client-device-policy-wrap {
|
||||
width: 72px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.client-device-policy {
|
||||
width: 72px;
|
||||
min-height: 22px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid color-mix(in oklch, var(--client-border) 82%, transparent);
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
font: 700 8px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
text-transform: uppercase;
|
||||
transition: border-color 180ms ease, color 180ms ease, opacity 180ms ease;
|
||||
}
|
||||
|
||||
.client-device-policy.is-direct {
|
||||
border-color: color-mix(in oklch, var(--client-accent) 52%, var(--client-border));
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-policy.is-failed {
|
||||
border-color: oklch(0.62 0.13 28);
|
||||
color: oklch(0.7 0.12 28);
|
||||
}
|
||||
|
||||
.client-device-policy.is-pending {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.client-device-policy:hover:not(:disabled),
|
||||
.client-device-policy:focus-visible {
|
||||
border-color: var(--client-accent);
|
||||
color: var(--client-accent);
|
||||
}
|
||||
|
||||
.client-device-policy-wrap.client-tooltip-anchor > .client-tooltip {
|
||||
right: 0;
|
||||
left: auto;
|
||||
text-transform: none;
|
||||
transform: translate(0, 2px);
|
||||
}
|
||||
|
||||
.client-device-policy-wrap.client-tooltip-anchor:hover > .client-tooltip,
|
||||
.client-device-policy-wrap.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
.client-device-alias {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
||||
@@ -4097,6 +4133,7 @@ p {
|
||||
.client-device,
|
||||
.client-device-pin,
|
||||
.client-device-pin svg,
|
||||
.client-device-policy,
|
||||
.client-device-edit,
|
||||
.client-device-edit svg,
|
||||
.client-device-edit-wrap,
|
||||
|
||||
Reference in New Issue
Block a user