fw_rules_builder/app/static/app.js

1074 lines
46 KiB
JavaScript

/**
* app.js — Firewall Rules Builder UI
*/
// ─── Глобальное состояние ────────────────────────────────────────────────────
const State = {
objects: {}, // servers + nets
groups: {},
services: {},
service_groups: {},
rules: [],
};
// ─── Утилиты ─────────────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const container = document.getElementById('toast-container');
const id = 'toast-' + Date.now();
const bg = type === 'success' ? 'bg-success' : type === 'danger' ? 'bg-danger' : 'bg-warning';
container.insertAdjacentHTML('beforeend', `
<div id="${id}" class="toast align-items-center text-white ${bg} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
const el = document.getElementById(id);
const t = new bootstrap.Toast(el, { delay: 3000 });
t.show();
el.addEventListener('hidden.bs.toast', () => el.remove());
}
function showError(elId, msg) {
const el = document.getElementById(elId);
if (el) { el.textContent = msg; el.classList.remove('d-none'); }
}
function hideError(elId) {
const el = document.getElementById(elId);
if (el) el.classList.add('d-none');
}
function parseAffinity(str) {
return str.split(',').map(s => s.trim()).filter(Boolean);
}
function affinityStr(arr) {
return Array.isArray(arr) ? arr.join(', ') : (arr || '');
}
async function api(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ─── Загрузка всех данных ─────────────────────────────────────────────────────
async function loadAll() {
const data = await api('GET', '/api/all');
State.objects = {};
for (const [k, v] of Object.entries(data.servers || {})) State.objects[k] = v;
for (const [k, v] of Object.entries(data.nets || {})) State.objects[k] = v;
State.groups = data.groups || {};
State.services = data.services || {};
State.service_groups = data.service_groups || {};
State.rules = data.rules || [];
}
// ─── App (импорт) ─────────────────────────────────────────────────────────────
const App = {
async importSettings() {
if (!confirm('Импортировать данные из fw_settings.py? Текущие данные будут заменены.')) return;
const r = await api('POST', '/api/import');
if (r.ok) {
showToast('Импорт выполнен успешно');
await loadAll();
Objects.render();
Groups.render();
Services.render();
SvcGroups.render();
Rules.render();
} else {
showToast('Ошибка импорта: ' + (r.error || ''), 'danger');
}
}
};
// ═══════════════════════════════════════════════════════════════════════════════
// OBJECTS
// ═══════════════════════════════════════════════════════════════════════════════
const Objects = {
modal: null,
_filter: '',
_typeFilter: '',
init() {
this.modal = new bootstrap.Modal(document.getElementById('modal-object'));
},
render() {
const tbody = document.getElementById('objects-tbody');
const q = this._filter.toLowerCase();
const tf = this._typeFilter;
const rows = [];
for (const [key, obj] of Object.entries(State.objects)) {
if (q && !key.toLowerCase().includes(q) && !(obj.description || '').toLowerCase().includes(q) && !(obj.ip || '').includes(q)) continue;
if (tf && obj.type !== tf) continue;
const typeBadge = obj.type === 'network'
? `<span class="badge-network">network</span>`
: `<span class="badge-host">host</span>`;
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${typeBadge}</td>
<td>${escHtml(obj.ip)}</td>
<td>${escHtml(obj.prefix)}</td>
<td>${escHtml(obj.gw)}</td>
<td>${escHtml(obj.domain)}</td>
<td>${escHtml(obj.description)}</td>
<td><small>${escHtml(affinityStr(obj.affinity))}</small></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Objects.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Objects.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="9" class="text-center text-muted">Нет объектов</td></tr>';
},
filter(val) {
if (val !== undefined) this._filter = val;
this._typeFilter = document.getElementById('obj-type-filter').value;
this.render();
},
openModal(key) {
hideError('obj-error');
const isEdit = !!key;
document.getElementById('modal-object-title').textContent = isEdit ? 'Редактировать объект' : 'Новый объект';
document.getElementById('obj-edit-key').value = key || '';
document.getElementById('obj-key').disabled = isEdit;
if (isEdit) {
const obj = State.objects[key];
document.getElementById('obj-key').value = key;
document.getElementById('obj-hostname').value = obj.hostname || '';
document.getElementById('obj-type').value = obj.type || 'host';
document.getElementById('obj-ip').value = obj.ip || '';
document.getElementById('obj-prefix').value = obj.prefix || '24';
document.getElementById('obj-gw').value = obj.gw || '';
document.getElementById('obj-domain').value = obj.domain || '';
document.getElementById('obj-description').value = obj.description || '';
document.getElementById('obj-affinity').value = affinityStr(obj.affinity);
} else {
document.getElementById('form-object').reset();
document.getElementById('obj-key').disabled = false;
}
this.modal.show();
},
async save() {
hideError('obj-error');
const editKey = document.getElementById('obj-edit-key').value;
const key = editKey || document.getElementById('obj-key').value.trim();
if (!key) { showError('obj-error', 'Ключ обязателен'); return; }
const payload = {
key,
hostname: document.getElementById('obj-hostname').value.trim() || key,
type: document.getElementById('obj-type').value,
ip: document.getElementById('obj-ip').value.trim(),
prefix: document.getElementById('obj-prefix').value.trim() || '24',
gw: document.getElementById('obj-gw').value.trim(),
domain: document.getElementById('obj-domain').value.trim(),
description: document.getElementById('obj-description').value.trim(),
affinity: parseAffinity(document.getElementById('obj-affinity').value),
};
let r;
if (editKey) {
r = await api('PUT', `/api/objects/${encodeURIComponent(editKey)}`, payload);
} else {
r = await api('POST', '/api/objects', payload);
}
if (r.ok) {
showToast(editKey ? 'Объект обновлён' : 'Объект создан');
this.modal.hide();
await loadAll();
this.render();
} else {
showError('obj-error', r.error || 'Ошибка');
}
},
async delete(key) {
if (!confirm(`Удалить объект "${key}"?`)) return;
const r = await api('DELETE', `/api/objects/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Объект удалён', 'warning');
await loadAll();
this.render();
}
}
};
// ═══════════════════════════════════════════════════════════════════════════════
// GROUPS
// ═══════════════════════════════════════════════════════════════════════════════
const Groups = {
modal: null,
_selectedItems: [], // [{hostname}]
_filter: '',
init() {
this.modal = new bootstrap.Modal(document.getElementById('modal-group'));
},
render() {
const tbody = document.getElementById('groups-tbody');
const q = this._filter.toLowerCase();
const rows = [];
for (const [key, grp] of Object.entries(State.groups)) {
if (q && !key.toLowerCase().includes(q) && !(grp.name || '').toLowerCase().includes(q)) continue;
const items = (grp.items || []).map(i => `<span class="cell-tag cell-tag-group">${escHtml(i.hostname)}</span>`).join(' ');
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${escHtml(grp.name)}</td>
<td><div class="cell-tags">${items || '<span class="text-muted">—</span>'}</div></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Groups.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Groups.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="4" class="text-center text-muted">Нет групп</td></tr>';
},
filter(val) {
if (val !== undefined) this._filter = val;
this.render();
},
openModal(key) {
hideError('grp-error');
const isEdit = !!key;
document.getElementById('modal-group-title').textContent = isEdit ? 'Редактировать группу' : 'Новая группа';
document.getElementById('grp-edit-key').value = key || '';
document.getElementById('grp-key').disabled = isEdit;
document.getElementById('grp-item-search').value = '';
document.getElementById('grp-item-type-filter').value = '';
if (isEdit) {
const grp = State.groups[key];
document.getElementById('grp-key').value = key;
document.getElementById('grp-name').value = grp.name || '';
this._selectedItems = [...(grp.items || [])];
} else {
document.getElementById('form-group').reset();
document.getElementById('grp-key').disabled = false;
this._selectedItems = [];
}
this.renderPicker();
this.modal.show();
},
filterItems() {
this.renderPicker();
},
renderPicker() {
const q = (document.getElementById('grp-item-search').value || '').toLowerCase();
const tf = document.getElementById('grp-item-type-filter').value;
const selectedHostnames = new Set(this._selectedItems.map(i => i.hostname));
// Доступные
const avail = document.getElementById('grp-available-list');
const availItems = [];
for (const [key, obj] of Object.entries(State.objects)) {
if (selectedHostnames.has(key)) continue;
if (q && !key.toLowerCase().includes(q) && !(obj.description || '').toLowerCase().includes(q)) continue;
if (tf && obj.type !== tf) continue;
const badge = obj.type === 'network' ? `<span class="badge-network item-badge">net</span>` : `<span class="badge-host item-badge">host</span>`;
availItems.push(`<div class="picker-item" onclick="Groups.addItem('${escHtml(key)}')">
<span class="item-label" title="${escHtml(obj.description)}">${escHtml(key)}</span>
${badge}
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
avail.innerHTML = availItems.join('') || '<div class="text-muted small p-2">Нет объектов</div>';
// Выбранные
const sel = document.getElementById('grp-selected-list');
const selItems = this._selectedItems.map((item, idx) => {
const obj = State.objects[item.hostname];
const badge = obj ? (obj.type === 'network' ? `<span class="badge-network item-badge">net</span>` : `<span class="badge-host item-badge">host</span>`) : '';
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(item.hostname)}</span>
${badge}
<span class="item-action" onclick="Groups.removeItem(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
});
sel.innerHTML = selItems.join('') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
document.getElementById('grp-selected-count').textContent = this._selectedItems.length;
},
addItem(hostname) {
if (!this._selectedItems.find(i => i.hostname === hostname)) {
this._selectedItems.push({ hostname });
}
this.renderPicker();
},
removeItem(idx) {
this._selectedItems.splice(idx, 1);
this.renderPicker();
},
async save() {
hideError('grp-error');
const editKey = document.getElementById('grp-edit-key').value;
const key = editKey || document.getElementById('grp-key').value.trim();
if (!key) { showError('grp-error', 'Ключ обязателен'); return; }
const payload = {
key,
name: document.getElementById('grp-name').value.trim() || key,
items: this._selectedItems,
};
let r;
if (editKey) {
r = await api('PUT', `/api/groups/${encodeURIComponent(editKey)}`, payload);
} else {
r = await api('POST', '/api/groups', payload);
}
if (r.ok) {
showToast(editKey ? 'Группа обновлена' : 'Группа создана');
this.modal.hide();
await loadAll();
this.render();
} else {
showError('grp-error', r.error || 'Ошибка');
}
},
async delete(key) {
if (!confirm(`Удалить группу "${key}"?`)) return;
const r = await api('DELETE', `/api/groups/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Группа удалена', 'warning');
await loadAll();
this.render();
}
}
};
// ═══════════════════════════════════════════════════════════════════════════════
// SERVICES
// ═══════════════════════════════════════════════════════════════════════════════
const Services = {
modal: null,
_filter: '',
_protoFilter: '',
init() {
this.modal = new bootstrap.Modal(document.getElementById('modal-service'));
},
render() {
const tbody = document.getElementById('services-tbody');
const q = this._filter.toLowerCase();
const pf = this._protoFilter;
const rows = [];
for (const [key, svc] of Object.entries(State.services)) {
if (q && !key.toLowerCase().includes(q) && !(svc.name || '').toLowerCase().includes(q) && !(svc.dport || '').includes(q)) continue;
if (pf && svc.proto !== pf) continue;
const protoBadge = `<span class="badge bg-secondary">${escHtml(svc.proto)}</span>`;
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${escHtml(svc.name)}</td>
<td>${protoBadge}</td>
<td>${escHtml(svc.sport)}</td>
<td>${escHtml(svc.dport)}</td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Services.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Services.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="6" class="text-center text-muted">Нет сервисов</td></tr>';
},
filter(val) {
if (val !== undefined) this._filter = val;
this._protoFilter = document.getElementById('svc-proto-filter').value;
this.render();
},
openModal(key) {
hideError('svc-error');
const isEdit = !!key;
document.getElementById('modal-service-title').textContent = isEdit ? 'Редактировать сервис' : 'Новый сервис';
document.getElementById('svc-edit-key').value = key || '';
document.getElementById('svc-key').disabled = isEdit;
if (isEdit) {
const svc = State.services[key];
document.getElementById('svc-key').value = key;
document.getElementById('svc-name').value = svc.name || '';
document.getElementById('svc-proto').value = svc.proto || 'tcp';
document.getElementById('svc-sport').value = svc.sport || 'any';
document.getElementById('svc-dport').value = svc.dport || '';
} else {
document.getElementById('form-service').reset();
document.getElementById('svc-key').disabled = false;
document.getElementById('svc-sport').value = 'any';
}
this.modal.show();
},
async save() {
hideError('svc-error');
const editKey = document.getElementById('svc-edit-key').value;
const key = editKey || document.getElementById('svc-key').value.trim();
if (!key) { showError('svc-error', 'Ключ обязателен'); return; }
const payload = {
key,
name: document.getElementById('svc-name').value.trim() || key,
proto: document.getElementById('svc-proto').value,
sport: document.getElementById('svc-sport').value.trim() || 'any',
dport: document.getElementById('svc-dport').value.trim(),
};
let r;
if (editKey) {
r = await api('PUT', `/api/services/${encodeURIComponent(editKey)}`, payload);
} else {
r = await api('POST', '/api/services', payload);
}
if (r.ok) {
showToast(editKey ? 'Сервис обновлён' : 'Сервис создан');
this.modal.hide();
await loadAll();
this.render();
} else {
showError('svc-error', r.error || 'Ошибка');
}
},
async delete(key) {
if (!confirm(`Удалить сервис "${key}"?`)) return;
const r = await api('DELETE', `/api/services/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Сервис удалён', 'warning');
await loadAll();
this.render();
}
}
};
// ═══════════════════════════════════════════════════════════════════════════════
// SERVICE GROUPS
// ═══════════════════════════════════════════════════════════════════════════════
const SvcGroups = {
modal: null,
_selectedItems: [], // [svc_key]
init() {
this.modal = new bootstrap.Modal(document.getElementById('modal-svcgroup'));
},
render() {
const tbody = document.getElementById('svcgroups-tbody');
const rows = [];
for (const [key, sg] of Object.entries(State.service_groups)) {
const items = (sg.items || []).map(svcKey => {
const svc = State.services[svcKey];
const label = svc ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
return `<span class="cell-tag cell-tag-sgr">${escHtml(label)}</span>`;
}).join(' ');
rows.push(`<tr>
<td><code>${escHtml(key)}</code></td>
<td>${escHtml(sg.name)}</td>
<td><div class="cell-tags">${items || '<span class="text-muted">—</span>'}</div></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="SvcGroups.openModal('${escHtml(key)}')"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="SvcGroups.delete('${escHtml(key)}')"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
}
tbody.innerHTML = rows.join('') || '<tr><td colspan="4" class="text-center text-muted">Нет групп сервисов</td></tr>';
},
openModal(key) {
hideError('sg-error');
const isEdit = !!key;
document.getElementById('modal-svcgroup-title').textContent = isEdit ? 'Редактировать группу сервисов' : 'Новая группа сервисов';
document.getElementById('sg-edit-key').value = key || '';
document.getElementById('sg-key').disabled = isEdit;
document.getElementById('sg-svc-search').value = '';
if (isEdit) {
const sg = State.service_groups[key];
document.getElementById('sg-key').value = key;
document.getElementById('sg-name').value = sg.name || '';
this._selectedItems = [...(sg.items || [])];
} else {
document.getElementById('form-svcgroup').reset();
document.getElementById('sg-key').disabled = false;
this._selectedItems = [];
}
this.renderPicker();
this.modal.show();
},
filterItems(val) {
this.renderPicker(val);
},
renderPicker(q) {
if (q === undefined) q = document.getElementById('sg-svc-search').value || '';
q = q.toLowerCase();
const selectedSet = new Set(this._selectedItems);
const avail = document.getElementById('sg-available-list');
const availItems = [];
for (const [key, svc] of Object.entries(State.services)) {
if (selectedSet.has(key)) continue;
if (q && !key.toLowerCase().includes(q) && !(svc.name || '').toLowerCase().includes(q) && !(svc.dport || '').includes(q)) continue;
availItems.push(`<div class="picker-item" onclick="SvcGroups.addItem('${escHtml(key)}')">
<span class="item-label" title="${escHtml(svc.name)}">${escHtml(key)}</span>
<span class="item-badge badge bg-secondary">${escHtml(svc.proto)}</span>
<span class="item-badge text-muted">${escHtml(svc.dport)}</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
avail.innerHTML = availItems.join('') || '<div class="text-muted small p-2">Нет сервисов</div>';
const sel = document.getElementById('sg-selected-list');
const selItems = this._selectedItems.map((svcKey, idx) => {
const svc = State.services[svcKey];
const label = svc ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(label)}</span>
<span class="item-action" onclick="SvcGroups.removeItem(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
});
sel.innerHTML = selItems.join('') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
document.getElementById('sg-selected-count').textContent = this._selectedItems.length;
},
addItem(key) {
if (!this._selectedItems.includes(key)) this._selectedItems.push(key);
this.renderPicker();
},
removeItem(idx) {
this._selectedItems.splice(idx, 1);
this.renderPicker();
},
async save() {
hideError('sg-error');
const editKey = document.getElementById('sg-edit-key').value;
const key = editKey || document.getElementById('sg-key').value.trim();
if (!key) { showError('sg-error', 'Ключ обязателен'); return; }
const payload = {
key,
name: document.getElementById('sg-name').value.trim() || key,
items: this._selectedItems,
};
let r;
if (editKey) {
r = await api('PUT', `/api/service_groups/${encodeURIComponent(editKey)}`, payload);
} else {
r = await api('POST', '/api/service_groups', payload);
}
if (r.ok) {
showToast(editKey ? 'Группа сервисов обновлена' : 'Группа сервисов создана');
this.modal.hide();
await loadAll();
this.render();
} else {
showError('sg-error', r.error || 'Ошибка');
}
},
async delete(key) {
if (!confirm(`Удалить группу сервисов "${key}"?`)) return;
const r = await api('DELETE', `/api/service_groups/${encodeURIComponent(key)}`);
if (r.ok) {
showToast('Группа сервисов удалена', 'warning');
await loadAll();
this.render();
}
}
};
// ═══════════════════════════════════════════════════════════════════════════════
// RULES
// ═══════════════════════════════════════════════════════════════════════════════
const Rules = {
modal: null,
spanModal: null,
_srcSelected: [], // [{ref_type, ref_key}]
_dstSelected: [],
_svcSelected: [], // [{ref_type: 'svc'|'sg', ref_key}]
init() {
this.modal = new bootstrap.Modal(document.getElementById('modal-rule'));
this.spanModal = new bootstrap.Modal(document.getElementById('modal-span'));
},
// ─── Рендер таблицы правил ─────────────────────────────────────────────────
render() {
const tbody = document.getElementById('rules-tbody');
const rows = [];
let ruleNum = 0;
State.rules.forEach((rule, idx) => {
if (rule.type === 'span') {
rows.push(`<tr class="rule-span" draggable="true" data-idx="${idx}">
<td colspan="10"><i class="bi bi-grip-vertical drag-handle me-2"></i>${escHtml(rule.name)}</td>
<td><small class="text-muted">${escHtml(affinityStr(rule.affinity))}</small></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Rules.openSpanModal(${idx})"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Rules.delete(${idx})"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
return;
}
ruleNum++;
const action = rule.action || 'allow';
const rowClass = `rule-${action}`;
const srcTags = this._renderRefList(rule.src_list || []);
const dstTags = this._renderRefList(rule.dst_list || []);
const svcTags = this._renderSvcList(rule.service_list || [], rule.service_group_list || []);
const actionBadge = `<span class="badge-action-${action}">${escHtml(action)}</span>`;
rows.push(`<tr class="${rowClass}" draggable="true" data-idx="${idx}">
<td class="text-center text-muted">${ruleNum}</td>
<td class="text-center">${escHtml(rule.order || '')}</td>
<td><strong>${escHtml(rule.name)}</strong></td>
<td><small>${escHtml(rule.description || '')}</small></td>
<td><div class="cell-tags">${srcTags}</div></td>
<td><div class="cell-tags">${dstTags}</div></td>
<td><div class="cell-tags">${svcTags}</div></td>
<td>${actionBadge}</td>
<td class="text-center"><small>${escHtml(rule.log || '')}</small></td>
<td class="text-center"><small>${escHtml(rule.idp || '')}</small></td>
<td><small>${escHtml(affinityStr(rule.affinity))}</small></td>
<td>
<button class="btn btn-sm btn-outline-secondary btn-action me-1" onclick="Rules.openModal(${idx})"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger btn-action" onclick="Rules.delete(${idx})"><i class="bi bi-trash"></i></button>
</td>
</tr>`);
});
tbody.innerHTML = rows.join('') || '<tr><td colspan="12" class="text-center text-muted">Нет правил</td></tr>';
this._initDragDrop();
},
_renderRefList(list) {
return (list || []).map(item => {
const rt = item.ref_type || 'group';
const rk = item.ref_key || '';
let cls = 'cell-tag-group';
let label = rk;
if (rt === 'server') { cls = 'cell-tag-server'; const o = State.objects[rk]; label = rk + (o ? ` (${o.ip})` : ''); }
else if (rt === 'net') { cls = 'cell-tag-net'; const o = State.objects[rk]; label = rk + (o ? ` (${o.ip}/${o.prefix})` : ''); }
return `<span class="cell-tag ${cls}" title="${escHtml(rt)}">${escHtml(label)}</span>`;
}).join('');
},
_renderSvcList(svcList, sgList) {
const parts = [];
(svcList || []).forEach(key => {
const svc = State.services[key];
const label = svc ? `${key} (${svc.proto}:${svc.dport})` : key;
parts.push(`<span class="cell-tag cell-tag-svc">${escHtml(label)}</span>`);
});
(sgList || []).forEach(key => {
parts.push(`<span class="cell-tag cell-tag-sgr">[${escHtml(key)}]</span>`);
});
return parts.join('');
},
// ─── Drag & Drop ───────────────────────────────────────────────────────────
_initDragDrop() {
const tbody = document.getElementById('rules-tbody');
let dragIdx = null;
tbody.querySelectorAll('tr[data-idx]').forEach(row => {
row.addEventListener('dragstart', e => {
dragIdx = parseInt(row.dataset.idx);
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
row.addEventListener('dragend', () => {
row.classList.remove('dragging');
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over'));
});
row.addEventListener('dragover', e => {
e.preventDefault();
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('drag-over'));
row.classList.add('drag-over');
});
row.addEventListener('drop', async e => {
e.preventDefault();
const targetIdx = parseInt(row.dataset.idx);
if (dragIdx === null || dragIdx === targetIdx) return;
// Перемещаем элемент
const newRules = [...State.rules];
const [moved] = newRules.splice(dragIdx, 1);
newRules.splice(targetIdx, 0, moved);
// Сохраняем новый порядок
const r = await api('POST', '/api/rules/reorder', { order: newRules.map((_, i) => i) });
// Обновляем через полную перезагрузку
await loadAll();
// Применяем новый порядок локально
State.rules = newRules;
this.render();
dragIdx = null;
});
});
},
// ─── Модал правила ─────────────────────────────────────────────────────────
openModal(idx) {
hideError('rule-error');
const isEdit = idx !== undefined;
document.getElementById('modal-rule-title').textContent = isEdit ? 'Редактировать правило' : 'Новое правило';
document.getElementById('rule-edit-idx').value = isEdit ? idx : '';
this._srcSelected = [];
this._dstSelected = [];
this._svcSelected = [];
if (isEdit) {
const rule = State.rules[idx];
document.getElementById('rule-name').value = rule.name || '';
document.getElementById('rule-order').value = rule.order || '';
document.getElementById('rule-action').value = rule.action || 'allow';
document.getElementById('rule-log').value = rule.log || 'false';
document.getElementById('rule-idp').value = rule.idp || 'false';
document.getElementById('rule-description').value = rule.description || '';
document.getElementById('rule-affinity').value = affinityStr(rule.affinity);
this._srcSelected = [...(rule.src_list || [])];
this._dstSelected = [...(rule.dst_list || [])];
// Сервисы
(rule.service_list || []).forEach(k => this._svcSelected.push({ ref_type: 'svc', ref_key: k }));
(rule.service_group_list || []).forEach(k => this._svcSelected.push({ ref_type: 'sg', ref_key: k }));
} else {
document.getElementById('form-rule').reset();
}
document.getElementById('rule-src-search').value = '';
document.getElementById('rule-dst-search').value = '';
document.getElementById('rule-svc-search').value = '';
this.renderSrcPicker();
this.renderDstPicker();
this.renderSvcPicker();
this.modal.show();
},
// ─── Пикер источника ───────────────────────────────────────────────────────
filterSrc(q) { this.renderSrcPicker(q); },
renderSrcPicker(q) {
if (q === undefined) q = document.getElementById('rule-src-search').value || '';
q = q.toLowerCase();
const selectedKeys = new Set(this._srcSelected.map(i => `${i.ref_type}:${i.ref_key}`));
const avail = document.getElementById('rule-src-available');
const items = this._buildObjectGroupItems(q, selectedKeys, 'src');
avail.innerHTML = items || '<div class="text-muted small p-2">Нет объектов</div>';
const sel = document.getElementById('rule-src-selected');
sel.innerHTML = this._renderSelectedRefs(this._srcSelected, 'src') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
document.getElementById('rule-src-count').textContent = this._srcSelected.length;
},
// ─── Пикер назначения ──────────────────────────────────────────────────────
filterDst(q) { this.renderDstPicker(q); },
renderDstPicker(q) {
if (q === undefined) q = document.getElementById('rule-dst-search').value || '';
q = q.toLowerCase();
const selectedKeys = new Set(this._dstSelected.map(i => `${i.ref_type}:${i.ref_key}`));
const avail = document.getElementById('rule-dst-available');
avail.innerHTML = this._buildObjectGroupItems(q, selectedKeys, 'dst') || '<div class="text-muted small p-2">Нет объектов</div>';
const sel = document.getElementById('rule-dst-selected');
sel.innerHTML = this._renderSelectedRefs(this._dstSelected, 'dst') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
document.getElementById('rule-dst-count').textContent = this._dstSelected.length;
},
// ─── Пикер сервисов ────────────────────────────────────────────────────────
filterSvc(q) { this.renderSvcPicker(q); },
renderSvcPicker(q) {
if (q === undefined) q = document.getElementById('rule-svc-search').value || '';
q = q.toLowerCase();
const selectedKeys = new Set(this._svcSelected.map(i => `${i.ref_type}:${i.ref_key}`));
const avail = document.getElementById('rule-svc-available');
const items = [];
// Сервисы
for (const [key, svc] of Object.entries(State.services)) {
if (selectedKeys.has(`svc:${key}`)) continue;
if (q && !key.toLowerCase().includes(q) && !(svc.name || '').toLowerCase().includes(q) && !(svc.dport || '').includes(q)) continue;
items.push(`<div class="picker-item" onclick="Rules.addSvc('svc','${escHtml(key)}')">
<span class="item-label">${escHtml(key)}</span>
<span class="item-badge badge bg-secondary">${escHtml(svc.proto)}</span>
<span class="item-badge text-muted">${escHtml(svc.dport)}</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
// Группы сервисов
for (const [key, sg] of Object.entries(State.service_groups)) {
if (selectedKeys.has(`sg:${key}`)) continue;
if (q && !key.toLowerCase().includes(q) && !(sg.name || '').toLowerCase().includes(q)) continue;
items.push(`<div class="picker-item" onclick="Rules.addSvc('sg','${escHtml(key)}')">
<span class="item-label">[${escHtml(key)}]</span>
<span class="item-badge badge bg-purple" style="background:#6f42c1;color:white">group</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
avail.innerHTML = items.join('') || '<div class="text-muted small p-2">Нет сервисов</div>';
const sel = document.getElementById('rule-svc-selected');
const selItems = this._svcSelected.map((item, idx) => {
const isSg = item.ref_type === 'sg';
const label = isSg ? `[${item.ref_key}]` : item.ref_key;
const badge = isSg ? `<span class="item-badge badge" style="background:#6f42c1;color:white;font-size:10px">group</span>` : `<span class="item-badge badge bg-secondary">${escHtml((State.services[item.ref_key] || {}).proto || '')}</span>`;
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(label)}</span>
${badge}
<span class="item-action" onclick="Rules.removeSvc(${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
});
sel.innerHTML = selItems.join('') || '<div class="text-muted small p-2">Ничего не выбрано</div>';
document.getElementById('rule-svc-count').textContent = this._svcSelected.length;
},
// ─── Вспомогательные методы пикеров ───────────────────────────────────────
_buildObjectGroupItems(q, selectedKeys, side) {
const items = [];
// Группы объектов
for (const [key, grp] of Object.entries(State.groups)) {
if (selectedKeys.has(`group:${key}`)) continue;
if (q && !key.toLowerCase().includes(q) && !(grp.name || '').toLowerCase().includes(q)) continue;
items.push(`<div class="picker-item" onclick="Rules.addRef('${side}','group','${escHtml(key)}')">
<span class="item-label">${escHtml(key)}</span>
<span class="item-badge badge" style="background:#0c5460;color:white;font-size:10px">group</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
// Серверы
for (const [key, obj] of Object.entries(State.objects)) {
if (obj.type !== 'host') continue;
if (selectedKeys.has(`server:${key}`)) continue;
if (q && !key.toLowerCase().includes(q) && !(obj.ip || '').includes(q) && !(obj.description || '').toLowerCase().includes(q)) continue;
items.push(`<div class="picker-item" onclick="Rules.addRef('${side}','server','${escHtml(key)}')">
<span class="item-label" title="${escHtml(obj.description)}">${escHtml(key)} <small class="text-muted">${escHtml(obj.ip)}</small></span>
<span class="item-badge badge-host">host</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
// Сети
for (const [key, obj] of Object.entries(State.objects)) {
if (obj.type !== 'network') continue;
if (selectedKeys.has(`net:${key}`)) continue;
if (q && !key.toLowerCase().includes(q) && !(obj.ip || '').includes(q) && !(obj.description || '').toLowerCase().includes(q)) continue;
items.push(`<div class="picker-item" onclick="Rules.addRef('${side}','net','${escHtml(key)}')">
<span class="item-label" title="${escHtml(obj.description)}">${escHtml(key)} <small class="text-muted">${escHtml(obj.ip)}/${escHtml(obj.prefix)}</small></span>
<span class="item-badge badge-network">net</span>
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
</div>`);
}
return items.join('');
},
_renderSelectedRefs(list, side) {
return list.map((item, idx) => {
const rt = item.ref_type;
const rk = item.ref_key;
let badge = '';
let label = rk;
if (rt === 'group') { badge = `<span class="item-badge badge" style="background:#0c5460;color:white;font-size:10px">group</span>`; }
else if (rt === 'server') { badge = `<span class="item-badge badge-host">host</span>`; const o = State.objects[rk]; if (o) label += ` (${o.ip})`; }
else if (rt === 'net') { badge = `<span class="item-badge badge-network">net</span>`; const o = State.objects[rk]; if (o) label += ` (${o.ip}/${o.prefix})`; }
return `<div class="picker-item picker-item-selected">
<span class="item-label">${escHtml(label)}</span>
${badge}
<span class="item-action" onclick="Rules.removeRef('${side}',${idx})"><i class="bi bi-x-circle"></i></span>
</div>`;
}).join('');
},
addRef(side, refType, refKey) {
const list = side === 'src' ? this._srcSelected : this._dstSelected;
if (!list.find(i => i.ref_type === refType && i.ref_key === refKey)) {
list.push({ ref_type: refType, ref_key: refKey });
}
if (side === 'src') this.renderSrcPicker(); else this.renderDstPicker();
},
removeRef(side, idx) {
const list = side === 'src' ? this._srcSelected : this._dstSelected;
list.splice(idx, 1);
if (side === 'src') this.renderSrcPicker(); else this.renderDstPicker();
},
addSvc(refType, refKey) {
if (!this._svcSelected.find(i => i.ref_type === refType && i.ref_key === refKey)) {
this._svcSelected.push({ ref_type: refType, ref_key: refKey });
}
this.renderSvcPicker();
},
removeSvc(idx) {
this._svcSelected.splice(idx, 1);
this.renderSvcPicker();
},
// ─── Сохранение правила ────────────────────────────────────────────────────
async save() {
hideError('rule-error');
const editIdx = document.getElementById('rule-edit-idx').value;
const name = document.getElementById('rule-name').value.trim();
if (!name) { showError('rule-error', 'Имя правила обязательно'); return; }
const svcList = this._svcSelected.filter(i => i.ref_type === 'svc').map(i => i.ref_key);
const sgList = this._svcSelected.filter(i => i.ref_type === 'sg').map(i => i.ref_key);
const rule = {
name,
order: parseInt(document.getElementById('rule-order').value) || 0,
type: 'rule',
description: document.getElementById('rule-description').value.trim(),
action: document.getElementById('rule-action').value,
log: document.getElementById('rule-log').value,
idp: document.getElementById('rule-idp').value,
affinity: parseAffinity(document.getElementById('rule-affinity').value),
src_list: this._srcSelected,
dst_list: this._dstSelected,
service_list: svcList,
service_group_list: sgList,
};
let r;
if (editIdx !== '') {
r = await api('PUT', `/api/rules/${editIdx}`, rule);
} else {
r = await api('POST', '/api/rules', rule);
}
if (r.ok) {
showToast(editIdx !== '' ? 'Правило обновлено' : 'Правило создано');
this.modal.hide();
await loadAll();
this.render();
} else {
showError('rule-error', r.error || 'Ошибка');
}
},
// ─── Разделитель (span) ────────────────────────────────────────────────────
openSpanModal(idx) {
document.getElementById('span-edit-idx').value = idx !== undefined ? idx : '';
if (idx !== undefined) {
const rule = State.rules[idx];
document.getElementById('span-name').value = rule.name || '';
document.getElementById('span-order').value = rule.order || '';
document.getElementById('span-affinity').value = affinityStr(rule.affinity);
} else {
document.getElementById('span-name').value = '';
document.getElementById('span-order').value = '';
document.getElementById('span-affinity').value = '';
}
this.spanModal.show();
},
async saveSpan() {
const editIdx = document.getElementById('span-edit-idx').value;
const rule = {
name: document.getElementById('span-name').value.trim(),
order: parseInt(document.getElementById('span-order').value) || 0,
type: 'span',
affinity: parseAffinity(document.getElementById('span-affinity').value),
};
let r;
if (editIdx !== '') {
r = await api('PUT', `/api/rules/${editIdx}`, rule);
} else {
r = await api('POST', '/api/rules', rule);
}
if (r.ok) {
showToast('Разделитель сохранён');
this.spanModal.hide();
await loadAll();
this.render();
} else {
showToast('Ошибка: ' + (r.error || ''), 'danger');
}
},
async delete(idx) {
const rule = State.rules[idx];
if (!confirm(`Удалить "${rule.name}"?`)) return;
const r = await api('DELETE', `/api/rules/${idx}`);
if (r.ok) {
showToast('Удалено', 'warning');
await loadAll();
this.render();
}
}
};
// ─── Инициализация ────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
Objects.init();
Groups.init();
Services.init();
SvcGroups.init();
Rules.init();
await loadAll();
Objects.render();
Groups.render();
Services.render();
SvcGroups.render();
Rules.render();
// Перерисовка при переключении вкладок
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => {
tab.addEventListener('shown.bs.tab', e => {
const target = e.target.getAttribute('href');
if (target === '#tab-objects') Objects.render();
else if (target === '#tab-groups') Groups.render();
else if (target === '#tab-services') Services.render();
else if (target === '#tab-svcgroups') SvcGroups.render();
else if (target === '#tab-rules') Rules.render();
});
});
});