v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s

Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 10:15:56 +02:00
parent 736f54b496
commit 5270bad76e
52 changed files with 10755 additions and 478 deletions
+123 -70
View File
@@ -9,19 +9,59 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Alert represents a persistent dashboard alert banner.
//
// LOCALISATION (v0.252.0, R-557). An alert is BUILT by a background health cycle and READ minutes
// later by whichever page the household opens, so its text cannot be rendered where it is made —
// that is the same shape as a flash in a redirect URL, one layer in. An alert therefore carries a
// bundle KEY plus its parameters, and GetAlerts renders it in the language the reader asked for.
//
// Two deliberate exceptions, both because the text is NOT ours to translate:
//
// - the health report's Issues and Warnings, which arrive as finished sentences and go ON THE WIRE
// to the hub (report `health.warnings`, pinned by internal/monitor's wire golden). They keep
// `Message` and stay Hungarian in every language until slice 3 gives the hub a language;
// - the agent-channel and endpoint-drift lines, whose text is composed by the channel-health
// checker. Filed as R-573.
//
// An alert with no MessageKey renders `Message` verbatim, which is what makes both exceptions work
// without a second mechanism.
type Alert struct {
ID string // unique identifier for filtering
Level string // "error", "warning", "info"
Message string // Hungarian display text
Link string // optional link to relevant page
LinkText string // link display text
PageOnly []string // if non-empty, only show on these pages (e.g., ["dashboard", "monitoring"])
Inline bool // if true, rendered by page template inline, not in layout banner
ID string // unique identifier for filtering
Level string // "error", "warning", "info"
Message string // display text; used verbatim when MessageKey is empty
MessageKey string // bundle key for Message (preferred); empty = Message is already text
MessageArgs []interface{} // the key's printf parameters, in order
Link string // optional link to relevant page
LinkText string // link display text; used verbatim when LinkTextKey is empty
LinkTextKey string // bundle key for LinkText
PageOnly []string // if non-empty, only show on these pages (e.g., ["dashboard", "monitoring"])
Inline bool // if true, rendered by page template inline, not in layout banner
}
// rendered returns a copy of the alert with its keys resolved in lang. A key the bundle does not
// carry falls back to whatever Message/LinkText already held, so an alert can never render blank.
func (a Alert) rendered(lang string) Alert {
b, err := i18n.Shared()
if err != nil {
return a
}
if a.MessageKey != "" {
if len(a.MessageArgs) > 0 {
a.Message = b.Msgf(lang, a.MessageKey, a.MessageArgs...)
} else {
a.Message = b.Msg(lang, a.MessageKey)
}
}
if a.LinkTextKey != "" {
a.LinkText = b.Msg(lang, a.LinkTextKey)
}
return a
}
// AlertManager generates and stores dashboard alerts from health check results.
@@ -72,11 +112,11 @@ func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) {
return
}
am.agentChannelAlert = &Alert{
ID: "agent-channel-down",
Level: "error",
Message: msg,
Link: "/settings",
LinkText: "Beállítások",
ID: "agent-channel-down",
Level: "error",
Message: msg, // composed by the channel-health checker -- R-573
Link: "/settings",
LinkTextKey: "alert.link.settings",
}
}
@@ -94,11 +134,11 @@ func (am *AlertManager) SetEndpointDriftAlert(drift bool, msg string) {
return
}
am.endpointDriftAlert = &Alert{
ID: "local-api-endpoint-drift",
Level: "error",
Message: msg,
Link: "/settings",
LinkText: "Beállítások",
ID: "local-api-endpoint-drift",
Level: "error",
Message: msg, // composed by the endpoint-drift checker -- R-573
Link: "/settings",
LinkTextKey: "alert.link.settings",
}
}
@@ -122,11 +162,12 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert {
}
if len(dead) > deadAppGroupThreshold {
return []Alert{{
ID: "deadapp-group",
Level: "warning",
Message: fmt.Sprintf("%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", len(dead)),
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "deadapp-group",
Level: "warning",
MessageKey: "alert.deadapp.group",
MessageArgs: []interface{}{len(dead)},
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
}}
}
alerts := make([]Alert, 0, len(dead))
@@ -135,16 +176,17 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert {
if name == "" {
name = d.Name
}
msg := "Telepített alkalmazás nem fut: " + name
key, args := "alert.deadapp.one", []interface{}{name}
if d.State != "" {
msg += " (" + d.State + ")"
key, args = "alert.deadapp.one_state", []interface{}{name, d.State}
}
alerts = append(alerts, Alert{
ID: "deadapp-" + simpleHash(d.Name),
Level: "warning",
Message: msg,
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "deadapp-" + simpleHash(d.Name),
Level: "warning",
MessageKey: key,
MessageArgs: args,
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
}
return alerts
@@ -173,11 +215,12 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
label = sp.Path
}
alerts = append(alerts, Alert{
ID: "storage-disconnected-" + simpleHash(sp.Path),
Level: "error",
Message: fmt.Sprintf("Meghajtó leválasztva: %s (%s)", label, sp.Path),
Link: "/settings",
LinkText: "Beállítások",
ID: "storage-disconnected-" + simpleHash(sp.Path),
Level: "error",
MessageKey: "alert.storage.disconnected",
MessageArgs: []interface{}{label, sp.Path},
Link: "/settings",
LinkTextKey: "alert.link.settings",
})
}
}
@@ -186,22 +229,22 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
// From health check issues (critical)
for _, issue := range report.Issues {
alerts = append(alerts, Alert{
ID: "health-" + simpleHash(issue),
Level: "error",
Message: issue,
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "health-" + simpleHash(issue),
Level: "error",
Message: issue, // ON THE WIRE (report health.issues) -- not ours to translate; slice 3
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
}
// From health check warnings
for i, w := range report.Warnings {
alert := Alert{
ID: "health-" + simpleHash(w),
Level: "warning",
Message: w,
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "health-" + simpleHash(w),
Level: "warning",
Message: w, // ON THE WIRE (report health.warnings) -- not ours to translate; slice 3
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
}
// R-553 — WHERE this warning is shown is decided by its KIND, not by the Hungarian words it
// contains. The old test was `strings.Contains(w, "meghajtón"/"adattároló"/"meghajtó")`, which
@@ -220,21 +263,22 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
// Hub connection status
if !cfg.Hub.Enabled || cfg.Hub.URL == "" {
alerts = append(alerts, Alert{
ID: "hub-disabled",
Level: "warning",
Message: "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív",
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "hub-disabled",
Level: "warning",
MessageKey: "alert.hub.disabled",
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
} else if am.hubPushStatusFn != nil {
ps := am.hubPushStatusFn()
if ps.LastError != "" && (ps.LastSuccess.IsZero() || time.Since(ps.LastSuccess) > 30*time.Minute) {
alerts = append(alerts, Alert{
ID: "hub-unreachable",
Level: "error",
Message: fmt.Sprintf("Hub nem elérhető — utolsó hiba: %s", ps.LastError),
Link: "/monitoring",
LinkText: "Rendszermonitor",
ID: "hub-unreachable",
Level: "error",
MessageKey: "alert.hub.unreachable",
MessageArgs: []interface{}{ps.LastError},
Link: "/monitoring",
LinkTextKey: "alert.link.monitoring",
})
}
}
@@ -242,22 +286,23 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
// Backup disabled
if !cfg.Backup.Enabled {
alerts = append(alerts, Alert{
ID: "backup-disabled",
Level: "warning",
Message: "A biztonsági mentés nincs bekapcsolva",
Link: "/settings",
LinkText: "Beállítások",
ID: "backup-disabled",
Level: "warning",
MessageKey: "alert.backup.disabled",
Link: "/settings",
LinkTextKey: "alert.link.settings",
})
}
// Update available
if updateAvailable && latestVersion != "" {
alerts = append(alerts, Alert{
ID: "update-available",
Level: "info",
Message: fmt.Sprintf("Új controller verzió elérhető: %s", latestVersion),
Link: "/settings",
LinkText: "Frissítés",
ID: "update-available",
Level: "info",
MessageKey: "alert.update.available",
MessageArgs: []interface{}{latestVersion},
Link: "/settings",
LinkTextKey: "alert.link.update",
})
}
@@ -270,7 +315,7 @@ func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config
}
// GetAlerts returns a copy of the current alerts, optionally excluding specific IDs.
func (am *AlertManager) GetAlerts(excludeIDs ...string) []Alert {
func (am *AlertManager) GetAlerts(lang string, excludeIDs ...string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
@@ -313,18 +358,23 @@ func (am *AlertManager) GetAlerts(excludeIDs ...string) []Alert {
overflow := len(result) - 5
result = result[:5]
result = append(result, Alert{
ID: "overflow",
Level: "info",
Message: fmt.Sprintf("+ %d további figyelmeztetés", overflow),
Link: "/monitoring",
ID: "overflow",
Level: "info",
MessageKey: "alert.overflow", MessageArgs: []interface{}{overflow},
Link: "/monitoring",
})
}
// Rendered LAST, once, on the way out: the alerts are stored as keys and only the reader knows
// the language. Every return path goes through here, so an alert cannot escape with a raw key.
for i := range result {
result[i] = result[i].rendered(lang)
}
return result
}
// GetInlineAlerts returns alerts marked as Inline for a specific page.
func (am *AlertManager) GetInlineAlerts(page string) []Alert {
func (am *AlertManager) GetInlineAlerts(page, lang string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
@@ -344,6 +394,9 @@ func (am *AlertManager) GetInlineAlerts(page string) []Alert {
}
}
}
for i := range result {
result[i] = result[i].rendered(lang)
}
return result
}