update
This commit is contained in:
parent
9133480863
commit
77087fb7b3
43
app/app.py
43
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, '<uploaded>', 'exec'), ns)
|
||||
|
||||
raw_servers = ns.get('servers', {})
|
||||
raw_nets = ns.get('nets', {})
|
||||
|
|
|
|||
|
|
@ -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 => `<span class="cell-tag cell-tag-group">${escHtml(i.hostname)}</span>`).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 ? '' : '<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>
|
||||
|
|
@ -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' ? `<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>
|
||||
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>`;
|
||||
|
|
@ -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 `<span class="cell-tag cell-tag-sgr">${escHtml(label)}</span>`;
|
||||
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>
|
||||
|
|
@ -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 `<div class="picker-item picker-item-selected">
|
||||
<span class="item-label">${escHtml(label)}</span>
|
||||
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>`;
|
||||
});
|
||||
|
|
@ -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 `<span class="cell-tag ${cls}" title="${escHtml(rt)}">${escHtml(label)}</span>`;
|
||||
|
||||
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('');
|
||||
},
|
||||
|
||||
|
|
@ -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(`<span class="cell-tag cell-tag-svc">${escHtml(label)}</span>`);
|
||||
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 => {
|
||||
parts.push(`<span class="cell-tag cell-tag-sgr">[${escHtml(key)}]</span>`);
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
// ─── 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(`<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-badge badge" style="background:#6f42c1;color:white">group</span>
|
||||
<span class="item-action"><i class="bi bi-plus-circle"></i></span>
|
||||
</div>`);
|
||||
}
|
||||
|
|
@ -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 ? `<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>
|
||||
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>`;
|
||||
|
|
@ -911,11 +997,26 @@ const Rules = {
|
|||
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>
|
||||
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>`;
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -13,13 +13,17 @@
|
|||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-dark bg-dark px-3 py-2">
|
||||
<span class="navbar-brand fw-bold"><i class="bi bi-shield-lock-fill me-2 text-info"></i>Firewall Rules Builder</span>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-warning" onclick="App.importSettings()">
|
||||
<i class="bi bi-upload me-1"></i>Импорт fw_settings.py
|
||||
</button>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<label class="btn btn-sm btn-outline-warning mb-0" title="Загрузить fw_settings.py">
|
||||
<i class="bi bi-upload me-1"></i>Загрузить fw_settings.py
|
||||
<input type="file" id="upload-input" accept=".py" style="display:none" onchange="App.uploadFile(this)">
|
||||
</label>
|
||||
<a class="btn btn-sm btn-outline-success" href="/api/export" download="fw_settings.py">
|
||||
<i class="bi bi-download me-1"></i>Экспорт fw_settings.py
|
||||
</a>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="App.resetAll()">
|
||||
<i class="bi bi-trash3 me-1"></i>Сбросить всё
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue