1074 lines
46 KiB
JavaScript
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,'&').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'
|
|
? `<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();
|
|
});
|
|
});
|
|
});
|