Refine routing defaults for global and device fallbacks
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
This commit is contained in:
@@ -27,7 +27,11 @@ function App() {
|
||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||
const [servers, setServers] = useState([]);
|
||||
const [customRules, setCustomRules] = useState([]);
|
||||
const [deviceRules, setDeviceRules] = useState([]);
|
||||
const [devicesConfig, setDevicesConfig] = useState({
|
||||
defaultTransparentMode: 'direct',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: [],
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState('');
|
||||
const [pendingTag, setPendingTag] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -68,7 +72,11 @@ function App() {
|
||||
setState(data);
|
||||
setServers(data.servers || []);
|
||||
if (!rulesDirtyRef.current) setCustomRules(data.customRules || []);
|
||||
setDeviceRules(data.deviceRules || []);
|
||||
setDevicesConfig(data.devicesConfig || {
|
||||
defaultTransparentMode: 'direct',
|
||||
proxyDefaultMode: 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setSelectedTag((prev) => prev || data.selectedTag || '');
|
||||
setPendingTag((prev) => prev || data.selectedTag || '');
|
||||
}
|
||||
@@ -195,35 +203,55 @@ function App() {
|
||||
});
|
||||
}
|
||||
|
||||
// === Device Rules ===
|
||||
async function saveDeviceRules(rules) {
|
||||
// === Devices ===
|
||||
async function saveDevicesConfig(nextConfig) {
|
||||
try {
|
||||
const data = await api.deviceRules.save(rules);
|
||||
setDeviceRules(data.deviceRules || rules);
|
||||
const data = await api.devices.save(nextConfig);
|
||||
setDevicesConfig({
|
||||
defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'direct',
|
||||
proxyDefaultMode: data.proxyDefaultMode || 'vpn',
|
||||
devices: data.devices || [],
|
||||
});
|
||||
setState((prev) => prev ? { ...prev, devicesUpdatedAt: data.devicesUpdatedAt } : prev);
|
||||
} catch (err) {
|
||||
pushToast({ kind: 'danger', title: 'Не удалось сохранить устройства', message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
const next = [
|
||||
...deviceRules,
|
||||
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, sourceIps: [], outbound: 'direct' },
|
||||
];
|
||||
setDeviceRules(next);
|
||||
saveDeviceRules(next);
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: [
|
||||
...devicesConfig.devices,
|
||||
{ id: `dev-${Date.now()}`, name: 'Новое устройство', enabled: true, ip: '', mac: '', mode: 'direct', lastSeen: null },
|
||||
],
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDevice(id, patch) {
|
||||
const next = deviceRules.map((d) => (d.id === id ? { ...d, ...patch } : d));
|
||||
setDeviceRules(next);
|
||||
saveDeviceRules(next);
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function removeDevice(id) {
|
||||
const next = deviceRules.filter((d) => d.id !== id);
|
||||
setDeviceRules(next);
|
||||
saveDeviceRules(next);
|
||||
const nextConfig = {
|
||||
...devicesConfig,
|
||||
devices: devicesConfig.devices.filter((d) => d.id !== id),
|
||||
};
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
function updateDeviceDefaults(patch) {
|
||||
const nextConfig = { ...devicesConfig, ...patch };
|
||||
setDevicesConfig(nextConfig);
|
||||
saveDevicesConfig(nextConfig);
|
||||
}
|
||||
|
||||
// === Rules CRUD ===
|
||||
@@ -326,11 +354,16 @@ function App() {
|
||||
);
|
||||
|
||||
const dirtyRules = rulesSaveStatus === 'pending' || rulesSaveStatus === 'saving';
|
||||
const dirtyDevices = Boolean(
|
||||
state?.devicesUpdatedAt &&
|
||||
(!state?.rulesAppliedAt || state.devicesUpdatedAt > state.rulesAppliedAt),
|
||||
);
|
||||
const dirtyServer = pendingTag && pendingTag !== state?.selectedTag;
|
||||
const dirty = dirtyRules || dirtyServer;
|
||||
const dirtyRouting = dirtyRules || dirtyDevices;
|
||||
const dirty = dirtyRouting || dirtyServer;
|
||||
|
||||
const sidebarBadges = {
|
||||
routing: dirtyRules ? { kind: 'warn', text: '●' } : null,
|
||||
routing: dirtyRouting ? { kind: 'warn', text: '●' } : null,
|
||||
servers: dirtyServer ? { kind: 'warn', text: '●' } : null,
|
||||
settings: !state?.hasSubscription ? { kind: 'danger', text: '!' } : null,
|
||||
};
|
||||
@@ -391,13 +424,14 @@ function App() {
|
||||
onRemove={removeRule}
|
||||
onSaveNow={saveRulesNow}
|
||||
onReorder={reorderRules}
|
||||
deviceRules={deviceRules}
|
||||
devicesConfig={devicesConfig}
|
||||
onUpdateDeviceDefaults={updateDeviceDefaults}
|
||||
onAddDevice={addDevice}
|
||||
onUpdateDevice={updateDevice}
|
||||
onRemoveDevice={removeDevice}
|
||||
/>
|
||||
)}
|
||||
{page === 'logs' && <LogsPage deviceRules={deviceRules} />}
|
||||
{page === 'logs' && <LogsPage devices={devicesConfig.devices} />}
|
||||
{page === 'settings' && (
|
||||
<SettingsPage
|
||||
state={state}
|
||||
@@ -413,19 +447,22 @@ function App() {
|
||||
)}
|
||||
|
||||
{/* Sticky bar — для routing/servers */}
|
||||
{(page === 'routing' && rulesSaveStatus !== 'saved') && (
|
||||
{(page === 'routing' && dirtyRouting) && (
|
||||
<div className="sticky-bar">
|
||||
<div className="flex">
|
||||
<span className={`dot ${rulesSaveStatus === 'error' ? 'danger' : 'warning'}`} />
|
||||
<strong>
|
||||
{rulesSaveStatus === 'saving' && 'Сохраняем…'}
|
||||
{rulesSaveStatus === 'pending' && 'Есть несохранённые изменения'}
|
||||
{rulesSaveStatus === 'saved' && dirtyDevices && 'Изменения устройств сохранены'}
|
||||
{rulesSaveStatus === 'error' && 'Ошибка сохранения'}
|
||||
</strong>
|
||||
<small className="muted">Изменения сохранены, но конфиг не пересобран. Применить — на странице «Серверы».</small>
|
||||
<small className="muted">Конфиг sing-box нужно пересобрать и применить.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
|
||||
{rulesSaveStatus !== 'saved' && (
|
||||
<button className="btn btn-secondary sm" onClick={saveRulesNow}>Сохранить сейчас</button>
|
||||
)}
|
||||
{state?.selectedTag && (
|
||||
<button className="btn btn-primary sm" onClick={() => applyServer(state.selectedTag)} disabled={busy}>
|
||||
Применить config
|
||||
|
||||
@@ -35,6 +35,15 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
devices: {
|
||||
get: () => request("/api/devices"),
|
||||
save: (devicesConfig) =>
|
||||
request("/api/devices", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(devicesConfig),
|
||||
}),
|
||||
},
|
||||
|
||||
ruleSets: {
|
||||
get: () => request("/api/rule-sets"),
|
||||
save: (ruleSets) =>
|
||||
@@ -93,10 +102,10 @@ export const api = {
|
||||
},
|
||||
|
||||
route: {
|
||||
check: ({ host, ip, port, network }) =>
|
||||
check: ({ host, ip, port, network, sourceIp, inbound }) =>
|
||||
request("/api/route/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ host, ip, port, network }),
|
||||
body: JSON.stringify({ host, ip, port, network, sourceIp, inbound }),
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
@@ -33,14 +33,13 @@ const CATEGORY_BADGE = {
|
||||
other: { cls: '', label: 'other' },
|
||||
};
|
||||
|
||||
function getDeviceName(sourceIp, deviceRules) {
|
||||
if (!sourceIp || !deviceRules?.length) return null;
|
||||
for (const d of deviceRules) {
|
||||
function getDeviceName(sourceIp, devices) {
|
||||
if (!sourceIp || !devices?.length) return null;
|
||||
for (const d of devices) {
|
||||
if (d.enabled === false) continue;
|
||||
for (const ip of (d.sourceIps || [])) {
|
||||
const plain = ip.endsWith('/32') ? ip.slice(0, -3) : ip;
|
||||
if (plain === sourceIp) return d.name;
|
||||
}
|
||||
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
|
||||
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
|
||||
if (plain === sourceIp) return d.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -64,7 +63,7 @@ function groupTraffic(list, sortBy = 'time') {
|
||||
return arr.sort((a, b) => b._lastTs - a._lastTs);
|
||||
}
|
||||
|
||||
function TrafficTab({ deviceRules = [] }) {
|
||||
function TrafficTab({ devices = [] }) {
|
||||
const [traffic, setTraffic] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
|
||||
@@ -104,11 +103,11 @@ function TrafficTab({ deviceRules = [] }) {
|
||||
e.outbound?.toLowerCase().includes(s) ||
|
||||
e.matchedRule?.toLowerCase().includes(s) ||
|
||||
e.sourceIp?.toLowerCase().includes(s) ||
|
||||
getDeviceName(e.sourceIp, deviceRules)?.toLowerCase().includes(s),
|
||||
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
|
||||
);
|
||||
}
|
||||
return grouped ? groupTraffic(list, sortBy) : list;
|
||||
}, [traffic, filter, search, grouped, sortBy]);
|
||||
}, [traffic, filter, search, grouped, sortBy, devices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoscroll || !containerRef.current) return;
|
||||
@@ -183,7 +182,7 @@ function TrafficTab({ deviceRules = [] }) {
|
||||
<tbody>
|
||||
{filtered.map((e, i) => {
|
||||
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
|
||||
const deviceName = getDeviceName(e.sourceIp, deviceRules);
|
||||
const deviceName = getDeviceName(e.sourceIp, devices);
|
||||
return (
|
||||
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
|
||||
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
|
||||
@@ -218,7 +217,7 @@ function TrafficTab({ deviceRules = [] }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage({ deviceRules = [] }) {
|
||||
export function LogsPage({ devices = [] }) {
|
||||
const [tab, setTab] = useState('traffic'); // traffic | logs
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
@@ -276,7 +275,7 @@ export function LogsPage({ deviceRules = [] }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'traffic' && <TrafficTab deviceRules={deviceRules} />}
|
||||
{tab === 'traffic' && <TrafficTab devices={devices} />}
|
||||
|
||||
{tab === 'logs' && (
|
||||
<>
|
||||
|
||||
@@ -136,7 +136,9 @@ function RoutingSummary({ state, onNav, onFlushDirectCache }) {
|
||||
const rules = state?.customRules || [];
|
||||
const enabled = rules.filter((r) => r.enabled).length;
|
||||
const cacheCount = state?.directBypassCount || 0;
|
||||
const cacheAvailable = state?.directBypassAvailable;
|
||||
const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled;
|
||||
const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'direct';
|
||||
const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn';
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
@@ -148,8 +150,9 @@ function RoutingSummary({ state, onNav, onFlushDirectCache }) {
|
||||
{state?.routingRuDirect && (
|
||||
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success">→ direct</span></div>
|
||||
)}
|
||||
<div className="row"><span className="key">Custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
||||
<div className="row"><span className="key">Остальное</span><span className="val text-warning">→ VPN</span></div>
|
||||
<div className="row"><span className="key">Global custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
||||
<div className="row"><span className="key">Transparent fallback</span><span className="val">→ {transparentDefault}</span></div>
|
||||
<div className="row"><span className="key">Proxy fallback</span><span className="val text-warning">→ {proxyDefault}</span></div>
|
||||
{cacheAvailable && (
|
||||
<div className="row">
|
||||
<span className="key">Direct bypass cache</span>
|
||||
|
||||
@@ -5,6 +5,8 @@ export function RouteChecker() {
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('443');
|
||||
const [network, setNetwork] = useState('tcp');
|
||||
const [sourceIp, setSourceIp] = useState('');
|
||||
const [inbound, setInbound] = useState('tproxy-in');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
@@ -14,7 +16,13 @@ export function RouteChecker() {
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await api.route.check({ host, port: port || undefined, network });
|
||||
const data = await api.route.check({
|
||||
host,
|
||||
port: port || undefined,
|
||||
network,
|
||||
sourceIp: sourceIp || undefined,
|
||||
inbound,
|
||||
});
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -52,6 +60,17 @@ export function RouteChecker() {
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="source IP"
|
||||
value={sourceIp}
|
||||
onChange={(e) => setSourceIp(e.target.value)}
|
||||
style={{ width: 145 }}
|
||||
/>
|
||||
<select className="select" value={inbound} onChange={(e) => setInbound(e.target.value)} style={{ width: 130 }}>
|
||||
<option value="tproxy-in">tproxy-in</option>
|
||||
<option value="mixed-in">mixed-in</option>
|
||||
</select>
|
||||
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,22 +18,69 @@ const OUTBOUND_KIND = {
|
||||
block: { kind: 'danger', label: 'block' },
|
||||
};
|
||||
|
||||
function DevicesCard({ devices, onAdd, onUpdate, onRemove }) {
|
||||
const DEVICE_MODES = {
|
||||
direct: { kind: 'success', label: 'direct', hint: 'fallback после global rules' },
|
||||
vpn: { kind: 'info', label: 'VPN', hint: 'fallback после global rules' },
|
||||
rules: { kind: 'neutral', label: 'default', hint: 'использует transparent default' },
|
||||
block: { kind: 'danger', label: 'block', hint: 'fallback после global rules' },
|
||||
};
|
||||
|
||||
function DeviceModeSelect({ value, onChange }) {
|
||||
return (
|
||||
<select className="select sm" value={value || 'rules'} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="rules">default</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) {
|
||||
const devices = devicesConfig?.devices || [];
|
||||
const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'direct';
|
||||
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Маршрутизация по устройствам</h2>
|
||||
<button className="btn btn-primary sm" onClick={onAdd}>
|
||||
+ Добавить устройство
|
||||
</button>
|
||||
<div>
|
||||
<h2>Устройства</h2>
|
||||
<small className="muted">Global rules применяются первыми. Эти значения — fallback после них.</small>
|
||||
</div>
|
||||
<div className="btn-group">
|
||||
<label className="field" style={{ minWidth: 180, margin: 0 }}>
|
||||
<span className="field-label">Transparent default</span>
|
||||
<select
|
||||
className="select sm"
|
||||
value={defaultTransparentMode}
|
||||
onChange={(e) => onDefaultsChange({ defaultTransparentMode: e.target.value })}
|
||||
>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ minWidth: 160, margin: 0 }}>
|
||||
<span className="field-label">Proxy default</span>
|
||||
<select
|
||||
className="select sm"
|
||||
value={proxyDefaultMode}
|
||||
onChange={(e) => onDefaultsChange({ proxyDefaultMode: e.target.value })}
|
||||
>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="direct">direct</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn btn-primary sm" onClick={onAdd}>
|
||||
+ Добавить устройство
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
|
||||
Правила по source IP — выполняются <strong>до</strong> правил маршрутизации.
|
||||
Укажи IP устройства в сети и куда направлять весь его трафик.
|
||||
</small>
|
||||
{devices.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '16px 0' }}>
|
||||
<p style={{ margin: 0 }}>Нет правил по устройствам — все используют общую маршрутизацию.</p>
|
||||
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
@@ -42,14 +89,16 @@ function DevicesCard({ devices, onAdd, onUpdate, onRemove }) {
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Название</th>
|
||||
<th>IP-адрес(а) устройства</th>
|
||||
<th style={{ width: 130 }}>Маршрут</th>
|
||||
<th style={{ width: 170 }}>IP</th>
|
||||
<th style={{ width: 150 }}>MAC</th>
|
||||
<th style={{ width: 150 }}>Mode</th>
|
||||
<th>Поведение</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((dev) => {
|
||||
const ob = OUTBOUND_KIND[dev.outbound] || OUTBOUND_KIND.direct;
|
||||
const mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
|
||||
return (
|
||||
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
|
||||
<td>
|
||||
@@ -72,29 +121,27 @@ function DevicesCard({ devices, onAdd, onUpdate, onRemove }) {
|
||||
<td>
|
||||
<input
|
||||
className="input sm"
|
||||
value={(dev.sourceIps || []).join(', ')}
|
||||
onChange={(e) =>
|
||||
onUpdate(dev.id, {
|
||||
sourceIps: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
placeholder="192.168.1.100"
|
||||
style={{ width: '100%', minWidth: 160 }}
|
||||
value={dev.ip || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
|
||||
placeholder="192.168.1.50"
|
||||
style={{ width: '100%', minWidth: 140 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
className="select sm"
|
||||
value={dev.outbound || 'direct'}
|
||||
onChange={(e) => onUpdate(dev.id, { outbound: e.target.value })}
|
||||
>
|
||||
<option value="direct">direct</option>
|
||||
<option value="vpn">VPN</option>
|
||||
<option value="block">block</option>
|
||||
</select>
|
||||
<input
|
||||
className="input sm"
|
||||
value={dev.mac || ''}
|
||||
onChange={(e) => onUpdate(dev.id, { mac: e.target.value })}
|
||||
placeholder="опционально"
|
||||
style={{ width: '100%', minWidth: 120 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<DeviceModeSelect value={dev.mode} onChange={(mode) => onUpdate(dev.id, { mode })} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${mode.kind}`}>{mode.label}</span>
|
||||
<small className="muted" style={{ marginLeft: 8 }}>{mode.hint}</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@@ -195,7 +242,7 @@ function TemplatesModal({ open, onClose, onAdd }) {
|
||||
export function RoutingPage({
|
||||
rules, saveStatus, busy,
|
||||
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
|
||||
deviceRules = [], onAddDevice, onUpdateDevice, onRemoveDevice,
|
||||
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
|
||||
}) {
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
@@ -240,7 +287,8 @@ export function RoutingPage({
|
||||
<RouteChecker />
|
||||
|
||||
<DevicesCard
|
||||
devices={deviceRules}
|
||||
devicesConfig={devicesConfig}
|
||||
onDefaultsChange={onUpdateDeviceDefaults}
|
||||
onAdd={onAddDevice}
|
||||
onUpdate={onUpdateDevice}
|
||||
onRemove={onRemoveDevice}
|
||||
|
||||
Reference in New Issue
Block a user