diff --git a/app/app.py b/app/app.py index 6232150..cc8f35b 100644 --- a/app/app.py +++ b/app/app.py @@ -3,9 +3,8 @@ app.py — Flask backend для Firewall Rules Builder """ import json import os -import sys -import re -from flask import Flask, jsonify, request, render_template, send_from_directory +import tempfile +from flask import Flask, jsonify, request, render_template app = Flask(__name__, template_folder='templates', static_folder='static') @@ -473,19 +472,35 @@ def _rule_ref_list(items): return parts -# ─── API: Импорт fw_settings.py ────────────────────────────────────────────── -@app.route('/api/import', methods=['POST']) -def import_settings(): - """Импортирует данные из fw_settings.py через exec().""" - try: - fw_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'fw_settings.py') - if not os.path.exists(fw_path): - return jsonify({'error': 'fw_settings.py не найден'}), 404 +# ─── API: Сброс всех данных ────────────────────────────────────────────────── +@app.route('/api/reset', methods=['POST']) +def reset_data(): + save_data(json.loads(json.dumps(EMPTY_DATA))) + return jsonify({'ok': True}) + +# ─── API: Загрузка fw_settings.py через браузер ────────────────────────────── +@app.route('/api/upload', methods=['POST']) +def upload_settings(): + """Принимает файл fw_settings.py через multipart/form-data и импортирует его.""" + if 'file' not in request.files: + return jsonify({'error': 'Файл не передан'}), 400 + f = request.files['file'] + if not f.filename: + return jsonify({'error': 'Имя файла пустое'}), 400 + + try: + source = f.read().decode('utf-8') + return _parse_and_save(source) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +def _parse_and_save(source): + """Парсит исходный код fw_settings.py и сохраняет данные.""" + try: ns = {} - with open(fw_path, 'r', encoding='utf-8') as f: - source = f.read() - exec(compile(source, fw_path, 'exec'), ns) + exec(compile(source, '', 'exec'), ns) raw_servers = ns.get('servers', {}) raw_nets = ns.get('nets', {}) diff --git a/app/static/app.js b/app/static/app.js index 3405f72..ac066d1 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -70,21 +70,45 @@ async function loadAll() { State.rules = data.rules || []; } -// ─── App (импорт) ───────────────────────────────────────────────────────────── +function renderAll() { + Objects.render(); + Groups.render(); + Services.render(); + SvcGroups.render(); + Rules.render(); +} + +// ─── App (загрузка файла, сброс) ───────────────────────────────────────────── const App = { - async importSettings() { - if (!confirm('Импортировать данные из fw_settings.py? Текущие данные будут заменены.')) return; - const r = await api('POST', '/api/import'); + 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('Импорт выполнен успешно'); + showToast('Все данные сброшены', 'warning'); await loadAll(); - Objects.render(); - Groups.render(); - Services.render(); - SvcGroups.render(); - Rules.render(); - } else { - showToast('Ошибка импорта: ' + (r.error || ''), 'danger'); + renderAll(); } } }; @@ -197,12 +221,14 @@ const Objects = { }, 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.render(); + Rules.render(); } } }; @@ -225,7 +251,12 @@ const Groups = { 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(' '); + const items = (grp.items || []).map(i => { + const exists = !!State.objects[i.hostname]; + const cls = exists ? 'cell-tag-group' : 'cell-tag-missing'; + const icon = exists ? '' : ''; + return `${icon}${escHtml(i.hostname)}`; + }).join(' '); rows.push(` ${escHtml(key)} ${escHtml(grp.name)} @@ -296,9 +327,10 @@ const Groups = { 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)} + const badge = obj ? (obj.type === 'network' ? `net` : `host`) : `удалён`; + const cls = obj ? '' : 'picker-item-missing'; + return `
+ ${obj ? '' : ''}${escHtml(item.hostname)} ${badge}
`; @@ -349,12 +381,12 @@ const Groups = { }, 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(); + Rules.render(); } } }; @@ -455,12 +487,13 @@ const Services = { }, 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(); + SvcGroups.render(); + Rules.render(); } } }; @@ -482,8 +515,11 @@ const SvcGroups = { 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)}`; + const exists = !!svc; + const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey; + const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing'; + const icon = exists ? '' : ''; + return `${icon}${escHtml(label)}`; }).join(' '); rows.push(` ${escHtml(key)} @@ -546,9 +582,12 @@ const SvcGroups = { 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)} + const exists = !!svc; + const label = exists ? `${svcKey} (${svc.proto}:${svc.dport})` : svcKey; + const cls = exists ? '' : 'picker-item-missing'; + return `
+ ${exists ? '' : ''}${escHtml(label)} + ${exists ? '' : 'удалён'}
`; }); @@ -596,12 +635,12 @@ const SvcGroups = { }, 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.render(); } } }; @@ -621,6 +660,15 @@ const Rules = { 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'); @@ -673,15 +721,33 @@ const Rules = { 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') { 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)}`; + + 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 ? '' : ''; + const title = exists ? rt : 'Объект/группа удалены'; + return `${icon}${escHtml(label)}`; }).join(''); }, @@ -689,16 +755,22 @@ const Rules = { const parts = []; (svcList || []).forEach(key => { const svc = State.services[key]; - const label = svc ? `${key} (${svc.proto}:${svc.dport})` : key; - parts.push(`${escHtml(label)}`); + const exists = !!svc; + const label = exists ? `${key} (${svc.proto}:${svc.dport})` : key; + const cls = exists ? 'cell-tag-svc' : 'cell-tag-missing'; + const icon = exists ? '' : ''; + parts.push(`${icon}${escHtml(label)}`); }); (sgList || []).forEach(key => { - parts.push(`[${escHtml(key)}]`); + const exists = !!State.service_groups[key]; + const cls = exists ? 'cell-tag-sgr' : 'cell-tag-missing'; + const icon = exists ? '' : ''; + parts.push(`${icon}[${escHtml(key)}]`); }); return parts.join(''); }, - // ─── Drag & Drop ─────────────────────────────────────────────────────────── + // ─── Drag & Drop с пересчётом order ─────────────────────────────────────── _initDragDrop() { const tbody = document.getElementById('rules-tbody'); let dragIdx = null; @@ -728,13 +800,20 @@ const 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) }); - // Обновляем через полную перезагрузку - await loadAll(); - // Применяем новый порядок локально - State.rules = newRules; - this.render(); + 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; }); }); @@ -767,6 +846,9 @@ const Rules = { (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 = ''; @@ -841,7 +923,7 @@ const Rules = { if (q && !key.toLowerCase().includes(q) && !(sg.name || '').toLowerCase().includes(q)) continue; items.push(`
[${escHtml(key)}] - group + group
`); } @@ -852,9 +934,13 @@ const Rules = { 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)} + const exists = isSg ? !!State.service_groups[item.ref_key] : !!State.services[item.ref_key]; + const cls = exists ? '' : 'picker-item-missing'; + const badge = isSg + ? `group` + : `${escHtml((State.services[item.ref_key] || {}).proto || '')}`; + return `
+ ${exists ? '' : ''}${escHtml(label)} ${badge}
`; @@ -911,11 +997,26 @@ const Rules = { 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)} + let exists = true; + + if (rt === 'group') { + exists = !!State.groups[rk]; + badge = `group`; + } else if (rt === 'server') { + exists = !!State.objects[rk]; + badge = `host`; + const o = State.objects[rk]; + if (o) label += ` (${o.ip})`; + } else if (rt === 'net') { + exists = !!State.objects[rk]; + badge = `net`; + const o = State.objects[rk]; + if (o) label += ` (${o.ip}/${o.prefix})`; + } + + const cls = exists ? '' : 'picker-item-missing'; + return `
+ ${exists ? '' : ''}${escHtml(label)} ${badge}
`; @@ -1000,7 +1101,8 @@ const Rules = { document.getElementById('span-affinity').value = affinityStr(rule.affinity); } else { document.getElementById('span-name').value = ''; - document.getElementById('span-order').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(); @@ -1033,8 +1135,6 @@ const Rules = { }, 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'); @@ -1053,11 +1153,7 @@ document.addEventListener('DOMContentLoaded', async () => { Rules.init(); await loadAll(); - Objects.render(); - Groups.render(); - Services.render(); - SvcGroups.render(); - Rules.render(); + renderAll(); // Перерисовка при переключении вкладок document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => { diff --git a/app/static/style.css b/app/static/style.css index e1c5fad..ac07b81 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -184,6 +184,22 @@ tr.rule-span td { color: #432874; } +.cell-tag-missing { + background: #f8d7da; + color: #842029; + border: 1px solid #f5c2c7; +} + +/* ─── Удалённые элементы в пикере ────────────────────────────────────────── */ +.picker-item-missing { + background: #fff3cd !important; + border: 1px solid #ffc107; +} + +.picker-item-missing:hover { + background: #ffe69c !important; +} + /* ─── Модальные окна ──────────────────────────────────────────────────────── */ .modal-header { padding: 10px 16px; diff --git a/app/templates/index.html b/app/templates/index.html index 70c14f9..7618985 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -13,13 +13,17 @@