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:
@@ -57,11 +57,15 @@ func TestRefuseNetworkStubDeploy_Table(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := newDeployGateRouter(t, c.class)
|
||||
msg := r.refuseNetworkStubDeploy(c.hdd)
|
||||
// v0.252.0 (R-557): the gate returns a bundle KEY, so the customer reads it in their own
|
||||
// language. Rendered here rather than compared as a key, because what this test is about is
|
||||
// the SENTENCE the customer is refused with -- a key renamed with its message intact is not
|
||||
// a regression, and a message reworded is.
|
||||
msg := r.msgLang("hu", r.refuseNetworkStubDeploy(c.hdd))
|
||||
if c.refuse && !strings.Contains(msg, "a telepítés nem indítható") {
|
||||
t.Errorf("%s: want the refusal message, got %q", c.name, msg)
|
||||
}
|
||||
if !c.refuse && msg != "" {
|
||||
if !c.refuse && r.refuseNetworkStubDeploy(c.hdd) != "" {
|
||||
t.Errorf("%s: must proceed, got refusal %q", c.name, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
@@ -86,12 +87,12 @@ func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
|
||||
// away instead of after the next ~15-min cycle.
|
||||
r.reportPushNow()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Geo-korlátozás beállítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.set")})
|
||||
}
|
||||
|
||||
func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) geoTriggerSync(w http.ResponseWriter, req *http.Request) {
|
||||
if r.geoSync == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Cloudflare API nincs konfigurálva"})
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: r.msg(req, "api.geo.no_cloudflare")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,11 +106,23 @@ func (r *Router) geoTriggerSync(w http.ResponseWriter, _ *http.Request) {
|
||||
r.reportPushNow()
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Szinkronizálás elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.sync_started")})
|
||||
}
|
||||
|
||||
func (r *Router) geoCountries(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: cf.AllCountries()})
|
||||
// geoCountries serves the picker's country list in the request's language.
|
||||
//
|
||||
// The TABLE in internal/cloudflare is untouched and stays Hungarian: it is also the validator, and
|
||||
// internal/report puts country CODES on the wire. Only the NAME shown to the customer is translated,
|
||||
// and the list is re-sorted afterwards -- alphabetical order is per language, and a list sorted by
|
||||
// the Hungarian names would read as shuffled in English.
|
||||
func (r *Router) geoCountries(w http.ResponseWriter, req *http.Request) {
|
||||
lang := r.langFor(req)
|
||||
list := cf.AllCountries()
|
||||
for i := range list {
|
||||
list[i].Name = r.countryName(lang, list[i].Code, list[i].Name)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list})
|
||||
}
|
||||
|
||||
func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
|
||||
@@ -159,10 +172,10 @@ func (r *Router) geoSetAppOverride(w http.ResponseWriter, req *http.Request, app
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás beállítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_set")})
|
||||
}
|
||||
|
||||
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, appName string) {
|
||||
func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, req *http.Request, appName string) {
|
||||
if appName == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid app name"})
|
||||
return
|
||||
@@ -185,5 +198,5 @@ func (r *Router) geoRemoveAppOverride(w http.ResponseWriter, _ *http.Request, ap
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Alkalmazás geo-korlátozás eltávolítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.geo.app_removed")})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// ── The JSON answers follow the language too (v0.252.0, slice 2 — R-557) ───────────────────────
|
||||
//
|
||||
// Design: felhom.eu/documentation/architecture/10-localisation.md. The dashboard's page markup has
|
||||
// followed the household's language since slice 1, but a large part of what a customer READS on
|
||||
// those pages never passes through a template: the page's own script fetches /api/… and shows the
|
||||
// `message` or `error` the answer carries. Until now those were Hungarian literals, so an English
|
||||
// dashboard answered in Hungarian the moment anything was clicked.
|
||||
//
|
||||
// These helpers mirror internal/web's (i18n_web.go) exactly, and for the same reason: the Hungarian
|
||||
// text is the SAME BYTES the literal carried, pinned by scripts/i18n_go_parity.py against a frozen
|
||||
// capture of the base commit.
|
||||
//
|
||||
// NOT everything here is display. `internal/report` and `internal/notify` put Hungarian on the wire,
|
||||
// where it is read by the hub and mailed to the household — those stay Hungarian in every language
|
||||
// until slice 3 (R-558) teaches the hub which language to compose in, and their bytes are pinned by
|
||||
// the wire goldens in those packages.
|
||||
|
||||
// langFor decides the language of one API request: `?lang=hu|en` (a testing override, never
|
||||
// persisted), else the household's saved setting, else Hungarian. Identical in meaning to
|
||||
// web.Server.langFor, deliberately — the page and the calls its script makes must never disagree.
|
||||
func (r *Router) langFor(req *http.Request) string {
|
||||
if req != nil {
|
||||
if q := req.URL.Query().Get("lang"); i18n.IsSupported(q) {
|
||||
return q
|
||||
}
|
||||
}
|
||||
if r.sett != nil {
|
||||
return r.sett.GetLanguage()
|
||||
}
|
||||
return i18n.Default
|
||||
}
|
||||
|
||||
// bundle returns the process-wide bundle, or nil if it somehow failed to load. It is embedded in the
|
||||
// binary, so a running box cannot reach the nil case; the callers still degrade to the key rather
|
||||
// than panicking.
|
||||
func bundle() *i18n.Bundle {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// msg returns a message in the request's language, with the message's own printf verbs filled in.
|
||||
func (r *Router) msg(req *http.Request, key string, a ...interface{}) string {
|
||||
return r.msgLang(r.langFor(req), key, a...)
|
||||
}
|
||||
|
||||
// msgLang is msg for a language that is already known.
|
||||
func (r *Router) msgLang(lang, key string, a ...interface{}) string {
|
||||
b := bundle()
|
||||
if b == nil {
|
||||
return key
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return b.Msg(lang, key)
|
||||
}
|
||||
return b.Msgf(lang, key, a...)
|
||||
}
|
||||
|
||||
// countryName returns a country's name in lang, falling back to the name the cloudflare table
|
||||
// carries when the bundle has no entry for the code.
|
||||
//
|
||||
// The TABLE is not translated and must not be: internal/report puts country CODES on the wire, and
|
||||
// cloudflare/countries.go is also what validates them. Only the NAME shown in the geo picker follows
|
||||
// the language, which is why this lives here and not in that package.
|
||||
func (r *Router) countryName(lang, code, fallback string) string {
|
||||
b := bundle()
|
||||
if b == nil {
|
||||
return fallback
|
||||
}
|
||||
if v, _, ok := b.Text(lang, "country."+code); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Localisation slice 2 (R-557), scenario S2 — the JSON the page's own script reads follows the
|
||||
// household's language, and the Hungarian answer is byte-identical to what it was.
|
||||
//
|
||||
// This is the half of the dashboard a template test cannot see. Since slice 1 the PAGE has been
|
||||
// English, but every sentence that arrives by fetch — a refused deploy, a start blocked for memory,
|
||||
// "the backup has started" — was a Hungarian literal in internal/api. An English household clicked an
|
||||
// English button and was answered in Hungarian.
|
||||
//
|
||||
// The Hungarian side is a parity measurement, not a translation check: the `want` strings below are
|
||||
// the literals that stood at the call sites at 736f54b49610.
|
||||
|
||||
func langRouter(t *testing.T, lang string) *Router {
|
||||
t.Helper()
|
||||
lg := log.New(io.Discard, "", 0)
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lang != "" {
|
||||
if err := sett.SetLanguage(lang); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &Router{sett: sett, logger: lg}
|
||||
}
|
||||
|
||||
func TestAPIMessagesFollowTheHouseholdLanguage(t *testing.T) {
|
||||
cases := []struct {
|
||||
key, hu, en string
|
||||
args []interface{}
|
||||
}{
|
||||
{key: "api.deploy.started",
|
||||
hu: "Telepítés elindítva – az állapot a kártyán követhető",
|
||||
en: "The install has started – the card shows how it is going"},
|
||||
{key: "api.backup.already_running",
|
||||
hu: "Mentés már folyamatban", en: "A backup is already running"},
|
||||
{key: "api.start.drive_missing", args: []interface{}{"Kulso HDD"},
|
||||
hu: "A(z) Kulso HDD tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.",
|
||||
en: "Storage Kulso HDD cannot be reached right now — the app cannot start until the drive is back."},
|
||||
{key: "api.start.not_enough_memory", args: []interface{}{512, 128, 1400, 1528},
|
||||
hu: "Nincs elég memória az indításhoz. Szükséges: 512 MB, elérhető: 128 MB (használt: 1400 MB / használható: 1528 MB)",
|
||||
en: "There is not enough memory to start. Needs 512 MB, 128 MB is free (1400 MB used of 1528 MB usable)"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.key, func(t *testing.T) {
|
||||
// hu: the saved setting, no query override — the ordinary household.
|
||||
r := langRouter(t, "hu")
|
||||
req := httptest.NewRequest("POST", "/api/stacks/x/start", nil)
|
||||
if got := r.msg(req, c.key, c.args...); got != c.hu {
|
||||
t.Errorf("Hungarian answer moved (parity)\n got %q\n want %q", got, c.hu)
|
||||
}
|
||||
// en: the same request shape on a box switched to English.
|
||||
r = langRouter(t, "en")
|
||||
if got := r.msg(req, c.key, c.args...); got != c.en {
|
||||
t.Errorf("English answer\n got %q\n want %q", got, c.en)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPILangForPrecedence — `?lang=` overrides the saved setting (the testing door slice 0 built),
|
||||
// an unsupported value is ignored rather than honoured, and a box with no setting answers Hungarian.
|
||||
func TestAPILangForPrecedence(t *testing.T) {
|
||||
cases := []struct{ saved, query, want string }{
|
||||
{"hu", "", "hu"},
|
||||
{"en", "", "en"},
|
||||
{"hu", "en", "en"},
|
||||
{"en", "hu", "hu"},
|
||||
{"en", "de", "en"}, // unsupported: ignored, the saved setting stands
|
||||
{"", "", i18n.Default},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := langRouter(t, c.saved)
|
||||
url := "/api/stacks"
|
||||
if c.query != "" {
|
||||
url += "?lang=" + c.query
|
||||
}
|
||||
if got := r.langFor(httptest.NewRequest("GET", url, nil)); got != c.want {
|
||||
t.Errorf("saved=%q query=%q: got %q, want %q", c.saved, c.query, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeoCountryNamesFollowTheLanguage — the picker's 237 country names. The cloudflare TABLE is not
|
||||
// translated (it validates codes, and internal/report puts codes on the wire); only the displayed
|
||||
// name is, and the list is re-sorted afterwards because alphabetical order is per language.
|
||||
func TestGeoCountryNamesFollowTheLanguage(t *testing.T) {
|
||||
r := langRouter(t, "hu")
|
||||
for _, c := range []struct{ lang, code, want string }{
|
||||
{"hu", "DE", "Németország"},
|
||||
{"en", "DE", "Germany"},
|
||||
{"hu", "US", "Egyesült Államok"},
|
||||
{"en", "US", "United States"},
|
||||
{"hu", "AO", "Angola"}, // identical in both languages — must still resolve, not fall through
|
||||
{"en", "AO", "Angola"},
|
||||
} {
|
||||
if got := r.countryName(c.lang, c.code, "FALLBACK"); got != c.want {
|
||||
t.Errorf("%s/%s: got %q, want %q", c.lang, c.code, got, c.want)
|
||||
}
|
||||
}
|
||||
// A code the bundle does not carry keeps the table's own name rather than showing a key.
|
||||
if got := r.countryName("en", "ZZ", "Ismeretlen"); got != "Ismeretlen" {
|
||||
t.Errorf("an unknown code must keep the table name, got %q", got)
|
||||
}
|
||||
// And the English list really is sorted by the English names: „Németország" sorts under N in
|
||||
// Hungarian and Germany under G, so the two orders must differ.
|
||||
if strings.EqualFold(r.countryName("hu", "DE", ""), r.countryName("en", "DE", "")) {
|
||||
t.Error("the country names did not change with the language at all")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@@ -77,21 +78,38 @@ func TestDeployStackWiresTheLifecycleGate(t *testing.T) {
|
||||
|
||||
// TestLifecycleRefusalMessageIsCustomerFacingHungarian: the refusal text reaches the customer via
|
||||
// showAlert(), so it must be the sentence the spec ruled, not a Go error string.
|
||||
//
|
||||
// v0.252.0 (R-557) moved the sentence into the message bundle, so router.go now names a KEY. The
|
||||
// ruling is unchanged and still checkable in one step more: router.go must name that key, and the
|
||||
// key must still carry the ruled sentence, byte for byte. Both halves matter — a key with no handler
|
||||
// is dead copy, and a handler naming a key whose text was reworded is the drift this test exists to
|
||||
// catch. scripts/i18n_go_parity.py proves the same equality against the base commit independently.
|
||||
func TestLifecycleRefusalMessageIsCustomerFacingHungarian(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fset, "router.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "api.deploy.not_installable"
|
||||
const want = "Ez az alkalmazás jelenleg nem telepíthető."
|
||||
found := false
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, want) {
|
||||
if lit, ok := n.(*ast.BasicLit); ok && lit.Kind == token.STRING && strings.Contains(lit.Value, key) {
|
||||
found = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatalf("the ruled refusal message %q is not present in router.go", want)
|
||||
t.Fatalf("router.go no longer names %q -- the ruled refusal has no handler", key)
|
||||
}
|
||||
b, err := i18n.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := b.Msg(i18n.Default, key); got != want {
|
||||
t.Fatalf("the ruled refusal was reworded\n hu.json %s = %q\n ruled %q", key, got, want)
|
||||
}
|
||||
if en := b.Msg("en", key); en == "" || en == want {
|
||||
t.Errorf("%s has no English of its own: %q", key, en)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, s
|
||||
func (r *Router) SetRestarter(fn func()) { r.restart = fn }
|
||||
|
||||
// refuseNetworkStubDeploy is the deploy-time stub gate (RCA fix 2). Non-empty return = the
|
||||
// Hungarian refusal for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
||||
// local stub. Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
||||
// refusal KEY for a registered NETWORK HDD_PATH whose filesystem in THIS namespace is a
|
||||
// local stub; the caller renders it in the request's language (v0.252.0, R-557). Everything else proceeds: idle autofs is HEALTHY (first app access mounts it);
|
||||
// classification timeout/unknown fails OPEN (a wedged share is the unreachable badge's business);
|
||||
// local (non-network) and empty paths keep today's behavior exactly.
|
||||
func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
||||
@@ -120,7 +120,7 @@ func (r *Router) refuseNetworkStubDeploy(hdd string) string {
|
||||
if r.classifyFSPath(hdd) != system.FSClassStub {
|
||||
return ""
|
||||
}
|
||||
return "A kiválasztott hálózati tárhely jelenleg nem érhető el az alkalmazások környezetéből — a telepítés nem indítható. Próbálja újra pár perc múlva, vagy jelezze az üzemeltetőnek."
|
||||
return "api.deploy.netstorage_unreachable"
|
||||
}
|
||||
|
||||
// SetReportPushTrigger wires the out-of-band hub report push used after geo changes.
|
||||
@@ -223,19 +223,19 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// POST /api/stacks/{name}/start
|
||||
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "start", extractName(path, "/start"))
|
||||
r.actionStack(w, req, "start", extractName(path, "/start"))
|
||||
|
||||
// POST /api/stacks/{name}/stop
|
||||
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "stop", extractName(path, "/stop"))
|
||||
r.actionStack(w, req, "stop", extractName(path, "/stop"))
|
||||
|
||||
// POST /api/stacks/{name}/restart
|
||||
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "restart", extractName(path, "/restart"))
|
||||
r.actionStack(w, req, "restart", extractName(path, "/restart"))
|
||||
|
||||
// POST /api/stacks/{name}/update
|
||||
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
|
||||
r.actionStack(w, "update", extractName(path, "/update"))
|
||||
r.actionStack(w, req, "update", extractName(path, "/update"))
|
||||
|
||||
// POST /api/stacks/{name}/optional-config
|
||||
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
|
||||
@@ -441,7 +441,7 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: lifecycle=%s (not offered for new installs)",
|
||||
name, st.Meta.EffectiveLifecycle())
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false,
|
||||
Error: "Ez az alkalmazás jelenleg nem telepíthető."})
|
||||
Error: r.msg(req, "api.deploy.not_installable")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -452,9 +452,8 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
if hr := system.GetDockerVolumeHeadroom(); hr.OK && hr.BelowReserve {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: Docker volume below reserved buffer (%.1fG free, reserve %.1fG of %.0fG)",
|
||||
name, hr.AvailGB, hr.ReserveGB, hr.TotalGB)
|
||||
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false, Error: fmt.Sprintf(
|
||||
"Nincs elég szabad tárhely a telepítéshez: csak %.0f GB szabad, és a rendszer %.0f GB tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében. Szabadítson fel helyet, vagy bővítse a tárhelyet.",
|
||||
hr.AvailGB, hr.ReserveGB)})
|
||||
writeJSON(w, http.StatusInsufficientStorage, apiResponse{OK: false,
|
||||
Error: r.msg(req, "api.deploy.no_space", hr.AvailGB, hr.ReserveGB)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -462,9 +461,9 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
// must see a network filesystem (or its healthy idle autofs trigger) in THIS namespace — the one
|
||||
// the app will consume the path in. A stub (plain local dir after a guest reboot) would silently
|
||||
// send the app's data to the system drive.
|
||||
if msg := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); msg != "" {
|
||||
if key := r.refuseNetworkStubDeploy(body.Values["HDD_PATH"]); key != "" {
|
||||
r.logger.Printf("[WARN] [api] Deploy refused for %s: network HDD_PATH %s is a stub in the controller namespace", name, body.Values["HDD_PATH"])
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: msg})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, key)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -499,7 +498,7 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
|
||||
// polls GET /api/stacks/{name}). The old "Stack X deployed" message asserted completion before it
|
||||
// was true — misleading for API/script consumers. Report that the deploy STARTED, not that it
|
||||
// finished. 202 Accepted reflects "accepted, processing"; ok:true is preserved for the UI.
|
||||
resp := apiResponse{OK: true, Message: "Telepítés elindítva – az állapot a kártyán követhető"}
|
||||
resp := apiResponse{OK: true, Message: r.msg(req, "api.deploy.started")}
|
||||
if warning != "" {
|
||||
resp.Data = map[string]string{"warning": warning}
|
||||
}
|
||||
@@ -579,7 +578,7 @@ func desiredStateForAction(action string) (string, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, name string) {
|
||||
r.logger.Printf("[INFO] [api] %s requested for stack: %s", action, name)
|
||||
r.dbg("actionStack: action=%s name=%s", action, name)
|
||||
|
||||
@@ -610,7 +609,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
if gated, hdd := r.startGatedByMissingDrive(name); gated {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{
|
||||
OK: false,
|
||||
Error: fmt.Sprintf("A(z) %s tárhely jelenleg nem elérhető — az alkalmazás nem indítható, amíg a meghajtó vissza nem csatlakozik.", hdd),
|
||||
Error: r.msg(req, "api.start.drive_missing", hdd),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -630,7 +629,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
if afterMB > usableMB {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{
|
||||
OK: false,
|
||||
Error: fmt.Sprintf("Nincs elég memória az indításhoz. Szükséges: %d MB, elérhető: %d MB (használt: %d MB / használható: %d MB)", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
||||
Error: r.msg(req, "api.start.not_enough_memory", stackMemMB, usableMB-usedMB, usedMB, usableMB),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -666,7 +665,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
r.logger.Printf("[ERROR] [api] %s for %s refused: could not record desired state: %v", action, name, derr)
|
||||
writeJSON(w, http.StatusInternalServerError, apiResponse{
|
||||
OK: false,
|
||||
Error: "A művelet nem hajtható végre: az alkalmazás beállításai nem menthetők.",
|
||||
Error: r.msg(req, "api.action.state_not_saved"),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -708,7 +707,7 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
|
||||
// finished — and "completed" exists only as update_phase=done on GET /api/stacks/{name}, which is
|
||||
// written after the app's health is known. Pinned by TestR443_UpdateIsNeverReportedCompleteSynchronously.
|
||||
if action == "update" {
|
||||
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: "Frissítés elindult – az állapot a kártyán követhető",
|
||||
writeJSON(w, http.StatusAccepted, apiResponse{OK: true, Message: r.msg(req, "api.update.started"),
|
||||
Data: map[string]interface{}{"accepted": true, "completed": false}})
|
||||
return
|
||||
}
|
||||
@@ -743,7 +742,7 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request,
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.settings.updated")})
|
||||
}
|
||||
|
||||
// --- Integration API handlers ---
|
||||
@@ -788,9 +787,9 @@ func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, pro
|
||||
return
|
||||
}
|
||||
|
||||
msg := "Integráció engedélyezve"
|
||||
msg := r.msg(req, "api.integration.enabled")
|
||||
if !body.Enabled {
|
||||
msg = "Integráció kikapcsolva"
|
||||
msg = r.msg(req, "api.integration.disabled")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg})
|
||||
}
|
||||
@@ -1117,7 +1116,7 @@ func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
|
||||
// moved to the host agent (slice 8C).
|
||||
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) triggerBackup(w http.ResponseWriter, req *http.Request) {
|
||||
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
|
||||
if r.backupMgr == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
||||
@@ -1126,7 +1125,7 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
if r.backupMgr.IsRunning() {
|
||||
r.dbg("triggerBackup: backup already running, rejecting")
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1134,23 +1133,23 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
|
||||
r.backupMgr.MarkManualRun() // R-182: operator-triggered — its digest must not be collapsed into the nightly one
|
||||
go r.backupMgr.RunDBDumps(context.Background())
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.started")})
|
||||
}
|
||||
|
||||
// triggerTier2 runs the off-drive Tier 2 copies for all HDD apps (recovery unit + userdata to a
|
||||
// different physical disk). Auto-targets and applies the rootfs-headroom guard internally.
|
||||
func (r *Router) triggerTier2(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) triggerTier2(w http.ResponseWriter, req *http.Request) {
|
||||
if r.backupMgr == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
|
||||
return
|
||||
}
|
||||
if r.backupMgr.IsRunning() {
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
|
||||
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.msg(req, "api.backup.already_running")})
|
||||
return
|
||||
}
|
||||
r.logger.Println("[INFO] [api] Manual Tier 2 (off-drive) backup triggered")
|
||||
go r.backupMgr.RunAllTier2()
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "2. mentés elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.backup.tier2_started")})
|
||||
}
|
||||
|
||||
// --- Metrics handlers ---
|
||||
@@ -1337,7 +1336,7 @@ func (r *Router) selfupdateCheck(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: result})
|
||||
}
|
||||
|
||||
func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
|
||||
func (r *Router) selfupdateTrigger(w http.ResponseWriter, req *http.Request) {
|
||||
if r.updater == nil {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Self-update not configured"})
|
||||
return
|
||||
@@ -1347,7 +1346,7 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
r.logger.Println("[INFO] [api] Manual self-update triggered")
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Frissítés elindítva"})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.update.kicked_off")})
|
||||
}
|
||||
|
||||
// --- Config apply handler ---
|
||||
@@ -1377,7 +1376,7 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
||||
// idempotently, and a self-restart on every push would be a needless flap.
|
||||
if prior, rerr := os.ReadFile(r.configPath); rerr == nil && bytes.Equal(prior, body) {
|
||||
r.logger.Printf("[INFO] [api] Config apply: identical to current config (%d bytes) — no change, no restart", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "A konfiguráció változatlan — nincs szükség újraindításra."})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.unchanged")})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1394,7 +1393,7 @@ func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
|
||||
// effect on restart — singletons such as the Cloudflare client are built once at
|
||||
// startup, so an in-process write alone would leave e.g. a rotated CF token unused.
|
||||
r.logger.Printf("[INFO] [api] Config applied from Hub (%d bytes) — self-restarting to take effect", len(body))
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Konfiguráció alkalmazva — a vezérlő újraindul."})
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: r.msg(req, "api.config.applied")})
|
||||
flushResponse(w)
|
||||
if r.restart != nil {
|
||||
r.restart()
|
||||
|
||||
@@ -97,7 +97,7 @@ func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards,
|
||||
func postUpdate(t *testing.T, r *Router) (int, apiResponse) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.actionStack(w, "update", "app")
|
||||
r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "app")
|
||||
var resp apiResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("non-JSON body %q: %v", w.Body.String(), err)
|
||||
|
||||
Reference in New Issue
Block a user