Show egress/ingress levels in the registry; freeze checks at the verdict
Registry: the "last result" column now also shows, per level (egress,
ingress), how many of the recorded checks of the latest cycle succeeded, split
by check family (tcp-22 and tcp-443 are both "tcp"). One grouped query per
chunk of addresses; new fields last_cycle_id, egress, ingress in
GET /admin/registry; the dashboard renders them under the verdict.
Verdict integrity (migration 0010):
- the prober is handed an address once per site and attempt, not on every
poll, so results are no longer overwritten by later probe rounds;
- UpsertCheckIfOpen refuses writes once the address is aggregating or has its
verdict, or for an older attempt; senders get {"ok":true,"ignored":N} and a
result_dropped event is recorded;
- the checking window counts from checking_started_at, not from assigned_at;
- checks.recorded_at (server clock) and checks.after_verdict (flag for rows
written after the verdict in existing data);
- the verdict rule is a pure function (computeVerdict) and the aggregated
event carries the egress/ingress check counts.
Rebuilt bin/control-api and bin/admin-dashboard to match. Plans and summaries
are in docs/changes; README, API, USAGE, DASHBOARD and DIAGRAMS are updated.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db73409e8f
commit
864208238f
34 files changed
+1570
-72
No files matched your search
@@ -264,8 +264,47 @@ type registryItem struct {
|
||||
LastCheckedAt *time.Time `json:"last_checked_at"`
|
||||
InQueue bool `json:"in_queue"`
|
||||
CurrentState string `json:"current_state"`
|
||||
// LastCycleID is the cycle counted by Egress and Ingress (0 = no checks).
|
||||
LastCycleID int `json:"last_cycle_id"`
|
||||
Egress levelResult `json:"egress"`
|
||||
Ingress levelResult `json:"ingress"`
|
||||
}
|
||||
|
||||
// levelResult is "ok of total" recorded checks of one level (egress or
|
||||
// ingress) in the last cycle, split by check family — see httpapi's
|
||||
// levelResultDTO.
|
||||
type levelResult struct {
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
ByType []typeStat `json:"by_type"`
|
||||
}
|
||||
|
||||
type typeStat struct {
|
||||
Type string `json:"type"`
|
||||
Total int `json:"total"`
|
||||
OK int `json:"ok"`
|
||||
}
|
||||
|
||||
// statClass picks the colour of an "ok of total" figure: all checks passed,
|
||||
// none passed, some passed, or nothing recorded.
|
||||
func statClass(ok, total int) string {
|
||||
switch {
|
||||
case total == 0:
|
||||
return "none"
|
||||
case ok == total:
|
||||
return "ok"
|
||||
case ok == 0:
|
||||
return "fail"
|
||||
}
|
||||
return "part"
|
||||
}
|
||||
|
||||
// Class is the CSS modifier for the level's total.
|
||||
func (l levelResult) Class() string { return statClass(l.OK, l.Total) }
|
||||
|
||||
// Class is the CSS modifier for one check family.
|
||||
func (t typeStat) Class() string { return statClass(t.OK, t.Total) }
|
||||
|
||||
type registryHistoryResponse struct {
|
||||
Registry registryItem `json:"registry"`
|
||||
Checks []check `json:"checks"`
|
||||
|
||||
@@ -590,6 +590,38 @@ func TestRegistryPageAndDetail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryPageShowsEgressIngressLevels proves the list shows, under the
|
||||
// verdict, "ok из total" per level and per check family, coloured by outcome,
|
||||
// and omits the block for an address with no recorded cycle.
|
||||
func TestRegistryPageShowsEgressIngressLevels(t *testing.T) {
|
||||
fake, caURL := newFakeControlAPI(t)
|
||||
now := time.Now()
|
||||
fake.registry["9.9.9.9"] = registryItem{
|
||||
IPAddress: "9.9.9.9", FirstSeenAt: now, LastSeenAt: now, TotalCycles: 1, LastResult: "partial",
|
||||
LastCycleID: 1,
|
||||
Egress: levelResult{Total: 5, OK: 5, ByType: []typeStat{{"https", 3, 3}, {"icmp", 2, 2}}},
|
||||
Ingress: levelResult{Total: 4, OK: 3, ByType: []typeStat{{"icmp", 1, 1}, {"tcp", 3, 2}}},
|
||||
}
|
||||
fake.registry["8.8.8.8"] = registryItem{IPAddress: "8.8.8.8", FirstSeenAt: now, LastSeenAt: now}
|
||||
ts := newTestServer(t, caURL)
|
||||
|
||||
body := get(t, ts, "/registry")
|
||||
for _, want := range []string{
|
||||
"Egress", "Ingress",
|
||||
`stat stat-ok">5 из 5<`, `stat stat-part">3 из 4<`,
|
||||
`chip chip-ok">https 3 из 3<`, `chip chip-ok">icmp 2 из 2<`,
|
||||
`chip chip-part">tcp 2 из 3<`,
|
||||
"цикла 1",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("expected %q in registry page, got:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Count(body, `class="levels"`) != 1 {
|
||||
t.Fatalf("expected the levels block only for the address with checks, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryFilterByQueryAndStatus proves the ?q=&status= params on
|
||||
// /registry narrow the list by address substring and by LastResult, and
|
||||
// that the filter form echoes the applied values back into its inputs.
|
||||
|
||||
@@ -2,6 +2,7 @@ package dashboard
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -115,6 +116,24 @@ var funcMap = template.FuncMap{
|
||||
"join": strings.Join,
|
||||
"joinInts": joinInts,
|
||||
"pluralAddr": pluralAddr,
|
||||
"dict": dict,
|
||||
}
|
||||
|
||||
// dict builds a map from alternating key/value arguments, so a sub-template
|
||||
// can be given several values: {{template "x" dict "Name" .A "L" .B}}.
|
||||
func dict(kv ...any) (map[string]any, error) {
|
||||
if len(kv)%2 != 0 {
|
||||
return nil, fmt.Errorf("dict: odd number of arguments")
|
||||
}
|
||||
m := make(map[string]any, len(kv)/2)
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
k, ok := kv[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dict: key %v is not a string", kv[i])
|
||||
}
|
||||
m[k] = kv[i+1]
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// pluralAddr returns the correctly declined Russian word for "address"
|
||||
|
||||
@@ -422,6 +422,22 @@ td.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; colo
|
||||
.pill-cancel { background: var(--cancel-soft); color: var(--cancel); }
|
||||
.pill-occupied { background: var(--occupied-soft); color: var(--occupied); }
|
||||
|
||||
/* ---------- registry: egress / ingress levels ---------- */
|
||||
.levels { margin-top: 6px; display: grid; gap: 4px; }
|
||||
.level { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; font-size: 12px; }
|
||||
.level-name { min-width: 52px; color: var(--neutral); font-weight: 600; }
|
||||
.level-types { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
||||
.stat { font-weight: 700; white-space: nowrap; }
|
||||
.stat-ok { color: var(--success); }
|
||||
.stat-part { color: var(--warning); }
|
||||
.stat-fail { color: var(--danger); }
|
||||
.stat-none { color: var(--neutral); }
|
||||
.chip { padding: 1px 6px; border-radius: var(--radius-xs); font-size: 11px; white-space: nowrap; }
|
||||
.chip-ok { background: var(--success-soft); color: var(--success); }
|
||||
.chip-part { background: var(--warning-soft); color: var(--warning); }
|
||||
.chip-fail { background: var(--danger-soft); color: var(--danger); }
|
||||
.chip-none { background: var(--neutral-soft); color: var(--neutral); }
|
||||
|
||||
/* ---------- alerts ---------- */
|
||||
.alert {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
|
||||
@@ -66,6 +66,15 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "registry_level"}}
|
||||
<div class="level">
|
||||
<span class="level-name">{{.Name}}</span>
|
||||
{{if .L.Total}}<span class="stat stat-{{.L.Class}}">{{.L.OK}} из {{.L.Total}}</span>
|
||||
<span class="level-types">{{range .L.ByType}}<span class="chip chip-{{.Class}}">{{.Type}} {{.OK}} из {{.Total}}</span>{{end}}</span>
|
||||
{{else}}<span class="stat stat-none">—</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "registry_table"}}
|
||||
{{if .Items}}
|
||||
<div class="panel">
|
||||
@@ -85,6 +94,10 @@
|
||||
{{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}}
|
||||
{{if .LastCycleID}}<div class="levels" title="Считаются записанные проверки цикла {{.LastCycleID}}; вердикт учитывает ещё и недостающие результаты.">
|
||||
{{template "registry_level" dict "Name" "Egress" "L" .Egress}}
|
||||
{{template "registry_level" dict "Name" "Ingress" "L" .Ingress}}
|
||||
</div>{{end}}
|
||||
</td>
|
||||
<td data-label="Сейчас в очереди">
|
||||
{{if .InQueue}}<span class="pill pill-info">{{.CurrentState}}</span>{{else}}<span class="pill pill-neutral">нет</span>{{end}}
|
||||
|
||||
Reference in new issue
Block a user