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:
+63
-14
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package scans
|
package scans
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -22,6 +24,8 @@ 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()
|
||||||
@@ -178,19 +241,49 @@ func (r *Runner) run(job ScanJob) {
|
|||||||
go worker()
|
go worker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(workCh)
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
workCh <- ip
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case workCh <- ip:
|
||||||
}
|
}
|
||||||
close(workCh)
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user