b03a105375
gates / gates (push) Successful in 17s
Hub only. No controller change, no agent change, no wire change — nothing to bake. demo-hp untouched: the operator is re-deploying it this evening. R-323 — the five-word phrase is „Tulajdonosi jelmondat". It was „Visszaállító jelszó": one word from the name retired last week, and false besides — it restores nothing, it proves the account owns the box being bound. Five sites, all in the hub; felhom-controller and felhom-agent carry the name nowhere, so no halt and no bake. Both suggested names were rejected with reasons: „Fiókjelszó" would collide with the dashboard login (a DIFFERENT real secret), and „Összekötési jelszó" would leave the two factors on this page separated only by kód-versus-jelszó — the exact shape being removed, since the other factor is the „Párosító kód". The chosen name differs on both axes, stem and noun. Naming only; the acceptance pin drives the real handler. R-324 — the hub's customer copy is under a guard for the first time. Retired names banned across all 95 hub files; retrieval stems registered in four declared customer surfaces. The selftest found a defect in its own instrument on the first run. One shared vocabulary in scripts/, drift-checked into the controller gate rather than copied (R-325 removes the scaffold). R-321 — a machine we told to be quiet is no longer reported as dead, and it was two doors, not one: because the state is RECORDED rather than deleted, the morning deadline check can skip it too. A deleted state returns "", which is not "down" — R-195's shape returning through a second door. The clock runs from the report the hub can see, so re-enabling starts it there and emits no recovery for an outage that never happened. Three red-proofs; the one that matters showed a genuinely dead machine sitting at "disabled" when the suppression was made unconditional. R-326 — "which claims are unproven" is answerable by a command now. The nine I have been repeating was the count of claims the 9 August pass DOWNGRADED, not the count of unproven ones. The real figures: 55 claims, 23 walked, 32 not — and only 6 of those 32 cite evidence. Its first run found a stale claim (R-327).
473 lines
22 KiB
Go
473 lines
22 KiB
Go
package notify
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// budapest timezone for formatting.
|
|
var budapest *time.Location
|
|
|
|
func init() {
|
|
var err error
|
|
budapest, err = time.LoadLocation("Europe/Budapest")
|
|
if err != nil {
|
|
budapest = time.FixedZone("CET", 3600)
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Operator email — concise, English
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
// FormatOperatorEmail returns (subject, textBody) for the operator channel.
|
|
func FormatOperatorEmail(customerID, eventType, severity, message, detailsJSON string) (string, string) {
|
|
// Icon is eventType-aware for recovery (v0.71.0, audit F11); severity stays the fallback.
|
|
icon := "⚠️"
|
|
switch {
|
|
case strings.HasSuffix(eventType, "_recovered"):
|
|
icon = "✅"
|
|
case severity == "error" || severity == "critical":
|
|
icon = "🔴"
|
|
}
|
|
|
|
subject := fmt.Sprintf("[Felhom] %s %s: %s", icon, customerID, eventType)
|
|
|
|
now := time.Now().In(budapest).Format("2006-01-02 15:04 MST")
|
|
body := fmt.Sprintf(`Customer: %s
|
|
Event: %s
|
|
Severity: %s
|
|
Time: %s
|
|
Message: %s`, customerID, eventType, severity, now, message)
|
|
|
|
// R-182: the backup run digest gets a rendered list instead of a raw JSON blob. It is the one
|
|
// operator mail that carries a VARIABLE-LENGTH payload, and a dozen apps as one line of JSON is
|
|
// unreadable on a phone at 07:00, which is the only time it matters.
|
|
if eventType == "backup_run_failures" {
|
|
if rendered, sub, ok := renderBackupRunFailures(customerID, detailsJSON); ok {
|
|
return sub, body + rendered + fmt.Sprintf("\n\nDashboard: https://hub.felhom.eu/customers/%s", customerID)
|
|
}
|
|
// Unparseable details fall through to the raw form below rather than losing the mail. A
|
|
// digest that renders badly still tells the operator something; a swallowed one does not.
|
|
}
|
|
|
|
if detailsJSON != "" && detailsJSON != "{}" {
|
|
body += fmt.Sprintf("\nDetails: %s", detailsJSON)
|
|
}
|
|
|
|
body += fmt.Sprintf("\n\nDashboard: https://hub.felhom.eu/customers/%s", customerID)
|
|
|
|
return subject, body
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Customer email — Hungarian, friendly
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
// customerMessages maps event_type → Hungarian customer message.
|
|
var customerMessages = map[string]string{
|
|
// Customer-claim arc (v0.50.0)
|
|
"claim_lockout": "Túl sok hibás beállító kód próbálkozás történt — a beállító oldal 15 percre zárolva lett. Ha nem te próbálkoztál, jelezd az üzemeltetőnek.",
|
|
// Backup events
|
|
"backup_completed": "A biztonsági mentés sikeresen elkészült.",
|
|
"backup_failed": "A biztonsági mentés sikertelen! Kérjük, ellenőrizd a rendszert.",
|
|
"db_dump_completed": "Az adatbázis mentés sikeresen elkészült.",
|
|
"db_dump_failed": "Az adatbázis mentés sikertelen!",
|
|
"backup_integrity_ok": "A mentés integritás ellenőrzés sikeres.",
|
|
"backup_integrity_failed": "A mentés integritás ellenőrzés hibát talált!",
|
|
"crossdrive_completed": "A másodlagos mentés sikeresen elkészült.",
|
|
"crossdrive_failed": "A másodlagos mentés sikertelen!",
|
|
// Offsite-repo continuity (controller v0.142.0)
|
|
"offbox_repo_orphaned": "A távoli mentési tároló elárvult: a benne lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Új mentés a tároló visszaállításáig nem készül — nyisd meg a Távoli mentés oldalt.",
|
|
"offbox_repo_reset": "A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.",
|
|
|
|
// Disk events (GUEST — the controller's own view) — `disk_warning` / `disk_critical`.
|
|
//
|
|
// DELIBERATELY NO ENTRY, from hub v0.89.0 / controller v0.191.0 (R-167, decision D-c). These two
|
|
// types were allowlisted here, carried generic Hungarian copy, sat in the controller's
|
|
// DefaultEnabledEvents and had a UI checkbox — and NOTHING IN ANY REPO EMITTED THEM. A complete
|
|
// customer pipeline with no producer; the sixth "built but never wired" instance in this project.
|
|
// The controller became their producer in v0.191.0.
|
|
//
|
|
// The producer sends a DYNAMIC Hungarian message naming the filesystem and its free space, so a
|
|
// static entry here would be actively harmful: FormatCustomerEmail PREFERS the entry over the
|
|
// message, so re-adding one would discard the drive name and the byte figures and leave the
|
|
// customer with "A lemezterület 90% felett van" — a warning with nothing to act on. Same reason
|
|
// `offbox_enlarge_blocked` and `disk_health_degraded` have no entry. Pinned by
|
|
// TestDiskFillTypesHaveNoGenericCustomerMessage.
|
|
|
|
// Host disk events (the Proxmox HOST root filesystem — distinct from the guest disk above)
|
|
"host_disk_warning": "A házszerver alaprendszerének (Proxmox-gazda) gyökérlemeze 90% felett van — kérjük, szabadíts fel helyet (pl. régi biztonsági mentések).",
|
|
"host_disk_critical": "A házszerver alaprendszerének gyökérlemeze kritikusan tele van (95%+) — azonnali beavatkozás szükséges, különben a rendszer hibázhat!",
|
|
|
|
// Per-storage fill events (a SPECIFIC tároló — mentési kötet, adatmeghajtó, tároló-készlet — telik be)
|
|
"storage_fill_warning": "Egy tároló a házszerveren 90% felett telt — kérjük, szabadíts fel helyet, mielőtt megtelik.",
|
|
"storage_fill_critical": "Egy tároló a házszerveren kritikusan tele van (95%+) — a rá készülő mentések/írások meghiúsulhatnak; azonnali beavatkozás szükséges!",
|
|
|
|
// Storage events
|
|
"storage_disconnected": "Egy meghajtó leválasztva — a mentések szünetelhetnek.",
|
|
"storage_reconnected": "A meghajtó újra csatlakoztatva.",
|
|
|
|
// E-2 — the drive the whole-system backup is written to is gone. Deliberately NOT folded into
|
|
// storage_disconnected: the customer action differs (reconnect THIS drive, or the system backup
|
|
// stops surviving a disk failure), and the fallback is honest about what still protects them.
|
|
"backup_target_absent": "A rendszermentés meghajtója nem érhető el — amíg vissza nem csatlakoztatod, a teljes rendszermentés nem készül el.",
|
|
"backup_target_restored": "A rendszermentés meghajtója újra elérhető — a mentés folytatódik.",
|
|
|
|
// Staleness events (Hub-generated)
|
|
"node_stale": "A szerver nem küldött jelentést az elmúlt időszakban.",
|
|
"node_down": "A szerver nem elérhető!",
|
|
"node_recovered": "A szerver újra elérhető.",
|
|
"host_recovered": "A házszerver alaprendszere (Proxmox-gazda) újra elérhető.",
|
|
|
|
// Health events
|
|
"health_degraded": "A rendszer állapota romlott.",
|
|
"health_critical": "A rendszer állapota kritikus!",
|
|
"health_recovered": "A rendszer állapota helyreállt.",
|
|
|
|
// Controller events
|
|
"controller_started": "A vezérlő elindult.",
|
|
"controller_updated": "A vezérlő frissítve lett.",
|
|
|
|
// Deadline events (Hub-generated)
|
|
"expected_backup_missed": "A mai biztonsági mentés nem készült el a határidőig!",
|
|
"expected_dbdump_missed": "A mai adatbázis mentés nem készült el a határidőig!",
|
|
|
|
// App lifecycle events
|
|
"app_deployed": "Alkalmazás telepítve.",
|
|
"app_removed": "Alkalmazás eltávolítva.",
|
|
"app_start_failed": "Egy telepített alkalmazás nem fut — ellenőrizze a rendszermonitort.",
|
|
|
|
// Disaster recovery events
|
|
"disaster_recovery_started": "Katasztrófa helyreállítás elindítva.",
|
|
"disaster_recovery_completed": "Katasztrófa helyreállítás befejezve.",
|
|
|
|
// Test
|
|
"test": "Ez egy teszt értesítés.",
|
|
}
|
|
|
|
// severityLabels maps severity to Hungarian labels.
|
|
var severityLabels = map[string]string{
|
|
"info": "Információ",
|
|
"warning": "Figyelmeztetés",
|
|
"error": "Hiba",
|
|
"critical": "Kritikus hiba",
|
|
}
|
|
|
|
// FormatCustomerEmail returns (subject, textBody) for the customer channel.
|
|
func FormatCustomerEmail(customerID, eventType, severity, message, detailsJSON string) (string, string) {
|
|
label := severityLabels[severity]
|
|
if label == "" {
|
|
label = severity
|
|
}
|
|
|
|
// Use the per-event-type Hungarian message if available, otherwise fall back to message
|
|
hunMessage := customerMessages[eventType]
|
|
if hunMessage == "" {
|
|
hunMessage = message
|
|
}
|
|
|
|
subject := fmt.Sprintf("[Felhom] %s: %s", label, hunMessage)
|
|
|
|
now := time.Now().In(budapest).Format("2006-01-02 15:04")
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
A Felhom rendszered a következő értesítést küldte:
|
|
|
|
%s
|
|
|
|
Részletek:
|
|
- Szerver: %s
|
|
- Időpont: %s
|
|
- Szint: %s
|
|
- Típus: %s`, hunMessage, customerID, now, label, eventType)
|
|
|
|
if message != "" && message != hunMessage {
|
|
body += fmt.Sprintf("\n- Üzenet: %s", message)
|
|
}
|
|
|
|
if detailsJSON != "" && detailsJSON != "{}" {
|
|
body += fmt.Sprintf("\n- Megjegyzés: %s", detailsJSON)
|
|
}
|
|
|
|
body += `
|
|
|
|
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
|
|
|
|
Üdvözlettel,
|
|
Felhom.eu monitoring`
|
|
|
|
return subject, body
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Customer-claim password arc (v0.50.0) — Hungarian code-delivery emails
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
// FormatClaimEmail returns (subject, textBody) for a claim-arc email. kind is one of
|
|
// "claim" | "reset" | "reenroll" | "claimed" (claim.EmailKind values). The code appears ONLY in the
|
|
// returned body — callers must never log it.
|
|
//
|
|
// R-295, HUB HALF (2026-08-13). ONE NAME PER SECRET, and it is „Beállító kód".
|
|
//
|
|
// The three-word code that gives a person control of the DASHBOARD is „Beállító kód" everywhere —
|
|
// in this mail, on the operator button, and on the box's own page, which has said so since the
|
|
// controller half shipped on 2026-08-10. The TEN-word code that opens the sealed backups is
|
|
// „Helyreállítási kód" and is a different secret entirely. **„Visszaállító kód" is retired**: it was
|
|
// a near-homograph of „Helyreállítási kód", the collision cost a real code, and the hub was the last
|
|
// place it survived — this half was dropped twice before it was finished.
|
|
//
|
|
// The rule the three branches below follow: ONE SECRET IN TWO SITUATIONS KEEPS ITS NAME, AND THE
|
|
// SENTENCE AROUND IT CHANGES. „reset" and „reenroll" carry the identical secret under the identical
|
|
// name; they differ only in which page the customer will actually be looking at.
|
|
//
|
|
// THIS IS NAMING, NOT FUNCTION. No acceptance logic moved: the code is minted, hashed, rotated,
|
|
// capped and consumed exactly as before, and a reset code is still accepted on the setup page.
|
|
// Pinned by TestFormatClaimEmail_OneNamePerSecret and, on the box side, by the controller's
|
|
// claim_code_naming_test.go.
|
|
func FormatClaimEmail(kind, customerID, domain, code string) (string, string) {
|
|
dashboardURL := "https://felhom." + domain
|
|
switch kind {
|
|
case "reset":
|
|
// The box HAS a password: the customer asked for a reset from the login screen, so the
|
|
// „Elfelejtett jelszó" link IS on their screen and naming it is correct here.
|
|
subject := "[Felhom] Beállító kód a jelszavad visszaállításához"
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
Jelszó-visszaállítást kértél a Felhom vezérlőpultodhoz.
|
|
|
|
Beállító kód: %s
|
|
|
|
A kód 72 óráig érvényes, és egyszer használható fel. Add meg a vezérlőpult
|
|
"Elfelejtett jelszó" oldalán, majd válassz új jelszót:
|
|
|
|
%s
|
|
|
|
Ha nem te kérted, hagyd figyelmen kívül — a jelenlegi jelszavad változatlan.
|
|
|
|
Üdvözlettel,
|
|
Felhom.eu`, code, dashboardURL)
|
|
return subject, body
|
|
case "reenroll":
|
|
// The box was rebuilt and has NO password, so it shows „A szerver beállítása" and serves no
|
|
// login page — there is no „Elfelejtett jelszó" link to send anyone to. Same secret, same
|
|
// name, the page the machine is actually showing.
|
|
//
|
|
// It deliberately says NOTHING about the apps or the backups. A clean-slate reinstall is
|
|
// exactly the situation in which such a reassurance could be false, and this project has
|
|
// spent four register rows removing promises it could not see were still true.
|
|
subject := "[Felhom] Új beállító kód — újratelepült a szervered"
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
A Felhom szervered újratelepült, ezért a vezérlőpultod belépését újra be kell
|
|
állítani. A korábbi jelszavad már nem érvényes.
|
|
|
|
Beállító kód: %s
|
|
|
|
A kód 72 óráig érvényes, és egyszer használható fel. Nyisd meg a vezérlőpultot —
|
|
"A szerver beállítása" oldal fogad —, add meg a kódot, majd válassz új jelszót:
|
|
|
|
%s
|
|
|
|
Ha nem te telepítetted újra a szervered, vedd fel a kapcsolatot az üzemeltetővel.
|
|
|
|
Üdvözlettel,
|
|
Felhom.eu`, code, dashboardURL)
|
|
return subject, body
|
|
case "claimed":
|
|
subject := "[Felhom] A vezérlőpultod mostantól jelszóval védett"
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
A Felhom vezérlőpultod beállítása elkészült — a vezérlőpultod mostantól
|
|
jelszóval védett. A megadott jelszóval tudsz bejelentkezni:
|
|
|
|
%s
|
|
|
|
Ha nem te végezted a beállítást, azonnal vedd fel a kapcsolatot az üzemeltetővel.
|
|
|
|
Üdvözlettel,
|
|
Felhom.eu`, dashboardURL)
|
|
return subject, body
|
|
default: // "claim"
|
|
subject := "[Felhom] Elindult a Felhom szervered — beállító kód"
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
Elindult a Felhom szervered. A vezérlőpult első használatához add meg az
|
|
alábbi beállító kódot, majd válassz saját jelszót:
|
|
|
|
Beállító kód: %s
|
|
|
|
A kód 72 óráig érvényes, és egyszer használható fel. A vezérlőpultot itt éred el:
|
|
|
|
%s
|
|
|
|
Ha nem kaptad volna meg időben, a vezérlőpult "Új kód kérése" gombjával
|
|
kérhetsz frisset — az mindig erre az e-mail címre érkezik.
|
|
|
|
Üdvözlettel,
|
|
Felhom.eu`, code, dashboardURL)
|
|
return subject, body
|
|
}
|
|
}
|
|
|
|
// FormatSelfBindEmail builds the customer-facing Hungarian email carrying the self-bind capability
|
|
// link (v0.66.0, R-27 slice 1). The link is the ONLY secret here — the passphrase is never in the
|
|
// mail (the customer already holds it), and the console pairing code is read off the box screen. The
|
|
// copy tells the customer they will need both factors on the page. Adult tone, no emoji.
|
|
//
|
|
// R-323, THE THIRD NAME (2026-08-13). This mail used to call the five-word phrase „visszaállító
|
|
// jelszó" — one word away from „Visszaállító kód", which had just been retired for colliding with
|
|
// „Helyreállítási kód". It is „Tulajdonosi jelmondat" now. Two reasons, and the second is the one
|
|
// that rules out the obvious alternatives:
|
|
//
|
|
// 1. THE OLD NAME WAS FALSE. The phrase restores nothing. It proves the account owns the box being
|
|
// linked — so the name says that.
|
|
// 2. IT MUST NOT COLLIDE WITH THE OTHER FACTOR ON THE SAME PAGE. Item 1 below is the „Párosító
|
|
// kód". Naming this one after the same act („Összekötési jelszó") would leave the two factors a
|
|
// customer types in one sitting distinguished only by kód-versus-jelszó — which is EXACTLY the
|
|
// „Visszaállító kód" / „Visszaállító jelszó" shape being removed. „Fiókjelszó" is worse still:
|
|
// there IS an account password (the dashboard login), so it would collide with a different real
|
|
// secret. „Tulajdonosi jelmondat" is distinct from all three on BOTH axes — the stem
|
|
// (Tulajdonosi vs Beállító / Helyreállítási / Párosító) and the noun (jelmondat vs kód / jelszó).
|
|
//
|
|
// Naming only: no acceptance logic moved, and the same phrase is still accepted. Pinned by
|
|
// TestSelfBind_ThirdSecretNaming and TestSelfBindPassphrase_StillAcceptedAfterTheRename.
|
|
func FormatSelfBindEmail(customerID, link string) (string, string) {
|
|
subject := "[Felhom] Kösd össze a Felhom dobozodat"
|
|
body := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
Elkészült a Felhom dobozod, és készen áll az összekötésre. Az alábbi hivatkozáson
|
|
tudod te magad összekötni a fiókoddal — nincs szükség bejelentkezésre:
|
|
|
|
%s
|
|
|
|
A hivatkozás megnyitása után két adatot kell megadnod:
|
|
|
|
1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható.
|
|
2. A tulajdonosi jelmondatodat (az 5 szóból álló kifejezést), amelyet a
|
|
beállításkor kaptál. Ez igazolja, hogy a fiók a tiéd.
|
|
|
|
A hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után
|
|
zárolódik — ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.
|
|
|
|
Ha nem te kérted ezt, hagyd figyelmen kívül ezt az e-mailt.
|
|
|
|
Üdvözlettel,
|
|
Felhom.eu`, link)
|
|
return subject, body
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// R-182 — the backup run digest
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
// backupRunFailure is one app's failed leg within a run.
|
|
type backupRunFailure struct {
|
|
App string `json:"app"`
|
|
Leg string `json:"leg"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// backupRunDetails is the digest payload the controller sends.
|
|
type backupRunDetails struct {
|
|
RunID string `json:"run_id"`
|
|
RunKind string `json:"run_kind"`
|
|
Failed int `json:"failed"`
|
|
Attempted int `json:"attempted"`
|
|
TargetPath string `json:"target_path"`
|
|
UsedGB float64 `json:"used_gb"`
|
|
AvailGB float64 `json:"avail_gb"`
|
|
TotalGB float64 `json:"total_gb"`
|
|
UsedPercent float64 `json:"used_percent"`
|
|
SpaceKnown bool `json:"space_known"`
|
|
Apps []backupRunFailure `json:"apps"`
|
|
}
|
|
|
|
// renderBackupRunFailures turns the digest details into an operator-readable block and a subject
|
|
// that says the count without being opened. Returns ok=false when the payload cannot be parsed or
|
|
// names no apps, so the caller can fall back to the raw rendering rather than mail an empty list.
|
|
//
|
|
// THE SUCCESS COUNT IS NOT DECORATION. "3 of 4 apps failed" is a catastrophe and "3 of 40" is a bad
|
|
// night; the list alone cannot tell them apart, and the operator's first decision — get up now, or
|
|
// look after coffee — depends entirely on which it is.
|
|
func renderBackupRunFailures(customerID, detailsJSON string) (string, string, bool) {
|
|
if detailsJSON == "" {
|
|
return "", "", false
|
|
}
|
|
var d backupRunDetails
|
|
if err := json.Unmarshal([]byte(detailsJSON), &d); err != nil || len(d.Apps) == 0 {
|
|
return "", "", false
|
|
}
|
|
|
|
kind := d.RunKind
|
|
if kind == "" {
|
|
kind = "backup"
|
|
}
|
|
subject := fmt.Sprintf("[Felhom] 🔴 %s: %d of %d apps failed to back up (%s run)",
|
|
customerID, d.Failed, d.Attempted, kind)
|
|
|
|
// Column-align the app names so the leg and reason line up and the block scans vertically.
|
|
width := 0
|
|
for _, a := range d.Apps {
|
|
if len(a.App) > width {
|
|
width = len(a.App)
|
|
}
|
|
}
|
|
legWidth := 0
|
|
for _, a := range d.Apps {
|
|
if len(a.Leg) > legWidth {
|
|
legWidth = len(a.Leg)
|
|
}
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "\n\nFAILED: %d of %d apps attempted in this %s run.\n\n", d.Failed, d.Attempted, kind)
|
|
for _, a := range d.Apps {
|
|
reason := trimRepeatedUsage(a.Reason, d.TargetPath)
|
|
if reason == "" {
|
|
reason = "(no reason recorded)"
|
|
}
|
|
fmt.Fprintf(&b, " %-*s %-*s %s\n", width, a.App, legWidth, a.Leg, reason)
|
|
}
|
|
|
|
// The space figures answer "is this one broken app or a full disk" before the reasons are read.
|
|
// An absent reading renders as unavailable, never as zeros — "0 GB free" and "we could not look"
|
|
// are opposite diagnoses (the UnitSpace rule, same reasoning, other side of the wire).
|
|
if d.SpaceKnown {
|
|
fmt.Fprintf(&b, "\nFilesystem: %s — %.1f/%.1f GB used (%.0f%%), %.1f GB free\n",
|
|
d.TargetPath, d.UsedGB, d.TotalGB, d.UsedPercent, d.AvailGB)
|
|
} else {
|
|
fmt.Fprintf(&b, "\nFilesystem: %s — usage unavailable (the filesystem could not be read)\n", d.TargetPath)
|
|
}
|
|
|
|
b.WriteString("\nEvery failure above is also recorded individually in the notification log,\n")
|
|
b.WriteString("whether or not this mail was sent.")
|
|
return b.String(), subject, true
|
|
}
|
|
|
|
// trimRepeatedUsage strips the trailing "— /path: X/Y GB used (Z%), W GB free" clause from a per-app
|
|
// reason, because the digest prints those figures ONCE for the whole run on its own line.
|
|
//
|
|
// This is a copy fix, and it was made after reading the first real digest rather than from the
|
|
// design. The reserve's refusal message is authored for a single-app alert, where naming the
|
|
// filesystem is exactly right; repeated down a list of a dozen apps it is the same forty characters
|
|
// twelve times, and it pushes the part that differs off the right-hand edge of a phone screen at
|
|
// 07:00 — which is the only moment this mail has to work.
|
|
//
|
|
// It trims ONLY an exact "— <target path>:" suffix, so a reason that mentions a different path, or
|
|
// none, is left completely alone. A reason that is nothing but the usage clause is left alone too:
|
|
// removing everything would turn a bad line into an empty one.
|
|
func trimRepeatedUsage(reason, targetPath string) string {
|
|
if reason == "" || targetPath == "" {
|
|
return reason
|
|
}
|
|
marker := " — " + targetPath + ":"
|
|
i := strings.LastIndex(reason, marker)
|
|
if i <= 0 {
|
|
return reason
|
|
}
|
|
return strings.TrimSpace(reason[:i])
|
|
}
|