Some checks failed
Build and Deploy Gateway / build-and-deploy (push) Failing after 13s
383 lines
16 KiB
JavaScript
383 lines
16 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
|
||
} from '@dnd-kit/core';
|
||
import {
|
||
arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable,
|
||
} from '@dnd-kit/sortable';
|
||
import { CSS } from '@dnd-kit/utilities';
|
||
import { ruleTemplates } from '../templates/ruleTemplates.js';
|
||
import { ruleErrors, hasErrors } from '../utils/validation.js';
|
||
import { RuleEditorDrawer } from './RuleEditorDrawer.jsx';
|
||
import { RouteChecker } from './RouteChecker.jsx';
|
||
import { api } from '../api.js';
|
||
|
||
const OUTBOUND_KIND = {
|
||
direct: { kind: 'success', label: 'direct' },
|
||
vpn: { kind: 'info', label: 'VPN' },
|
||
block: { kind: 'danger', label: 'block' },
|
||
};
|
||
|
||
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 || 'vpn';
|
||
const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn';
|
||
|
||
return (
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<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>
|
||
{devices.length === 0 ? (
|
||
<div className="empty-state" style={{ padding: '16px 0' }}>
|
||
<p style={{ margin: 0 }}>Нет профилей устройств. Неизвестные transparent-устройства используют transparent default.</p>
|
||
</div>
|
||
) : (
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 40 }}></th>
|
||
<th>Название</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 mode = DEVICE_MODES[dev.mode] || DEVICE_MODES.rules;
|
||
return (
|
||
<tr key={dev.id} className={dev.enabled !== false ? '' : 'disabled'}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={dev.enabled !== false}
|
||
onChange={(e) => onUpdate(dev.id, { enabled: e.target.checked })}
|
||
style={{ accentColor: 'var(--accent)' }}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="input sm"
|
||
value={dev.name || ''}
|
||
onChange={(e) => onUpdate(dev.id, { name: e.target.value })}
|
||
placeholder="Название устройства"
|
||
style={{ width: '100%', minWidth: 120 }}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<input
|
||
className="input sm"
|
||
value={dev.ip || ''}
|
||
onChange={(e) => onUpdate(dev.id, { ip: e.target.value })}
|
||
placeholder="192.168.1.50"
|
||
style={{ width: '100%', minWidth: 140 }}
|
||
/>
|
||
</td>
|
||
<td>
|
||
<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
|
||
className="btn btn-ghost sm"
|
||
onClick={() => {
|
||
if (confirm('Удалить устройство?')) onRemove(dev.id);
|
||
}}
|
||
>×</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function summary(rule) {
|
||
const parts = [];
|
||
const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0);
|
||
if (totalDomains) parts.push(`${totalDomains} дом.`);
|
||
if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`);
|
||
if (rule.ports?.length) parts.push(`${rule.ports.length} портов`);
|
||
if (rule.networks?.length) parts.push(rule.networks.join('/'));
|
||
return parts.join(' · ') || '—';
|
||
}
|
||
|
||
function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) {
|
||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
|
||
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
|
||
const errors = ruleErrors(rule);
|
||
const invalid = hasErrors(errors);
|
||
const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct;
|
||
|
||
return (
|
||
<tr ref={setNodeRef} style={style} className={`rule-row ${rule.enabled ? '' : 'disabled'} ${invalid ? 'invalid' : ''}`}>
|
||
<td style={{ width: 30 }}>
|
||
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить">⠿</span>
|
||
</td>
|
||
<td style={{ width: 36 }} className="muted text-mono">#{index + 1}</td>
|
||
<td>
|
||
<div className="flex" style={{ alignItems: 'center' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={rule.enabled !== false}
|
||
onChange={(e) => onUpdate(rule.id, { enabled: e.target.checked })}
|
||
style={{ accentColor: 'var(--accent)' }}
|
||
/>
|
||
<button className="btn btn-link" style={{ padding: 0, fontWeight: 600 }} onClick={() => onEdit(rule.id)}>
|
||
{rule.name || '(без названия)'}
|
||
</button>
|
||
{invalid && <span className="badge danger">ошибки</span>}
|
||
{conflict && <span className={`badge ${conflict.severity === 'warning' ? 'warning' : 'info'}`} title={`Перекрывается с #${conflict.conflictWithIndex + 1}`}>конфликт</span>}
|
||
</div>
|
||
</td>
|
||
<td><span className={`badge ${ob.kind}`}>{ob.label}</span></td>
|
||
<td className="muted" style={{ fontSize: 12 }}>{summary(rule)}</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
<div className="row-actions">
|
||
<button className="btn btn-ghost sm" onClick={() => onEdit(rule.id)}>Редактировать</button>
|
||
<button className="btn btn-ghost sm" onClick={() => { if (confirm('Удалить правило?')) onRemove(rule.id); }}>×</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function TemplatesModal({ open, onClose, onAdd }) {
|
||
if (!open) return null;
|
||
return (
|
||
<div className="modal-backdrop" onClick={onClose}>
|
||
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<h3>Шаблоны маршрутизации</h3>
|
||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||
</div>
|
||
<div className="modal-body">
|
||
<div className="template-grid">
|
||
{ruleTemplates.map((tpl) => (
|
||
<div key={tpl.key} className="template-card">
|
||
<h4>{tpl.label}</h4>
|
||
<small>{tpl.description}</small>
|
||
<button className="btn btn-secondary sm" onClick={() => { onAdd(tpl.build()); onClose(); }}>
|
||
+ Добавить
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function RoutingPage({
|
||
rules, saveStatus, busy,
|
||
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
|
||
devicesConfig, onUpdateDeviceDefaults, onAddDevice, onUpdateDevice, onRemoveDevice,
|
||
}) {
|
||
const [editingId, setEditingId] = useState(null);
|
||
const [showTemplates, setShowTemplates] = useState(false);
|
||
const [conflicts, setConflicts] = useState([]);
|
||
const [availableRuleSets, setAvailableRuleSets] = useState([]);
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||
);
|
||
|
||
useEffect(() => {
|
||
api.ruleSets.get().then((data) => setAvailableRuleSets(data.ruleSets || [])).catch(() => {});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const t = setTimeout(() => {
|
||
api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {});
|
||
}, 600);
|
||
return () => { cancelled = true; clearTimeout(t); };
|
||
}, [rules]);
|
||
|
||
const conflictsByRuleId = useMemo(() => {
|
||
const map = {};
|
||
for (const c of conflicts) map[c.ruleId] = c;
|
||
return map;
|
||
}, [conflicts]);
|
||
|
||
function handleDragEnd(event) {
|
||
const { active, over } = event;
|
||
if (!over || active.id === over.id) return;
|
||
const oldIndex = rules.findIndex((r) => r.id === active.id);
|
||
const newIndex = rules.findIndex((r) => r.id === over.id);
|
||
if (oldIndex < 0 || newIndex < 0) return;
|
||
onReorder(arrayMove(rules, oldIndex, newIndex));
|
||
}
|
||
|
||
const editing = rules.find((r) => r.id === editingId) || null;
|
||
|
||
return (
|
||
<div className="section-stack">
|
||
<RouteChecker />
|
||
|
||
<DevicesCard
|
||
devicesConfig={devicesConfig}
|
||
onDefaultsChange={onUpdateDeviceDefaults}
|
||
onAdd={onAddDevice}
|
||
onUpdate={onUpdateDevice}
|
||
onRemove={onRemoveDevice}
|
||
/>
|
||
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<h2>Правила маршрутизации</h2>
|
||
<div className="btn-group">
|
||
<button className="btn btn-secondary sm" onClick={() => setShowTemplates(true)}>Шаблоны</button>
|
||
<button className="btn btn-primary sm" onClick={() => { const newId = `rule-${Date.now()}`; onAdd(); setTimeout(() => setEditingId(newId), 50); }}>
|
||
+ Добавить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{conflicts.length > 0 && (
|
||
<div className="conflict-banner" style={{ marginBottom: 12 }}>
|
||
<span>⚠</span>
|
||
<div>
|
||
<strong>{conflicts.length} конфликт(ов) обнаружено</strong>
|
||
<div style={{ marginTop: 4 }}>
|
||
{conflicts.slice(0, 3).map((c, i) => (
|
||
<div key={i} style={{ fontSize: 12 }}>
|
||
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
|
||
Применяются <strong>сверху вниз</strong>. Перетаскивай ⠿ чтобы менять порядок.
|
||
</small>
|
||
|
||
{rules.length === 0 ? (
|
||
<div className="empty-state">
|
||
<h3>Правил пока нет</h3>
|
||
<p>Добавь шаблон (например «League of Legends → direct») или создай пустое правило.</p>
|
||
<button className="btn btn-primary" onClick={() => setShowTemplates(true)} style={{ marginTop: 12 }}>
|
||
Открыть шаблоны
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th></th>
|
||
<th>#</th>
|
||
<th>Правило</th>
|
||
<th>Outbound</th>
|
||
<th>Условия</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
|
||
{rules.map((rule, i) => (
|
||
<SortableRuleRow
|
||
key={rule.id}
|
||
rule={rule}
|
||
index={i}
|
||
total={rules.length}
|
||
onEdit={setEditingId}
|
||
onUpdate={onUpdate}
|
||
onRemove={onRemove}
|
||
conflict={conflictsByRuleId[rule.id]}
|
||
/>
|
||
))}
|
||
</SortableContext>
|
||
</DndContext>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<RuleEditorDrawer
|
||
rule={editing}
|
||
onUpdate={onUpdate}
|
||
onClose={() => setEditingId(null)}
|
||
onRemove={onRemove}
|
||
availableRuleSets={availableRuleSets}
|
||
/>
|
||
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
|
||
</div>
|
||
);
|
||
}
|