R-516 items 7-10: one banner per drive, local times, te-form, health banners in the household's language
- item 7: the storage page shows the disconnect time in local time (fmtTimeStr), not the raw RFC3339 UTC. - item 8: a disconnected drive no longer has two banners - the health check's warning for it is dropped when the dedicated alert.storage.disconnected banner was built (it stays on the wire). - item 9: alert.deadapp.group says "nezd meg" (te-form); formal ceiling 14 -> 13. - item 10: the disk/memory/CPU/temperature health banners show the dashboard's own sentence (health.* keys, hu + en) via HealthReport.WarningMsgs/IssueMsgs; the wire text is unchanged. checkResources split out of RunHealthCheck as the test seam. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
| `stacks.ErrStackNotFound` / `ErrProtectedStack` / `ErrNotDeployed` / `ErrStillRunning` / `ErrNotOrphaned` | controller/internal/stacks/stack_errors.go | sentinels | the API's stop/start/restart/update, remove and delete status code (`api.stackOpStatusFor`, R-569) | 404 / 403 / 409 / 409 / 409. Only the producers in manager.go and delete.go carry them; a new refusal on those paths must carry one or it answers 500 |
|
||||
| `backup.ErrOffsiteQuota` | controller/internal/backup/offbox.go | sentinel | `ClassifyOffsiteFailure` telling a quota over-run apart | The other arms of that switch stay TEXT matches on purpose — they are restic's and ssh's own English output, which we neither write nor translate |
|
||||
| `monitor.WarnKind*` + `HealthReport.addWarning` / `WarningKindAt` | controller/internal/monitor/healthcheck.go | `(text, kind string)` | a health warning whose PLACEMENT the dashboard decides | Internal only: `internal/report/builder.go` copies Status/Issues/Warnings, so kinds never reach the hub (pinned) |
|
||||
| `monitor.MsgRef` + `HealthReport.addWarningMsg` / `addIssue` / `WarningMsgAt` / `IssueMsgAt` | controller/internal/monitor/healthcheck.go | `MsgRef{Key, Args}` beside the wire text | a health warning/issue whose BANNER must follow the household's language (R-516 item 10) | The wire text (report health.*) stays byte-identical; `AlertManager.Refresh` renders the key and falls back to the text when Key is empty. Every issue goes through `addIssue` so the parallel slices cannot drift (pinned) |
|
||||
| `settings.OffboxTarget.LastWarningKind` + `backup.OffboxWarnNoAppsSelected` | controller/internal/settings/settings.go | persisted string | the Távoli mentés page's stale-note substitution | Written and cleared with `LastWarning`; the text fallback in `offboxWarningDisplay` is LEGACY only (kind == "") and is removed when R-570 closes |
|
||||
|
||||
### HTTP/JSON envelopes + flash messages
|
||||
|
||||
@@ -1563,6 +1563,13 @@
|
||||
"func.time.tomorrow_at": "tomorrow %s",
|
||||
"func.time.yesterday": "yesterday",
|
||||
"health.no_probe_container": "No health check ran: no matching container.",
|
||||
"health.system_disk_high": "System storage is filling up: %.0f%% used",
|
||||
"health.system_disk_critical": "System storage is almost full: %.0f%% used",
|
||||
"health.data_disk_high": "The data drive is filling up: %.0f%% used",
|
||||
"health.data_disk_critical": "The data drive is almost full: %.0f%% used",
|
||||
"health.memory_high": "Memory usage high: %.0f%%",
|
||||
"health.cpu_high": "CPU usage high: %.0f%%",
|
||||
"health.temperature_high": "Temperature high: %.0f°C (%s)",
|
||||
"hold.copy_holds.db_only": "holds only the settings and the database, not the files",
|
||||
"hold.copy_holds.files": "holds the settings, the database and the files",
|
||||
"hold.copy_holds.volumes": "holds the settings, the database and the data volumes",
|
||||
@@ -2255,7 +2262,7 @@
|
||||
"storage.levalasztas": "Disconnect",
|
||||
"storage.levalasztas_folyamatban": "Disconnecting…",
|
||||
"storage.levalasztva": "Disconnected",
|
||||
"storage.levalasztva_2": "Disconnected: {{.DisconnectedAt}}",
|
||||
"storage.levalasztva_2": "Disconnected: {{fmtTimeStr .DisconnectedAt}}",
|
||||
"storage.majd_a_z": "then",
|
||||
"storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Add already mounted storage by hand",
|
||||
"storage.megerosites": "Confirm",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"alert.agent_channel.unknown": "A tárolókezelő ügynök nem elérhető.",
|
||||
"alert.agent_channel.unreachable": "A tárolókezelő ügynök nem elérhető.",
|
||||
"alert.backup.disabled": "A biztonsági mentés nincs bekapcsolva",
|
||||
"alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort",
|
||||
"alert.deadapp.group": "%d telepített alkalmazás nem fut — nézd meg a rendszermonitort",
|
||||
"alert.deadapp.single": "Telepített alkalmazás nem fut: %s",
|
||||
"alert.deadapp.single_state": "Telepített alkalmazás nem fut: %s (%s)",
|
||||
"alert.endpoint_drift": "A tárolókezelő ügynök címe elavult a beállításokban.",
|
||||
@@ -1551,6 +1551,13 @@
|
||||
"func.time.tomorrow_at": "holnap %s",
|
||||
"func.time.yesterday": "tegnap",
|
||||
"health.no_probe_container": "Nem futott egészségellenőrzés: nincs hozzá tartozó konténer.",
|
||||
"health.system_disk_high": "A rendszer tárhelye fogy: %.0f%% foglalt",
|
||||
"health.system_disk_critical": "A rendszer tárhelye majdnem megtelt: %.0f%% foglalt",
|
||||
"health.data_disk_high": "Az adatmeghajtó tárhelye fogy: %.0f%% foglalt",
|
||||
"health.data_disk_critical": "Az adatmeghajtó majdnem megtelt: %.0f%% foglalt",
|
||||
"health.memory_high": "A memóriahasználat magas: %.0f%%",
|
||||
"health.cpu_high": "A processzor terhelése magas: %.0f%%",
|
||||
"health.temperature_high": "A hőmérséklet magas: %.0f°C (%s)",
|
||||
"hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem",
|
||||
"hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza",
|
||||
"hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza",
|
||||
@@ -2243,7 +2250,7 @@
|
||||
"storage.levalasztas": "Leválasztás",
|
||||
"storage.levalasztas_folyamatban": "Leválasztás folyamatban…",
|
||||
"storage.levalasztva": "Leválasztva",
|
||||
"storage.levalasztva_2": "Leválasztva: {{.DisconnectedAt}}",
|
||||
"storage.levalasztva_2": "Leválasztva: {{fmtTimeStr .DisconnectedAt}}",
|
||||
"storage.majd_a_z": "majd a(z)",
|
||||
"storage.mar_csatlakoztatott_tarhely_hozzaadasa_k": "Már csatlakoztatott tárhely hozzáadása kézzel",
|
||||
"storage.megerosites": "Megerősítés",
|
||||
|
||||
@@ -26,8 +26,15 @@ type HealthReport struct {
|
||||
// Issues and Warnings only, so the hub report is unchanged — pinned by
|
||||
// TestR553_HubReportWarningsAreUnchangedOnTheWire.
|
||||
WarningKinds []string
|
||||
Info []string // informational items
|
||||
Timestamp time.Time
|
||||
// WarningMsgs / IssueMsgs are parallel to Warnings / Issues (R-516 item 10): the bundle key and
|
||||
// arguments of the dashboard's OWN sentence for that entry, in the household's language. The
|
||||
// Warnings/Issues text stays exactly what it was — it is ON THE WIRE (report health.*), and
|
||||
// internal/report/builder.go copies Status, Issues and Warnings only (pinned by
|
||||
// TestR553_HubReportWarningsAreUnchangedOnTheWire). An entry with no key shows its text verbatim.
|
||||
WarningMsgs []MsgRef
|
||||
IssueMsgs []MsgRef
|
||||
Info []string // informational items
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// Warning kinds (R-553). A kind names WHAT the warning is about; the text stays the only thing shown.
|
||||
@@ -54,8 +61,43 @@ const (
|
||||
// addWarning appends a warning together with its kind, so the two slices cannot drift apart. Every
|
||||
// warning goes through here; `WarningKindAt` reads them back.
|
||||
func (r *HealthReport) addWarning(text, kind string) {
|
||||
r.addWarningMsg(text, kind, MsgRef{})
|
||||
}
|
||||
|
||||
// MsgRef names a dashboard sentence by bundle key; the zero value means "show the text verbatim".
|
||||
type MsgRef struct {
|
||||
Key string
|
||||
Args []interface{}
|
||||
}
|
||||
|
||||
// addWarningMsg is addWarning with the dashboard's own sentence beside the wire text (R-516 item 10).
|
||||
func (r *HealthReport) addWarningMsg(text, kind string, msg MsgRef) {
|
||||
r.Warnings = append(r.Warnings, text)
|
||||
r.WarningKinds = append(r.WarningKinds, kind)
|
||||
r.WarningMsgs = append(r.WarningMsgs, msg)
|
||||
}
|
||||
|
||||
// addIssue appends a critical issue with its dashboard sentence (zero MsgRef = verbatim), so the two
|
||||
// slices cannot drift apart. Every issue goes through here; IssueMsgAt reads them back.
|
||||
func (r *HealthReport) addIssue(text string, msg MsgRef) {
|
||||
r.Issues = append(r.Issues, text)
|
||||
r.IssueMsgs = append(r.IssueMsgs, msg)
|
||||
}
|
||||
|
||||
// WarningMsgAt / IssueMsgAt return the dashboard sentence of entry i, or the zero MsgRef when there is
|
||||
// none (a report built by hand in a test, or an entry with no key).
|
||||
func (r *HealthReport) WarningMsgAt(i int) MsgRef {
|
||||
if r == nil || i < 0 || i >= len(r.WarningMsgs) {
|
||||
return MsgRef{}
|
||||
}
|
||||
return r.WarningMsgs[i]
|
||||
}
|
||||
|
||||
func (r *HealthReport) IssueMsgAt(i int) MsgRef {
|
||||
if r == nil || i < 0 || i >= len(r.IssueMsgs) {
|
||||
return MsgRef{}
|
||||
}
|
||||
return r.IssueMsgs[i]
|
||||
}
|
||||
|
||||
// WarningKindAt returns the kind of Warnings[i], or "" when there is none (older callers, a report
|
||||
@@ -89,109 +131,11 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
sysInfo.CPUPercent, sysInfo.TemperatureCelsius, sysInfo.TemperatureSource)
|
||||
}
|
||||
|
||||
// 1. Disk usage (SSD). NOTE (storage-split): sysInfo.DiskPercent statfs's the controller
|
||||
// container's "/", whose overlay upperdir lives on the guest's /var/lib/docker volume — so this
|
||||
// IS the Docker-data volume guard (post-split it's the dedicated data volume; pre-split it's the
|
||||
// rootfs — either way it's wherever Docker's data-root lives). Warn at 80% / crit at 90% used
|
||||
// trips ABOVE the prevention layer's 10%-free reserved buffer, so the customer is warned before
|
||||
// the deploy gate even engages.
|
||||
if sysInfo.DiskPercent > 0 {
|
||||
if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
|
||||
report.Issues = append(report.Issues, fmt.Sprintf("SSD disk usage critical: %.0f%%", sysInfo.DiskPercent))
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] SSD disk: CRITICAL (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
|
||||
}
|
||||
} else if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
|
||||
report.addWarning(fmt.Sprintf("SSD disk usage high: %.0f%%", sysInfo.DiskPercent), "")
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] SSD disk: WARN (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("SSD: %.0f%% used", sysInfo.DiskPercent))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] SSD disk: OK (%.0f%%)", sysInfo.DiskPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HDD disk usage
|
||||
if sysInfo.HDDConfigured && sysInfo.HDDPercent > 0 {
|
||||
if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
|
||||
report.Issues = append(report.Issues, fmt.Sprintf("HDD disk usage critical: %.0f%%", sysInfo.HDDPercent))
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
|
||||
}
|
||||
} else if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
|
||||
report.addWarning(fmt.Sprintf("HDD disk usage high: %.0f%%", sysInfo.HDDPercent), "")
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Memory usage
|
||||
if sysInfo.MemPercent > 0 {
|
||||
if sysInfo.MemPercent >= float64(cfg.Monitoring.Thresholds.MemoryWarnPercent) {
|
||||
report.addWarning(fmt.Sprintf("Memory usage high: %.0f%%", sysInfo.MemPercent), "")
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Memory threshold breached: %.0f%% (limit: %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Memory: WARN (%.0f%% >= %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("Memory: %.0f%% used", sysInfo.MemPercent))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Memory: OK (%.0f%%)", sysInfo.MemPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. CPU usage
|
||||
if sysInfo.CPUPercent > 0 {
|
||||
if sysInfo.CPUPercent >= float64(cfg.Monitoring.Thresholds.CPUWarnPercent) {
|
||||
report.addWarning(fmt.Sprintf("CPU usage high: %.0f%%", sysInfo.CPUPercent), "")
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] CPU threshold breached: %.0f%% (limit: %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] CPU: WARN (%.0f%% >= %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("CPU: %.0f%%", sysInfo.CPUPercent))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] CPU: OK (%.0f%%)", sysInfo.CPUPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Temperature
|
||||
if sysInfo.TemperatureCelsius > 0 {
|
||||
if sysInfo.TemperatureCelsius >= float64(cfg.Monitoring.Thresholds.TemperatureWarnCelsius) {
|
||||
report.addWarning(fmt.Sprintf("Temperature high: %.0f°C (%s)", sysInfo.TemperatureCelsius, sysInfo.TemperatureSource), "")
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Temperature threshold breached: %.0f°C (limit: %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Temperature: WARN (%.0f°C >= %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("Temperature: %.0f°C", sysInfo.TemperatureCelsius))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Temperature: OK (%.0f°C)", sysInfo.TemperatureCelsius)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkResources(report, sysInfo, cfg, logger, debug)
|
||||
|
||||
// 5. Docker health
|
||||
if err := checkDocker(); err != nil {
|
||||
report.Issues = append(report.Issues, fmt.Sprintf("Docker: %v", err))
|
||||
report.addIssue(fmt.Sprintf("Docker: %v", err), MsgRef{})
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Docker daemon: FAIL (%v)", err)
|
||||
}
|
||||
@@ -210,7 +154,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
}
|
||||
missingProtected := checkProtectedContainers(protected)
|
||||
for _, name := range missingProtected {
|
||||
report.Issues = append(report.Issues, fmt.Sprintf("Protected container not running: %s", name))
|
||||
report.addIssue(fmt.Sprintf("Protected container not running: %s", name), MsgRef{})
|
||||
}
|
||||
if debug {
|
||||
if len(missingProtected) > 0 {
|
||||
@@ -222,7 +166,9 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
|
||||
|
||||
// 7. Storage paths
|
||||
storageIssues, storageWarnings, storageKinds := checkStoragePaths(storagePaths)
|
||||
report.Issues = append(report.Issues, storageIssues...)
|
||||
for _, is := range storageIssues {
|
||||
report.addIssue(is, MsgRef{})
|
||||
}
|
||||
for i, w := range storageWarnings {
|
||||
report.addWarning(w, storageKinds[i])
|
||||
}
|
||||
@@ -395,3 +341,115 @@ func checkStoragePaths(paths []settings.StoragePath) (issues, warnings, kinds []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// checkResources is the threshold half of RunHealthCheck (disk, memory, CPU, temperature), split out
|
||||
// so a test can drive it with a made-up SystemInfo instead of the box it runs on (R-516 item 10:
|
||||
// TestR516_ResourceWarningsCarryTheirDashboardSentence).
|
||||
func checkResources(report *HealthReport, sysInfo system.SystemInfo, cfg *config.Config, logger *log.Logger, debug bool) {
|
||||
// 1. Disk usage (SSD). NOTE (storage-split): sysInfo.DiskPercent statfs's the controller
|
||||
// container's "/", whose overlay upperdir lives on the guest's /var/lib/docker volume — so this
|
||||
// IS the Docker-data volume guard (post-split it's the dedicated data volume; pre-split it's the
|
||||
// rootfs — either way it's wherever Docker's data-root lives). Warn at 80% / crit at 90% used
|
||||
// trips ABOVE the prevention layer's 10%-free reserved buffer, so the customer is warned before
|
||||
// the deploy gate even engages.
|
||||
if sysInfo.DiskPercent > 0 {
|
||||
if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
|
||||
report.addIssue(fmt.Sprintf("SSD disk usage critical: %.0f%%", sysInfo.DiskPercent),
|
||||
MsgRef{"health.system_disk_critical", []interface{}{sysInfo.DiskPercent}})
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] SSD disk: CRITICAL (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
|
||||
}
|
||||
} else if sysInfo.DiskPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
|
||||
report.addWarningMsg(fmt.Sprintf("SSD disk usage high: %.0f%%", sysInfo.DiskPercent), "",
|
||||
MsgRef{"health.system_disk_high", []interface{}{sysInfo.DiskPercent}})
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (SSD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] SSD disk: WARN (%.0f%% >= %d%%)", sysInfo.DiskPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("SSD: %.0f%% used", sysInfo.DiskPercent))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] SSD disk: OK (%.0f%%)", sysInfo.DiskPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HDD disk usage
|
||||
if sysInfo.HDDConfigured && sysInfo.HDDPercent > 0 {
|
||||
if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskCritPercent) {
|
||||
report.addIssue(fmt.Sprintf("HDD disk usage critical: %.0f%%", sysInfo.HDDPercent),
|
||||
MsgRef{"health.data_disk_critical", []interface{}{sysInfo.HDDPercent}})
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskCritPercent)
|
||||
}
|
||||
} else if sysInfo.HDDPercent >= float64(cfg.Monitoring.Thresholds.DiskWarnPercent) {
|
||||
report.addWarningMsg(fmt.Sprintf("HDD disk usage high: %.0f%%", sysInfo.HDDPercent), "",
|
||||
MsgRef{"health.data_disk_high", []interface{}{sysInfo.HDDPercent}})
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Disk (HDD) threshold breached: %.0f%% (limit: %d%%)", sysInfo.HDDPercent, cfg.Monitoring.Thresholds.DiskWarnPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Memory usage
|
||||
if sysInfo.MemPercent > 0 {
|
||||
if sysInfo.MemPercent >= float64(cfg.Monitoring.Thresholds.MemoryWarnPercent) {
|
||||
report.addWarningMsg(fmt.Sprintf("Memory usage high: %.0f%%", sysInfo.MemPercent), "",
|
||||
MsgRef{"health.memory_high", []interface{}{sysInfo.MemPercent}})
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Memory threshold breached: %.0f%% (limit: %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Memory: WARN (%.0f%% >= %d%%)", sysInfo.MemPercent, cfg.Monitoring.Thresholds.MemoryWarnPercent)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("Memory: %.0f%% used", sysInfo.MemPercent))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Memory: OK (%.0f%%)", sysInfo.MemPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. CPU usage
|
||||
if sysInfo.CPUPercent > 0 {
|
||||
if sysInfo.CPUPercent >= float64(cfg.Monitoring.Thresholds.CPUWarnPercent) {
|
||||
report.addWarningMsg(fmt.Sprintf("CPU usage high: %.0f%%", sysInfo.CPUPercent), "",
|
||||
MsgRef{"health.cpu_high", []interface{}{sysInfo.CPUPercent}})
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] CPU threshold breached: %.0f%% (limit: %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] CPU: WARN (%.0f%% >= %d%%)", sysInfo.CPUPercent, cfg.Monitoring.Thresholds.CPUWarnPercent)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("CPU: %.0f%%", sysInfo.CPUPercent))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] CPU: OK (%.0f%%)", sysInfo.CPUPercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Temperature
|
||||
if sysInfo.TemperatureCelsius > 0 {
|
||||
if sysInfo.TemperatureCelsius >= float64(cfg.Monitoring.Thresholds.TemperatureWarnCelsius) {
|
||||
report.addWarningMsg(fmt.Sprintf("Temperature high: %.0f°C (%s)", sysInfo.TemperatureCelsius, sysInfo.TemperatureSource), "",
|
||||
MsgRef{"health.temperature_high", []interface{}{sysInfo.TemperatureCelsius, sysInfo.TemperatureSource}})
|
||||
if logger != nil {
|
||||
logger.Printf("[WARN] [monitor] Temperature threshold breached: %.0f°C (limit: %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
|
||||
}
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Temperature: WARN (%.0f°C >= %d°C)", sysInfo.TemperatureCelsius, cfg.Monitoring.Thresholds.TemperatureWarnCelsius)
|
||||
}
|
||||
} else {
|
||||
report.Info = append(report.Info, fmt.Sprintf("Temperature: %.0f°C", sysInfo.TemperatureCelsius))
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] [monitor] Temperature: OK (%.0f°C)", sysInfo.TemperatureCelsius)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// R-516 item 10 — the resource warnings („SSD disk usage high: 90%") reached the household's banner in
|
||||
// English on every page. checkResources now attaches the dashboard's own sentence (a bundle key) beside
|
||||
// each one, and the wire text is byte-for-byte what it was.
|
||||
//
|
||||
// COMPANION RED-PROOF: give the SSD warning a zero MsgRef (addWarning) — the "warn" case fails here
|
||||
// and the banner test in internal/web shows the English wire text again.
|
||||
func TestR516_ResourceWarningsCarryTheirDashboardSentence(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cases := []struct {
|
||||
name string
|
||||
info system.SystemInfo
|
||||
wantIssue, wantKey []string
|
||||
wantWire []string
|
||||
}{
|
||||
{"warn", system.SystemInfo{DiskPercent: 85, HDDConfigured: true, HDDPercent: 85, MemPercent: 90, CPUPercent: 95,
|
||||
TemperatureCelsius: 80, TemperatureSource: "coretemp"}, nil,
|
||||
[]string{"health.system_disk_high", "health.data_disk_high", "health.memory_high", "health.cpu_high", "health.temperature_high"},
|
||||
[]string{"SSD disk usage high: 85%", "HDD disk usage high: 85%", "Memory usage high: 90%", "CPU usage high: 95%", "Temperature high: 80°C (coretemp)"}},
|
||||
{"critical", system.SystemInfo{DiskPercent: 95, HDDConfigured: true, HDDPercent: 96},
|
||||
[]string{"health.system_disk_critical", "health.data_disk_critical"}, nil,
|
||||
[]string{"SSD disk usage critical: 95%", "HDD disk usage critical: 96%"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := &HealthReport{}
|
||||
checkResources(r, c.info, cfg, nil, false)
|
||||
texts, keys := r.Warnings, []string{}
|
||||
for i := range r.Warnings {
|
||||
keys = append(keys, r.WarningMsgAt(i).Key)
|
||||
}
|
||||
if c.wantIssue != nil {
|
||||
texts, keys = r.Issues, nil
|
||||
for i := range r.Issues {
|
||||
keys = append(keys, r.IssueMsgAt(i).Key)
|
||||
}
|
||||
}
|
||||
want := c.wantKey
|
||||
if c.wantIssue != nil {
|
||||
want = c.wantIssue
|
||||
}
|
||||
if len(texts) != len(c.wantWire) {
|
||||
t.Fatalf("%s: %d entries %q, want %d — the cases below would be compared against nothing", c.name, len(texts), texts, len(c.wantWire))
|
||||
}
|
||||
for i := range texts {
|
||||
if texts[i] != c.wantWire[i] {
|
||||
t.Errorf("%s: the wire text moved: %q, want %q", c.name, texts[i], c.wantWire[i])
|
||||
}
|
||||
if keys[i] != want[i] {
|
||||
t.Errorf("%s: %q carries dashboard key %q, want %q", c.name, texts[i], keys[i], want[i])
|
||||
}
|
||||
}
|
||||
if len(r.Warnings) != len(r.WarningKinds) || len(r.Warnings) != len(r.WarningMsgs) || len(r.Issues) != len(r.IssueMsgs) {
|
||||
t.Errorf("%s: the parallel slices drifted apart", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,11 +235,16 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
|
||||
}
|
||||
|
||||
// From health check issues (critical)
|
||||
for _, issue := range report.Issues {
|
||||
for i, issue := range report.Issues {
|
||||
// R-516 item 10: the banner shows the dashboard's own sentence (household language) when the
|
||||
// check named one; the wire text is the fallback and stays untouched on the wire.
|
||||
msg := report.IssueMsgAt(i)
|
||||
alerts = append(alerts, Alert{
|
||||
ID: "health-" + simpleHash(issue),
|
||||
Level: "error",
|
||||
Message: issue, // ON THE WIRE (report health.issues) -- not ours to translate; slice 3
|
||||
Message: issue, // ON THE WIRE (report health.issues) -- the fallback when MessageKey is empty
|
||||
MessageKey: msg.Key,
|
||||
MessageArgs: msg.Args,
|
||||
Link: "/monitoring",
|
||||
LinkTextKey: "alert.link.monitoring",
|
||||
})
|
||||
@@ -247,10 +252,21 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
|
||||
|
||||
// From health check warnings
|
||||
for i, w := range report.Warnings {
|
||||
// R-516 item 8: a disconnected drive already has its own banner above (alert.storage.disconnected,
|
||||
// in the household's language). The health check's „Meghajtó leválasztva" warning for the same
|
||||
// drive was a SECOND banner on every page — dropped here whenever the dedicated banners were built
|
||||
// from the storage list. The warning itself stays on the wire (report health.warnings).
|
||||
// Pinned by TestR516_DisconnectedDriveHasOneBanner.
|
||||
if len(storagePaths) > 0 && report.WarningKindAt(i) == monitor.WarnKindStorageDisconnected {
|
||||
continue
|
||||
}
|
||||
msg := report.WarningMsgAt(i) // R-516 item 10, as for the issues above
|
||||
alert := Alert{
|
||||
ID: "health-" + simpleHash(w),
|
||||
Level: "warning",
|
||||
Message: w, // ON THE WIRE (report health.warnings) -- not ours to translate; slice 3
|
||||
Message: w, // ON THE WIRE (report health.warnings) -- the fallback when MessageKey is empty
|
||||
MessageKey: msg.Key,
|
||||
MessageArgs: msg.Args,
|
||||
Link: "/monitoring",
|
||||
LinkTextKey: "alert.link.monitoring",
|
||||
}
|
||||
|
||||
@@ -194,10 +194,10 @@ func TestPluralEnglish(t *testing.T) {
|
||||
hu7 := am.GetAlerts("hu")[0].Message
|
||||
en7 := am.GetAlerts("en")[0].Message
|
||||
|
||||
if hu4 != "4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" {
|
||||
if hu4 != "4 telepített alkalmazás nem fut — nézd meg a rendszermonitort" { // R-516 item 9: te-form
|
||||
t.Errorf("the Hungarian sentence moved: %q", hu4)
|
||||
}
|
||||
if hu7 != "7 telepített alkalmazás nem fut — nézze meg a rendszermonitort" {
|
||||
if hu7 != "7 telepített alkalmazás nem fut — nézd meg a rendszermonitort" { // R-516 item 9: te-form
|
||||
t.Errorf("the Hungarian sentence moved: %q", hu7)
|
||||
}
|
||||
if !strings.Contains(en4, "4 installed apps are not running") {
|
||||
@@ -213,7 +213,7 @@ func TestPluralEnglish(t *testing.T) {
|
||||
t.Errorf("English singular: %q", got)
|
||||
}
|
||||
// And Hungarian at 1 is the SAME single form — no .one, no change.
|
||||
if got := b.Msgf("hu", "alert.deadapp.group", 1); got != "1 telepített alkalmazás nem fut — nézze meg a rendszermonitort" {
|
||||
if got := b.Msgf("hu", "alert.deadapp.group", 1); got != "1 telepített alkalmazás nem fut — nézd meg a rendszermonitort" {
|
||||
t.Errorf("Hungarian must have one form at every count: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// R-516 item 8 — a disconnected drive showed TWO banners on every page: its own
|
||||
// (alert.storage.disconnected, in the household's language) and the health check's Hungarian
|
||||
// „Meghajtó leválasztva" warning for the same drive. The dedicated banner is the one kept.
|
||||
//
|
||||
// COMPANION RED-PROOF: drop the WarnKindStorageDisconnected `continue` in AlertManager.Refresh — the
|
||||
// first half fails with two banners.
|
||||
func TestR516_DisconnectedDriveHasOneBanner(t *testing.T) {
|
||||
const warn = "Meghajtó leválasztva: Külső (/mnt/usb)"
|
||||
hr := &monitor.HealthReport{Status: "warn", Warnings: []string{warn, "CPU usage high: 91%"},
|
||||
WarningKinds: []string{monitor.WarnKindStorageDisconnected, ""}}
|
||||
cfg := &config.Config{}
|
||||
cfg.Hub.Enabled = false
|
||||
sp := []settings.StoragePath{{Path: "/mnt/usb", Label: "Külső", Disconnected: true}}
|
||||
|
||||
count := func(alerts []Alert) (dedicated, health, cpu int) {
|
||||
for _, a := range alerts {
|
||||
switch {
|
||||
case strings.HasPrefix(a.ID, "storage-disconnected-"):
|
||||
dedicated++
|
||||
case a.Message == warn:
|
||||
health++
|
||||
case a.Message == "CPU usage high: 91%":
|
||||
cpu++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
am := NewAlertManager(log.New(io.Discard, "", 0))
|
||||
am.Refresh(hr, cfg, nil, false, "", sp)
|
||||
d, h, c := count(am.GetAlerts("hu"))
|
||||
if d != 1 || h != 0 {
|
||||
t.Errorf("a disconnected drive must have exactly one banner: dedicated=%d health-warning=%d", d, h)
|
||||
}
|
||||
if c != 1 {
|
||||
t.Errorf("positive control: another health warning must still reach the banners (got %d)", c)
|
||||
}
|
||||
|
||||
// Without the storage list there is no dedicated banner, so the health warning is the only one
|
||||
// that can say it — it must stay.
|
||||
am2 := NewAlertManager(log.New(io.Discard, "", 0))
|
||||
am2.Refresh(hr, cfg, nil, false, "")
|
||||
if d, h, _ := count(am2.GetAlerts("hu")); d != 0 || h != 1 {
|
||||
t.Errorf("without the storage list the health warning is the only banner: dedicated=%d health-warning=%d", d, h)
|
||||
}
|
||||
}
|
||||
|
||||
// R-516 item 7 — the storage page printed the disconnect time as the raw stored RFC3339 UTC string
|
||||
// („Leválasztva: 2026-09-14T19:58:02Z"). It now goes through fmtTimeStr, the page's local-time format,
|
||||
// in both languages.
|
||||
//
|
||||
// COMPANION RED-PROOF: put `{{.DisconnectedAt}}` back in storage.levalasztva_2 (hu.json) — the hu half
|
||||
// fails with the raw string.
|
||||
func TestR516_DisconnectTimeIsLocalNotRawUTC(t *testing.T) {
|
||||
const raw = "2026-09-14T19:58:02Z"
|
||||
for _, tc := range []struct{ lang, prefix string }{{"hu", "Leválasztva: "}, {"en", "Disconnected: "}} {
|
||||
d := i18nLayoutData("storage", "Tárhely")
|
||||
paths := i18nStoragePaths()
|
||||
paths[0]["DisconnectedAt"] = raw
|
||||
d["StoragePaths"] = paths
|
||||
h := renderBackupPageLang(t, tc.lang, "storage", d)
|
||||
if strings.Contains(h, raw) {
|
||||
t.Errorf("%s: the raw UTC timestamp reached the page", tc.lang)
|
||||
}
|
||||
if want := tc.prefix + fmtRFC3339Local(raw); !strings.Contains(h, want) {
|
||||
t.Errorf("%s: the page must carry %q", tc.lang, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-516 item 10 — a health warning with a dashboard sentence shows it in the household's language;
|
||||
// a warning without one shows its wire text verbatim (the fail-open fallback).
|
||||
func TestR516_HealthBannersFollowTheHousehold(t *testing.T) {
|
||||
hr := &monitor.HealthReport{Status: "fail",
|
||||
Issues: []string{"SSD disk usage critical: 95%"},
|
||||
IssueMsgs: []monitor.MsgRef{{Key: "health.system_disk_critical", Args: []interface{}{95.0}}},
|
||||
Warnings: []string{"SSD disk usage high: 90%", "Docker: something"}, WarningKinds: []string{"", ""},
|
||||
WarningMsgs: []monitor.MsgRef{{Key: "health.system_disk_high", Args: []interface{}{90.0}}, {}}}
|
||||
cfg := &config.Config{}
|
||||
cfg.Hub.Enabled = false
|
||||
am := NewAlertManager(log.New(io.Discard, "", 0))
|
||||
am.Refresh(hr, cfg, nil, false, "")
|
||||
got := map[string][]string{}
|
||||
for _, lang := range []string{"hu", "en"} {
|
||||
for _, a := range am.GetAlerts(lang) {
|
||||
if strings.HasPrefix(a.ID, "health-") {
|
||||
got[lang] = append(got[lang], a.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
want := map[string][]string{
|
||||
"hu": {"A rendszer tárhelye majdnem megtelt: 95% foglalt", "A rendszer tárhelye fogy: 90% foglalt", "Docker: something"},
|
||||
"en": {"System storage is almost full: 95% used", "System storage is filling up: 90% used", "Docker: something"},
|
||||
}
|
||||
for lang, w := range want {
|
||||
if strings.Join(got[lang], "|") != strings.Join(w, "|") {
|
||||
t.Errorf("%s banners:\n got %q\n want %q", lang, got[lang], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,14 @@
|
||||
"note.offsite.fail_locked": "BORN AS A KEY (R-104) -- the cause line for a repository lock that survived the self-heal; a NEW sentence, never a Go literal. Pinned in both languages by TestR104_SurvivingLockIsNamed.",
|
||||
"err.backup.restore_drive_gone": "BORN AS A KEY (R-362) -- names the drive a restore could not reach instead of a raw permission error; a NEW sentence, never a Go literal. Pinned in both languages by TestR362_DetachedDriveIsNamed.",
|
||||
"restore.refuse.files.second_drive_whole": "BORN AS A KEY (R-675) -- the unit-restore refusal names the second drive's WHOLE restore (decision 26); a NEW sentence, never a Go literal. Pinned in both languages by TestR675_RefusalNamesTheWholeCopy.",
|
||||
"health.system_disk_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.",
|
||||
"health.system_disk_critical": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.",
|
||||
"health.data_disk_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.",
|
||||
"health.data_disk_critical": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.",
|
||||
"health.memory_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.",
|
||||
"health.cpu_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.",
|
||||
"health.temperature_high": "BORN AS A KEY (R-516 item 10) -- the dashboard banner's own sentence for a health-check warning/issue whose wire text (report health.*) is English and stays so; a NEW sentence, never a Go literal. Pinned in both languages by TestR516_HealthBannersFollowTheHousehold.",
|
||||
"alert.deadapp.group": "R-516 item 9 -- REWORDED on purpose: the formal „nézze meg\" became the product's te-form „nézd meg\"; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned in both languages by TestPluralEnglish (i18n_flash_test.go).",
|
||||
"event.appstop.failed": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.",
|
||||
"event.appstop.failed_held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.",
|
||||
"event.appstop.held": "BORN AS A KEY (R-585) -- the interrupted-operation alert used to send the OPERATOR's English sentence (backup.AppStopRecovery.Message) to every household through the customer-enabled backup_failed type; a NEW Hungarian sentence, never a Go literal. Pinned in both languages by TestR585_InterruptedOperationFollowsTheHousehold.",
|
||||
@@ -641,7 +649,6 @@
|
||||
"alert.link.settings": "Beállítások",
|
||||
"alert.link.monitoring": "Rendszermonitor",
|
||||
"alert.link.update": "Frissítés",
|
||||
"alert.deadapp.group": "%d telepített alkalmazás nem fut — nézze meg a rendszermonitort",
|
||||
"alert.deadapp.single": [
|
||||
"Telepített alkalmazás nem fut: "
|
||||
],
|
||||
|
||||
@@ -38,7 +38,7 @@ TEMPLATE_ROOTS = [os.path.join(CTRL, "internal", "web", "templates")]
|
||||
MARKER = re.compile(r'\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}')
|
||||
|
||||
EN_MISSING_CEILING = 0
|
||||
HU_FORMAL_CEILING = 14 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0) -> 18 (v0.269.0) -> 17 (v0.283.0, the recovery wizard, R-725) -> 14 (R-516 item 4, the storage pages in the te-form): the count follows the
|
||||
HU_FORMAL_CEILING = 13 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0) -> 18 (v0.269.0) -> 17 (v0.283.0, the recovery wizard, R-725) -> 14 (R-516 item 4, the storage pages in the te-form) -> 13 (R-516 item 9, alert.deadapp.group „nézd meg"): the count follows the
|
||||
# conversion, it is not a judgement. Moving a Go literal into hu.json makes an „ön" form the gate could
|
||||
# not see before VISIBLE to it -- the two that arrived are `flash.login.password_changed` („kérjük")
|
||||
# and `alert.deadapp.group` („nézze meg"), both word-for-word what the Go code already said. Slice 2 may
|
||||
|
||||
Reference in New Issue
Block a user