feat(admin): очистка всех сканов — POST /api/admin/purge-scans + UI (NETTOPO_RESET_DB_SECRET)
Made-with: Cursor
This commit is contained in:
@@ -5,3 +5,5 @@ RUN_MIGRATIONS=true
|
|||||||
SNMP_ENABLED=true
|
SNMP_ENABLED=true
|
||||||
SNMP_COMMUNITY=public
|
SNMP_COMMUNITY=public
|
||||||
# NETTOPO_LOG_PING_FAILURES=true # логировать каждый неответивший ping (по умолчанию выключено — иначе засоряет journal)
|
# NETTOPO_LOG_PING_FAILURES=true # логировать каждый неответивший ping (по умолчанию выключено — иначе засоряет journal)
|
||||||
|
# «Начать заново»: POST /api/admin/purge-scans с заголовком X-Nettopo-Reset-Key: <секрет>
|
||||||
|
# NETTOPO_RESET_DB_SECRET=change-me-long-random
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ func main() {
|
|||||||
SNMPEnabled: cfg.SNMPEnabled,
|
SNMPEnabled: cfg.SNMPEnabled,
|
||||||
SNMPCommunity: cfg.SNMPCommunity,
|
SNMPCommunity: cfg.SNMPCommunity,
|
||||||
})
|
})
|
||||||
handler := api.NewHandler(scanStore, runner)
|
handler := api.NewHandler(scanStore, runner, cfg.ResetDBSecret)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.HTTPAddr,
|
Addr: cfg.HTTPAddr,
|
||||||
|
|||||||
+30
-4
@@ -12,8 +12,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store scans.Store
|
store scans.Store
|
||||||
run *scans.Runner
|
run *scans.Runner
|
||||||
|
resetDBSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LinkView struct {
|
type LinkView struct {
|
||||||
@@ -49,8 +50,8 @@ type ScanDiffResponse struct {
|
|||||||
MissingLinks []string `json:"missing_links"`
|
MissingLinks []string `json:"missing_links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(store scans.Store, run *scans.Runner) *Handler {
|
func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Handler {
|
||||||
return &Handler{store: store, run: run}
|
return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Routes() http.Handler {
|
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}/interfaces", h.getScanInterfaces)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
|
mux.HandleFunc("GET /api/scans/{id}/links", h.getScanLinks)
|
||||||
mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology)
|
mux.HandleFunc("GET /api/scans/{id}/topology", h.getScanTopology)
|
||||||
|
mux.HandleFunc("POST /api/admin/purge-scans", h.postAdminPurgeScans)
|
||||||
return withJSON(mux)
|
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) {
|
func (h *Handler) health(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "os"
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
DBDSN string
|
DBDSN string
|
||||||
StoreBackend string
|
StoreBackend string
|
||||||
RunMigrations bool
|
RunMigrations bool
|
||||||
SNMPEnabled bool
|
SNMPEnabled bool
|
||||||
SNMPCommunity string
|
SNMPCommunity string
|
||||||
|
ResetDBSecret string // NETTOPO_RESET_DB_SECRET — секрет для POST /api/admin/purge-scans
|
||||||
}
|
}
|
||||||
|
|
||||||
func FromEnv() Config {
|
func FromEnv() Config {
|
||||||
@@ -47,5 +51,6 @@ func FromEnv() Config {
|
|||||||
RunMigrations: runMigrations,
|
RunMigrations: runMigrations,
|
||||||
SNMPEnabled: snmpEnabled,
|
SNMPEnabled: snmpEnabled,
|
||||||
SNMPCommunity: snmpCommunity,
|
SNMPCommunity: snmpCommunity,
|
||||||
|
ResetDBSecret: strings.TrimSpace(os.Getenv("NETTOPO_RESET_DB_SECRET")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -532,3 +532,9 @@ limit $2`,
|
|||||||
}
|
}
|
||||||
return out
|
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
|
ListLLDPResults(scanID string, limit int) []LLDPResult
|
||||||
SaveInterfaceResult(scanID string, result InterfaceResult) error
|
SaveInterfaceResult(scanID string, result InterfaceResult) error
|
||||||
ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult
|
ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult
|
||||||
|
// PurgeAllScanData удаляет все сканы и связанные строки (хосты, порты, SNMP, LLDP, интерфейсы).
|
||||||
|
PurgeAllScanData() error
|
||||||
}
|
}
|
||||||
|
|
||||||
type HostResult struct {
|
type HostResult struct {
|
||||||
@@ -330,6 +332,18 @@ func (s *MemoryStore) SaveInterfaceResult(scanID string, result InterfaceResult)
|
|||||||
return nil
|
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 {
|
func (s *MemoryStore) ListInterfaceResults(scanID string, filterIP string, limit int) []InterfaceResult {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 4096
|
limit = 4096
|
||||||
|
|||||||
@@ -55,6 +55,14 @@
|
|||||||
<button id="diffBtn">Сравнить</button>
|
<button id="diffBtn">Сравнить</button>
|
||||||
<button id="exportDiffBtn">Экспорт diff JSON</button>
|
<button id="exportDiffBtn">Экспорт diff JSON</button>
|
||||||
</div>
|
</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 id="status">Ожидание действия. Строка статуса показывает текущую операцию и прогресс скана.</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -1097,6 +1105,50 @@
|
|||||||
});
|
});
|
||||||
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
|
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
|
||||||
createScanBtn.addEventListener("click", createScan);
|
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);
|
watchScanBtn.addEventListener("click", watchScan);
|
||||||
scanSelect.addEventListener("change", () => {
|
scanSelect.addEventListener("change", () => {
|
||||||
if (scanSelect.value) scanIdInput.value = scanSelect.value;
|
if (scanSelect.value) scanIdInput.value = scanSelect.value;
|
||||||
|
|||||||
Reference in New Issue
Block a user