From a782d36af946aef01609d89de203958a9469cc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9B=D1=83=D1=86=D0=B5=D0=BD=D0=BA=D0=BE=20=D0=90=D0=BD?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D0=B9=20=D0=90=D0=BD=D0=B0=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 10 Apr 2026 16:08:35 +1000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D0=BE=D1=87=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D1=81=D0=B5=D1=85=20=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=20=E2=80=94=20POST=20/api/admin/purge-scans?= =?UTF-8?q?=20+=20UI=20(NETTOPO=5FRESET=5FDB=5FSECRET)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .env.example | 2 ++ cmd/server/main.go | 2 +- internal/api/handler.go | 34 ++++++++++++++++++--- internal/config/config.go | 19 +++++++----- internal/scans/postgres_store.go | 6 ++++ internal/scans/store.go | 14 +++++++++ internal/webui/index.html | 52 ++++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 10894f1..511a2c2 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,5 @@ RUN_MIGRATIONS=true SNMP_ENABLED=true SNMP_COMMUNITY=public # NETTOPO_LOG_PING_FAILURES=true # логировать каждый неответивший ping (по умолчанию выключено — иначе засоряет journal) +# «Начать заново»: POST /api/admin/purge-scans с заголовком X-Nettopo-Reset-Key: <секрет> +# NETTOPO_RESET_DB_SECRET=change-me-long-random diff --git a/cmd/server/main.go b/cmd/server/main.go index e6c5c0b..c7a4513 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -24,7 +24,7 @@ func main() { SNMPEnabled: cfg.SNMPEnabled, SNMPCommunity: cfg.SNMPCommunity, }) - handler := api.NewHandler(scanStore, runner) + handler := api.NewHandler(scanStore, runner, cfg.ResetDBSecret) server := &http.Server{ Addr: cfg.HTTPAddr, diff --git a/internal/api/handler.go b/internal/api/handler.go index 7e76f48..b947299 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -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", diff --git a/internal/config/config.go b/internal/config/config.go index 3c6e52f..06f0c3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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")), } } diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index ae88c59..b28378a 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -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 +} diff --git a/internal/scans/store.go b/internal/scans/store.go index 0124a19..5de3758 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -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 diff --git a/internal/webui/index.html b/internal/webui/index.html index 0afc93f..49bda7a 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -55,6 +55,14 @@ +
+ Админ: начать всё заново — очистить данные сканов в БД +

На сервере должен быть задан NETTOPO_RESET_DB_SECRET. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.

+
+ + +
+
Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.
@@ -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 = + 'Нет scan_id. Загрузите после нового скана.'; + 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;