v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
gates / gates (push) Successful in 24s
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. 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:
@@ -108,13 +108,13 @@ func writeDiskJSON(w http.ResponseWriter, status int, ok bool, errMsg string, da
|
||||
func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.Disks(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk list via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// Deterministic order: the agent's storage view iterates a Go map (unordered), so the list would
|
||||
@@ -141,13 +141,13 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.ListCandidates(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk candidates via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores()))
|
||||
@@ -225,12 +225,12 @@ func (s *Server) agentDiskAssignHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if err := client.AssignDisk(r.Context(), req.UUID, req.Where, req.FSType, req.Options); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk assign via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]interface{}{
|
||||
@@ -253,13 +253,13 @@ func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.EjectDisk(r.Context(), req.Where)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk eject via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", resp)
|
||||
@@ -285,7 +285,7 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID)
|
||||
@@ -301,7 +301,7 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk format via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", resp)
|
||||
|
||||
@@ -28,13 +28,13 @@ func (s *Server) ServeHostMetricsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
// Unprovisioned guest / no local API configured — the UI shows "host metrics unavailable".
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.HostMetrics(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] host metrics via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// The agent enumerates storages via `pvesm` in a non-deterministic order, so #host-storage-bars
|
||||
|
||||
@@ -176,9 +176,9 @@ func buildDeadAppAlerts(dead []DeadApp) []Alert {
|
||||
if name == "" {
|
||||
name = d.Name
|
||||
}
|
||||
key, args := "alert.deadapp.one", []interface{}{name}
|
||||
key, args := "alert.deadapp.single", []interface{}{name}
|
||||
if d.State != "" {
|
||||
key, args = "alert.deadapp.one_state", []interface{}{name, d.State}
|
||||
key, args = "alert.deadapp.single_state", []interface{}{name, d.State}
|
||||
}
|
||||
alerts = append(alerts, Alert{
|
||||
ID: "deadapp-" + simpleHash(d.Name),
|
||||
|
||||
@@ -307,7 +307,7 @@ func (s *Server) handleBackupTriggerAPI(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[ERROR] [web] backup trigger failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] manual whole-guest backup triggered (quiesce loop)")
|
||||
@@ -318,12 +318,12 @@ func (s *Server) handleBackupTriggerAPI(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) handleBackupStatusAPI(w http.ResponseWriter, r *http.Request) {
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
st, err := client.BackupStatus(r.Context())
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
||||
|
||||
@@ -59,9 +59,11 @@ func tier3State(configured, toggled bool, escrowState string) string {
|
||||
//
|
||||
// It is pure, and it takes tier3State's OWN vocabulary rather than re-deriving the state, so the row
|
||||
// and the sentence can never disagree:
|
||||
// "active" → the copy exists and runs → „védi"
|
||||
// "escrow_pending" → enabled, paused for the code → „védené … szünetel" + the route to fix it
|
||||
// "off"/"unconfig" → no off-site at all → say so, and name both ways out
|
||||
//
|
||||
// "active" → the copy exists and runs → „védi"
|
||||
// "escrow_pending" → enabled, paused for the code → „védené … szünetel" + the route to fix it
|
||||
// "off"/"unconfig" → no off-site at all → say so, and name both ways out
|
||||
//
|
||||
// The link is returned separately so the template renders a real anchor and the copy gate sees plain
|
||||
// text; empty note means render nothing (an app whose data is in its volumes needs no sentence).
|
||||
func driveFilesNoteFor(hasDriveFileLegs bool, tier3State string, tier2Configured bool) (note, linkHref, linkText string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errAgentUnreadable = errors.New("a meghajtók listája nem olvasható")
|
||||
errDriveNotFound = errors.New("a meghajtó nem található")
|
||||
errAgentUnreadable = util.MsgError("err.web.a_meghajtok_listaja_nem_olvashato")
|
||||
errDriveNotFound = util.MsgError("err.web.a_meghajto_nem_talalhato")
|
||||
)
|
||||
|
||||
// BackupTargetState is the customer-facing answer to "does the whole-system backup survive a disk
|
||||
@@ -283,13 +283,13 @@ func (s *Server) handleBackupTargetAssign(w http.ResponseWriter, r *http.Request
|
||||
// that register/attach already navigate.
|
||||
raw, err := s.rawMountForStable(r.Context(), agent, stable)
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
res, err := agent.SetBackupTarget(r.Context(), raw)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] [web] backup-target assign %s (raw %s): %v", stable, raw, err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, "a mentési cél beállítása nem sikerült: "+err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, "a mentési cél beállítása nem sikerült: "+s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// Record the INTENT only after the agent accepted, so our flag can never claim a target the agent
|
||||
|
||||
@@ -34,8 +34,8 @@ func splitTestData() map[string]interface{} {
|
||||
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: true, Enabled: true},
|
||||
},
|
||||
"OffsiteStoreState": string(offsiteStoreKnown),
|
||||
"OffboxQuotaPct": 0,
|
||||
"GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"},
|
||||
"OffboxQuotaPct": 0,
|
||||
"GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ func TestClaimPage_BothBranchesNameTheSameSecretTheSameWay(t *testing.T) {
|
||||
html := claimPageHTML(t, isReset)
|
||||
|
||||
if strings.Contains(html, "isszaállító kód") {
|
||||
t.Errorf("[%s branch] the retired name „Visszaállító kód" +
|
||||
t.Errorf("[%s branch] the retired name „Visszaállító kód"+
|
||||
"\" is still on the page — it collides with the escrow „Helyreállítási kód", branch)
|
||||
}
|
||||
if !strings.Contains(html, "eállító kód") {
|
||||
t.Errorf("[%s branch] the page no longer names the secret „Beállító kód" +
|
||||
t.Errorf("[%s branch] the page no longer names the secret „Beállító kód"+
|
||||
"\" at all", branch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,12 +655,12 @@ func TestDiskAlertDecision_Table(t *testing.T) {
|
||||
{"escalate Warn→Fail", &diskRecord{Verdict: int(agentapi.DiskVerdictWarn), Alerted: true,
|
||||
AlertedVerdict: int(agentapi.DiskVerdictWarn), AlertedAt: base}, agentapi.DiskVerdictFail, 8, true, false},
|
||||
{"steady Fail, no growth, no time", alertedFail(352, time.Hour), agentapi.DiskVerdictFail, 352, false, false},
|
||||
{"exactly 2x but only 23h", alertedFail(64, 23 * time.Hour), agentapi.DiskVerdictFail, 128, false, false},
|
||||
{"exactly 2x at exactly 24h", alertedFail(64, 24 * time.Hour), agentapi.DiskVerdictFail, 128, true, true},
|
||||
{"25h but only 1.9x", alertedFail(64, 25 * time.Hour), agentapi.DiskVerdictFail, 121, false, false},
|
||||
{"improved Fail→Warn is silent", alertedFail(352, 48 * time.Hour), agentapi.DiskVerdictWarn, 8, false, false},
|
||||
{"exactly 2x but only 23h", alertedFail(64, 23*time.Hour), agentapi.DiskVerdictFail, 128, false, false},
|
||||
{"exactly 2x at exactly 24h", alertedFail(64, 24*time.Hour), agentapi.DiskVerdictFail, 128, true, true},
|
||||
{"25h but only 1.9x", alertedFail(64, 25*time.Hour), agentapi.DiskVerdictFail, 121, false, false},
|
||||
{"improved Fail→Warn is silent", alertedFail(352, 48*time.Hour), agentapi.DiskVerdictWarn, 8, false, false},
|
||||
{"Fail from heat (0 sectors) never re-alerts on the doubling rule",
|
||||
alertedFail(0, 48 * time.Hour), agentapi.DiskVerdictFail, 0, false, false},
|
||||
alertedFail(0, 48*time.Hour), agentapi.DiskVerdictFail, 0, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
emit, worsened := diskAlertDecision(c.prev, c.v, c.sectors, base)
|
||||
|
||||
@@ -385,7 +385,7 @@ func (s *Server) debugTestEvent(w http.ResponseWriter, r *http.Request) {
|
||||
statusCode, err := s.notifier.PushTestEventSync(req.EventType, req.Severity,
|
||||
fmt.Sprintf("Teszt esemény: %s (%s)", req.EventType, req.Severity))
|
||||
if err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{
|
||||
"hub_status": statusCode,
|
||||
})
|
||||
return
|
||||
@@ -579,7 +579,7 @@ func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.debugCallbacks.TriggerHubReportPush()
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{"latency_ms": latency})
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{"latency_ms": latency})
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true, "Hub jelentés elküldve",
|
||||
@@ -597,7 +597,7 @@ func (s *Server) debugHubConnectivity(w http.ResponseWriter, r *http.Request) {
|
||||
"latency_ms": latency,
|
||||
}
|
||||
if err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), data)
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), data)
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true,
|
||||
@@ -611,7 +611,7 @@ func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
prefs := s.settings.GetNotificationPrefs()
|
||||
if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), nil)
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true, "Preferenciák szinkronizálva", nil)
|
||||
@@ -628,7 +628,7 @@ func (s *Server) debugGiteaConnectivity(w http.ResponseWriter, r *http.Request)
|
||||
"latency_ms": latency,
|
||||
}
|
||||
if err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), data)
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), data)
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true,
|
||||
@@ -646,7 +646,7 @@ func (s *Server) debugTelemetry(w http.ResponseWriter, r *http.Request) {
|
||||
telemetry, err := s.debugCallbacks.GetTelemetryPreview()
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{"latency_ms": latency})
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{"latency_ms": latency})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -776,7 +776,7 @@ func (s *Server) debugAgentLogs(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), nil)
|
||||
writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{
|
||||
|
||||
@@ -152,7 +152,7 @@ func (s *Server) apiExportEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Export estimate failed for %s: %v", stackName, err)
|
||||
s.logger.Printf("[DEBUG] [web] apiExportEstimate error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
jsonError(w, s.errText(r, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Export start failed for %s: %v", req.StackName, err)
|
||||
s.logger.Printf("[DEBUG] [web] apiExportStart error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusConflict)
|
||||
jsonError(w, s.errText(r, err), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ func (s *Server) apiExportManifest(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err != nil {
|
||||
s.logger.Printf("[DEBUG] [web] apiExportManifest: error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||
jsonError(w, s.errText(r, err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ func (s *Server) apiImportStart(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Import start failed for %s: %v", req.Path, err)
|
||||
s.logger.Printf("[DEBUG] [web] apiImportStart error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusConflict)
|
||||
jsonError(w, s.errText(r, err), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ func (s *Server) apiExportDownloadEstimate(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
jsonError(w, s.errText(r, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
est, err := s.appExporter.EstimateExport(stackName, s.fabDownloadRoot())
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] download-export estimate failed for %s: %v", stackName, err)
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
jsonError(w, s.errText(r, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonResponse(w, map[string]interface{}{"ok": true, "data": est})
|
||||
@@ -95,7 +95,7 @@ func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request)
|
||||
// Opportunistic TTL sweep so an abandoned bundle never outlives fabDownloadTTL by much.
|
||||
sweepFabDownloads(s.fabDownloadDir(), time.Now(), fabDownloadTTL, s.logger)
|
||||
if err := os.MkdirAll(s.fabDownloadDir(), 0755); err != nil {
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
jsonError(w, s.errText(r, err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// A concurrent export/import gets the exporter's own busy answer (single-flight).
|
||||
@@ -108,7 +108,7 @@ func (s *Server) apiExportDownloadStart(w http.ResponseWriter, r *http.Request)
|
||||
OptInExcluded: req.OptInExcluded,
|
||||
}); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] download-export start failed for %s: %v", req.StackName, err)
|
||||
jsonError(w, err.Error(), http.StatusConflict)
|
||||
jsonError(w, s.errText(r, err), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] download-export started for %s (staging dir)", req.StackName)
|
||||
|
||||
@@ -15,7 +15,7 @@ type m = map[string]interface{}
|
||||
func i18nBackupStatus(running bool, withResults bool) m {
|
||||
at := i18nFixtureTime()
|
||||
b := m{"DBDumpSchedule": "02:30", "NextDBDump": at, "Running": running,
|
||||
"AppDataInfo": []m{{"StackName": "privatebin"}},
|
||||
"AppDataInfo": []m{{"StackName": "privatebin"}},
|
||||
"DiscoveredDBs": []m{{"StackName": "kimai"}},
|
||||
}
|
||||
if withResults {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -163,3 +164,56 @@ func flashSentence(t *testing.T, location string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestPluralEnglish — localisation slice 2 release B (R-557), scenario S4.
|
||||
//
|
||||
// English needs two forms where the noun or the verb changes with the count; Hungarian does not
|
||||
// inflect after a numeral and therefore has ONE form, which is the form it already had. So this test
|
||||
// asserts two different things about the same message: that English changes with the count, and that
|
||||
// Hungarian does NOT — the second is the parity half, and it is the one that would fail silently.
|
||||
//
|
||||
// It goes through the real producer (buildDeadAppAlerts → GetAlerts), not through the bundle, so it
|
||||
// also pins that the alert path carries the count as its first parameter, which is what the plural
|
||||
// rule in i18n.Bundle.form depends on.
|
||||
func TestPluralEnglish(t *testing.T) {
|
||||
dead := func(n int) []DeadApp {
|
||||
out := make([]DeadApp, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, DeadApp{Name: "app" + strconv.Itoa(i), DisplayName: "App " + strconv.Itoa(i)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Above deadAppGroupThreshold the banner collapses to ONE counted sentence — that is the message
|
||||
// with a count in it. 4 and 7 are both "other"; there is no "one" case for the grouped banner by
|
||||
// construction, so the singular is checked on the bundle directly below.
|
||||
am := NewAlertManager(nil)
|
||||
am.SetDeadAppAlerts(dead(4))
|
||||
hu4 := am.GetAlerts("hu")[0].Message
|
||||
en4 := am.GetAlerts("en")[0].Message
|
||||
am.SetDeadAppAlerts(dead(7))
|
||||
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" {
|
||||
t.Errorf("the Hungarian sentence moved: %q", hu4)
|
||||
}
|
||||
if hu7 != "7 telepített alkalmazás nem fut — nézze meg a rendszermonitort" {
|
||||
t.Errorf("the Hungarian sentence moved: %q", hu7)
|
||||
}
|
||||
if !strings.Contains(en4, "4 installed apps are not running") {
|
||||
t.Errorf("English plural: %q", en4)
|
||||
}
|
||||
if !strings.Contains(en7, "7 installed apps are not running") {
|
||||
t.Errorf("English plural: %q", en7)
|
||||
}
|
||||
|
||||
// The singular, straight off the bundle: same key, count 1.
|
||||
b := flashServer(t).i18n
|
||||
if got := b.Msgf("en", "alert.deadapp.group", 1); !strings.Contains(got, "1 installed app is not running") {
|
||||
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" {
|
||||
t.Errorf("Hungarian must have one form at every count: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// ── i18n (v0.247.0) — the dashboard in more than one language ──────────────────────────────────
|
||||
@@ -80,6 +81,27 @@ func (s *Server) msgN(r *http.Request, key string, n int) string {
|
||||
return b.Plural(s.langFor(r), key, n)
|
||||
}
|
||||
|
||||
// ── Errors on a page (v0.253.0, slice 2 release B) ─────────────────────────────────────────────
|
||||
//
|
||||
// An error is MADE deep in a package that has no request, and PRINTED by a handler that has one.
|
||||
// util.MsgError carries the key across that gap; errText is the handler side of it.
|
||||
//
|
||||
// A plain error — restic, docker, ssh, the Go stdlib — prints verbatim, in both languages, because
|
||||
// that sentence is not ours (10-localisation.md §9). So this is safe to put in front of EVERY
|
||||
// err.Error() on a display path, converted or not, and that is exactly what release B does.
|
||||
|
||||
// errText renders an error in the request's language: its bundle message when it carries one, its own
|
||||
// text otherwise.
|
||||
func (s *Server) errText(r *http.Request, err error) string {
|
||||
return util.ErrText(s.langFor(r), err)
|
||||
}
|
||||
|
||||
// errTextLang is errText for a language already resolved (a background run, or a handler that
|
||||
// resolved it once).
|
||||
func (s *Server) errTextLang(lang string, err error) string {
|
||||
return util.ErrText(lang, err)
|
||||
}
|
||||
|
||||
// ── Flash lines (v0.252.0) ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A flash travels to the page INSIDE THE REDIRECT URL (`?flash=…`), so it is rendered by a DIFFERENT
|
||||
@@ -93,6 +115,21 @@ func (s *Server) msgN(r *http.Request, key string, n int) string {
|
||||
// shown verbatim, exactly as it was before. That also covers a hand-typed `?flash=<script>` — which
|
||||
// html/template escapes, as it always did.
|
||||
|
||||
// flashForErr turns an error into what a flash carries: its bundle KEY plus its parameters when it
|
||||
// has one, and its own text otherwise.
|
||||
//
|
||||
// The second half is what makes this safe to use everywhere: a restic, docker or ssh sentence goes
|
||||
// into the URL as text and is shown verbatim by flashText, exactly as it was before v0.252.0.
|
||||
func flashForErr(err error) (string, []string) {
|
||||
if err == nil {
|
||||
return "", nil
|
||||
}
|
||||
if m, ok := util.AsMsg(err); ok {
|
||||
return m.Key(), m.Args()
|
||||
}
|
||||
return err.Error(), nil
|
||||
}
|
||||
|
||||
// flashArgParam is the repeated query parameter carrying a flash message's parameters, in order.
|
||||
const flashArgParam = "fa"
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ func (s *Server) handleStorageDisconnect(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
if err := s.settings.SetDisconnected(where, true, stopped); err != nil {
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
go s.SyncFileBrowserMounts()
|
||||
@@ -581,7 +581,7 @@ func (s *Server) handleStorageReconnect(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
if decommissioned {
|
||||
if _, cerr := s.reEnrollClearMarker(where); cerr != nil {
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, cerr.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, s.errText(r, cerr), nil)
|
||||
return
|
||||
}
|
||||
// decommission-anyway stopped the apps WITHOUT persisting StoppedStacks — re-discover them.
|
||||
@@ -589,16 +589,16 @@ func (s *Server) handleStorageReconnect(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if aerr := agent.GuestAttach(r.Context(), agentWhere(where)); aerr != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, "újracsatolás sikertelen: "+aerr.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, "újracsatolás sikertelen: "+s.errText(r, aerr), nil)
|
||||
return
|
||||
}
|
||||
s.restartStacks(stopped)
|
||||
if err := s.settings.ClearDisconnected(where); err != nil {
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusInternalServerError, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
go s.SyncFileBrowserMounts()
|
||||
|
||||
@@ -136,7 +136,7 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
agent, err := s.netAgentForAdd()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// Capability gate (the publish-train backstop): the coupled add semantics shipped with agent
|
||||
@@ -313,12 +313,12 @@ func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
agent, err := s.netAgentForAdd()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if err := agent.RemoveNetStorage(r.Context(), name); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] netstorage remove %q via agent failed: %v", name, err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if err := s.settings.RemoveStoragePath(where); err != nil {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNoErrErrorInPageOutput — localisation slice 2 release B (R-557).
|
||||
//
|
||||
// An error printed straight onto a page or into a JSON answer is a sentence that cannot follow the
|
||||
// household's language, however carefully its producer was converted: `err.Error()` renders the
|
||||
// Hungarian and stops there. Release B routed every display path through `errText`; this is what
|
||||
// keeps the next one from slipping back, because nothing else would notice.
|
||||
//
|
||||
// WHAT IT DOES NOT CLAIM. It reads the SOURCE, so it sees the shape of the call and not what the call
|
||||
// does at run time — a sink named in some other way is invisible to it. That is the honest limit of a
|
||||
// source rule, and it is worth having anyway: every instance it convicts is real, and the four sink
|
||||
// shapes below are the ones this codebase actually uses.
|
||||
//
|
||||
// Three things are deliberately NOT convictions:
|
||||
//
|
||||
// - a LOG line. Logs are English by convention and are never a household's copy.
|
||||
// - `strings.Contains(err.Error(), …)` — that is a COMPARISON, and a Hungarian one would be R-553
|
||||
// all over again. The four English ones that remain are R-569, filed and not fixed here.
|
||||
// - a value PERSISTED for later display (`EndRestoreOp`, `LastError`). Those are written by a
|
||||
// background run with no request; release C decides their language at write time.
|
||||
//
|
||||
// RED-PROOF (REPORT): put `err.Error()` back at any converted sink and this test names the file,
|
||||
// the line and the sink.
|
||||
func TestNoErrErrorInPageOutput(t *testing.T) {
|
||||
// The sinks that write to a response. Each one's argument reaches a customer.
|
||||
sinks := regexp.MustCompile(`\b(writeJSON|writeDiskJSON|writeDebugJSON|jsonError|escrowJSON|http\.Error)\(`)
|
||||
// A comparison, not a display.
|
||||
compare := regexp.MustCompile(`strings\.(Contains|HasPrefix|HasSuffix|EqualFold|Index)\(`)
|
||||
// Any `<thing>.Error()`.
|
||||
errCall := regexp.MustCompile(`\b\w+(?:\.\w+)*\.Error\(\)`)
|
||||
|
||||
// Each exemption carries its reason. An exemption with no reason is how a rule rots.
|
||||
allow := map[string]string{
|
||||
"escrow_readiness.go": "an operator-facing readiness STRING, never rendered as customer copy " +
|
||||
"(it names an agent/preflight fault in English); R-574 territory",
|
||||
"handler_debug.go:entry[\"last_error\"]": "a diagnostic DUMP value the operator copies out, " +
|
||||
"not page copy — translating it would change what an operator pastes into a report",
|
||||
}
|
||||
|
||||
var bad []string
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked := 0
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
if _, ok := allow[f]; ok {
|
||||
continue
|
||||
}
|
||||
src, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, line := range strings.Split(string(src), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
if !sinks.MatchString(line) || compare.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if errCall.MatchString(line) {
|
||||
bad = append(bad, " "+f+":"+strconv.Itoa(i+1)+" "+trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The instrument must be shown to be looking at something: a glob that matched nothing, or a sink
|
||||
// pattern that no longer matches the code, would pass this test in silence.
|
||||
if checked < 40 {
|
||||
t.Fatalf("only %d display-sink lines were examined — the sink patterns no longer match the code", checked)
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
t.Errorf("an error is printed to a page or a JSON answer without going through errText, so it "+
|
||||
"cannot follow the household's language (%d):\n%s", len(bad), strings.Join(bad, "\n"))
|
||||
}
|
||||
t.Logf("examined %d display-sink lines across %d files", checked, len(files))
|
||||
}
|
||||
@@ -350,7 +350,8 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// a redirect that lands the customer back where they started. Diagnosing a silence is
|
||||
// what this project has spent a fortnight removing.
|
||||
s.logger.Printf("[WARN] [web] off-box full-restore preparation REFUSED for %s (no job started): %v", app, err)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), err.Error(), true)
|
||||
key, args := flashForErr(err)
|
||||
offboxRedirectTo(w, r, restoreWizardPath(app), key, true, args...)
|
||||
return
|
||||
}
|
||||
// The success half is logged too: it is the step that decides the customer may proceed, and
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestR400_DeletedControlsLeftNoPanelOrScript(t *testing.T) {
|
||||
// ASCII-only fragments (R-364): accented Hungarian through a template read is not the hazard here,
|
||||
// but the rule is uniform and these identifiers are ASCII anyway.
|
||||
for _, orphan := range []string{
|
||||
"watchdog-status", // the panel div and its two loaders
|
||||
"watchdog-status", // the panel div and its two loaders
|
||||
"renderWatchdogStatus",
|
||||
"loadWatchdogStatus",
|
||||
"simulateDisconnect",
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestR553_DiskWarningPlacementSurvivesWordingChange(t *testing.T) {
|
||||
// health block is its own struct; this pins that it carries exactly three fields, their JSON names,
|
||||
// and that a health report WITH kinds marshals to the same bytes as one without.
|
||||
//
|
||||
// RED-PROOF (REPORT): add `WarningKinds []string \`json:"warning_kinds"\`` to report.HealthReport and
|
||||
// RED-PROOF (REPORT): add `WarningKinds []string \`json:"warning_kinds"\“ to report.HealthReport and
|
||||
// both halves fail.
|
||||
func TestR553_HubReportWarningsAreUnchangedOnTheWire(t *testing.T) {
|
||||
rt := reflect.TypeOf(report.HealthReport{})
|
||||
|
||||
@@ -33,10 +33,10 @@ const testRepoPW = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1
|
||||
// fakeRecoverer is the agent seam. It records every code it was handed so a test can prove the
|
||||
// handler passed the RIGHT one, and can fail on demand for the wrong-code path.
|
||||
type fakeRecoverer struct {
|
||||
mu sync.Mutex
|
||||
pw string
|
||||
sha string
|
||||
fail bool
|
||||
mu sync.Mutex
|
||||
pw string
|
||||
sha string
|
||||
fail bool
|
||||
// failWith overrides `fail` with an EXACT error value, so a test can drive one specific
|
||||
// R-224 class (a 502 fetch failure, a transport error, an unrecognised status).
|
||||
failWith error
|
||||
|
||||
@@ -44,7 +44,7 @@ type Server struct {
|
||||
updater *selfupdate.Updater
|
||||
logger *log.Logger
|
||||
version string
|
||||
encKey []byte // AES-256 key for decrypting app.yaml values
|
||||
encKey []byte // AES-256 key for decrypting app.yaml values
|
||||
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
|
||||
tmplByLang map[string]*template.Template
|
||||
i18n *i18n.Bundle
|
||||
|
||||
@@ -2,8 +2,8 @@ package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
// errNotShareable is the single refusal surfaced for every deny-listed / out-of-root path. It is
|
||||
// deliberately uniform: a distinct message per reason would let the picker act as a filesystem oracle.
|
||||
var errNotShareable = errors.New("Ez a mappa nem osztható meg.")
|
||||
var errNotShareable = util.MsgError("err.web.ez_a_mappa_nem_oszthato_meg")
|
||||
|
||||
// pathWithin reports whether path IS root or lives under it. Segment-wise and separator-agnostic, so
|
||||
// neither a "/mnt/drive-evil" sibling nor a non-POSIX separator can pass as containment.
|
||||
@@ -244,7 +244,15 @@ func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.executeTemplate(w, r, "sharing", data)
|
||||
}
|
||||
|
||||
// sharingRedirect returns to the page with a Hungarian flash.
|
||||
// sharingRedirectErr is sharingRedirect for an error: it forwards the error's bundle key and
|
||||
// parameters when it has one, so the page renders the refusal in the household's language. An error
|
||||
// that is not ours travels as its own text and is shown verbatim.
|
||||
func sharingRedirectErr(w http.ResponseWriter, r *http.Request, err error) {
|
||||
key, args := flashForErr(err)
|
||||
http.Redirect(w, r, "/sharing?"+flashQuery("flash", key, args...), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// sharingRedirect returns to the page with a flash.
|
||||
func sharingRedirect(w http.ResponseWriter, r *http.Request, flash string) {
|
||||
http.Redirect(w, r, "/sharing?"+flashQuery("flash", flash), http.StatusSeeOther)
|
||||
}
|
||||
@@ -261,7 +269,7 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if name != "" {
|
||||
if err := settings.ValidateSMBServerName(name); err != nil {
|
||||
sharingRedirect(w, r, err.Error())
|
||||
sharingRedirectErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.settings.SetSMBServerName(name); err != nil {
|
||||
@@ -364,7 +372,7 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
|
||||
readOnly := r.FormValue("read_only") == "on"
|
||||
|
||||
if err := settings.ValidateSMBShareName(name); err != nil {
|
||||
sharingRedirect(w, r, err.Error())
|
||||
sharingRedirectErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -373,14 +381,14 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
|
||||
case "new":
|
||||
root, err := s.sharingResolveStorageRoot(r.FormValue("storage_root"))
|
||||
if err != nil {
|
||||
sharingRedirect(w, r, errNotShareable.Error())
|
||||
sharingRedirectErr(w, r, errNotShareable)
|
||||
return
|
||||
}
|
||||
dir := filepath.Join(root, "shares", name)
|
||||
// The name is already NetBIOS-validated (no slash/dot), but assert containment anyway —
|
||||
// a join that escaped its root must never reach MkdirAll.
|
||||
if !pathWithin(dir, root) {
|
||||
sharingRedirect(w, r, errNotShareable.Error())
|
||||
sharingRedirectErr(w, r, errNotShareable)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o775); err != nil {
|
||||
@@ -396,12 +404,12 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
|
||||
case "existing":
|
||||
resolved, err := s.sharingResolvePath(r.FormValue("path"))
|
||||
if err != nil {
|
||||
sharingRedirect(w, r, errNotShareable.Error())
|
||||
sharingRedirectErr(w, r, errNotShareable)
|
||||
return
|
||||
}
|
||||
target = resolved
|
||||
default:
|
||||
sharingRedirect(w, r, errNotShareable.Error())
|
||||
sharingRedirectErr(w, r, errNotShareable)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -413,7 +421,7 @@ func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Reques
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := s.settings.AddSMBShare(share); err != nil {
|
||||
sharingRedirect(w, r, err.Error())
|
||||
sharingRedirectErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.stackMgr.ReconcileSamba(); err != nil {
|
||||
@@ -437,7 +445,7 @@ func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
if err := s.settings.RemoveSMBShare(name); err != nil {
|
||||
sharingRedirect(w, r, err.Error())
|
||||
sharingRedirectErr(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := s.stackMgr.ReconcileSamba(); err != nil {
|
||||
@@ -452,7 +460,7 @@ func (s *Server) sharingShareOffsiteHandler(w http.ResponseWriter, r *http.Reque
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
on := r.FormValue("offsite") == "on" || r.FormValue("offsite") == "true"
|
||||
if err := s.settings.SetSMBShareOffsite(name, on); err != nil {
|
||||
sharingRedirect(w, r, err.Error())
|
||||
sharingRedirectErr(w, r, err)
|
||||
return
|
||||
}
|
||||
sharingRedirect(w, r, "flash.sharing.saved")
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
@@ -49,7 +49,7 @@ var validFSTypes = map[string]bool{"ext4": true, "xfs": true}
|
||||
func mountWhere(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if !mountNameRe.MatchString(name) {
|
||||
return "", fmt.Errorf("érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)")
|
||||
return "", util.MsgError("err.web.ervenytelen_csatlakoztatasi_nev_csak_betuk_szamok")
|
||||
}
|
||||
return "/mnt/" + name, nil
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
|
||||
}
|
||||
}
|
||||
if !validFSTypes[fstype] {
|
||||
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
|
||||
return storageInitResult{}, util.MsgError("err.web.nem_tamogatott_fajlrendszer_ext4_vagy_xfs", fstype)
|
||||
}
|
||||
// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
|
||||
// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
|
||||
@@ -135,7 +135,7 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
|
||||
return res, nil // STOP — no bypass; the UI surfaces Opsign.
|
||||
}
|
||||
if err != nil && !isAgentTimeout(err) {
|
||||
return storageInitResult{}, fmt.Errorf("formázás sikertelen: %w", err)
|
||||
return storageInitResult{}, util.MsgError("err.web.formazas_sikertelen", err)
|
||||
}
|
||||
if !fr.Formatted {
|
||||
// A slow mkfs (e.g. a large USB) outran the agent client's 15 s timeout — the agent runs the
|
||||
@@ -152,14 +152,14 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs
|
||||
setP(storageInitPhaseMounting)
|
||||
uuid := resolveEnrollUUID(ctx, agent, device)
|
||||
if uuid == "" {
|
||||
return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót")
|
||||
return storageInitResult{}, util.MsgError("err.web.formazas_kesz_de_az_uj_fajlrendszer")
|
||||
}
|
||||
// 3. Mount (benign assign) at the raw /mnt/<name>. 4. Bind felhom-data under the shared parent FIRST
|
||||
// (intermediary model) so the drive's STABLE path is live in the guest, THEN register + skeleton there
|
||||
// (the controller can only see/write the drive at the stable path post-attach). 5. Register the stable
|
||||
// path — that is what apps' HDD_PATH / FileBrowser / monitoring use.
|
||||
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
|
||||
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
|
||||
return storageInitResult{}, util.MsgError("err.web.csatlakoztatas_sikertelen", err)
|
||||
}
|
||||
s.attachIntoGuest(ctx, agent, where)
|
||||
// 5. Register the stable path — THE LAST step (marker-last, F6/Scenario B): a crash before this
|
||||
@@ -191,10 +191,10 @@ func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device,
|
||||
// not in /disks — resolved via the raw-device scan's durable_id).
|
||||
uuid := resolveEnrollUUID(ctx, agent, device)
|
||||
if uuid == "" {
|
||||
return storageInitResult{}, fmt.Errorf("a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)")
|
||||
return storageInitResult{}, util.MsgError("err.web.a_kivalasztott_meghajtohoz_nem_talalhato_fajlrendszer")
|
||||
}
|
||||
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
|
||||
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
|
||||
return storageInitResult{}, util.MsgError("err.web.csatlakoztatas_sikertelen", err)
|
||||
}
|
||||
s.attachIntoGuest(ctx, agent, where)
|
||||
stable := stablePathForName(path.Base(where))
|
||||
@@ -232,7 +232,7 @@ func (s *Server) awaitAgentFormat(ctx context.Context, agent diskAgent, device s
|
||||
if err != nil {
|
||||
consecutiveErrs++
|
||||
if consecutiveErrs >= 5 {
|
||||
return agentapi.FormatResult{}, fmt.Errorf("a formázás állapota nem kérdezhető le: %w", err)
|
||||
return agentapi.FormatResult{}, util.MsgError("err.web.a_formazas_allapota_nem_kerdezheto_le", err)
|
||||
}
|
||||
} else {
|
||||
consecutiveErrs = 0
|
||||
@@ -240,9 +240,9 @@ func (s *Server) awaitAgentFormat(ctx context.Context, agent diskAgent, device s
|
||||
case "done": // the agent's format-job terminal phases (internal/localapi/formatjob.go)
|
||||
return agentapi.FormatResult{Device: device, Formatted: true}, nil
|
||||
case "failed":
|
||||
return agentapi.FormatResult{}, fmt.Errorf("formázás sikertelen: %s", st.Error)
|
||||
return agentapi.FormatResult{}, util.MsgError("err.web.formazas_sikertelen_2", st.Error)
|
||||
case "idle":
|
||||
return agentapi.FormatResult{}, fmt.Errorf("a formázás nem indult el az eszközön (%s)", device)
|
||||
return agentapi.FormatResult{}, util.MsgError("err.web.a_formazas_nem_indult_el_az", device)
|
||||
// "running" (or an unexpected phase) → keep polling
|
||||
}
|
||||
}
|
||||
@@ -261,10 +261,10 @@ func (s *Server) reEnrollClearMarker(where string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.settings.ClearDecommissioned(where); err != nil {
|
||||
return false, fmt.Errorf("leszerelés visszavonása sikertelen: %w", err)
|
||||
return false, util.MsgError("err.web.leszereles_visszavonasa_sikertelen", err)
|
||||
}
|
||||
if err := s.settings.SetSchedulable(where, true); err != nil {
|
||||
return false, fmt.Errorf("ütemezhetőség visszaállítása sikertelen: %w", err)
|
||||
return false, util.MsgError("err.web.utemezhetoseg_visszaallitasa_sikertelen", err)
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] re-enrolled decommissioned drive — marker cleared: %s", where)
|
||||
return true, nil
|
||||
@@ -301,7 +301,7 @@ func (s *Server) registerStoragePath(where, label string, setDefault bool) error
|
||||
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||
return fmt.Errorf("regisztráció sikertelen: %w", err)
|
||||
return util.MsgError("err.web.regisztracio_sikertelen", err)
|
||||
}
|
||||
go s.SyncFileBrowserMounts()
|
||||
return nil
|
||||
@@ -428,7 +428,7 @@ func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id, err := s.stackMgr.MigrateAll(r.Context(), strings.TrimSpace(req.Source), strings.TrimSpace(req.Target))
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
|
||||
@@ -453,7 +453,7 @@ func (s *Server) handleStorageMigrateApp(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
id, err := s.stackMgr.MigrateApp(r.Context(), strings.TrimSpace(req.App), strings.TrimSpace(req.Target))
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
|
||||
@@ -507,7 +507,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
// source once every app has moved and come up on the target. A VALIDATE refusal returns here.
|
||||
id, err := s.stackMgr.MigrateAllAndDecommission(r.Context(), req.Where, strings.TrimSpace(req.Target))
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusConflict, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id, "mode": "migrate"})
|
||||
@@ -539,7 +539,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
if errors.Is(err, errLastUsableDrive) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeDiskJSON(w, status, false, err.Error(), nil)
|
||||
writeDiskJSON(w, status, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"decommissioned": true, "where": req.Where, "stopped_apps": stopped})
|
||||
@@ -552,7 +552,7 @@ func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Reques
|
||||
// errLastUsableDrive is the M1 refusal (never leave zero default) — a POLICY verdict, not a
|
||||
// gateway failure. The decommission handler maps it to 409 so the JSON refusal reaches the
|
||||
// browser intact (a 502 would be swallowed by the Cloudflare edge error page).
|
||||
var errLastUsableDrive = errors.New("ez az egyetlen használható tárhely — a leszerelés megtagadva (előbb adj hozzá vagy állíts be másik alapértelmezett meghajtót)")
|
||||
var errLastUsableDrive = util.MsgError("err.web.ez_az_egyetlen_hasznalhato_tarhely_a")
|
||||
|
||||
// finalizeDecommission resolves the agent client then soft-marks + decommissions (see *With).
|
||||
func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo string) error {
|
||||
@@ -576,7 +576,7 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
|
||||
return errLastUsableDrive
|
||||
}
|
||||
if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
|
||||
return fmt.Errorf("nyilvántartás frissítése sikertelen: %w", err)
|
||||
return util.MsgError("err.web.nyilvantartas_frissitese_sikertelen", err)
|
||||
}
|
||||
if promote != "" {
|
||||
if derr := s.settings.SetDefaultStoragePath(promote); derr != nil {
|
||||
@@ -587,7 +587,7 @@ func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent,
|
||||
}
|
||||
// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
|
||||
if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
|
||||
return fmt.Errorf("a meghajtó leszerelése sikertelen: %w", err)
|
||||
return util.MsgError("err.web.a_meghajto_leszerelese_sikertelen", err)
|
||||
}
|
||||
if s.stackMgr != nil {
|
||||
go s.SyncFileBrowserMounts()
|
||||
@@ -629,7 +629,7 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
where, err := mountWhere(req.MountName)
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
@@ -638,7 +638,7 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// F6: run format→mount→register as a DETACHED job (off the request context) the wizard polls via
|
||||
@@ -760,7 +760,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath. req.Where is
|
||||
@@ -783,7 +783,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
|
||||
if perr != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", perr.Error()), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", s.errText(r, perr)), nil)
|
||||
return
|
||||
}
|
||||
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
|
||||
@@ -792,7 +792,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
|
||||
if ferr != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", ferr.Error()), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "disk.err.wipe_failed", s.errText(r, ferr)), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
|
||||
@@ -808,7 +808,7 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) HandleServerReboot(w http.ResponseWriter, r *http.Request) {
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
s.serverReboot(w, r, agent)
|
||||
@@ -819,7 +819,7 @@ func (s *Server) HandleServerReboot(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) serverReboot(w http.ResponseWriter, r *http.Request, agent diskAgent) {
|
||||
if err := agent.GuestReboot(r.Context()); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] server reboot (guest restart) failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[WARN] [web] full server (guest) restart requested by operator")
|
||||
@@ -855,7 +855,7 @@ func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
|
||||
stable := stablePathForName(path.Base(req.Where))
|
||||
if err := s.registerStoragePath(stable, req.Label, req.SetDefault); err != nil {
|
||||
s.logger.Printf("[WARN] [web] storage register %s (stable %s) failed: %v", req.Where, stable, err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s → %s", req.Where, stable)
|
||||
@@ -908,7 +908,7 @@ func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
if err := s.registerStoragePath(match.Path, req.Label, req.SetDefault); err != nil {
|
||||
s.logger.Printf("[WARN] [web] mounted-store register %s failed: %v", match.Path, err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] storage path registered (already-mounted store): %s", match.Path)
|
||||
@@ -923,7 +923,7 @@ func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
where, err := mountWhere(req.MountName)
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
@@ -932,12 +932,12 @@ func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
res, err := s.runStorageAttach(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", res)
|
||||
@@ -963,13 +963,13 @@ func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// The registered path is the STABLE /mnt/felhom-drives/<name>; the agent operates on the raw mount.
|
||||
res, err := agent.EjectDisk(r.Context(), agentWhere(req.Where))
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, s.errText(r, err), nil)
|
||||
return
|
||||
}
|
||||
// Deregister the path (best-effort — the unmount already succeeded).
|
||||
|
||||
Reference in New Issue
Block a user