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
+6
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
"log"
"sort"
"strings"
"time"
@@ -132,19 +133,24 @@ limit 1`
return ScanJob{}, false
}
if err != nil {
log.Printf("postgres GetScan id=%q: row scan: %v", id, err)
return ScanJob{}, false
}
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
}
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
}
if err = json.Unmarshal([]byte(optionsRaw), &job.Options); err != nil {
log.Printf("postgres GetScan id=%q: options json: %v", id, err)
return ScanJob{}, false
}
if err = json.Unmarshal([]byte(statsRaw), &job.Stats); err != nil {
log.Printf("postgres GetScan id=%q: stats json: %v", id, err)
return ScanJob{}, false
}
if startedAt.Valid {
+106 -13
View File
@@ -1,7 +1,9 @@
package scans
import (
"context"
"encoding/hex"
"errors"
"fmt"
"log"
"net"
@@ -20,8 +22,10 @@ import (
)
type Runner struct {
store Store
cfg RunnerConfig
store Store
cfg RunnerConfig
mu sync.Mutex
cancels map[string]context.CancelFunc
}
type RunnerConfig struct {
@@ -52,10 +56,64 @@ func scanProgressBlend(quickDone, fullDone, total int) int {
}
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()
_, _ = r.store.UpdateScan(job.ID, ScanUpdate{
Status: "running",
@@ -64,6 +122,11 @@ func (r *Runner) run(job ScanJob) {
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)
if err != nil {
finished := time.Now().UTC()
@@ -109,10 +172,10 @@ func (r *Runner) run(job ScanJob) {
Status: "running",
Progress: prog,
Stats: ScanStats{
HostsTotal: total,
HostsUp: u,
HostsPingPhaseDone: q,
HostsProcessed: f,
HostsTotal: total,
HostsUp: u,
HostsPingPhaseDone: q,
HostsProcessed: f,
},
})
}
@@ -178,19 +241,49 @@ func (r *Runner) run(job ScanJob) {
go worker()
}
for _, ip := range ips {
workCh <- ip
}
close(workCh)
go func() {
defer close(workCh)
for _, ip := range ips {
select {
case <-ctx.Done():
return
case workCh <- ip:
}
}
}()
wg.Wait()
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{
Status: "done",
Progress: 100,
Stats: ScanStats{
HostsTotal: total,
HostsUp: up,
HostsUp: upFinal,
HostsPingPhaseDone: total,
HostsProcessed: total,
},
+2 -2
View File
@@ -14,8 +14,8 @@ func TestNewScanIDShape(t *testing.T) {
if len(parts) < 3 {
t.Fatalf("expected at least time_slug_suffix, got %q", id)
}
if len(parts[len(parts)-1]) != 4 {
t.Fatalf("expected 4-hex suffix, got %q", id)
if len(parts[len(parts)-1]) != 8 {
t.Fatalf("expected 8-hex suffix, got %q", id)
}
if !strings.Contains(id, "192.168.160.0_24") {
t.Fatalf("expected sanitized cidr in id: %q", id)
+2 -2
View File
@@ -548,9 +548,9 @@ func validateCreateScanRequest(req *CreateScanRequest) error {
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) {
var tail [2]byte
var tail [4]byte
if _, err := rand.Read(tail[:]); err != nil {
return "", err
}