fix: 503 при сбое GetScan вместо ложного 404 scan not found

- Store.GetScan возвращает (job, ok, error); Postgres отдаёт err при ошибке БД/JSON

- API: getScanOrWriteError → 503 scan storage temporarily unavailable

- Web UI: понятное сообщение для HTTP 503 при опросе скана

Made-with: Cursor
This commit is contained in:
PTah
2026-04-13 15:38:04 +10:00
parent 6ad59c9990
commit 0086302605
5 changed files with 74 additions and 45 deletions
+37 -29
View File
@@ -14,6 +14,9 @@ import (
"nettopo-go/internal/webui" "nettopo-go/internal/webui"
) )
// errScanStorageUnavailable — ответ API при сбое GetScan (БД, а не «нет такого scan_id»).
const errScanStorageUnavailable = "scan storage temporarily unavailable"
type Handler struct { type Handler struct {
store scans.Store store scans.Store
run *scans.Runner run *scans.Runner
@@ -83,6 +86,26 @@ func scanPathID(r *http.Request) string {
return strings.TrimSpace(raw) 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 { 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))
@@ -189,12 +212,10 @@ func (h *Handler) getScansDiff(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "both from and to are required") writeError(w, http.StatusBadRequest, "both from and to are required")
return return
} }
if _, ok := h.store.GetScan(fromID); !ok { if _, ok := h.getScanOrWriteError(w, fromID, "from scan not found"); !ok {
writeError(w, http.StatusNotFound, "from scan not found")
return return
} }
if _, ok := h.store.GetScan(toID); !ok { if _, ok := h.getScanOrWriteError(w, toID, "to scan not found"); !ok {
writeError(w, http.StatusNotFound, "to scan not found")
return return
} }
@@ -221,10 +242,8 @@ func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) {
return return
} }
job, ok := h.store.GetScan(id) job, ok := h.getScanOrWriteError(w, id, "")
if !ok { if !ok {
log.Printf("getScan: not found id=%q (len=%d)", id, len(id))
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
@@ -241,9 +260,8 @@ func (h *Handler) postCancelScan(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { cur, ok := h.getScanOrWriteError(w, id, "")
log.Printf("postCancelScan: not found id=%q (len=%d)", id, len(id)) if !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
runnerSignaled := false 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}) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "runner_canceled": true})
return return
} }
cur, _ := h.store.GetScan(id)
if cur.Status == "queued" { if cur.Status == "queued" {
fin := time.Now().UTC() fin := time.Now().UTC()
_, _ = h.store.UpdateScan(id, scans.ScanUpdate{Status: "canceled", FinishedAt: &fin}) _, _ = 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") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
@@ -296,8 +312,7 @@ func (h *Handler) getScanPorts(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
@@ -322,8 +337,7 @@ func (h *Handler) getScanSNMP(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
@@ -348,8 +362,7 @@ func (h *Handler) getScanLLDP(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
@@ -374,8 +387,7 @@ func (h *Handler) getScanInterfaces(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
ip := strings.TrimSpace(r.URL.Query().Get("ip")) 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") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
ip := strings.TrimSpace(r.URL.Query().Get("ip")) 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") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
raw := strings.TrimSpace(r.URL.Query().Get("mac")) 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") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
@@ -675,8 +684,7 @@ func (h *Handler) getScanTopology(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "scan id is required") writeError(w, http.StatusBadRequest, "scan id is required")
return return
} }
if _, ok := h.store.GetScan(id); !ok { if _, ok := h.getScanOrWriteError(w, id, ""); !ok {
writeError(w, http.StatusNotFound, "scan not found")
return return
} }
+14 -9
View File
@@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"log" "log"
"sort" "sort"
"strings" "strings"
@@ -98,7 +99,7 @@ insert into scan_jobs (
return job, nil return job, nil
} }
func (s *PostgresStore) GetScan(id string) (ScanJob, bool) { func (s *PostgresStore) GetScan(id string) (ScanJob, bool, error) {
query := ` query := `
select id, name, status, cidrs::text, exclude_ips::text, options::text, select id, name, status, cidrs::text, exclude_ips::text, options::text,
progress, stats::text, coalesce(snmp_credentials_id::text, ''), progress, stats::text, coalesce(snmp_credentials_id::text, ''),
@@ -130,28 +131,28 @@ limit 1`
&finishedAt, &finishedAt,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return ScanJob{}, false return ScanJob{}, false, nil
} }
if err != nil { if err != nil {
log.Printf("postgres GetScan id=%q: row scan: %v", id, err) 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 { 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) 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 { if err = json.Unmarshal([]byte(excludeRaw), &job.ExcludeIPs); err != nil {
log.Printf("postgres GetScan id=%q: exclude_ips json: %v", id, err) 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 { if err = json.Unmarshal([]byte(optionsRaw), &job.Options); err != nil {
log.Printf("postgres GetScan id=%q: options json: %v", id, err) 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 { if err = json.Unmarshal([]byte(statsRaw), &job.Stats); err != nil {
log.Printf("postgres GetScan id=%q: stats json: %v", id, err) 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 { if startedAt.Valid {
job.StartedAt = startedAt.Time job.StartedAt = startedAt.Time
@@ -160,7 +161,7 @@ limit 1`
job.FinishedAt = finishedAt.Time job.FinishedAt = finishedAt.Time
} }
return job, true return job, true, nil
} }
func (s *PostgresStore) ListScans(limit int) []ScanJob { func (s *PostgresStore) ListScans(limit int) []ScanJob {
@@ -230,7 +231,11 @@ limit $1`
} }
func (s *PostgresStore) UpdateScan(id string, update ScanUpdate) (ScanJob, bool) { 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 { if !ok {
return ScanJob{}, false return ScanJob{}, false
} }
+9 -2
View File
@@ -88,7 +88,11 @@ func (r *Runner) CancelScan(id string) bool {
} }
func (r *Runner) markJobCanceledFromStore(jobID string) { 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 { if !ok {
return return
} }
@@ -106,7 +110,10 @@ func (r *Runner) markJobCanceledFromStore(jobID string) {
} }
func (r *Runner) run(ctx context.Context, job ScanJob) { 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 return
} }
if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) { if err := ctx.Err(); err != nil && errors.Is(err, context.Canceled) {
+4 -3
View File
@@ -61,7 +61,8 @@ type ScanUpdate struct {
type Store interface { type Store interface {
CreateScan(CreateScanRequest) (ScanJob, error) 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 ListScans(limit int) []ScanJob
UpdateScan(id string, update ScanUpdate) (ScanJob, bool) UpdateScan(id string, update ScanUpdate) (ScanJob, bool)
SaveHostResult(scanID string, host HostResult) error SaveHostResult(scanID string, host HostResult) error
@@ -195,11 +196,11 @@ func (s *MemoryStore) CreateScan(req CreateScanRequest) (ScanJob, error) {
return job, nil return job, nil
} }
func (s *MemoryStore) GetScan(id string) (ScanJob, bool) { func (s *MemoryStore) GetScan(id string) (ScanJob, bool, error) {
s.mu.RLock() s.mu.RLock()
job, ok := s.jobs[id] job, ok := s.jobs[id]
s.mu.RUnlock() s.mu.RUnlock()
return job, ok return job, ok, nil
} }
func (s *MemoryStore) ListScans(limit int) []ScanJob { func (s *MemoryStore) ListScans(limit int) []ScanJob {
+10 -2
View File
@@ -233,8 +233,16 @@
async function fetchScanJob(id) { async function fetchScanJob(id) {
const res = await fetch(`/api/scans/${encodeURIComponent(id)}`); const res = await fetch(`/api/scans/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); const body = await res.text();
return res.json(); 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 в БД). */ /** Просит сервер остановить скан (отмена воркера + статус canceled в БД). */