1170 lines
50 KiB
JavaScript
1170 lines
50 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 || [];
|
||
}
|
||
|
||
function renderAll() {
|
||
Objects.render();
|
||
Groups.render();
|
||
Services.render();
|
||
SvcGroups.render();
|
||
Rules.render();
|
||
}
|
||
|
||
// ─── App (загрузка файла, сброс) ─────────────────────────────────────────────
|
||
const App = {
|
||
async uploadFile(input) {
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
try {
|
||
const resp = await fetch('/api/upload', { method: 'POST', body: formData });
|
||
const r = await resp.json();
|
||
if (r.ok) {
|
||
showToast('Файл загружен и импортирован успешно');
|
||
await loadAll();
|
||
renderAll();
|
||
} else {
|
||
showToast('Ошибка импорта: ' + (r.error || ''), 'danger');
|
||
}
|
||
} catch (e) {
|
||
showToast('Ошибка: ' + e.message, 'danger');
|
||
}
|
||
// Сбрасываем input чтобы можно было загрузить тот же файл повторно
|
||
input.value = '';
|
||
},
|
||
|
||
async resetAll() {
|
||
if (!confirm('Сбросить ВСЕ данные? Это действие необратимо.')) return;
|
||
const r = await api('POST', '/api/reset');
|
||
if (r.ok) {
|
||
showToast('Все данные сброшены', 'warning');
|
||
await loadAll();
|
||
renderAll();
|
||
}
|
||
}
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// 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) {
|
||
const r = await api('DELETE', `/api/objects/${encodeURIComponent(key)}`);
|
||
if (r.ok) {
|
||
showToast('Объект удалён', 'warning');
|
||
await loadAll();
|
||
this.render();
|
||
// Обновляем группы и правила — там могут быть ссылки на удалённый объект
|
||
Groups.render();
|
||
Rules.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 => {
|
||
const exists = !!State.objects[i.hostname];
|
||
const cls = exists ? 'cell-tag-group' : 'cell-tag-missing';
|
||
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
|
||
return `<span class="cell-tag ${cls}" title="${exists ? '' : 'Объект удалён'}">${icon}${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>`) : `<span class="item-badge badge bg-danger">удалён</span>`;
|
||
const cls = obj ? '' : 'picker-item-missing';
|
||
return `<div class="picker-item picker-item-selected ${cls}">
|
||
<span class="item-label">${obj ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${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) {
|
||
const r = await api('DELETE', `/api/groups/${encodeURIComponent(key)}`);
|
||
if (r.ok) {
|
||
showToast('Группа удалена', 'warning');
|
||
await loadAll();
|
||
this.render();
|
||
Rules.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) {
|
||
const r = await api('DELETE', `/api/services/${encodeURIComponent(key)}`);
|
||
if (r.ok) {
|
||
showToast('Сервис удалён', 'warning');
|
||
await loadAll();
|
||
this.render();
|
||
SvcGroups.render();
|
||
Rules.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 exists = !!svc;
|
||
const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
|
||
const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing';
|
||
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
|
||
return `<span class="cell-tag ${cls}" title="${exists ? '' : 'Сервис удалён'}">${icon}${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 exists = !!svc;
|
||
const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey;
|
||
const cls = exists ? '' : 'picker-item-missing';
|
||
return `<div class="picker-item picker-item-selected ${cls}">
|
||
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${escHtml(label)}</span>
|
||
${exists ? '' : '<span class="item-badge badge bg-danger">удалён</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) {
|
||
const r = await api('DELETE', `/api/service_groups/${encodeURIComponent(key)}`);
|
||
if (r.ok) {
|
||
showToast('Группа сервисов удалена', 'warning');
|
||
await loadAll();
|
||
this.render();
|
||
Rules.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'));
|
||
},
|
||
|
||
// ─── Пересчёт order по позиции ────────────────────────────────────────────
|
||
_recalcOrders(rules) {
|
||
// Назначаем order кратно 10 по позиции в массиве
|
||
return rules.map((rule, idx) => ({
|
||
...rule,
|
||
order: (idx + 1) * 10
|
||
}));
|
||
},
|
||
|
||
// ─── Рендер таблицы правил ─────────────────────────────────────────────────
|
||
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();
|
||
},
|
||
|
||
// ─── Рендер ссылок (src/dst) с проверкой существования ───────────────────
|
||
_renderRefList(list) {
|
||
return (list || []).map(item => {
|
||
const rt = item.ref_type || 'group';
|
||
const rk = item.ref_key || '';
|
||
let exists = true;
|
||
let cls = 'cell-tag-group';
|
||
let label = rk;
|
||
|
||
if (rt === 'server') {
|
||
exists = !!State.objects[rk];
|
||
cls = exists ? 'cell-tag-server' : 'cell-tag-missing';
|
||
const o = State.objects[rk];
|
||
label = rk + (o ? ` (${o.ip})` : '');
|
||
} else if (rt === 'net') {
|
||
exists = !!State.objects[rk];
|
||
cls = exists ? 'cell-tag-net' : 'cell-tag-missing';
|
||
const o = State.objects[rk];
|
||
label = rk + (o ? ` (${o.ip}/${o.prefix})` : '');
|
||
} else {
|
||
exists = !!State.groups[rk];
|
||
cls = exists ? 'cell-tag-group' : 'cell-tag-missing';
|
||
}
|
||
|
||
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
|
||
const title = exists ? rt : 'Объект/группа удалены';
|
||
return `<span class="cell-tag ${cls}" title="${escHtml(title)}">${icon}${escHtml(label)}</span>`;
|
||
}).join('');
|
||
},
|
||
|
||
_renderSvcList(svcList, sgList) {
|
||
const parts = [];
|
||
(svcList || []).forEach(key => {
|
||
const svc = State.services[key];
|
||
const exists = !!svc;
|
||
const label = exists ? `${key} (${svc.proto}:${svc.dport})` : key;
|
||
const cls = exists ? 'cell-tag-svc' : 'cell-tag-missing';
|
||
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
|
||
parts.push(`<span class="cell-tag ${cls}" title="${exists ? '' : 'Сервис удалён'}">${icon}${escHtml(label)}</span>`);
|
||
});
|
||
(sgList || []).forEach(key => {
|
||
const exists = !!State.service_groups[key];
|
||
const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing';
|
||
const icon = exists ? '' : '<i class="bi bi-exclamation-triangle-fill me-1"></i>';
|
||
parts.push(`<span class="cell-tag ${cls}" title="${exists ? '' : 'Группа сервисов удалена'}">${icon}[${escHtml(key)}]</span>`);
|
||
});
|
||
return parts.join('');
|
||
},
|
||
|
||
// ─── Drag & Drop с пересчётом order ───────────────────────────────────────
|
||
_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);
|
||
|
||
// Пересчитываем order для всех правил
|
||
const recalculated = this._recalcOrders(newRules);
|
||
|
||
// Сохраняем через reorder + обновление каждого правила
|
||
// Используем bulk-сохранение: сначала reorder, потом обновляем order
|
||
const r = await api('POST', '/api/rules/reorder', { order: newRules.map((_, i) => i) });
|
||
if (r.ok) {
|
||
// Обновляем order для каждого правила
|
||
for (let i = 0; i < recalculated.length; i++) {
|
||
await api('PUT', `/api/rules/${i}`, recalculated[i]);
|
||
}
|
||
await loadAll();
|
||
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();
|
||
// Предлагаем следующий order
|
||
const maxOrder = State.rules.reduce((m, r) => Math.max(m, r.order || 0), 0);
|
||
document.getElementById('rule-order').value = maxOrder + 10;
|
||
}
|
||
|
||
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" 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 exists = isSg ? !!State.service_groups[item.ref_key] : !!State.services[item.ref_key];
|
||
const cls = exists ? '' : 'picker-item-missing';
|
||
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 ${cls}">
|
||
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${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;
|
||
let exists = true;
|
||
|
||
if (rt === 'group') {
|
||
exists = !!State.groups[rk];
|
||
badge = `<span class="item-badge badge" style="background:#0c5460;color:white;font-size:10px">group</span>`;
|
||
} else if (rt === 'server') {
|
||
exists = !!State.objects[rk];
|
||
badge = `<span class="item-badge badge-host">host</span>`;
|
||
const o = State.objects[rk];
|
||
if (o) label += ` (${o.ip})`;
|
||
} else if (rt === 'net') {
|
||
exists = !!State.objects[rk];
|
||
badge = `<span class="item-badge badge-network">net</span>`;
|
||
const o = State.objects[rk];
|
||
if (o) label += ` (${o.ip}/${o.prefix})`;
|
||
}
|
||
|
||
const cls = exists ? '' : 'picker-item-missing';
|
||
return `<div class="picker-item picker-item-selected ${cls}">
|
||
<span class="item-label">${exists ? '' : '<i class="bi bi-exclamation-triangle-fill text-danger me-1"></i>'}${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 = '';
|
||
const maxOrder = State.rules.reduce((m, r) => Math.max(m, r.order || 0), 0);
|
||
document.getElementById('span-order').value = maxOrder + 10;
|
||
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 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();
|
||
renderAll();
|
||
|
||
// Перерисовка при переключении вкладок
|
||
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();
|
||
});
|
||
});
|
||
});
|