v0.81.0: retire drive-activation banner; add standalone "Kiszolgáló újraindítása" button

In the intermediary-mount model an enrolled drive binds live into the running
guest (no reboot), so the "… meghajtó aktiválásra vár / Újraindítás most" banner
was an obsolete relic — also dead since v0.78 (pendingActivationDrives keyed on
the raw MountPath vs the now-stable sp.Path). Removed the banner block +
activatePendingDrives JS (settings.html), the PendingDrives feed (handlers.go),
and the dead pendingActivationDrives helper + its unused internal/system import.

Renamed handleStorageActivate -> HandleServerReboot (split out a testable
serverReboot core), removed the /api/storage/activate case, and mounted the
handler at the new non-storage route /api/server/reboot (RequireAuth+CsrfProtect).
The agent GuestReboot primitive is reused unchanged.

Added the standalone "Kiszolgáló újraindítása" settings card (sibling to the
controller-only "Vezérlő újraindítása"), reusing the pollRestart() loop.

Test: TestHandleServerReboot_CallsGuestReboot (fake diskAgent asserts GuestReboot
invoked once + 202). diskAgent/mockAgent gained GuestReboot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PsnU2ASocYrvzqE82YDYW
This commit is contained in:
2026-06-23 18:46:31 +02:00
parent 7cce19797e
commit 242b835a19
7 changed files with 104 additions and 69 deletions
+24
View File
@@ -1,5 +1,29 @@
## Changelog ## Changelog
### v0.81.0 — retire the drive-activation banner; add a standalone "Kiszolgáló újraindítása" button (2026-06-23)
- **Removed the obsolete drive-activation banner.** In the intermediary-mount model an enrolled drive
binds **live** into the running guest (agent `disks.go` — no `pct set -mpN`, no slot, no reboot), so
the "… meghajtó aktiválásra vár / Újraindítás most (~30 mp)" banner was a relic of the old per-drive
reboot model. It was also effectively dead since v0.78 (`pendingActivationDrives` keyed `attached` by
the agent's RAW `MountPath` but compared it to the now-STABLE `sp.Path`). Removed: the
`{{if .PendingDrives}}` banner block + `window.activatePendingDrives` JS (`settings.html`), the
`data["PendingDrives"]` feed (`handlers.go`), and the dead `pendingActivationDrives` helper +
its now-unused `internal/system` import (`storage_handlers.go`).
- **Repointed the reboot endpoint to a non-storage route.** Renamed `handleStorageActivate`
`HandleServerReboot` and split out a testable `serverReboot` core (mirrors `runStorageInit`); removed
the `/api/storage/activate` case from `ServeStorageAPI`; mounted the handler at the new
`/api/server/reboot` (same `RequireAuth` + `CsrfProtect`) in `cmd/controller/main.go`. The agent
`GuestReboot` primitive is reused unchanged. (`/api/storage/activate` now returns 404.)
*Note:* the handler is exported (`HandleServerReboot`) because `cmd/controller/main.go` wires it
cross-package — same convention as every other web handler mounted there.
- **Added the standalone "Kiszolgáló újraindítása" settings card.** A deliberate full-server (guest)
restart affordance, a sibling to the existing "Vezérlő újraindítása" controller-only restart.
New `settings-card` + `restartServer()` JS (reuses the existing `pollRestart()` loop) in
`settings.html`; posts to `/api/server/reboot`.
- **Test:** `TestHandleServerReboot_CallsGuestReboot` (`storage_handlers_test.go`) — a fake `diskAgent`
asserts `GuestReboot` is invoked exactly once and the response is 202 `{ok:true, rebooting:true}`.
`diskAgent`/`mockAgent` gained `GuestReboot`. Green gate: `go build ./... && go vet ./... && go test ./...`.
### v0.80.0 — disk card: show + act on the stable path, not the raw host mount (2026-06-23) ### v0.80.0 — disk card: show + act on the stable path, not the raw host mount (2026-06-23)
- Follow-up to v0.78/0.79. The storage disk card displayed the drive's **raw** host PVE mount - Follow-up to v0.78/0.79. The storage disk card displayed the drive's **raw** host PVE mount
(`/mnt/<name>`) — which doesn't exist inside the guest — instead of the **stable** in-guest path (`/mnt/<name>`) — which doesn't exist inside the guest — instead of the **stable** in-guest path
+2
View File
@@ -1271,6 +1271,8 @@ Bearer token authentication, 3-attempt retry with 5-second backoff. Push status
**Manual restart (v0.70.0):** `POST /api/selfrestart` (session auth + CSRF via the `/api/` mount) runs the same helper — surfaced as the **"Vezérlő újraindítása"** button on the settings page (confirm → POST → poll `GET /` every 2 s → reload), so a customer can recover the controller without rebooting the whole guest. **Manual restart (v0.70.0):** `POST /api/selfrestart` (session auth + CSRF via the `/api/` mount) runs the same helper — surfaced as the **"Vezérlő újraindítása"** button on the settings page (confirm → POST → poll `GET /` every 2 s → reload), so a customer can recover the controller without rebooting the whole guest.
**Full-server restart (v0.81.0):** `POST /api/server/reboot` (session auth + CSRF) reboots the **whole guest** via the host agent's `GuestReboot` primitive (`HandleServerReboot` in `internal/web/storage_handlers.go`, delegating to the testable `serverReboot` core; agent reboots detached + returns 202). Surfaced as a separate **"Kiszolgáló újraindítása"** settings card alongside the controller-only restart, reusing the same `pollRestart()` reload loop. It replaces the retired drive-activation banner (v0.81.0): in the intermediary-mount model an enrolled drive binds **live** into the running guest, so storage no longer needs a reboot to activate — this button is purely a deliberate full-system restart.
#### App Telemetry (`internal/metrics/telemetry.go`, `internal/metrics/logscanner.go`, `internal/report/telemetry.go`) #### App Telemetry (`internal/metrics/telemetry.go`, `internal/metrics/logscanner.go`, `internal/report/telemetry.go`)
Each report push now includes per-app telemetry data: Each report push now includes per-app telemetry data:
+3
View File
@@ -726,6 +726,9 @@ func main() {
mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI)))) mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
// Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry). // Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry).
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI)))) mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
// Standalone full-server (guest) restart — the "Kiszolgáló újraindítása" maintenance affordance,
// a sibling to the controller-only /api/selfrestart. Reuses the agent GuestReboot primitive.
mux.Handle("/api/server/reboot", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.HandleServerReboot))))
// Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's // Whole-guest (appliance) backup visibility + manual trigger. Distinct prefix from apiRouter's
// app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree. // app-data /api/backup/{run,status} (DB dumps) to avoid shadowing the /api/ catch-all subtree.
mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI)))) mux.Handle("/api/guest-backup/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeBackupAPI))))
-3
View File
@@ -903,9 +903,6 @@ func (s *Server) settingsData() map[string]interface{} {
storageViews = append(storageViews, view) storageViews = append(storageViews, view)
} }
data["StoragePaths"] = storageViews data["StoragePaths"] = storageViews
// Drives enrolled but not yet activated in the guest (slice 10 P2): they need the user-triggered
// "Újraindítás most" to take effect (the host-side live inject is blocked on an unprivileged guest).
data["PendingDrives"] = s.pendingActivationDrives()
// Recovery info for emergency section // Recovery info for emergency section
data["RetrievalPassword"] = s.settings.GetRetrievalPassword() data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
+17 -47
View File
@@ -17,7 +17,6 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
) )
// Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin // Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin
@@ -35,6 +34,7 @@ type diskAgent interface {
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error) EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
Decommission(ctx context.Context, where string) (agentapi.DecommissionResult, error) Decommission(ctx context.Context, where string) (agentapi.DecommissionResult, error)
GuestAttach(ctx context.Context, where string) error GuestAttach(ctx context.Context, where string) error
GuestReboot(ctx context.Context) error
} }
// mountNameRe is the safe `/mnt/<name>` component (DNS-ish: letters, digits, _ , -). // mountNameRe is the safe `/mnt/<name>` component (DNS-ish: letters, digits, _ , -).
@@ -164,44 +164,6 @@ func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device,
return storageInitResult{Registered: true, Where: stable}, nil return storageInitResult{Registered: true, Where: stable}, nil
} }
// pendingActivationDrives returns registered storage paths that are NOT yet live-mounted in this
// container but whose backing drive the agent reports present+attached — enrolled drives waiting for
// the guest restart that activates their bind (slice 10 P2; the host-side live inject is blocked on an
// unprivileged guest). The customer activates them with the "Újraindítás most" button (one restart
// batches all). Best-effort: agent unreachable → none.
func (s *Server) pendingActivationDrives() []string {
paths := s.settings.GetStoragePaths()
if len(paths) == 0 {
return nil
}
agent, err := s.agentClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := agent.Disks(ctx)
if err != nil {
return nil
}
attached := map[string]bool{}
for _, d := range resp.Disks {
if d.MountPath != "" && d.State == "attached" {
attached[d.MountPath] = true
}
}
var pending []string
for _, sp := range paths {
if sp.Decommissioned {
continue
}
if attached[sp.Path] && !system.IsMountPoint(sp.Path) {
pending = append(pending, sp.Path)
}
}
return pending
}
// reEnrollClearMarker un-retires a re-plugged decommissioned drive (Change 4): clears the soft marker // reEnrollClearMarker un-retires a re-plugged decommissioned drive (Change 4): clears the soft marker
// and restores Schedulable so its apps' "missing storage" indicator clears. Returns true if it acted. // and restores Schedulable so its apps' "missing storage" indicator clears. Returns true if it acted.
func (s *Server) reEnrollClearMarker(where string) (bool, error) { func (s *Server) reEnrollClearMarker(where string) (bool, error) {
@@ -283,8 +245,6 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.handleStorageImpact(w, r) s.handleStorageImpact(w, r)
case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost: case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
s.handleStorageRegister(w, r) s.handleStorageRegister(w, r)
case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost:
s.handleStorageActivate(w, r)
case r.URL.Path == "/api/storage/migrate" && r.Method == http.MethodPost: case r.URL.Path == "/api/storage/migrate" && r.Method == http.MethodPost:
s.handleStorageMigrate(w, r) s.handleStorageMigrate(w, r)
case r.URL.Path == "/api/storage/migrate-app" && r.Method == http.MethodPost: case r.URL.Path == "/api/storage/migrate-app" && r.Method == http.MethodPost:
@@ -643,21 +603,31 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID}) writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
} }
// handleStorageActivate reboots the guest to activate pending drive binds (slice 10 P2). The agent // HandleServerReboot reboots the whole guest (server) as a deliberate maintenance action — the
// reboots detached + returns 202; this controller restarts with the guest, so the caller's response // standalone "Kiszolgáló újraindítása" affordance, a sibling to the controller-only restart
// may be cut short — the UI handles that and reloads after the restart window. // (/api/selfrestart). The agent reboots detached + returns 202; this controller restarts with the
func (s *Server) handleStorageActivate(w http.ResponseWriter, r *http.Request) { // guest, so the caller's response may be cut short — the UI handles that and reloads after the
// restart window. (It reuses the agent GuestReboot primitive that previously backed the now-retired
// drive-activation banner; in the intermediary-mount model a drive binds live, so no reboot is needed
// to activate storage — this button is purely a full-server restart.)
func (s *Server) HandleServerReboot(w http.ResponseWriter, r *http.Request) {
agent, err := s.agentClient() agent, err := s.agentClient()
if err != nil { if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return return
} }
s.serverReboot(w, r, agent)
}
// serverReboot is the testable core of HandleServerReboot: invoke the agent's guest reboot and return
// the 202 envelope (or the agent error). Split out so it can be exercised with a fake diskAgent.
func (s *Server) serverReboot(w http.ResponseWriter, r *http.Request, agent diskAgent) {
if err := agent.GuestReboot(r.Context()); err != nil { if err := agent.GuestReboot(r.Context()); err != nil {
s.logger.Printf("[ERROR] [web] guest reboot (activate pending drives) failed: %v", err) 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, err.Error(), nil)
return return
} }
s.logger.Printf("[WARN] [web] guest restart requested to activate pending drive binds") s.logger.Printf("[WARN] [web] full server (guest) restart requested by operator")
writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"rebooting": true}) writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"rebooting": true})
} }
@@ -40,6 +40,8 @@ type mockAgent struct {
formatCalls []formatCall formatCalls []formatCall
guestAttachCalls []string guestAttachCalls []string
decommissionCalls []string decommissionCalls []string
guestRebootCalls int
guestRebootErr error
} }
type assignCall struct{ uuid, where, fstype string } type assignCall struct{ uuid, where, fstype string }
@@ -71,6 +73,10 @@ func (m *mockAgent) GuestAttach(_ context.Context, where string) error {
m.guestAttachCalls = append(m.guestAttachCalls, where) m.guestAttachCalls = append(m.guestAttachCalls, where)
return nil return nil
} }
func (m *mockAgent) GuestReboot(context.Context) error {
m.guestRebootCalls++
return m.guestRebootErr
}
func testServer(t *testing.T) *Server { func testServer(t *testing.T) *Server {
t.Helper() t.Helper()
@@ -255,6 +261,38 @@ func TestHandleStorageRegister_RegistersStablePath(t *testing.T) {
} }
} }
// TestHandleServerReboot_CallsGuestReboot exercises the standalone "Kiszolgáló újraindítása" core:
// it must invoke the agent's GuestReboot exactly once and return the 202 envelope. (The HTTP handler
// HandleServerReboot delegates to this core after building the live agent client — same split-out
// pattern as runStorageInit, so the fake diskAgent can be injected here.)
func TestHandleServerReboot_CallsGuestReboot(t *testing.T) {
s := testServer(t)
agent := &mockAgent{}
req := httptest.NewRequest(http.MethodPost, "/api/server/reboot", nil)
rr := httptest.NewRecorder()
s.serverReboot(rr, req, agent)
if agent.guestRebootCalls != 1 {
t.Fatalf("expected GuestReboot invoked exactly once, got %d", agent.guestRebootCalls)
}
if rr.Code != http.StatusAccepted {
t.Fatalf("expected 202 Accepted, got %d (body %s)", rr.Code, rr.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Rebooting bool `json:"rebooting"`
} `json:"data"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || !resp.Data.Rebooting {
t.Fatalf("expected {ok:true, data.rebooting:true}, got %+v", resp)
}
}
func TestFSUUIDForDevice(t *testing.T) { func TestFSUUIDForDevice(t *testing.T) {
disks := agentapi.DisksResponse{Disks: []agentapi.DiskInfo{ disks := agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{BackingDevice: "/dev/sda1", DurableID: "uuid:AAAA"}, {BackingDevice: "/dev/sda1", DurableID: "uuid:AAAA"},
+20 -19
View File
@@ -408,15 +408,6 @@ function pollUntilBack() {
<a href="/settings/storage/attach" class="btn btn-sm btn-outline">🔗 Meglévő meghajtó csatolása</a> <a href="/settings/storage/attach" class="btn btn-sm btn-outline">🔗 Meglévő meghajtó csatolása</a>
</div> </div>
{{if .PendingDrives}}
<div class="alert alert-warning" style="margin-top:1rem">
<strong>{{len .PendingDrives}} meghajtó aktiválásra vár.</strong>
Az újonnan csatolt adatmeghajtók a vendég rövid (~30 mp) újraindítása után válnak elérhetővé az alkalmazások számára.
<div style="margin-top:.6rem"><button class="btn btn-sm btn-primary" id="activate-drives-btn" onclick="activatePendingDrives()">Újraindítás most (~30 mp)</button></div>
<div id="activate-result" style="margin-top:.5rem"></div>
</div>
{{end}}
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4> <h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4>
<p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete. A meghajtó <strong>szerepkörét</strong> az ügynök saját vizsgálattal állapítja meg: a rendszer- és biztonsági-mentés meghajtók védettek (csak operátori aláírással módosíthatók), a felhasználói adatmeghajtókat Ön kezeli.</p> <p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete. A meghajtó <strong>szerepkörét</strong> az ügynök saját vizsgálattal állapítja meg: a rendszer- és biztonsági-mentés meghajtók védettek (csak operátori aláírással módosíthatók), a felhasználói adatmeghajtókat Ön kezeli.</p>
@@ -564,16 +555,6 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
location.reload(); location.reload();
}catch(e){ alert('Hiba: '+e.message); } }catch(e){ alert('Hiba: '+e.message); }
}; };
// Activate pending drive binds by rebooting the guest (~30s). The reboot takes the controller down
// too, so the fetch may not resolve — we reload after the restart window regardless.
window.activatePendingDrives=function(){
if(!confirm('A vendég újraindul (~30 másodperc). Eközben az alkalmazások és a vezérlőpult rövid időre nem elérhetők. Folytatja?')) return;
var btn=document.getElementById('activate-drives-btn'); var out=document.getElementById('activate-result');
if(btn) btn.disabled=true;
if(out) out.innerHTML='<span class="form-hint">Újraindítás folyamatban… az oldal automatikusan újratöltődik.</span>';
fetch('/api/storage/activate',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders())}).catch(function(){});
setTimeout(function(){location.reload();}, 45000);
};
window.confirmEject=function(where){ window.confirmEject=function(where){
// The confirm name is the drive BASENAME (matches the server's path.Base(where) check); `where` may // The confirm name is the drive BASENAME (matches the server's path.Base(where) check); `where` may
// be the stable /mnt/felhom-drives/<name> path, so strip the whole directory, not just the /mnt/ prefix. // be the stable /mnt/felhom-drives/<name> path, so strip the whole directory, not just the /mnt/ prefix.
@@ -1144,6 +1125,16 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
<button type="button" class="btn btn-outline" id="btn-restart-controller" onclick="restartController()">Vezérlő újraindítása</button> <button type="button" class="btn btn-outline" id="btn-restart-controller" onclick="restartController()">Vezérlő újraindítása</button>
</div> </div>
<!-- Section: Full server (guest) restart — a deliberate maintenance affordance, sibling to the controller restart -->
<div class="settings-card">
<h3>Kiszolgáló újraindítása</h3>
<p class="settings-card-desc">
Az egész kiszolgáló (szerver) újraindítása. Minden alkalmazás rövid időre leáll, és a vezérlőpult kb. 30 másodpercig nem elérhető. Akkor használja, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.
</p>
<div id="server-restart-status"></div>
<button type="button" class="btn btn-outline" id="btn-restart-server" onclick="restartServer()">Kiszolgáló újraindítása</button>
</div>
<script> <script>
function restartController() { function restartController() {
if (!confirm('Biztosan újraindítja a vezérlőt? A művelet néhány másodpercig tart, és a felület rövid időre elérhetetlen lesz.')) return; if (!confirm('Biztosan újraindítja a vezérlőt? A művelet néhány másodpercig tart, és a felület rövid időre elérhetetlen lesz.')) return;
@@ -1155,6 +1146,16 @@ function restartController() {
.then(function(){ pollRestart(0); }) .then(function(){ pollRestart(0); })
.catch(function(){ pollRestart(0); }); // connection may drop as the process exits — poll regardless .catch(function(){ pollRestart(0); }); // connection may drop as the process exits — poll regardless
} }
function restartServer() {
if (!confirm('Biztosan újraindítja a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.')) return;
var btn = document.getElementById('btn-restart-server');
var status = document.getElementById('server-restart-status');
if (btn) btn.disabled = true;
if (status) status.innerHTML = '<div class="alert alert-info">Újraindítás folyamatban… a vezérlőpult néhány másodperc múlva újratölt.</div>';
fetch('/api/server/reboot', { method: 'POST', headers: csrfHeaders() })
.then(function(){ pollRestart(0); })
.catch(function(){ pollRestart(0); }); // the guest reboot drops the connection — poll regardless
}
function pollRestart(attempt) { function pollRestart(attempt) {
if (attempt > 60) { // ~2 min cap — never leave the user on a dead page silently if (attempt > 60) { // ~2 min cap — never leave the user on a dead page silently
document.getElementById('restart-status').innerHTML = document.getElementById('restart-status').innerHTML =