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 @@ + + + {#if app.detailTask} diff --git a/web/src/routes/indstillinger/data/+page.svelte b/web/src/routes/indstillinger/data/+page.svelte index 0ce424f..1bf937d 100644 --- a/web/src/routes/indstillinger/data/+page.svelte +++ b/web/src/routes/indstillinger/data/+page.svelte @@ -534,6 +534,18 @@
{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} +
+ + saveBeacon({ publish_min: Number(e.currentTarget.value) })} + /> +

{t('beacon.publishMinHint')}

+
+ {/if}

{t('beacon.publishHint')}

{/if} {/if}