feat(admin): очистка всех сканов — POST /api/admin/purge-scans + UI (NETTOPO_RESET_DB_SECRET)
Made-with: Cursor
This commit is contained in:
+30
-4
@@ -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",
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user