feat(admin): очистка всех сканов — POST /api/admin/purge-scans + UI (NETTOPO_RESET_DB_SECRET)

Made-with: Cursor
This commit is contained in:
Луценко Андрей Анатольевич
2026-04-10 16:08:35 +10:00
parent a7f80eac9a
commit a782d36af9
7 changed files with 117 additions and 12 deletions
+30 -4
View File
@@ -12,8 +12,9 @@ import (
)
type Handler struct {
store scans.Store
run *scans.Runner
store scans.Store
run *scans.Runner
resetDBSecret string
}
type LinkView struct {
@@ -49,8 +50,8 @@ type ScanDiffResponse struct {
MissingLinks []string `json:"missing_links"`
}
func NewHandler(store scans.Store, run *scans.Runner) *Handler {
return &Handler{store: store, run: run}
func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Handler {
return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)}
}
func (h *Handler) Routes() http.Handler {
@@ -70,9 +71,34 @@ func (h *Handler) Routes() http.Handler {
mux.HandleFunc("GET /api/scans/{id}/interfaces", h.getScanInterfaces)
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology)
mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans)
return withJSON(mux)
}
func (h *Handler) postAdminPurgeScans(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "use POST")
return
}
if h.resetDBSecret == "" {
writeError(w, http.StatusForbidden, "purge disabled: set NETTOPO_RESET_DB_SECRET in environment")
return
}
key := strings.TrimSpace(r.Header.Get("X-Nettopo-Reset-Key"))
if key == "" || key != h.resetDBSecret {
writeError(w, http.StatusUnauthorized, "invalid or missing X-Nettopo-Reset-Key header")
return
}
if err := h.store.PurgeAllScanData(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "all scan data removed (scan_jobs and related rows)",
})
}
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
+12 -7
View File
@@ -1,14 +1,18 @@
package config
import "os"
import (
"os"
"strings"
)
type Config struct {
HTTPAddr string
DBDSN string
StoreBackend string
RunMigrations bool
SNMPEnabled bool
SNMPCommunity string
HTTPAddr string
DBDSN string
StoreBackend string
RunMigrations bool
SNMPEnabled bool
SNMPCommunity string
ResetDBSecret string // NETTOPO_RESET_DB_SECRET — секрет для POST /api/admin/purge-scans
}
func FromEnv() Config {
@@ -47,5 +51,6 @@ func FromEnv() Config {
RunMigrations: runMigrations,
SNMPEnabled: snmpEnabled,
SNMPCommunity: snmpCommunity,
ResetDBSecret: strings.TrimSpace(os.Getenv("NETTOPO_RESET_DB_SECRET")),
}
}
+6
View File
@@ -532,3 +532,9 @@ limit $2`,
}
return out
}
func (s *PostgresStore) PurgeAllScanData() error {
// Дочерние таблицы ссылаются на scan_jobs — CASCADE очищает всё; RESTART IDENTITY сбрасывает bigserial.
_, err := s.db.ExecContext(context.Background(), `truncate table scan_jobs restart identity cascade`)
return err
}
+14
View File
@@ -69,6 +69,8 @@ type Store interface {
ListLLDPResults(scanID string, limit int) []LLDPResult
SaveInterfaceResult(scanID string, result InterfaceResult) error
ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult
// PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы).
PurgeAllScanData() error
}
type HostResult struct {
@@ -330,6 +332,18 @@ func (s *MemoryStore) SaveInterfaceResult(scanID string, result InterfaceResult)
return nil
}
func (s *MemoryStore) PurgeAllScanData() error {
s.mu.Lock()
defer s.mu.Unlock()
s.jobs = make(map[string]ScanJob)
s.hosts = make(map[string][]HostResult)
s.ports = make(map[string][]OpenPortResult)
s.snmp = make(map[string][]SNMPResult)
s.lldp = make(map[string][]LLDPResult)
s.ifaces = make(map[string][]InterfaceResult)
return nil
}
func (s *MemoryStore) ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult {
if limit <= 0 {
limit = 4096
+52
View File
@@ -55,6 +55,14 @@
<button id="diffBtn">Сравнить</button>
<button id="exportDiffBtn">Экспорт diff JSON</button>
</div>
<details class="admin-purge" style="margin:10px 0;padding:8px 12px;background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;max-width:720px;">
<summary style="cursor:pointer;font-weight:600;color:#9a3412;">Админ: начать всё заново — очистить данные сканов в БД</summary>
<p class="hint" style="margin:8px 0;">На сервере должен быть задан <code>NETTOPO_RESET_DB_SECRET</code>. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.</p>
<div class="row" style="flex-wrap:wrap;gap:8px;align-items:center;">
<input type="password" id="purgeSecretInput" placeholder="секрет (X-Nettopo-Reset-Key)" style="min-width:260px;" autocomplete="off" />
<button type="button" id="purgeDbBtn" style="background:#b91c1c;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;">Удалить все сканы и результаты</button>
</div>
</details>
<div id="status">Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.</div>
<div class="panel">
@@ -1097,6 +1105,50 @@
});
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
createScanBtn.addEventListener("click", createScan);
document.getElementById("purgeDbBtn")?.addEventListener("click", async () => {
const inp = document.getElementById("purgeSecretInput");
const secret = (inp && inp.value.trim()) || "";
if (!secret) {
setStatus("Введите секрет (тот же, что NETTOPO_RESET_DB_SECRET на сервере).");
return;
}
if (
!confirm(
"Удалить ВСЕ задачи скана и связанные данные (хосты, порты, SNMP, LLDP, интерфейсы)? Это необратимо."
)
) {
return;
}
setStatus("Очистка данных сканов…");
try {
const res = await fetch("/api/admin/purge-scans", {
method: "POST",
headers: { "X-Nettopo-Reset-Key": secret },
});
const text = await res.text();
if (!res.ok) {
setStatus(`Ошибка очистки: ${res.status} ${text}`);
return;
}
scanIdInput.value = "";
fromScanIdInput.value = "";
toScanIdInput.value = "";
clearDeviceDetailPanel();
deviceRowsCache = [];
deviceTableBody.innerHTML =
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id. Загрузите после нового скана.</td></tr>';
lastTopology = null;
lastDiff = null;
lastHighlight = null;
clearSVG();
edgeListPanel.style.display = "none";
diffBox.textContent = "";
await loadScanOptions();
setStatus("Данные сканов удалены. Создайте новый scan или выберите из списка.");
} catch (e) {
setStatus(`Ошибка: ${e.message}`);
}
});
watchScanBtn.addEventListener("click", watchScan);
scanSelect.addEventListener("change", () => {
if (scanSelect.value) scanIdInput.value = scanSelect.value;