v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
gates / gates (push) Successful in 23s
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user