Closes #102.
The only operational surface was /healthz and a fold-out roster inside the owner's own reading page. This adds /admin: an owner-only page carrying the Reader roster and one row per Poll Lane.
- **Poller seam.** `latest.Poller` records each Lane's last pass (`Site`, `Due`, `Checked`, `LastRun`, `Gap`, `Clamped`, `Browser`) and answers `LaneStatus()`; the page reads that snapshot, never a table. A pass that returns before computing its figures (refusal backoff, sidecar down) carries the previous pass's figures forward rather than recording zeroes, and a Lane that has never reached a pace renders no gap at all. Refusal and sidecar reachability are derived at snapshot time.
- **Owner gate at registration.** Every route reaching past the acting Reader lives in `adminRoutes()` and is wrapped in `requireOwner` when it is registered, so a missing gate is visible in the route list rather than hidden in a handler. `web.AdminPatterns()` is what the gate test walks, so a new route cannot be added without being tested. A non-owner gets 404, never 403.
- **Nil poller is a first-class state.** `main.newRouter` takes the reporter as an interface and converts a nil `*Poller` to a nil interface; no poller and no completed pass both render "No data yet" with the reason spelled out, rather than confident zeroes.
- **Roster moved** off the reading page onto /admin, with the Sighting counters and a confirm-gated `Clear marks` control. #103 fills those counters, so on delivery they read zero for everyone - deliberate ordering.
- **One accent, `--patina`** (verdigris, both colour branches): the far side of the wheel from ember's crimson and clear of the archive blue. Ember still means new chapter only; revocation still wears --danger.
Verification: `go vet ./...` and `go test ./...` green (Docker-backed); admin page screenshotted at 1100px and 390px in both colour schemes. Reviewed on both axes (spec, standards); findings on the accent hue, zero-figure honesty and three tests that could not fail are fixed in 58014eb.
Reviewed-on: #107
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
This commit was merged in pull request #107.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
-- The owner's administrative page (issue #102) renders these counters and
|
||||
-- offers a control to clear them, deliberately shipped before the Sighting
|
||||
-- feature (issue #103) that fills them, so a false mark never needs SQL
|
||||
-- against production. Zero counters mean a trusted Reader.
|
||||
ALTER TABLE readers ADD COLUMN sighting_agreements integer NOT NULL DEFAULT 0;
|
||||
ALTER TABLE readers ADD COLUMN sighting_disagreements integer NOT NULL DEFAULT 0;
|
||||
@@ -315,25 +315,42 @@ func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, e
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// SightingDisagreementLimit is the number of contradictions that stop that
|
||||
// Reader's Sightings from deferring a Poll (issue #103). The counters exist
|
||||
// before the mechanism that moves them, so the admin page (issue #102) can
|
||||
// clear a false mark without waiting for the Sighting feature.
|
||||
const SightingDisagreementLimit = 3
|
||||
|
||||
// Blocked reports whether this Reader's Sighting marks have reached the
|
||||
// disagreement limit, which stops their Sightings from deferring a Poll.
|
||||
func (r ReaderSummary) Blocked() bool {
|
||||
return r.Disagreements >= SightingDisagreementLimit
|
||||
}
|
||||
|
||||
// ReaderSummary is one Reader as the owner's administration panel sees them:
|
||||
// who they are and how many live sessions they hold. No credential material,
|
||||
// hashed or otherwise, is exposed.
|
||||
// who they are, how many live sessions they hold, and their Sighting marks.
|
||||
// No credential material, hashed or otherwise, is exposed.
|
||||
type ReaderSummary struct {
|
||||
ID int64
|
||||
DiscordID string
|
||||
// Sessions counts unexpired session rows — what the owner revokes.
|
||||
Sessions int
|
||||
// Agreements and Disagreements are the Sighting counters (issue #102);
|
||||
// zero means a trusted Reader.
|
||||
Agreements int
|
||||
Disagreements int
|
||||
}
|
||||
|
||||
// Readers lists every Reader with their live session count, oldest first, so
|
||||
// the owner row (always the oldest) heads the list.
|
||||
// Readers lists every Reader with their live session count and Sighting
|
||||
// marks, oldest first, so the owner row (always the oldest) heads the list.
|
||||
func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT r.id, r.discord_id,
|
||||
r.sighting_agreements, r.sighting_disagreements,
|
||||
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
|
||||
FROM readers r
|
||||
LEFT JOIN sessions sess ON sess.reader_id = r.id
|
||||
GROUP BY r.id, r.discord_id
|
||||
GROUP BY r.id, r.discord_id, r.sighting_agreements, r.sighting_disagreements
|
||||
ORDER BY r.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query readers: %w", err)
|
||||
@@ -343,7 +360,7 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||
out := []ReaderSummary{}
|
||||
for rows.Next() {
|
||||
var r ReaderSummary
|
||||
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil {
|
||||
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Agreements, &r.Disagreements, &r.Sessions); err != nil {
|
||||
return nil, fmt.Errorf("scan reader: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
@@ -351,6 +368,18 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ClearReaderMarks zeroes a Reader's Sighting counters. It is the owner's
|
||||
// remedy for a mark produced by a broken Site adapter rather than a dishonest
|
||||
// Reader: it restores a privilege, it is not destruction.
|
||||
func (s *Store) ClearReaderMarks(readerID int64) error {
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_agreements = 0, sighting_disagreements = 0
|
||||
WHERE id = $1`, readerID); err != nil {
|
||||
return fmt.Errorf("clear reader marks for reader %d: %w", readerID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readersMigration is the version that creates the readers table. The owner
|
||||
// seed runs between two migrate passes, so that the run-once migration which
|
||||
// attaches existing bookmarks (0004) finds the owner row.
|
||||
|
||||
@@ -1334,6 +1334,86 @@ func TestReadersAndSessionRevocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Sighting counters ship before the mechanism that fills them (issue
|
||||
// #102 before #103), so the admin page depends on their default: a fresh
|
||||
// Reader reads back trusted. Marking one directly proves Readers() reports
|
||||
// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
|
||||
// the owner's remedy for a false mark — zeroes them again.
|
||||
func TestReaderSightingMarks(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
other := secondReader(t, s)
|
||||
|
||||
readers, err := s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
if len(readers) != 2 {
|
||||
t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
||||
t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
|
||||
}
|
||||
}
|
||||
|
||||
// The counters' default is the trusted state; writing them directly is
|
||||
// the only way to exercise the read path until issue #103 moves them.
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
||||
WHERE id = $1`, other); err != nil {
|
||||
t.Fatalf("mark reader: %v", err)
|
||||
}
|
||||
readers, err = s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
var marked *ReaderSummary
|
||||
for i := range readers {
|
||||
if readers[i].ID == other {
|
||||
marked = &readers[i]
|
||||
}
|
||||
}
|
||||
if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
|
||||
t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
|
||||
}
|
||||
if marked.Blocked() {
|
||||
t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(`
|
||||
UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
|
||||
t.Fatalf("block reader: %v", err)
|
||||
}
|
||||
readers, err = s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.ID == other && !r.Blocked() {
|
||||
t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
|
||||
}
|
||||
if r.ID == s.OwnerID() && r.Blocked() {
|
||||
t.Fatalf("untouched owner became blocked: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ClearReaderMarks(other); err != nil {
|
||||
t.Fatalf("ClearReaderMarks: %v", err)
|
||||
}
|
||||
if err := s.ClearReaderMarks(other + 9999); err != nil {
|
||||
t.Fatalf("ClearReaderMarks(unknown id): %v", err)
|
||||
}
|
||||
readers, err = s.Readers()
|
||||
if err != nil {
|
||||
t.Fatalf("Readers: %v", err)
|
||||
}
|
||||
for _, r := range readers {
|
||||
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
||||
t.Fatalf("reader %d not cleared: %+v", r.ID, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Two Readers on one Series: one series row, two independent progresses. The
|
||||
// second Reader starts at zero however far the first has read, and the shared
|
||||
// row is still due exactly once.
|
||||
|
||||
Reference in New Issue
Block a user