diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index c6d11b1..b33cc45 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -1564,7 +1564,15 @@ paths:
schema:
type: object
properties:
- installs: { type: integer }
+ installs:
+ type: [integer, "null"]
+ description: |
+ Null below the collector's floor (`publish_min`, 25 by
+ default). A number that *falls* cannot be published as a
+ fact — a bad release, a DNS change that quietly stops the
+ pings, or lost rows all look like "fewer people use this
+ now" — and a small number mostly says how little it takes
+ to move it.
window_days: { type: integer }
"404":
description: Not a collector, or not published.
@@ -1590,7 +1598,19 @@ paths:
version: { type: string }
is_collector: { type: boolean }
publish_count: { type: boolean }
+ publish_min:
+ type: integer
+ description: The floor under the published count.
last_ping_at: { type: string, format: date-time }
+ last_error:
+ type: string
+ description: |
+ Why the last attempt did not arrive; absent when the last
+ one did. Shown rather than only logged: a beacon that
+ cannot reach its collector otherwise fails in complete
+ silence, and the only trace is a "last sent" date that
+ quietly gets older.
+ last_error_at: { type: string, format: date-time }
put:
tags: [auth]
summary: Turn the beacon off, or point it somewhere else. Administrators only.
@@ -1610,12 +1630,74 @@ paths:
make the server reach inside its own network.
is_collector: { type: boolean }
publish_count: { type: boolean }
+ publish_min: { type: integer, minimum: 0 }
responses:
"200":
description: The configuration as it now stands.
"422":
description: The collector address is not an http or https URL.
+ /api/v1/beacon/notice:
+ get:
+ tags: [auth]
+ summary: Whether the disclosure is still owed, and what it says. Administrators only.
+ description: |
+ The beacon is on by default, and that is only defensible if it is said
+ unprompted. A settings page where it is stated fully is not the same as
+ saying it: that page is opened by people who already suspect there is
+ something to read.
+
+ The three values are in the response — the real id, the real version, the
+ real collector address — so the notice can show what is sent rather than
+ say "anonymous data" and ask to be believed.
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ pending:
+ type: boolean
+ description: False once anybody has answered, panel-wide.
+ enabled: { type: boolean }
+ instance_id: { type: string }
+ version: { type: string }
+ collector_url: { type: string }
+ post:
+ tags: [auth]
+ summary: Answer the notice. Administrators only.
+ description: |
+ One call for both answers — "keep it on" and "turn it off" are the same
+ act, somebody deciding, and two endpoints would mean one of them could
+ forget to record that a decision was made.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [keep]
+ properties:
+ keep:
+ type: boolean
+ description: |
+ Required, and rejected when absent. An empty body must not be
+ able to mean "turn it off": an opt-out nobody chose is as much
+ a broken promise as an opt-in nobody chose.
+ responses:
+ "200":
+ description: Recorded.
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ enabled: { type: boolean }
+ "422":
+ description: No answer was given.
+
/api/v1/panel/restart:
post:
tags: [auth]
diff --git a/internal/httpapi/beacon_handlers.go b/internal/httpapi/beacon_handlers.go
index a79a63c..a121f78 100644
--- a/internal/httpapi/beacon_handlers.go
+++ b/internal/httpapi/beacon_handlers.go
@@ -20,6 +20,11 @@ import (
const beaconScope = "beacon"
+// defaultPublishMin er, hvor mange installationer der skal være, før tallet
+// offentliggøres. Selve tallet er et skøn; det, gulvet køber, er det ikke: under
+// det fortæller et offentligt tal mest om, hvor lidt der skal til at flytte det.
+const defaultPublishMin = 25
+
// beaconConfig is what this instance has been told to do. The zero value is what
// a fresh installation gets, which is why Enabled defaults to true elsewhere and
// not here — a zero struct must never mean "silently on".
@@ -30,6 +35,33 @@ type beaconConfig struct {
IsCollector bool `json:"is_collector"`
PublishCount bool `json:"publish_count"`
LastPingAt int64 `json:"last_ping_at,omitempty"`
+ // LastVersion er den version, der sidst nåede frem. Gemt, fordi en
+ // installation, der opdaterer sig selv om natten, ellers står som gårsdagens
+ // version i op til et døgn — og en tælling efter version, der er en dag bagud,
+ // kan man ikke se en udrulning i.
+ LastVersion string `json:"last_version,omitempty"`
+ // LastError er grunden til, at det sidste forsøg ikke nåede frem.
+ //
+ // Skrevet ned, fordi alternativet er tavshed: et beacon, der ikke kan nå sin
+ // collector, prøver igen i morgen og siger ingenting, og det opdages først ved
+ // at tælle installationer i den anden ende og savne en. Det er ikke en fejl,
+ // der skal afbryde nogen — det er en linje på en side, der ellers påstår, at
+ // alt er sendt.
+ LastError string `json:"last_error,omitempty"`
+ LastErrorAt int64 `json:"last_error_at,omitempty"`
+ // NoticeAck er sat, når nogen har set beskeden om, hvad der bliver sendt.
+ //
+ // Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt
+ // uopfordret — ikke på en indstillingsside, ingen åbner af sig selv. Feltet er
+ // det, der husker, at det er blevet sagt.
+ NoticeAck bool `json:"notice_ack"`
+ // PublishMin er gulvet under det offentliggjorte tal.
+ //
+ // Et tal, der *falder*, kan ikke offentliggøres som en kendsgerning: en dårlig
+ // udrulning, en DNS-ændring, der stille stopper meldingerne, eller rækker, der
+ // er væk, ser alle sammen ud som "færre bruger det nu". Under gulvet svares
+ // der ingenting frem for et lille tal, ingen kan tolke.
+ PublishMin int `json:"publish_min"`
}
// beaconSettings reads the configuration, filling in the defaults and minting the
@@ -40,7 +72,9 @@ type beaconConfig struct {
// also be a value the collector could compare against a guess — which is the
// difference between anonymous and pseudonymous, and it is the whole point.
func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
- cfg := beaconConfig{Enabled: true, Collector: beacon.DefaultCollector}
+ cfg := beaconConfig{
+ Enabled: true, Collector: beacon.DefaultCollector, PublishMin: defaultPublishMin,
+ }
stored, err := s.db.InstanceSettings(ctx, beaconScope)
if err != nil {
@@ -65,6 +99,21 @@ func (s *Server) beaconSettings(ctx context.Context) (beaconConfig, error) {
if v, ok := stored["last_ping_at"].(float64); ok {
cfg.LastPingAt = int64(v)
}
+ if v, ok := stored["last_version"].(string); ok {
+ cfg.LastVersion = v
+ }
+ if v, ok := stored["last_error"].(string); ok {
+ cfg.LastError = v
+ }
+ if v, ok := stored["last_error_at"].(float64); ok {
+ cfg.LastErrorAt = int64(v)
+ }
+ if v, ok := stored["notice_ack"].(bool); ok {
+ cfg.NoticeAck = v
+ }
+ if v, ok := stored["publish_min"].(float64); ok && v >= 0 {
+ cfg.PublishMin = int(v)
+ }
if cfg.InstanceID == "" {
cfg.InstanceID = store.NewID()
@@ -83,20 +132,31 @@ func (s *Server) saveBeaconSettings(ctx context.Context, cfg beaconConfig) error
"is_collector": cfg.IsCollector,
"publish_count": cfg.PublishCount,
"last_ping_at": cfg.LastPingAt,
+ "last_version": cfg.LastVersion,
+ "last_error": cfg.LastError,
+ "last_error_at": cfg.LastErrorAt,
+ "notice_ack": cfg.NoticeAck,
+ "publish_min": cfg.PublishMin,
})
}
// --- what the settings page shows ------------------------------------------------
type beaconStatusJSON struct {
- Enabled bool `json:"enabled"`
- Collector string `json:"collector_url"`
- InstanceID string `json:"instance_id"`
- Version string `json:"version"`
- IsCollector bool `json:"is_collector"`
- PublishCount bool `json:"publish_count"`
- LastPingAt string `json:"last_ping_at,omitempty"`
- Stats *store.BeaconStats `json:"stats,omitempty"`
+ Enabled bool `json:"enabled"`
+ Collector string `json:"collector_url"`
+ InstanceID string `json:"instance_id"`
+ Version string `json:"version"`
+ IsCollector bool `json:"is_collector"`
+ PublishCount bool `json:"publish_count"`
+ PublishMin int `json:"publish_min"`
+ LastPingAt string `json:"last_ping_at,omitempty"`
+ // Sidste forsøg, der ikke nåede frem — tomt, når det sidste gik godt. Vist
+ // frem for talt: en dato under "sidst sendt", der bliver ældre og ældre, er
+ // det eneste spor, et tavst beacon ellers efterlader.
+ LastError string `json:"last_error,omitempty"`
+ LastErrorAt string `json:"last_error_at,omitempty"`
+ Stats *store.BeaconStats `json:"stats,omitempty"`
}
func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
@@ -109,10 +169,15 @@ func (s *Server) handleBeaconStatus(w http.ResponseWriter, r *http.Request) {
out := beaconStatusJSON{
Enabled: cfg.Enabled, Collector: cfg.Collector, InstanceID: cfg.InstanceID,
Version: Version, IsCollector: cfg.IsCollector, PublishCount: cfg.PublishCount,
+ PublishMin: cfg.PublishMin,
}
if cfg.LastPingAt > 0 {
out.LastPingAt = time.Unix(cfg.LastPingAt, 0).Format(time.RFC3339)
}
+ if cfg.LastError != "" {
+ out.LastError = cfg.LastError
+ out.LastErrorAt = time.Unix(cfg.LastErrorAt, 0).Format(time.RFC3339)
+ }
if cfg.IsCollector {
stats, err := s.db.BeaconSummary(r.Context())
if err != nil {
@@ -129,6 +194,7 @@ type beaconRequest struct {
Collector *string `json:"collector_url"`
IsCollector *bool `json:"is_collector"`
PublishCount *bool `json:"publish_count"`
+ PublishMin *int `json:"publish_min"`
}
func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
@@ -151,6 +217,13 @@ func (s *Server) handleSetBeacon(w http.ResponseWriter, r *http.Request) {
if req.PublishCount != nil {
cfg.PublishCount = *req.PublishCount
}
+ if req.PublishMin != nil {
+ if *req.PublishMin < 0 {
+ writeFieldErrors(w, map[string]string{"publish_min": "must not be negative"})
+ return
+ }
+ cfg.PublishMin = *req.PublishMin
+ }
if req.Collector != nil {
url := strings.TrimSpace(*req.Collector)
if url == "" {
@@ -237,6 +310,16 @@ func (s *Server) handleBeaconCount(w http.ResponseWriter, r *http.Request) {
}
n, err := s.db.BeaconCount(r.Context(), 30*24*time.Hour)
+ if err == nil && n < cfg.PublishMin {
+ // Ingenting frem for et lille tal. Et tal under gulvet fortæller mest om,
+ // hvor lidt der skal til at flytte det — og et, der falder, ville blive
+ // læst som "færre bruger det nu", også når det i virkeligheden var en
+ // udrulning, der gik galt, eller en adresse, der stille holdt op med at
+ // blive ramt.
+ w.Header().Set("Cache-Control", "public, max-age=60")
+ writeJSON(w, http.StatusOK, map[string]any{"installs": nil, "window_days": 30})
+ return
+ }
if err != nil {
s.internal(w, r, "beacon count", err)
return
@@ -269,11 +352,15 @@ func (s *Server) SendBeacon(ctx context.Context) error {
if err := s.db.RecordBeacon(ctx, cfg.InstanceID, Version); err != nil {
return err
}
- cfg.LastPingAt = time.Now().Unix()
- return s.saveBeaconSettings(ctx, cfg)
+ return s.beaconSent(ctx, cfg)
}
- if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour {
+ // Undtagelsen fra døgnet er en version, der er skiftet. En installation, der
+ // opdaterer sig selv om natten og først melder ind igen et døgn senere, står
+ // som gårsdagens version hele dagen — og så kan man ikke se en udrulning i
+ // tallene. Målt hos Yggdrasil: to paneler rapporterede den forrige version i
+ // et døgn efter at være opdateret.
+ if time.Since(time.Unix(cfg.LastPingAt, 0)) < 24*time.Hour && cfg.LastVersion == Version {
return nil
}
@@ -289,9 +376,100 @@ func (s *Server) SendBeacon(ctx context.Context) error {
// installation's problem and must never look like one — it is somebody
// else's server, and the only consequence is a number being one lower.
s.log.Info("beacon not delivered", "err", err)
- return nil
+ // Men skrevet ned. Uden det her fejler et beacon, der ikke kan nå sin
+ // collector, i fuldstændig tavshed: det prøver igen i morgen, siden siger
+ // "sidst sendt" med en dato, der bare bliver ældre, og ingen opdager det.
+ // Fundet hos Yggdrasil ved at tælle installationer i den anden ende og
+ // savne en.
+ cfg.LastError, cfg.LastErrorAt = err.Error(), time.Now().Unix()
+ return s.saveBeaconSettings(ctx, cfg)
}
+ return s.beaconSent(ctx, cfg)
+}
+
+// beaconSent skriver, at det lykkedes: tidspunktet, versionen der nåede frem, og
+// en fejl, der ikke længere gælder. Ét sted, fordi de fire felter skal skrives
+// sammen — en gemning, der glemte at rydde fejlen, ville lade en linje stå på
+// siden om noget, der virker.
+func (s *Server) beaconSent(ctx context.Context, cfg beaconConfig) error {
cfg.LastPingAt = time.Now().Unix()
+ cfg.LastVersion = Version
+ cfg.LastError, cfg.LastErrorAt = "", 0
return s.saveBeaconSettings(ctx, cfg)
}
+
+// --- beskeden, der bliver sagt uopfordret ------------------------------------------
+
+// handleBeaconNotice says whether the disclosure is still owed, and what it would
+// say.
+//
+// Slået til som udgangspunkt kan kun forsvares, hvis det bliver sagt uopfordret.
+// En indstillingsside, hvor det står rigtigt og fyldestgørende, er ikke det samme
+// som at sige det: den bliver åbnet af dem, der i forvejen har en mistanke om, at
+// der er noget at læse. Uden beskeden her er "du kan slå den fra" et tilbud til
+// dem, der ved, at der er noget at slå fra.
+//
+// De tre værdier står i svaret — det rigtige id, den rigtige version, den rigtige
+// adresse — så beskeden kan vise, hvad der bliver sendt, frem for at sige
+// "anonyme data" og bede om at blive troet på. Det er den eneste version af det
+// løfte, der er noget værd: en, læseren selv kan kontrollere.
+//
+// Panelbredt frem for pr. bruger: det er installationen, der melder ind, ikke
+// personen, og en besked pr. konto ville stille det samme spørgsmål til folk, der
+// ikke kan svare på det.
+func (s *Server) handleBeaconNotice(w http.ResponseWriter, r *http.Request) {
+ cfg, err := s.beaconSettings(r.Context())
+ if err != nil {
+ s.internal(w, r, "beacon settings", err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "pending": !cfg.NoticeAck,
+ "enabled": cfg.Enabled,
+ "instance_id": cfg.InstanceID,
+ "version": Version,
+ "collector_url": cfg.Collector,
+ })
+}
+
+type beaconNoticeRequest struct {
+ // Keep er en peger og ikke en bool, og det er hele pointen.
+ //
+ // En tom krop ville ellers betyde "slå det fra", og det er den vej, en fejl
+ // aldrig må falde: et fravalg, ingen har truffet, er lige så meget et brudt
+ // løfte som et tilvalg, ingen har truffet. Yggdrasil har den fejl i sin
+ // tilsvarende ende og er blevet bidt af den.
+ Keep *bool `json:"keep"`
+}
+
+// handleAckBeaconNotice records that the notice has been seen, and turns the
+// beacon off if that was the answer.
+//
+// Ét kald til begge svar. "Behold den" og "slå den fra" er den samme handling —
+// nogen har taget stilling — og to endpoints ville betyde, at det ene kunne
+// glemme at skrive, at der var taget stilling, og beskeden dermed komme igen.
+func (s *Server) handleAckBeaconNotice(w http.ResponseWriter, r *http.Request) {
+ var req beaconNoticeRequest
+ if err := decodeJSON(w, r, &req); err != nil {
+ return
+ }
+ if req.Keep == nil {
+ writeFieldErrors(w, map[string]string{"keep": "required"})
+ return
+ }
+
+ cfg, err := s.beaconSettings(r.Context())
+ if err != nil {
+ s.internal(w, r, "beacon settings", err)
+ return
+ }
+ cfg.NoticeAck = true
+ cfg.Enabled = *req.Keep
+ if err := s.saveBeaconSettings(r.Context(), cfg); err != nil {
+ s.internal(w, r, "save beacon settings", err)
+ return
+ }
+ s.log.Info("beacon notice answered", "keep", *req.Keep, "user", userFrom(r.Context()).ID)
+ writeJSON(w, http.StatusOK, map[string]any{"enabled": cfg.Enabled})
+}
diff --git a/internal/httpapi/beacon_notice_test.go b/internal/httpapi/beacon_notice_test.go
new file mode 100644
index 0000000..33f6dde
--- /dev/null
+++ b/internal/httpapi/beacon_notice_test.go
@@ -0,0 +1,132 @@
+package httpapi
+
+import (
+ "net/http"
+ "testing"
+)
+
+// Beaconet er slået til som udgangspunkt, og det kan kun forsvares, hvis det
+// bliver sagt uopfordret. Beskeden venter, indtil nogen har taget stilling — og
+// den viser de rigtige værdier, så løftet kan efterprøves frem for tros på.
+func TestTheBeaconSaysWhatItSendsBeforeAnybodyAsks(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("hent besked: %d", resp.StatusCode)
+ }
+ if notice["pending"] != true {
+ t.Fatalf("beskeden er ikke i vente på en frisk installation: %v", notice)
+ }
+ if notice["enabled"] != true {
+ t.Errorf("beaconet er ikke slået til som udgangspunkt: %v", notice)
+ }
+ // De tre værdier, beskeden viser frem, er de rigtige — ikke et eksempel.
+ settings := beaconSettingsOf(t, ts)
+ if notice["instance_id"] != settings["instance_id"] {
+ t.Errorf("id i beskeden = %v, i indstillingerne = %v",
+ notice["instance_id"], settings["instance_id"])
+ }
+ if notice["collector_url"] != settings["collector_url"] {
+ t.Errorf("adressen i beskeden = %v, i indstillingerne = %v",
+ notice["collector_url"], settings["collector_url"])
+ }
+ if notice["version"] == "" || notice["version"] == nil {
+ t.Error("beskeden siger ikke, hvilken version der bliver sendt")
+ }
+}
+
+// "Behold den" er et svar, og det svar skal huskes: beskeden må ikke komme igen
+// ved næste login.
+func TestKeepingTheBeaconAnswersTheNoticeForGood(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": true})
+ if resp.StatusCode != http.StatusOK || out["enabled"] != true {
+ t.Fatalf("behold: %d %v", resp.StatusCode, out)
+ }
+ if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
+ t.Errorf("beskeden kommer igen efter et svar: %v", notice)
+ }
+ if s := beaconSettingsOf(t, ts); s["enabled"] != true {
+ t.Errorf("beaconet blev slået fra af et ja: %v", s["enabled"])
+ }
+}
+
+// Og "slå den fra" slår den fra — ét kald, både svaret og virkningen.
+func TestTurningTheBeaconOffFromTheNoticeStopsIt(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, out := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{"keep": false})
+ if resp.StatusCode != http.StatusOK || out["enabled"] != false {
+ t.Fatalf("slå fra: %d %v", resp.StatusCode, out)
+ }
+ s := beaconSettingsOf(t, ts)
+ if s["enabled"] != false {
+ t.Errorf("beaconet kører stadig efter et nej: %v", s["enabled"])
+ }
+ if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != false {
+ t.Errorf("beskeden kommer igen efter et nej: %v", notice)
+ }
+}
+
+// En tom krop må ikke kunne betyde "slå det fra". Et fravalg, ingen har truffet,
+// er lige så meget et brudt løfte som et tilvalg, ingen har truffet.
+func TestAnEmptyAnswerToTheNoticeIsRefused(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ resp, _ := ts.do(t, "POST", "/api/v1/beacon/notice", map[string]any{})
+ if resp.StatusCode != http.StatusUnprocessableEntity {
+ t.Fatalf("tom krop gav %d, want 422", resp.StatusCode)
+ }
+ if s := beaconSettingsOf(t, ts); s["enabled"] != true {
+ t.Errorf("en tom krop slog beaconet fra: %v", s["enabled"])
+ }
+ if _, notice := ts.do(t, "GET", "/api/v1/beacon/notice", nil); notice["pending"] != true {
+ t.Errorf("en tom krop talte som et svar: %v", notice)
+ }
+}
+
+// Et tal, der falder, kan ikke offentliggøres som en kendsgerning. Under gulvet
+// svares der ingenting frem for et lille tal, ingen kan tolke.
+func TestThePublishedCountHasAFloorUnderIt(t *testing.T) {
+ ts := newTestServer(t)
+ ts.bootstrap(t)
+
+ if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings", map[string]any{
+ "is_collector": true, "publish_count": true,
+ }); resp.StatusCode != http.StatusOK {
+ t.Fatal("bliv collector")
+ }
+
+ // Instansen tæller sig selv, så der er én. Gulvet er højere.
+ resp, out := ts.do(t, "GET", "/api/v1/beacon/count", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("tælling: %d", resp.StatusCode)
+ }
+ if out["installs"] != nil {
+ t.Errorf("et tal under gulvet blev offentliggjort: %v", out["installs"])
+ }
+
+ // Sættes gulvet ned, står tallet der.
+ if resp, _ := ts.do(t, "PUT", "/api/v1/beacon/settings",
+ map[string]any{"publish_min": 0}); resp.StatusCode != http.StatusOK {
+ t.Fatal("sænk gulvet")
+ }
+ if _, out := ts.do(t, "GET", "/api/v1/beacon/count", nil); out["installs"] == nil {
+ t.Errorf("tallet blev holdt tilbage uden et gulv: %v", out)
+ }
+}
+
+func beaconSettingsOf(t *testing.T, ts *testServer) map[string]any {
+ t.Helper()
+ resp, out := ts.do(t, "GET", "/api/v1/beacon/settings", nil)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("hent beacon-indstillinger: %d", resp.StatusCode)
+ }
+ return out
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 369bc9a..017f4b4 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -512,6 +512,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
// installation and it either phones home or it does not.
r.Get("/beacon/settings", s.handleBeaconStatus)
r.Put("/beacon/settings", s.handleSetBeacon)
+ // Beskeden om, hvad der bliver sendt, og svaret på den. I samme
+ // gruppe som resten: den, der kan slå beaconet fra, er den, der
+ // skal have beskeden, og at spørge alle andre ville være at
+ // stille et spørgsmål, de ikke kan svare på.
+ r.Get("/beacon/notice", s.handleBeaconNotice)
+ r.Post("/beacon/notice", s.handleAckBeaconNotice)
// A backup file is a complete copy of the database, so this whole
// group is sessions-only as well as administrators-only: a leaked
// token must not be able to download everybody's data.
diff --git a/web/src/lib/api.js b/web/src/lib/api.js
index f56cdef..b60ffa6 100644
--- a/web/src/lib/api.js
+++ b/web/src/lib/api.js
@@ -280,6 +280,11 @@ export const api = {
beacon: () => get('/beacon/settings'),
setBeacon: (body) => put('/beacon/settings', body),
+ // Beskeden om, hvad installationen melder ind, og svaret på den. `keep` sendes
+ // altid — en tom krop måtte ikke kunne betyde "slå det fra", og serveren
+ // afviser den derfor.
+ beaconNotice: () => get('/beacon/notice'),
+ answerBeaconNotice: (keep) => post('/beacon/notice', { keep }),
listBackups: () => get('/backups'),
runBackup: () => post('/backups'),
diff --git a/web/src/lib/components/BeaconNotice.svelte b/web/src/lib/components/BeaconNotice.svelte
new file mode 100644
index 0000000..913d5ba
--- /dev/null
+++ b/web/src/lib/components/BeaconNotice.svelte
@@ -0,0 +1,144 @@
+
+
+{#if notice}
+
+{/if}
+
+
diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js
index 13f5b59..1c0ba37 100644
--- a/web/src/lib/locales/da.js
+++ b/web/src/lib/locales/da.js
@@ -90,6 +90,22 @@ export const da = {
'beacon.active7': 'Aktive 7 dage',
'beacon.active30': 'Aktive 30 dage',
'beacon.byVersion': 'Efter version (30 dage):',
+ // Beskeden, der bliver sagt uopfordret første gang. Værdierne står i den, ikke
+ // ordet 'anonymt': et løfte om telemetri er præcis så meget værd som
+ // læserens mulighed for at efterprøve det.
+ 'beacon.noticeTitle': 'Denne installation melder, at den findes',
+ 'beacon.noticeBody':
+ 'Én gang i døgnet sender Verdande to værdier til projektet, så det kan ses, hvor mange installationer der er. Præcis de her to — intet andet, ingen IP bliver gemt, intet om hvad du bruger det til:',
+ 'beacon.noticeTo': 'Sendes til {url}',
+ 'beacon.noticeKeep': 'Behold den',
+ 'beacon.noticeStop': 'Slå den fra',
+ 'beacon.noticeMore': 'Læs mere',
+ 'beacon.noticeKept': 'Tak. Du kan altid slå den fra under Indstillinger → Data.',
+ 'beacon.noticeStopped': 'Meldingen er slået fra. Der bliver ikke sendt noget.',
+ 'beacon.lastError': 'Sidste forsøg nåede ikke frem ({when}): {why}',
+ 'beacon.publishMin': 'Offentliggør først fra',
+ 'beacon.publishMinHint':
+ 'Under det tal svares der ingenting. Et lille tal fortæller mest om, hvor lidt der skal til at flytte det — og et, der falder, ville blive læst som “færre bruger det nu”, også når det var en udrulning, der gik galt.',
'beacon.publish': 'Offentliggør tallet på /api/v1/beacon/count',
'beacon.publishHint': 'Uden login, mellemlagret et minut. Viser installationer set de seneste 30 dage — ikke et løbende total, som kun ville stige.',
'notes.newHere': 'Ny note',
diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js
index 40b812d..feb2ff0 100644
--- a/web/src/lib/locales/en.js
+++ b/web/src/lib/locales/en.js
@@ -87,6 +87,22 @@ export const en = {
'beacon.active7': 'Active 7 days',
'beacon.active30': 'Active 30 days',
'beacon.byVersion': 'By version (30 days):',
+ // The notice said unprompted the first time. The values are in it rather than
+ // the word 'anonymous': a promise about telemetry is worth exactly as much as
+ // the reader's ability to check it.
+ 'beacon.noticeTitle': 'This installation reports that it exists',
+ 'beacon.noticeBody':
+ 'Once a day Verdande sends two values to the project, so it can see how many installations there are. Exactly these two — nothing else, no IP is stored, nothing about what you use it for:',
+ 'beacon.noticeTo': 'Sent to {url}',
+ 'beacon.noticeKeep': 'Keep it on',
+ 'beacon.noticeStop': 'Turn it off',
+ 'beacon.noticeMore': 'Read more',
+ 'beacon.noticeKept': 'Thank you. You can always turn it off under Settings → Data.',
+ 'beacon.noticeStopped': 'Reporting is off. Nothing is being sent.',
+ 'beacon.lastError': 'The last attempt did not arrive ({when}): {why}',
+ 'beacon.publishMin': 'Publish only from',
+ 'beacon.publishMinHint':
+ 'Below that number nothing is answered. A small number mostly says how little it takes to move it — and one that falls would read as “fewer people use this now”, even when it was a release that went wrong.',
'beacon.publish': 'Publish the count at /api/v1/beacon/count',
'beacon.publishHint': 'No login, cached for a minute. Reports installations seen in the last 30 days — not a running total, which would only ever climb.',
'notes.newHere': 'New note',
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 2ae25c3..3c12204 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -11,6 +11,7 @@
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
+ import BeaconNotice from '$lib/components/BeaconNotice.svelte';
let { children } = $props();
@@ -208,6 +209,11 @@
{t('beacon.exactly')}
{JSON.stringify({ instance_id: beacon.instance_id, version: beacon.version }, null, 2)}
+ {#if beacon.last_error}
+
+ + {t('beacon.lastError', { + when: new Date(beacon.last_error_at).toLocaleString(tag()), + why: beacon.last_error + })} +
+ {/if} {#if beacon.last_ping_at}{t('beacon.lastPing', { when: new Date(beacon.last_ping_at).toLocaleString(tag()) })}
{/if} @@ -592,6 +604,20 @@ /> {t('beacon.publish')} + + {#if beacon.publish_count} +{t('beacon.publishMinHint')}
+{t('beacon.publishHint')}
{/if} {/if}