diff --git a/internal/api/handler.go b/internal/api/handler.go index c901923..a57b4c5 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -14,6 +14,9 @@ import ( "nettopo-go/internal/webui" ) +// errScanStorageUnavailable — ответ API при сбое GetScan (БД, а не «нет такого scan_id»). +const errScanStorageUnavailable = "scan storage temporarily unavailable" + type Handler struct { store scans.Store run *scans.Runner @@ -83,6 +86,26 @@ func scanPathID(r *http.Request) string { return strings.TrimSpace(raw) } +// getScanOrWriteError загружает скан; при ошибке хранилища пишет 503, при отсутствии строки — 404 с notFoundMsg (или «scan not found»). +func (h *Handler) getScanOrWriteError(w http.ResponseWriter, id, notFoundMsg string) (scans.ScanJob, bool) { + job, found, err := h.store.GetScan(id) + if err != nil { + log.Printf("GetScan id=%q (len=%d): %v", id, len(id), err) + writeError(w, http.StatusServiceUnavailable, errScanStorageUnavailable) + return scans.ScanJob{}, false + } + if !found { + msg := notFoundMsg + if msg == "" { + msg = "scan not found" + } + log.Printf("getScan: not found id=%q (len=%d) msg=%q", id, len(id), msg) + writeError(w, http.StatusNotFound, msg) + return scans.ScanJob{}, false + } + return job, true +} + func (h *Handler) Routes() http.Handler { mux := http.NewServeMux() webFS := http.FileServer(http.FS(webui.FS)) @@ -189,12 +212,10 @@ func (h *Handler) getScansDiff(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "both from and to are required") return } - if _, ok := h.store.GetScan(fromID); !ok { - writeError(w, http.StatusNotFound, "from scan not found") + if _, ok := h.getScanOrWriteError(w, fromID, "from scan not found"); !ok { return } - if _, ok := h.store.GetScan(toID); !ok { - writeError(w, http.StatusNotFound, "to scan not found") + if _, ok := h.getScanOrWriteError(w, toID, "to scan not found"); !ok { return } @@ -221,10 +242,8 @@ func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { return } - job, ok := h.store.GetScan(id) + job, ok := h.getScanOrWriteError(w, id, "") if !ok { - log.Printf("getScan: not found id=%q (len=%d)", id, len(id)) - writeError(w, http.StatusNotFound, "scan not found") return } @@ -241,9 +260,8 @@ func (h *Handler) postCancelScan(w http.ResponseWriter, r *http.Request) { 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") + cur, ok := h.getScanOrWriteError(w, id, "") + if !ok { return } runnerSignaled := false @@ -254,7 +272,6 @@ func (h *Handler) postCancelScan(w http.ResponseWriter, r *http.Request) { 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}) @@ -270,8 +287,7 @@ func (h *Handler) getScanHosts(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } @@ -296,8 +312,7 @@ func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } @@ -322,8 +337,7 @@ func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } @@ -348,8 +362,7 @@ func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } @@ -374,8 +387,7 @@ func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } ip := strings.TrimSpace(r.URL.Query().Get("ip")) @@ -403,8 +415,7 @@ func (h *Handler) getScanPortDevices(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } ip := strings.TrimSpace(r.URL.Query().Get("ip")) @@ -463,8 +474,7 @@ func (h *Handler) getScanMacLocations(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } raw := strings.TrimSpace(r.URL.Query().Get("mac")) @@ -612,8 +622,7 @@ func (h *Handler) getScanLinks(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } @@ -675,8 +684,7 @@ func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "scan id is required") return } - if _, ok := h.store.GetScan(id); !ok { - writeError(w, http.StatusNotFound, "scan not found") + if _, ok := h.getScanOrWriteError(w, id, ""); !ok { return } diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index e075b5c..e4b9a2d 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log" "sort" "strings" @@ -98,7 +99,7 @@ insert into scan_jobs ( return job, nil } -func (s *PostgresStore) GetScan(id string) (ScanJob, bool) { +func (s *PostgresStore) GetScan(id string) (ScanJob, bool, error) { query := ` select id, name, status, cidrs::text, exclude_ips::text, options::text, progress, stats::text, coalesce(snmp_credentials_id::text, ''), @@ -130,28 +131,28 @@ limit 1` &finishedAt, ) if errors.Is(err, sql.ErrNoRows) { - return ScanJob{}, false + return ScanJob{}, false, nil } if err != nil { log.Printf("postgres GetScan id=%q: row scan: %v", id, err) - return ScanJob{}, false + return ScanJob{}, false, fmt.Errorf("postgres GetScan id=%q: %w", id, err) } 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, fmt.Errorf("postgres GetScan id=%q cidrs: %w", id, err) } 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, fmt.Errorf("postgres GetScan id=%q exclude_ips: %w", id, err) } 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, fmt.Errorf("postgres GetScan id=%q options: %w", id, err) } 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, fmt.Errorf("postgres GetScan id=%q stats: %w", id, err) } if startedAt.Valid { job.StartedAt = startedAt.Time @@ -160,7 +161,7 @@ limit 1` job.FinishedAt = finishedAt.Time } - return job, true + return job, true, nil } func (s *PostgresStore) ListScans(limit int) []ScanJob { @@ -230,7 +231,11 @@ limit $1` } func (s *PostgresStore) UpdateScan(id string, update ScanUpdate) (ScanJob, bool) { - current, ok := s.GetScan(id) + current, ok, err := s.GetScan(id) + if err != nil { + log.Printf("postgres UpdateScan: GetScan id=%q: %v", id, err) + return ScanJob{}, false + } if !ok { return ScanJob{}, false } diff --git a/internal/scans/runner.go b/internal/scans/runner.go index d7d1c88..be4d424 100644 --- a/internal/scans/runner.go +++ b/internal/scans/runner.go @@ -88,7 +88,11 @@ func (r *Runner) CancelScan(id string) bool { } func (r *Runner) markJobCanceledFromStore(jobID string) { - cur, ok := r.store.GetScan(jobID) + cur, ok, err := r.store.GetScan(jobID) + if err != nil { + log.Printf("runner markJobCanceledFromStore GetScan %q: %v", jobID, err) + return + } if !ok { return } @@ -106,7 +110,10 @@ func (r *Runner) markJobCanceledFromStore(jobID string) { } func (r *Runner) run(ctx context.Context, job ScanJob) { - if cur, ok := r.store.GetScan(job.ID); ok && cur.Status == "canceled" { + if cur, ok, err := r.store.GetScan(job.ID); err != nil { + log.Printf("runner run GetScan %q: %v", job.ID, err) + return + } else if ok && cur.Status == "canceled" { return } if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) { diff --git a/internal/scans/store.go b/internal/scans/store.go index 7fa1be0..76303bb 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -61,7 +61,8 @@ type ScanUpdate struct { type Store interface { CreateScan(CreateScanRequest) (ScanJob, error) - GetScan(id string) (ScanJob, bool) + // GetScan: при found==false и err==nil записи нет; err!=nil — сбой хранилища (например БД). + GetScan(id string) (ScanJob, bool, error) ListScans(limit int) []ScanJob UpdateScan(id string, update ScanUpdate) (ScanJob, bool) SaveHostResult(scanID string, host HostResult) error @@ -195,11 +196,11 @@ func (s *MemoryStore) CreateScan(req CreateScanRequest) (ScanJob, error) { return job, nil } -func (s *MemoryStore) GetScan(id string) (ScanJob, bool) { +func (s *MemoryStore) GetScan(id string) (ScanJob, bool, error) { s.mu.RLock() job, ok := s.jobs[id] s.mu.RUnlock() - return job, ok + return job, ok, nil } func (s *MemoryStore) ListScans(limit int) []ScanJob { diff --git a/internal/webui/index.html b/internal/webui/index.html index 7f699b4..c2e046c 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -233,8 +233,16 @@ async function fetchScanJob(id) { const res = await fetch(`/api/scans/${encodeURIComponent(id)}`); - if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); - return res.json(); + const body = await res.text(); + if (!res.ok) { + if (res.status === 503) { + throw new Error( + `Временно недоступно хранилище сканов (HTTP ${res.status}). Повторите через несколько секунд. ${body}` + ); + } + throw new Error(`HTTP ${res.status}: ${body}`); + } + return JSON.parse(body); } /** Просит сервер остановить скан (отмена воркера + статус canceled в БД). */