Add scan diff endpoint and update spec checklist.

Compare two scans by alive hosts and LLDP-derived links, then reflect completed diff milestone in technical spec.

Made-with: Cursor
This commit is contained in:
Andrey Lutsenko
2026-04-09 22:05:09 +10:00
parent ab94409a52
commit df32d43ac5
3 changed files with 81 additions and 3 deletions
+7
View File
@@ -8,6 +8,7 @@
- `POST /api/scans` - создание задания скана. - `POST /api/scans` - создание задания скана.
- `GET /api/scans` - список сканов. - `GET /api/scans` - список сканов.
- `GET /api/scans/{id}` - просмотр созданного задания. - `GET /api/scans/{id}` - просмотр созданного задания.
- `GET /api/scans/diff?from=&to=` - сравнение двух сканов (hosts/links).
- `GET /api/scans/{id}/hosts` - результаты проверки хостов по скану. - `GET /api/scans/{id}/hosts` - результаты проверки хостов по скану.
- `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану. - `GET /api/scans/{id}/ports` - результаты проверки TCP-портов по скану.
- `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`). - `GET /api/scans/{id}/snmp` - результаты SNMP v2c (`sysName`, `sysDescr`, `sysObjectID`).
@@ -131,6 +132,12 @@ curl -s http://localhost:8080/api/scans/<scan_id>
curl -s http://localhost:8080/api/scans?limit=20 curl -s http://localhost:8080/api/scans?limit=20
``` ```
Сравнить два scan:
```bash
curl -s "http://localhost:8080/api/scans/diff?from=<scan_id_1>&to=<scan_id_2>"
```
В ответе у scan есть: В ответе у scan есть:
- `status`: `queued`, `running`, `done`, `failed` - `status`: `queued`, `running`, `done`, `failed`
+3 -3
View File
@@ -25,7 +25,7 @@
- [x] сбор LLDP соседей по SNMP; - [x] сбор LLDP соседей по SNMP;
- [x] сохранение результатов скана в БД; - [x] сохранение результатов скана в БД;
- [x] построение базового графа связей устройств (`/links`, `/topology`); - [x] построение базового графа связей устройств (`/links`, `/topology`);
- [ ] история сканов и сравнение изменений. - [x] история сканов и сравнение изменений (`/api/scans/diff?from=&to=`).
## Нефункциональные требования ## Нефункциональные требования
@@ -58,7 +58,7 @@
- [x] `POST /api/scans` - [x] `POST /api/scans`
- [x] `GET /api/scans/{id}` - [x] `GET /api/scans/{id}`
- [x] `GET /api/scans/{id}/topology` - [x] `GET /api/scans/{id}/topology`
- [ ] `GET /api/topology/diff?from=&to=` - [x] `GET /api/scans/diff?from=&to=`
## Модель данных (минимум) ## Модель данных (минимум)
@@ -86,7 +86,7 @@
3. [x] Ping + port scan 3. [x] Ping + port scan
4. [x] SNMP system + LLDP 4. [x] SNMP system + LLDP
5. [ ] Граф топологии в UI 5. [ ] Граф топологии в UI
6. [ ] История и diff 6. [x] История и diff
## Критерии приемки v1 ## Критерии приемки v1
+71
View File
@@ -39,6 +39,15 @@ type TopologyEdge struct {
TargetPort string `json:"target_port,omitempty"` TargetPort string `json:"target_port,omitempty"`
} }
type ScanDiffResponse struct {
FromScanID string `json:"from_scan_id"`
ToScanID string `json:"to_scan_id"`
NewHosts []string `json:"new_hosts"`
MissingHosts []string `json:"missing_hosts"`
NewLinks []string `json:"new_links"`
MissingLinks []string `json:"missing_links"`
}
func NewHandler(store scans.Store, run *scans.Runner) *Handler { func NewHandler(store scans.Store, run *scans.Runner) *Handler {
return &Handler{store: store, run: run} return &Handler{store: store, run: run}
} }
@@ -48,6 +57,7 @@ func (h *Handler) Routes() http.Handler {
mux.HandleFunc("GET /health", h.health) mux.HandleFunc("GET /health", h.health)
mux.HandleFunc("POST /api/scans", h.createScan) mux.HandleFunc("POST /api/scans", h.createScan)
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/{id}", h.getScan) mux.HandleFunc("GET /api/scans/{id}", h.getScan)
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)
@@ -108,6 +118,38 @@ func (h *Handler) listScans(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (h *Handler) getScansDiff(w http.ResponseWriter, r *http.Request) {
fromID := strings.TrimSpace(r.URL.Query().Get("from"))
toID := strings.TrimSpace(r.URL.Query().Get("to"))
if fromID == "" || toID == "" {
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")
return
}
if _, ok := h.store.GetScan(toID); !ok {
writeError(w, http.StatusNotFound, "to scan not found")
return
}
fromHosts := hostUpSet(h.store.ListHostResults(fromID, 200000))
toHosts := hostUpSet(h.store.ListHostResults(toID, 200000))
fromLinks := linkSet(h.store.ListLLDPResults(fromID, 200000))
toLinks := linkSet(h.store.ListLLDPResults(toID, 200000))
resp := ScanDiffResponse{
FromScanID: fromID,
ToScanID: toID,
NewHosts: setDiff(toHosts, fromHosts),
MissingHosts: setDiff(fromHosts, toHosts),
NewLinks: setDiff(toLinks, fromLinks),
MissingLinks: setDiff(fromLinks, toLinks),
}
writeJSON(w, http.StatusOK, resp)
}
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 := r.PathValue("id")
if id == "" { if id == "" {
@@ -374,6 +416,35 @@ func withJSON(next http.Handler) http.Handler {
}) })
} }
func hostUpSet(items []scans.HostResult) map[string]struct{} {
out := make(map[string]struct{}, len(items))
for _, h := range items {
if h.IsUp {
out[h.IP] = struct{}{}
}
}
return out
}
func linkSet(items []scans.LLDPResult) map[string]struct{} {
out := make(map[string]struct{}, len(items))
for _, l := range items {
key := l.IP + "|" + l.LocalPortNum + "|" + l.RemoteSysName + "|" + l.RemotePortID + "|" + l.RemoteChassisID
out[key] = struct{}{}
}
return out
}
func setDiff(a, b map[string]struct{}) []string {
out := make([]string, 0)
for k := range a {
if _, ok := b[k]; !ok {
out = append(out, k)
}
}
return out
}
func writeJSON(w http.ResponseWriter, status int, payload any) { func writeJSON(w http.ResponseWriter, status int, payload any) {
w.WriteHeader(status) w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload) _ = json.NewEncoder(w).Encode(payload)