feat: отмена скана на сервере, стабильнее scan_id, упрощённый UI

- Runner: context, POST /api/scans/{id}/cancel, частичный canceled; Start до ответа createScan

- API: scanPathID (PathUnescape), лог при 404; Postgres GetScan логирует ошибки чтения

- newScanID: суффикс 8 hex; тест обновлён

- Web UI: убраны строки scan_id/from-to; скрытое поле scanId; cancel при таймауте/новом скане

Made-with: Cursor
This commit is contained in:
PTah
2026-04-13 15:26:23 +10:00
parent 5b3eeaed45
commit 088be32ec5
6 changed files with 224 additions and 210 deletions
+63 -14
View File
@@ -2,7 +2,9 @@ package api
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -70,6 +72,17 @@ func NewHandler(store scans.Store, run *scans.Runner, resetDBSecret string) *Han
return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)} return &Handler{store: store, run: run, resetDBSecret: strings.TrimSpace(resetDBSecret)}
} }
func scanPathID(r *http.Request) string {
raw := r.PathValue("id")
if raw == "" {
return ""
}
if dec, err := url.PathUnescape(raw); err == nil {
return strings.TrimSpace(dec)
}
return strings.TrimSpace(raw)
}
func (h *Handler) Routes() http.Handler { func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
webFS := http.FileServer(http.FS(webui.FS)) webFS := http.FileServer(http.FS(webui.FS))
@@ -80,6 +93,7 @@ func (h *Handler) Routes() http.Handler {
mux.HandleFunc("GET /api/scans", h.listScans) mux.HandleFunc("GET /api/scans", h.listScans)
mux.HandleFunc("GET /api/scans/diff", h.getScansDiff) mux.HandleFunc("GET /api/scans/diff", h.getScansDiff)
mux.HandleFunc("GET /api/scans/{id}", h.getScan) mux.HandleFunc("GET /api/scans/{id}", h.getScan)
mux.HandleFunc("POST /api/scans/{id}/cancel", h.postCancelScan)
mux.HandleFunc("GET /api/scans/{id}/hosts", h.getScanHosts) mux.HandleFunc("GET /api/scans/{id}/hosts", h.getScanHosts)
mux.HandleFunc("GET /api/scans/{id}/ports", h.getScanPorts) mux.HandleFunc("GET /api/scans/{id}/ports", h.getScanPorts)
mux.HandleFunc("GET /api/scans/{id}/snmp", h.getScanSNMP) mux.HandleFunc("GET /api/scans/{id}/snmp", h.getScanSNMP)
@@ -142,14 +156,15 @@ func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) {
return return
} }
// Start до ответа клиенту: иначе возможен POST /cancel до регистрации cancel в Runner (задача останется queued/running).
if h.run != nil {
h.run.Start(job)
}
writeJSON(w, http.StatusCreated, map[string]any{ writeJSON(w, http.StatusCreated, map[string]any{
"scan_id": job.ID, "scan_id": job.ID,
"status": job.Status, "status": job.Status,
}) })
if h.run != nil {
h.run.Start(job)
}
} }
func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) { func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) {
@@ -200,7 +215,7 @@ func (h *Handler) getScansDiff(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -208,6 +223,7 @@ func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) {
job, ok := h.store.GetScan(id) job, ok := h.store.GetScan(id)
if !ok { if !ok {
log.Printf("getScan: not found id=%q (len=%d)", id, len(id))
writeError(w, http.StatusNotFound, "scan not found") writeError(w, http.StatusNotFound, "scan not found")
return return
} }
@@ -215,8 +231,41 @@ func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, job) writeJSON(w, http.StatusOK, job)
} }
func (h *Handler) postCancelScan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "use POST")
return
}
id := scanPathID(r)
if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required")
return
}
if _, ok := h.store.GetScan(id); !ok {
log.Printf("postCancelScan: not found id=%q (len=%d)", id, len(id))
writeError(w, http.StatusNotFound, "scan not found")
return
}
runnerSignaled := false
if h.run != nil {
runnerSignaled = h.run.CancelScan(id)
}
if runnerSignaled {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": true})
return
}
cur, _ := h.store.GetScan(id)
if cur.Status == "queued" {
fin := time.Now().UTC()
_, _ = h.store.UpdateScan(id, scans.ScanUpdate{Status: "canceled", FinishedAt: &fin})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false, "status": "canceled"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": false})
}
func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -242,7 +291,7 @@ func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -268,7 +317,7 @@ func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -294,7 +343,7 @@ func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -320,7 +369,7 @@ func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -349,7 +398,7 @@ func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -409,7 +458,7 @@ func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -558,7 +607,7 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
@@ -621,7 +670,7 @@ func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) { func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := scanPathID(r)
if id == "" { if id == "" {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
+6
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -132,19 +133,24 @@ limit 1`
return ScanJob{}, false return ScanJob{}, false
} }
if err != nil { if err != nil {
log.Printf("postgres GetScan id=%q: row scan: %v", id, err)
return ScanJob{}, false return ScanJob{}, false
} }
if err = json.Unmarshal([]byte(cidrsRaw), &job.CIDRs); err != nil { if err = json.Unmarshal([]byte(cidrsRaw), &job.CIDRs); err != nil {
log.Printf("postgres GetScan id=%q: cidrs json (len=%d): %v", id, len(cidrsRaw), err)
return ScanJob{}, false return ScanJob{}, false
} }
if err = json.Unmarshal([]byte(excludeRaw), &job.ExcludeIPs); err != nil { if err = json.Unmarshal([]byte(excludeRaw), &job.ExcludeIPs); err != nil {
log.Printf("postgres GetScan id=%q: exclude_ips json: %v", id, err)
return ScanJob{}, false return ScanJob{}, false
} }
if err = json.Unmarshal([]byte(optionsRaw), &job.Options); err != nil { if err = json.Unmarshal([]byte(optionsRaw), &job.Options); err != nil {
log.Printf("postgres GetScan id=%q: options json: %v", id, err)
return ScanJob{}, false return ScanJob{}, false
} }
if err = json.Unmarshal([]byte(statsRaw), &job.Stats); err != nil { if err = json.Unmarshal([]byte(statsRaw), &job.Stats); err != nil {
log.Printf("postgres GetScan id=%q: stats json: %v", id, err)
return ScanJob{}, false return ScanJob{}, false
} }
if startedAt.Valid { if startedAt.Valid {
+106 -13
View File
@@ -1,7 +1,9 @@
package scans package scans
import ( import (
"context"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"log" "log"
"net" "net"
@@ -20,8 +22,10 @@ import (
) )
type Runner struct { type Runner struct {
store Store store Store
cfg RunnerConfig cfg RunnerConfig
mu sync.Mutex
cancels map[string]context.CancelFunc
} }
type RunnerConfig struct { type RunnerConfig struct {
@@ -52,10 +56,64 @@ func scanProgressBlend(quickDone, fullDone, total int) int {
} }
func (r *Runner) Start(job ScanJob) { func (r *Runner) Start(job ScanJob) {
go r.run(job) ctx, cancel := context.WithCancel(context.Background())
r.mu.Lock()
if r.cancels == nil {
r.cancels = make(map[string]context.CancelFunc)
}
r.cancels[job.ID] = cancel
r.mu.Unlock()
go func() {
defer func() {
cancel()
r.mu.Lock()
delete(r.cancels, job.ID)
r.mu.Unlock()
}()
r.run(ctx, job)
}()
} }
func (r *Runner) run(job ScanJob) { // CancelScan отменяет активный воркер скана в этом процессе. Возвращает false, если скан не числится в памяти Runner (уже завершён или не стартовал).
func (r *Runner) CancelScan(id string) bool {
r.mu.Lock()
c, ok := r.cancels[id]
r.mu.Unlock()
if !ok || c == nil {
return false
}
c()
return true
}
func (r *Runner) markJobCanceledFromStore(jobID string) {
cur, ok := r.store.GetScan(jobID)
if !ok {
return
}
switch cur.Status {
case "done", "failed", "canceled":
return
}
fin := time.Now().UTC()
_, _ = r.store.UpdateScan(jobID, ScanUpdate{
Status: "canceled",
Progress: cur.Progress,
Stats: cur.Stats,
FinishedAt: &fin,
})
}
func (r *Runner) run(ctx context.Context, job ScanJob) {
if cur, ok := r.store.GetScan(job.ID); ok && cur.Status == "canceled" {
return
}
if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) {
r.markJobCanceledFromStore(job.ID)
return
}
started := time.Now().UTC() started := time.Now().UTC()
_, _ = r.store.UpdateScan(job.ID, ScanUpdate{ _, _ = r.store.UpdateScan(job.ID, ScanUpdate{
Status: "running", Status: "running",
@@ -64,6 +122,11 @@ func (r *Runner) run(job ScanJob) {
StartedAt: &started, StartedAt: &started,
}) })
if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) {
r.markJobCanceledFromStore(job.ID)
return
}
ips, err := expandTargets(job.CIDRs, job.ExcludeIPs) ips, err := expandTargets(job.CIDRs, job.ExcludeIPs)
if err != nil { if err != nil {
finished := time.Now().UTC() finished := time.Now().UTC()
@@ -109,10 +172,10 @@ func (r *Runner) run(job ScanJob) {
Status: "running", Status: "running",
Progress: prog, Progress: prog,
Stats: ScanStats{ Stats: ScanStats{
HostsTotal: total, HostsTotal: total,
HostsUp: u, HostsUp: u,
HostsPingPhaseDone: q, HostsPingPhaseDone: q,
HostsProcessed: f, HostsProcessed: f,
}, },
}) })
} }
@@ -178,19 +241,49 @@ func (r *Runner) run(job ScanJob) {
go worker() go worker()
} }
for _, ip := range ips { go func() {
workCh <- ip defer close(workCh)
} for _, ip := range ips {
close(workCh) select {
case <-ctx.Done():
return
case workCh <- ip:
}
}
}()
wg.Wait() wg.Wait()
finished := time.Now().UTC() finished := time.Now().UTC()
mu.Lock()
upFinal := up
full := fullDone
quick := quickDone
mu.Unlock()
if ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) && full < total {
st := ScanStats{
HostsTotal: total,
HostsUp: upFinal,
HostsPingPhaseDone: quick,
HostsProcessed: full,
}
prog := scanProgressBlend(quick, full, total)
_, _ = r.store.UpdateScan(job.ID, ScanUpdate{
Status: "canceled",
Progress: prog,
Stats: st,
FinishedAt: &finished,
})
return
}
_, _ = r.store.UpdateScan(job.ID, ScanUpdate{ _, _ = r.store.UpdateScan(job.ID, ScanUpdate{
Status: "done", Status: "done",
Progress: 100, Progress: 100,
Stats: ScanStats{ Stats: ScanStats{
HostsTotal: total, HostsTotal: total,
HostsUp: up, HostsUp: upFinal,
HostsPingPhaseDone: total, HostsPingPhaseDone: total,
HostsProcessed: total, HostsProcessed: total,
}, },
+2 -2
View File
@@ -14,8 +14,8 @@ func TestNewScanIDShape(t *testing.T) {
if len(parts) < 3 { if len(parts) < 3 {
t.Fatalf("expected at least time_slug_suffix, got %q", id) t.Fatalf("expected at least time_slug_suffix, got %q", id)
} }
if len(parts[len(parts)-1]) != 4 { if len(parts[len(parts)-1]) != 8 {
t.Fatalf("expected 4-hex suffix, got %q", id) t.Fatalf("expected 8-hex suffix, got %q", id)
} }
if !strings.Contains(id, "192.168.160.0_24") { if !strings.Contains(id, "192.168.160.0_24") {
t.Fatalf("expected sanitized cidr in id: %q", id) t.Fatalf("expected sanitized cidr in id: %q", id)
+2 -2
View File
@@ -548,9 +548,9 @@ func validateCreateScanRequest(req *CreateScanRequest) error {
return nil return nil
} }
// newScanID — человекочитаемый scan_id: UTC-время с наносекундами + CIDR из запроса (как ввёл пользователь, до expand) + 4 hex (уникальность при коллизии по времени/CIDR). // newScanID — человекочитаемый scan_id: UTC-время с наносекундами + CIDR из запроса (как ввёл пользователь, до expand) + 8 hex (коллизии по времени/CIDR).
func newScanID(cidrLabels []string) (string, error) { func newScanID(cidrLabels []string) (string, error) {
var tail [2]byte var tail [4]byte
if _, err := rand.Read(tail[:]); err != nil { if _, err := rand.Read(tail[:]); err != nil {
return "", err return "", err
} }
+45 -179
View File
@@ -37,8 +37,9 @@
</style> </style>
</head> </head>
<body> <body>
<input type="hidden" id="scanId" value="" />
<h2>NetTopo: просмотр топологии</h2> <h2>NetTopo: просмотр топологии</h2>
<div class="hint">После скана нажмите «Загрузить»: граф раскладывается пружинной моделью по LLDP, стрелки от локального порта к соседу; полный список связей — блок под графом. Таблица устройств: сортировка по заголовкам. <strong>Поиск MAC</strong> — блок с фиолетовой рамкой сразу под полем scan_id: на каком коммутаторе и порту виден адрес (FDB в БД).</div> <div class="hint">После скана граф и таблица обновляются автоматически; граф — пружинная модель по LLDP, стрелки от локального порта к соседу; полный список связей — блок под графом. Таблица устройств: сортировка по заголовкам. <strong>Поиск MAC</strong> — блок с фиолетовой рамкой ниже (нужен текущий scan после «Создать scan и ждать»): на каком коммутаторе и порту виден адрес (FDB в БД).</div>
<div class="pipeline"> <div class="pipeline">
<strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже. <strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже.
</div> </div>
@@ -47,18 +48,10 @@
<button id="createScanBtn">Создать scan и ждать</button> <button id="createScanBtn">Создать scan и ждать</button>
<button id="watchScanBtn">Ждать завершения (текущий scan_id)</button> <button id="watchScanBtn">Ждать завершения (текущий scan_id)</button>
</div> </div>
<div class="row">
<input id="scanId" placeholder="scan_id" style="min-width: 380px;" />
<select id="scanSelect" style="min-width: 360px;"></select>
<button id="loadBtn">Загрузить</button>
<button id="latestBtn">Последний scan</button>
<button id="exportTopologyBtn">Экспорт topology JSON</button>
<button id="fitBtn">По центру</button>
</div>
<div id="macSearchPanel" style="margin:10px 0;padding:12px 14px;background:#eef2ff;border:2px solid #6366f1;border-radius:10px;box-shadow:0 1px 3px rgba(99,102,241,0.2);"> <div id="macSearchPanel" style="margin:10px 0;padding:12px 14px;background:#eef2ff;border:2px solid #6366f1;border-radius:10px;box-shadow:0 1px 3px rgba(99,102,241,0.2);">
<div style="font-weight:700;font-size:15px;color:#1e1b4b;margin-bottom:6px;">Где этот MAC подключён? (таблица FDB по выбранному scan)</div> <div style="font-weight:700;font-size:15px;color:#1e1b4b;margin-bottom:6px;">Где этот MAC подключён? (таблица FDB по выбранному scan)</div>
<p class="hint" style="margin:0 0 10px;">Сначала укажите <strong>scan_id</strong> в поле выше (или «Последний scan» / «Загрузить»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же <strong>VLAN</strong> и порту (вероятный доступ), затем «между порогами», затем транк/uplink.</p> <p class="hint" style="margin:0 0 10px;">Нужен <strong>текущий scan</strong> (после «Создать scan и ждать» или «Ждать завершения»). Введите MAC. Строки сортируются: сначала порты с малым числом уникальных MAC на том же <strong>VLAN</strong> и порту (вероятный доступ), затем «между порогами», затем транк/uplink.</p>
<div class="row" style="align-items:center;flex-wrap:wrap;gap:8px;"> <div class="row" style="align-items:center;flex-wrap:wrap;gap:8px;">
<label for="macSearchInput" style="font-weight:600;color:#312e81;">MAC</label> <label for="macSearchInput" style="font-weight:600;color:#312e81;">MAC</label>
<input id="macSearchInput" placeholder="aa:bb:cc:dd:ee:ff" style="min-width:260px;font-family:monospace;" autocomplete="off" /> <input id="macSearchInput" placeholder="aa:bb:cc:dd:ee:ff" style="min-width:260px;font-family:monospace;" autocomplete="off" />
@@ -88,20 +81,12 @@
</tr> </tr>
</thead> </thead>
<tbody id="macSearchTableBody"> <tbody id="macSearchTableBody">
<tr><td colspan="10" style="padding:10px;color:#64748b;">Введите MAC и нажмите кнопку (нужен scan_id в поле выше).</td></tr> <tr><td colspan="10" style="padding:10px;color:#64748b;">Введите MAC и нажмите кнопку (нужен активный scan после скана).</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="row">
<input id="fromScanId" placeholder="from scan_id" style="min-width: 280px;" />
<input id="toScanId" placeholder="to scan_id" style="min-width: 280px;" />
<select id="fromScanSelect" style="min-width: 240px;"></select>
<select id="toScanSelect" style="min-width: 240px;"></select>
<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;"> <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> <summary style="cursor:pointer;font-weight:600;color:#9a3412;">Админ: начать всё заново — очистить данные сканов в БД</summary>
<p class="hint" style="margin:8px 0;">На сервере должен быть задан <code>NETTOPO_RESET_DB_SECRET</code>. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.</p> <p class="hint" style="margin:8px 0;">На сервере должен быть задан <code>NETTOPO_RESET_DB_SECRET</code>. Ключ ниже передаётся только в заголовке запроса и нигде не сохраняется.</p>
@@ -130,7 +115,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="deviceTableBody"> <tbody id="deviceTableBody">
<tr><td colspan="5" style="padding:10px;color:#64748b;">Укажите scan_id или создайте scan.</td></tr> <tr><td colspan="5" style="padding:10px;color:#64748b;">Создайте scan или дождитесь завершения — таблица обновится автоматически.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -209,19 +194,8 @@
<script> <script>
const scanIdInput = document.getElementById("scanId"); const scanIdInput = document.getElementById("scanId");
const cidrInput = document.getElementById("cidrInput"); const cidrInput = document.getElementById("cidrInput");
const scanSelect = document.getElementById("scanSelect");
const fromScanIdInput = document.getElementById("fromScanId");
const toScanIdInput = document.getElementById("toScanId");
const fromScanSelect = document.getElementById("fromScanSelect");
const toScanSelect = document.getElementById("toScanSelect");
const loadBtn = document.getElementById("loadBtn");
const createScanBtn = document.getElementById("createScanBtn"); const createScanBtn = document.getElementById("createScanBtn");
const watchScanBtn = document.getElementById("watchScanBtn"); const watchScanBtn = document.getElementById("watchScanBtn");
const latestBtn = document.getElementById("latestBtn");
const diffBtn = document.getElementById("diffBtn");
const exportTopologyBtn = document.getElementById("exportTopologyBtn");
const exportDiffBtn = document.getElementById("exportDiffBtn");
const fitBtn = document.getElementById("fitBtn");
const nodeFilter = document.getElementById("nodeFilter"); const nodeFilter = document.getElementById("nodeFilter");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const diffBox = document.getElementById("diffBox"); const diffBox = document.getElementById("diffBox");
@@ -238,7 +212,6 @@
const NS = "http://www.w3.org/2000/svg"; const NS = "http://www.w3.org/2000/svg";
let lastTopology = null; let lastTopology = null;
let lastHighlight = null; let lastHighlight = null;
let lastDiff = null;
/** @type {{ip:string,ping:string,name:string,snmp:string,lldp:string,lldpCount:number}[]} */ /** @type {{ip:string,ping:string,name:string,snmp:string,lldp:string,lldpCount:number}[]} */
let deviceRowsCache = []; let deviceRowsCache = [];
let deviceSort = { key: "ip", dir: 1 }; let deviceSort = { key: "ip", dir: 1 };
@@ -264,6 +237,20 @@
return res.json(); return res.json();
} }
/** Просит сервер остановить скан (отмена воркера + статус canceled в БД). */
async function cancelServerScan(id) {
const s = String(id || "").trim();
if (!s) return;
try {
const res = await fetch(`/api/scans/${encodeURIComponent(s)}/cancel`, { method: "POST" });
if (!res.ok && res.status !== 404) {
console.warn("cancel scan", s, res.status, await res.text());
}
} catch (e) {
console.warn("cancel scan", s, e);
}
}
/** Человекочитаемое описание задачи для строки статуса */ /** Человекочитаемое описание задачи для строки статуса */
function describeScanProgress(job) { function describeScanProgress(job) {
const id = job.id || ""; const id = job.id || "";
@@ -294,20 +281,25 @@
if (st === "failed") { if (st === "failed") {
return `Скан ${id}: помечен как failed (смотрите journalctl на сервере).`; return `Скан ${id}: помечен как failed (смотрите journalctl на сервере).`;
} }
if (st === "canceled") {
return `Скан ${id}: отменён. Частичные результаты (если были) остаются в БД; прогресс ${p}%, адресов в задании: ${t}.`;
}
return `Скан ${id}: статус «${st}», прогресс ${p}%.`; return `Скан ${id}: статус «${st}», прогресс ${p}%.`;
} }
/** /**
* Опрашивает /api/scans/{id} до done/failed или таймаута. * Опрашивает /api/scans/{id} до done/failed/canceled или таймаута.
* @param {{ intervalMs?: number, maxWaitMs?: number, onDone?: function }} opts * @param {{ intervalMs?: number, maxWaitMs?: number, cancelOnSeqAbort?: boolean, onDone?: function }} opts
*/ */
async function pollScanUntilDone(scanId, opts = {}) { async function pollScanUntilDone(scanId, opts = {}) {
const cancelOnSeqAbort = opts.cancelOnSeqAbort !== false;
const mySeq = ++scanPollSeq; const mySeq = ++scanPollSeq;
const intervalMs = opts.intervalMs ?? 1000; const intervalMs = opts.intervalMs ?? 1000;
const maxWaitMs = opts.maxWaitMs ?? 20 * 60 * 1000; const maxWaitMs = opts.maxWaitMs ?? 20 * 60 * 1000;
const started = Date.now(); const started = Date.now();
while (Date.now() - started < maxWaitMs) { while (Date.now() - started < maxWaitMs) {
if (mySeq !== scanPollSeq) { if (mySeq !== scanPollSeq) {
if (cancelOnSeqAbort) await cancelServerScan(scanId);
return null; return null;
} }
let job; let job;
@@ -320,6 +312,7 @@
return null; return null;
} }
if (mySeq !== scanPollSeq) { if (mySeq !== scanPollSeq) {
if (cancelOnSeqAbort) await cancelServerScan(scanId);
return null; return null;
} }
setStatus(describeScanProgress(job)); setStatus(describeScanProgress(job));
@@ -341,7 +334,8 @@
await sleep(intervalMs); await sleep(intervalMs);
} }
if (mySeq === scanPollSeq) { if (mySeq === scanPollSeq) {
setStatus(`Таймаут ожидания скана ${scanId} (${Math.round(maxWaitMs / 60000)} мин).`); setStatus(`Таймаут ожидания скана ${scanId} (${Math.round(maxWaitMs / 60000)} мин). Запрашиваю отмену на сервере…`);
await cancelServerScan(scanId);
} }
return null; return null;
} }
@@ -668,7 +662,7 @@
async function selectDeviceAndLoadLinks(ip) { async function selectDeviceAndLoadLinks(ip) {
const scanId = scanIdInput.value.trim(); const scanId = scanIdInput.value.trim();
if (!scanId) { if (!scanId) {
setStatus("Укажите scan_id, чтобы загрузить связи."); setStatus("Нет scan_id: сначала создайте scan или дождитесь его завершения.");
return; return;
} }
selectedDeviceIP = ip; selectedDeviceIP = ip;
@@ -784,7 +778,8 @@
if (!scanId) { if (!scanId) {
deviceRowsCache = []; deviceRowsCache = [];
clearDeviceDetailPanel(); clearDeviceDetailPanel();
deviceTableBody.innerHTML = '<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id.</td></tr>'; deviceTableBody.innerHTML =
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id — создайте scan или дождитесь завершения.</td></tr>';
updateDeviceSortIndicators(); updateDeviceSortIndicators();
return; return;
} }
@@ -839,17 +834,6 @@
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function setDiff(text) { diffBox.textContent = text; } function setDiff(text) { diffBox.textContent = text; }
function downloadJSON(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function clearSVG() { function clearSVG() {
while (svg.firstChild) svg.removeChild(svg.firstChild); while (svg.firstChild) svg.removeChild(svg.firstChild);
@@ -1107,35 +1091,14 @@
} }
} }
function fillSelect(selectEl, scans, placeholder) {
selectEl.innerHTML = "";
const empty = document.createElement("option");
empty.value = "";
empty.textContent = placeholder;
selectEl.appendChild(empty);
scans.forEach(s => {
const opt = document.createElement("option");
opt.value = s.id;
opt.textContent = `${s.id} (${s.status})`;
selectEl.appendChild(opt);
});
}
async function loadScanOptions() { async function loadScanOptions() {
try { try {
const res = await fetch("/api/scans?limit=20"); const res = await fetch("/api/scans?limit=20");
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
const scans = data.items || []; const scans = data.items || [];
fillSelect(scanSelect, scans, "Выбрать scan");
fillSelect(fromScanSelect, scans, "from scan");
fillSelect(toScanSelect, scans, "to scan");
if (scans[0] && scans[0].id) { if (scans[0] && scans[0].id) {
scanIdInput.value = scans[0].id; scanIdInput.value = scans[0].id;
toScanIdInput.value = scans[0].id;
}
if (scans[1] && scans[1].id) {
fromScanIdInput.value = scans[1].id;
} }
} catch (_) {} } catch (_) {}
} }
@@ -1143,7 +1106,7 @@
async function loadTopology() { async function loadTopology() {
const id = scanIdInput.value.trim(); const id = scanIdInput.value.trim();
if (!id) { if (!id) {
setStatus("Введите scan_id"); setStatus("Нет текущего scan_id: сначала создайте scan или нажмите «Ждать завершения».");
return; return;
} }
setStatus("Загружаю topology..."); setStatus("Загружаю topology...");
@@ -1172,6 +1135,11 @@
} }
setStatus("Отправка запроса на сервер: создание задачи scan…"); setStatus("Отправка запроса на сервер: создание задачи scan…");
try { try {
const prevScanId = scanIdInput.value.trim();
if (prevScanId) {
setStatus(`Отменяю предыдущий скан ${prevScanId} на сервере…`);
await cancelServerScan(prevScanId);
}
const payload = { const payload = {
name: `manual-${new Date().toISOString()}`, name: `manual-${new Date().toISOString()}`,
cidrs: [cidr], cidrs: [cidr],
@@ -1197,7 +1165,6 @@
const d = await res.json(); const d = await res.json();
const sid = d.scan_id; const sid = d.scan_id;
scanIdInput.value = sid; scanIdInput.value = sid;
toScanIdInput.value = sid;
setStatus(`Задача ${sid} создана (статус: ${d.status || "queued"}). Фоновый воркер запустил полный цикл по сети — жду завершения…`); setStatus(`Задача ${sid} создана (статус: ${d.status || "queued"}). Фоновый воркер запустил полный цикл по сети — жду завершения…`);
await loadScanOptions(); await loadScanOptions();
@@ -1207,7 +1174,7 @@
if (job.status === "done") { if (job.status === "done") {
await loadDeviceTable(sid); await loadDeviceTable(sid);
await loadTopology(); await loadTopology();
} else if (job.status === "failed") { } else if (job.status === "failed" || job.status === "canceled") {
await loadDeviceTable(sid); await loadDeviceTable(sid);
setStatus(describeScanProgress(job) + " Частичные результаты (если есть) в таблице."); setStatus(describeScanProgress(job) + " Частичные результаты (если есть) в таблице.");
} }
@@ -1221,87 +1188,26 @@
async function watchScan() { async function watchScan() {
const id = scanIdInput.value.trim(); const id = scanIdInput.value.trim();
if (!id) { if (!id) {
setStatus("Введите scan_id или выберите из списка."); setStatus("Нет текущего scan: сначала «Создать scan и ждать» или «Ждать завершения» для уже созданной задачи.");
return; return;
} }
setStatus(`Подключаюсь к задаче ${id}, буду опрашивать статус…`); setStatus(`Подключаюсь к задаче ${id}, буду опрашивать статус…`);
await pollScanUntilDone(id, { await pollScanUntilDone(id, {
intervalMs: 1500, intervalMs: 1500,
cancelOnSeqAbort: false,
onDone: async (job) => { onDone: async (job) => {
await loadScanOptions(); await loadScanOptions();
setStatus(describeScanProgress(job)); setStatus(describeScanProgress(job));
if (job.status === "done") { if (job.status === "done") {
await loadDeviceTable(id); await loadDeviceTable(id);
await loadTopology(); await loadTopology();
} else if (job.status === "failed") { } else if (job.status === "failed" || job.status === "canceled") {
await loadDeviceTable(id); await loadDeviceTable(id);
} }
}, },
}); });
} }
async function loadLatestScan() {
setStatus("Ищу последний scan...");
try {
const res = await fetch("/api/scans?limit=1");
if (!res.ok) {
setStatus(`Ошибка ${res.status} при получении scan list`);
return;
}
const data = await res.json();
const latest = data.items && data.items[0];
if (!latest || !latest.id) {
setStatus("Сканы не найдены");
return;
}
scanIdInput.value = latest.id;
toScanIdInput.value = latest.id;
setStatus(`Найден последний scan: ${latest.id}`);
await loadDeviceTable(latest.id);
await loadTopology();
} catch (e) {
setStatus(`Ошибка загрузки последнего scan: ${e.message}`);
}
}
async function loadDiff() {
const fromId = fromScanIdInput.value.trim();
const toId = toScanIdInput.value.trim();
if (!fromId || !toId) {
setStatus("Введите from и to scan_id");
return;
}
setStatus("Сравниваю сканы...");
try {
const url = `/api/scans/diff?from=${encodeURIComponent(fromId)}&to=${encodeURIComponent(toId)}`;
const res = await fetch(url);
if (!res.ok) {
const t = await res.text();
setStatus(`Ошибка ${res.status}: ${t}`);
return;
}
const d = await res.json();
lastDiff = d;
setDiff(JSON.stringify(d, null, 2));
const topoRes = await fetch(`/api/scans/${encodeURIComponent(toId)}/topology?limit=5000`);
if (topoRes.ok) {
const topo = await topoRes.json();
const highlight = {
newHosts: new Set(d.new_hosts || []),
missingHosts: new Set(d.missing_hosts || []),
};
lastTopology = topo;
lastHighlight = highlight;
drawTopology(topo, highlight);
}
setStatus(
`Diff готов: +hosts ${d.new_hosts?.length || 0}, -hosts ${d.missing_hosts?.length || 0}, +links ${d.new_links?.length || 0}, -links ${d.missing_links?.length || 0}`
);
} catch (e) {
setStatus(`Ошибка diff: ${e.message}`);
}
}
function macPortKindRu(kind) { function macPortKindRu(kind) {
if (kind === "access") return "доступ (мало MAC)"; if (kind === "access") return "доступ (мало MAC)";
if (kind === "strong_transit") return "транк / uplink"; if (kind === "strong_transit") return "транк / uplink";
@@ -1313,7 +1219,7 @@
const scanId = scanIdInput.value.trim(); const scanId = scanIdInput.value.trim();
const mac = (macSearchInput && macSearchInput.value.trim()) || ""; const mac = (macSearchInput && macSearchInput.value.trim()) || "";
if (!scanId) { if (!scanId) {
setStatus("Укажите scan_id для поиска MAC."); setStatus("Сначала выполните скан («Создать scan и ждать»), затем повторите поиск MAC.");
return; return;
} }
if (!mac) { if (!mac) {
@@ -1400,10 +1306,6 @@
} }
} }
loadBtn.addEventListener("click", async () => {
await loadTopology();
await loadDeviceTable(scanIdInput.value.trim());
});
refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim())); refreshDevicesBtn.addEventListener("click", () => loadDeviceTable(scanIdInput.value.trim()));
macSearchBtn?.addEventListener("click", runMacSearch); macSearchBtn?.addEventListener("click", runMacSearch);
macSearchInput?.addEventListener("keydown", (ev) => { macSearchInput?.addEventListener("keydown", (ev) => {
@@ -1441,58 +1343,22 @@
return; return;
} }
scanIdInput.value = ""; scanIdInput.value = "";
fromScanIdInput.value = "";
toScanIdInput.value = "";
clearDeviceDetailPanel(); clearDeviceDetailPanel();
deviceRowsCache = []; deviceRowsCache = [];
deviceTableBody.innerHTML = deviceTableBody.innerHTML =
'<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id. Загрузите после нового скана.</td></tr>'; '<tr><td colspan="5" style="padding:10px;color:#64748b;">Нет scan_id. Создайте новый scan.</td></tr>';
lastTopology = null; lastTopology = null;
lastDiff = null;
lastHighlight = null; lastHighlight = null;
clearSVG(); clearSVG();
edgeListPanel.style.display = "none"; edgeListPanel.style.display = "none";
diffBox.textContent = ""; diffBox.textContent = "";
await loadScanOptions(); await loadScanOptions();
setStatus("Данные сканов удалены. Создайте новый scan или выберите из списка."); setStatus("Данные сканов удалены. Создайте новый scan.");
} catch (e) { } catch (e) {
setStatus(`Ошибка: ${e.message}`); setStatus(`Ошибка: ${e.message}`);
} }
}); });
watchScanBtn.addEventListener("click", watchScan); watchScanBtn.addEventListener("click", watchScan);
scanSelect.addEventListener("change", () => {
if (scanSelect.value) scanIdInput.value = scanSelect.value;
});
fromScanSelect.addEventListener("change", () => {
if (fromScanSelect.value) fromScanIdInput.value = fromScanSelect.value;
});
toScanSelect.addEventListener("change", () => {
if (toScanSelect.value) toScanIdInput.value = toScanSelect.value;
});
latestBtn.addEventListener("click", loadLatestScan);
diffBtn.addEventListener("click", loadDiff);
exportTopologyBtn.addEventListener("click", () => {
const scanId = scanIdInput.value.trim() || "unknown";
if (!lastTopology) {
setStatus("Сначала загрузите topology");
return;
}
downloadJSON(`topology_${scanId}.json`, lastTopology);
setStatus("Topology JSON экспортирован");
});
exportDiffBtn.addEventListener("click", () => {
const fromId = fromScanIdInput.value.trim() || "from";
const toId = toScanIdInput.value.trim() || "to";
if (!lastDiff) {
setStatus("Сначала выполните diff");
return;
}
downloadJSON(`diff_${fromId}_to_${toId}.json`, lastDiff);
setStatus("Diff JSON экспортирован");
});
fitBtn.addEventListener("click", () => {
svg.setAttribute("viewBox", lastGraphViewBox || "0 0 1200 620");
});
nodeFilter.addEventListener("change", () => { nodeFilter.addEventListener("change", () => {
if (lastTopology) { if (lastTopology) {
drawTopology(lastTopology, lastHighlight); drawTopology(lastTopology, lastHighlight);