/**
* 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', `
`);
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,'&').replace(//g,'>');
}
// ─── Загрузка всех данных ─────────────────────────────────────────────────────
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'
? `network `
: `host `;
rows.push(`
${escHtml(key)}
${typeBadge}
${escHtml(obj.ip)}
${escHtml(obj.prefix)}
${escHtml(obj.gw)}
${escHtml(obj.domain)}
${escHtml(obj.description)}
${escHtml(affinityStr(obj.affinity))}
`);
}
tbody.innerHTML = rows.join('') || 'Нет объектов ';
},
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 => `${escHtml(i.hostname)} `).join(' ');
rows.push(`
${escHtml(key)}
${escHtml(grp.name)}
${items || '— '}
`);
}
tbody.innerHTML = rows.join('') || 'Нет групп ';
},
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' ? `net ` : `host `;
availItems.push(`
${escHtml(key)}
${badge}
`);
}
avail.innerHTML = availItems.join('') || 'Нет объектов
';
// Выбранные
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' ? `net ` : `host `) : '';
return `
${escHtml(item.hostname)}
${badge}
`;
});
sel.innerHTML = selItems.join('') || 'Ничего не выбрано
';
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 = `${escHtml(svc.proto)} `;
rows.push(`
${escHtml(key)}
${escHtml(svc.name)}
${protoBadge}
${escHtml(svc.sport)}
${escHtml(svc.dport)}
`);
}
tbody.innerHTML = rows.join('') || 'Нет сервисов ';
},
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 `${escHtml(label)} `;
}).join(' ');
rows.push(`
${escHtml(key)}
${escHtml(sg.name)}
${items || '— '}
`);
}
tbody.innerHTML = rows.join('') || 'Нет групп сервисов ';
},
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(`
${escHtml(key)}
${escHtml(svc.proto)}
${escHtml(svc.dport)}
`);
}
avail.innerHTML = availItems.join('') || 'Нет сервисов
';
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 `
${escHtml(label)}
`;
});
sel.innerHTML = selItems.join('') || 'Ничего не выбрано
';
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(`
${escHtml(rule.name)}
${escHtml(affinityStr(rule.affinity))}
`);
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 = `${escHtml(action)} `;
rows.push(`
${ruleNum}
${escHtml(rule.order || '')}
${escHtml(rule.name)}
${escHtml(rule.description || '')}
${srcTags}
${dstTags}
${svcTags}
${actionBadge}
${escHtml(rule.log || '')}
${escHtml(rule.idp || '')}
${escHtml(affinityStr(rule.affinity))}
`);
});
tbody.innerHTML = rows.join('') || 'Нет правил ';
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 `${escHtml(label)} `;
}).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(`${escHtml(label)} `);
});
(sgList || []).forEach(key => {
parts.push(`[${escHtml(key)}] `);
});
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 || 'Нет объектов
';
const sel = document.getElementById('rule-src-selected');
sel.innerHTML = this._renderSelectedRefs(this._srcSelected, 'src') || 'Ничего не выбрано
';
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') || 'Нет объектов
';
const sel = document.getElementById('rule-dst-selected');
sel.innerHTML = this._renderSelectedRefs(this._dstSelected, 'dst') || 'Ничего не выбрано
';
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(`
${escHtml(key)}
${escHtml(svc.proto)}
${escHtml(svc.dport)}
`);
}
// Группы сервисов
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(`
[${escHtml(key)}]
group
`);
}
avail.innerHTML = items.join('') || 'Нет сервисов
';
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 ? `group ` : `${escHtml((State.services[item.ref_key] || {}).proto || '')} `;
return `
${escHtml(label)}
${badge}
`;
});
sel.innerHTML = selItems.join('') || 'Ничего не выбрано
';
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(`
${escHtml(key)}
group
`);
}
// Серверы
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(`
${escHtml(key)} ${escHtml(obj.ip)}
host
`);
}
// Сети
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(`
${escHtml(key)} ${escHtml(obj.ip)}/${escHtml(obj.prefix)}
net
`);
}
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 = `group `; }
else if (rt === 'server') { badge = `host `; const o = State.objects[rk]; if (o) label += ` (${o.ip})`; }
else if (rt === 'net') { badge = `net `; const o = State.objects[rk]; if (o) label += ` (${o.ip}/${o.prefix})`; }
return `
${escHtml(label)}
${badge}
`;
}).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();
});
});
});