Fix registry badge showing pass despite a partial/failed cycle

fillRegistrySummary derived LastResult from whichever single checks row
happened to have the latest checked_at, not the cycle's actual aggregated
result — a cycle with a mix of passing and failing checks (e.g. one egress
target timed out while the rest, including the chronologically-last check,
succeeded) rendered as a green "pass" badge on /registry, disagreeing with
the correct "partial" badge already shown on /ips for the same address.

Now prefers ip_queue.overall_result (the orchestrator's own aggregation)
when a live queue row has a finished cycle, leaves the badge blank while a
cycle is still in progress, and only falls back to classifying the most
recent cycle's own checks (pass/fail/partial) once the address has been
deleted from the queue and overall_result is no longer available.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-23 11:03:56 +03:00
1 parent 3b6b2d04c9
commit db97b83cd6
9 files changed
+207 -17

No files matched your search

@@ -39,7 +39,9 @@
<td class="num" data-label="Циклов">{{.TotalCycles}}</td>
<td data-label="Последний результат">
{{if eq .LastResult "pass"}}<span class="pill pill-success">pass</span>
{{else if eq .LastResult "partial"}}<span class="pill pill-warning">partial</span>
{{else if eq .LastResult "fail"}}<span class="pill pill-danger">fail</span>
{{else if eq .LastResult "cancelled"}}<span class="pill pill-cancel">cancelled</span>
{{else}}<span class="pill pill-neutral">—</span>{{end}}
</td>
<td data-label="Сейчас в очереди">
@@ -23,6 +23,10 @@
<p><a href="/registry">&larr; К реестру</a></p>
<div class="topbar">
<h1 class="mono" style="display:flex;align-items:center;gap:10px">{{.History.Registry.IPAddress}}
{{if eq .History.Registry.LastResult "pass"}}<span class="pill pill-success">pass</span>
{{else if eq .History.Registry.LastResult "partial"}}<span class="pill pill-warning">partial</span>
{{else if eq .History.Registry.LastResult "fail"}}<span class="pill pill-danger">fail</span>
{{else if eq .History.Registry.LastResult "cancelled"}}<span class="pill pill-cancel">cancelled</span>{{end}}
{{if .History.Registry.InQueue}}<a class="pill pill-info" href="/ips/{{.History.Registry.IPAddress}}">{{.History.Registry.CurrentState}} · в очереди</a>{{else}}<span class="pill pill-neutral">не в очереди</span>{{end}}
</h1>
</div>
+69 -13
View File
@@ -110,6 +110,13 @@ func (d *DB) GetRegistryByAddress(ctx context.Context, address string) (*Registr
return s, nil
}
// fillRegistrySummary computes the badge-level summary shown on the
// registry list/detail pages. LastResult must reflect the *aggregated*
// outcome of the most recent cycle (pass/partial/fail/cancelled), not the
// success flag of whichever individual check happens to have the latest
// checked_at — a cycle with a mix of passing and failing checks (e.g. one
// egress target timed out while the rest succeeded) is "partial", even
// though the chronologically-last check to report in might have passed.
func (d *DB) fillRegistrySummary(ctx context.Context, s *RegistrySummary) error {
if err := d.QueryRowContext(ctx, `
SELECT COUNT(DISTINCT cycle_id) FROM checks WHERE registry_id=?
@@ -117,37 +124,86 @@ func (d *DB) fillRegistrySummary(ctx context.Context, s *RegistrySummary) error
return err
}
var lastResult sql.NullBool
var lastCheckedAt sql.NullString
if err := d.QueryRowContext(ctx, `
SELECT success, checked_at FROM checks WHERE registry_id=? ORDER BY cycle_id DESC, checked_at DESC LIMIT 1
`, s.ID).Scan(&lastResult, &lastCheckedAt); err != nil && err != sql.ErrNoRows {
SELECT checked_at FROM checks WHERE registry_id=? ORDER BY cycle_id DESC, checked_at DESC LIMIT 1
`, s.ID).Scan(&lastCheckedAt); err != nil && err != sql.ErrNoRows {
return err
}
if lastResult.Valid {
if lastResult.Bool {
s.LastResult = "pass"
} else {
s.LastResult = "fail"
}
}
t, err := nullStringToTimePtr(lastCheckedAt)
if err != nil {
return err
}
s.LastCheckedAt = t
var state sql.NullString
if err := d.QueryRowContext(ctx, `SELECT state FROM ip_queue WHERE registry_id=?`, s.ID).Scan(&state); err != nil && err != sql.ErrNoRows {
var state, overallResult sql.NullString
err = d.QueryRowContext(ctx, `SELECT state, overall_result FROM ip_queue WHERE registry_id=?`, s.ID).
Scan(&state, &overallResult)
if err != nil && err != sql.ErrNoRows {
return err
}
if state.Valid {
if err == nil {
s.InQueue = true
s.CurrentState = state.String
}
switch {
case overallResult.Valid && overallResult.String != "":
// The address has a live ip_queue row with a finished cycle
// (done/failed) — overall_result is the orchestrator's own
// aggregation (internal/orchestrator.aggregateAndRelease /
// db.CancelIP), the authoritative source of truth. Use it as-is
// rather than re-deriving it from raw check rows.
s.LastResult = overallResult.String
case s.InQueue:
// A live ip_queue row exists but its current cycle hasn't finished
// yet (still queued/checking/etc, overall_result not set) — no
// verdict to show yet; leave LastResult empty rather than guessing
// from a still-incomplete set of checks.
default:
// No live ip_queue row (deleted from the queue) — fall back to
// classifying the most recent recorded cycle from its own checks,
// the same pass/fail/partial rule aggregateAndRelease uses (minus
// "missing" checks, which aren't knowable after the fact).
result, err := lastCycleResultFromChecks(ctx, d, s.ID)
if err != nil {
return err
}
s.LastResult = result
}
return nil
}
// lastCycleResultFromChecks classifies the most recent cycle recorded for
// registryID directly from its checks rows: pass if every recorded check
// succeeded, fail if every one failed, partial on a mix. Returns "" if no
// checks are recorded at all.
func lastCycleResultFromChecks(ctx context.Context, d *DB, registryID int64) (string, error) {
var cycle sql.NullInt64
if err := d.QueryRowContext(ctx, `SELECT MAX(cycle_id) FROM checks WHERE registry_id=?`, registryID).Scan(&cycle); err != nil {
return "", err
}
if !cycle.Valid {
return "", nil
}
var total, passed int
if err := d.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(success), 0) FROM checks WHERE registry_id=? AND cycle_id=?
`, registryID, cycle.Int64).Scan(&total, &passed); err != nil {
return "", err
}
switch {
case total == 0:
return "", nil
case passed == 0:
return ResultFail, nil
case passed == total:
return ResultPass, nil
default:
return ResultPartial, nil
}
}
func scanRegistryItems(rows *sql.Rows) ([]RegistryItem, error) {
var out []RegistryItem
for rows.Next() {
+128
View File
@@ -3,6 +3,7 @@ package db
import (
"errors"
"testing"
"time"
"cloudipvalidator/internal/config"
)
@@ -240,3 +241,130 @@ func TestGetSetHistoryRetentionCyclesRoundTrip(t *testing.T) {
t.Fatalf("expected 5, got %d", s.HistoryRetentionCycles)
}
}
// TestRegistryLastResultReflectsAggregatedOutcome guards against the badge
// bug where LastResult was derived from whichever single check happened to
// have the latest checked_at, instead of the cycle's actual aggregated
// result — a cycle with one failing check and several passing ones is
// "partial", even if the chronologically-last check to report in passed.
func TestRegistryLastResultReflectsAggregatedOutcome(t *testing.T) {
d, ctx := newTestDB(t)
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("submit ips: %v", err)
}
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get ip: %v", err)
}
// The chronologically-last check (icmp, checked_at later) passes, but
// an earlier one (https) failed — the cycle as a whole is partial.
now := Now()
if err := d.UpsertCheck(ctx, Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
Success: false, CheckedAt: now,
}); err != nil {
t.Fatalf("upsert check (https, fail): %v", err)
}
if err := d.UpsertCheck(ctx, Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: SourceEgress, CheckType: "icmp", Target: "https://example.test",
Success: true, CheckedAt: now.Add(time.Second),
}); err != nil {
t.Fatalf("upsert check (icmp, pass): %v", err)
}
// Simulate what orchestrator.aggregateAndRelease actually does: compute
// the aggregated result and persist it via FinishIP.
if err := d.FinishIP(ctx, ip.ID, ResultPartial); err != nil {
t.Fatalf("finish ip: %v", err)
}
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get registry: %v", err)
}
if summary.LastResult != ResultPartial {
t.Fatalf("expected LastResult=partial (aggregated outcome), got %q", summary.LastResult)
}
if !summary.InQueue || summary.CurrentState != IPDone {
t.Fatalf("expected still in queue as done, got in_queue=%v state=%q", summary.InQueue, summary.CurrentState)
}
all, err := d.ListRegistry(ctx)
if err != nil {
t.Fatalf("list registry: %v", err)
}
if len(all) != 1 || all[0].LastResult != ResultPartial {
t.Fatalf("expected ListRegistry to agree, got %+v", all)
}
}
// TestRegistryLastResultEmptyWhileCycleInProgress proves an address with a
// live but not-yet-finished cycle (overall_result still empty) doesn't get
// a premature pass/fail verdict.
func TestRegistryLastResultEmptyWhileCycleInProgress(t *testing.T) {
d, ctx := newTestDB(t)
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("submit ips: %v", err)
}
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get registry: %v", err)
}
if summary.LastResult != "" {
t.Fatalf("expected no verdict yet for a still-queued address, got %q", summary.LastResult)
}
if !summary.InQueue || summary.CurrentState != IPQueued {
t.Fatalf("expected in_queue=true state=queued, got in_queue=%v state=%q", summary.InQueue, summary.CurrentState)
}
}
// TestRegistryLastResultFallsBackToChecksAfterDeletion proves that once an
// address's ip_queue row is gone (no more overall_result to read), the
// registry still derives a sensible partial/pass/fail verdict from the
// last recorded cycle's own checks.
func TestRegistryLastResultFallsBackToChecksAfterDeletion(t *testing.T) {
d, ctx := newTestDB(t)
if _, err := d.SubmitIPs(ctx, []string{"1.2.3.4"}); err != nil {
t.Fatalf("submit ips: %v", err)
}
ip, err := d.GetIPByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get ip: %v", err)
}
now := Now()
if err := d.UpsertCheck(ctx, Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: SourceEgress, CheckType: "https", Target: "https://example.test",
Success: false, CheckedAt: now,
}); err != nil {
t.Fatalf("upsert check (fail): %v", err)
}
if err := d.UpsertCheck(ctx, Check{
IPID: ip.ID, IPAddress: ip.IPAddress, AttemptNumber: ip.AttemptNumber,
Source: SourceEgress, CheckType: "icmp", Target: "https://example.test",
Success: true, CheckedAt: now.Add(time.Second),
}); err != nil {
t.Fatalf("upsert check (pass): %v", err)
}
if err := d.DeleteIP(ctx, ip.ID); err != nil {
t.Fatalf("delete ip: %v", err)
}
summary, err := d.GetRegistryByAddress(ctx, "1.2.3.4")
if err != nil {
t.Fatalf("get registry: %v", err)
}
if summary.InQueue {
t.Fatalf("expected address no longer in queue")
}
if summary.LastResult != ResultPartial {
t.Fatalf("expected fallback classification partial (mixed pass/fail checks), got %q", summary.LastResult)
}
}