v0.310.0: „A gép kikapcsolása" — the household turns the whole machine off (R-998); honest restart text (R-999)
gates / gates (push) Successful in 48s

POST /api/server/poweroff (dashboard login, POST only, the typed word KIKAPCSOLÁS / TURN OFF)
→ agent POST /host/poweroff (MinAgent 0.155.0; hidden for an older agent). The controller refuses
during its own backup/restore/app update; the agent's reasons are shown as household sentences.
„Kiszolgáló újraindítása" becomes „A Felhom újraindítása" and says it restarts the Felhom part;
/api/server/reboot is POST only (R-983). Tests red-proofed; parity fixtures regenerated on purpose.
This commit is contained in:
2026-10-13 20:17:30 +02:00
parent 1b2f74c52f
commit 201f936e9a
20 changed files with 743 additions and 41 deletions
+1
View File
@@ -2346,6 +2346,7 @@ func (s *Server) systemPageData() map[string]interface{} {
}
// Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability.
s.memoryCardData(data)
s.powerOffCardData(data) // R-998: hide „A gép kikapcsolása" only for a definitely-too-old agent
// „Hálózat" card (R-66): where the box IS, live-computed per render and stored NOWHERE — the
// guest holds its address by DHCP, so a stored copy eventually misdirects people (S-5); an
@@ -213,6 +213,7 @@ func i18nCasesA() []i18nCase {
d["NetLANAddress"], d["NetSMBName"], d["NetGateway"] = "192.168.1.10", "FELHOM", "192.168.1.1"
d["MemorySupported"], d["MemoryReachable"] = true, true
d["MemoryAllocatedMB"], d["MemoryUsageMB"], d["MemoryMinMB"], d["MemoryMaxMB"] = 4096, 2048, 2048, 8192
d["PowerOffSupported"] = true // R-998: the power-off card with its button
return d
}},
{"settings_system_failed", "settings_system", func() map[string]interface{} {
@@ -223,6 +224,7 @@ func i18nCasesA() []i18nCase {
d["RegistryAnonymous"] = false
d["LastUpdateState"] = map[string]interface{}{"Status": "failed", "Error": "pull failed"}
d["MemorySupported"], d["MemoryReachable"], d["MemoryProcMB"] = true, false, 4096
d["PowerOffSupported"] = true
return d
}},
{"settings_system_pending", "settings_system", func() map[string]interface{} {
@@ -231,11 +233,13 @@ func i18nCasesA() []i18nCase {
d["SelfUpdateEnabled"] = true
d["LastUpdateState"] = map[string]interface{}{"Status": "pending"}
d["MemorySupported"], d["MemoryReachable"], d["MemoryProcMB"] = true, false, 0
d["PowerOffSupported"] = true
return d
}},
{"settings_system_nomemory", "settings_system", func() map[string]interface{} {
d := i18nLayoutData("settings", "Rendszer beállítások")
d["MemorySupported"] = false
d["PowerOffSupported"] = false // R-998: an old agent — the card's one-line note instead of the button
return d
}},
{"settings_security_full", "settings_security", func() map[string]interface{} {
@@ -0,0 +1,142 @@
package web
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// ── „A gép kikapcsolása" — the household turns the whole machine off (R-998) ─────────────────────────────
//
// Design: felhom.eu documentation/architecture/14-host-power-off.md. The controller is the front door only; the
// agent's POST /host/poweroff holds the real guards (10 min after boot, one per boot, no other guest, no job).
// Here: dashboard login (RequireAuth + CSRF, wired in main.go), POST only, the typed confirmation word, and the
// controller's OWN busy work (a backup/restore/app update the agent cannot see). Pinned by poweroff_handlers_test.go.
// powerOffAgent is the agent seam: the call, and the version gate.
type powerOffAgent interface {
HostPowerOff(ctx context.Context) error
Supports(ctx context.Context, f agentapi.Feature) agentapi.SupportState
}
// powerOffConfirmWords are what the household may type. The unaccented spelling is accepted on purpose: a phone or
// a foreign keyboard may not have „Á", and the word is a deliberate act, not a password.
var powerOffConfirmWords = map[string]bool{"KIKAPCSOLÁS": true, "KIKAPCSOLAS": true, "TURN OFF": true}
func powerOffConfirmed(typed string) bool {
return powerOffConfirmWords[strings.ToUpper(strings.Join(strings.Fields(typed), " "))]
}
func (s *Server) powerOffAgentFor() (powerOffAgent, error) {
if s.powerOffAgentFn != nil {
return s.powerOffAgentFn()
}
c, err := s.agentClient()
if err != nil {
return nil, err
}
return c, nil
}
// powerOffLocalBusy is the controller-side work the agent cannot see.
func (s *Server) powerOffLocalBusy() (bool, string) {
if s.powerOffBusyFn != nil {
return s.powerOffBusyFn()
}
if s.backupMgr != nil && (s.backupMgr.IsRunning() || s.backupMgr.RestoreStatus().Running) {
return true, "a backup or restore is running"
}
if s.stackMgr != nil && s.stackMgr.AnyUpdating() {
return true, "an app update is running"
}
return false, ""
}
// powerOffCardData fills the settings card: hidden (with a note) only on a DEFINITE "agent too old".
func (s *Server) powerOffCardData(data map[string]interface{}) {
data["PowerOffSupported"] = true
agent, err := s.powerOffAgentFor()
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if agent.Supports(ctx, agentapi.FeatureHostPowerOff) == agentapi.SupportNo {
data["PowerOffSupported"] = false
}
}
// powerOffRefusalKey maps the agent's reason to the household sentence.
func powerOffRefusalKey(reason string) string {
switch reason {
case agentapi.PowerOffTooSoon:
return "poweroff.refused.too_soon"
case agentapi.PowerOffAlreadyAsked:
return "poweroff.refused.already"
case agentapi.PowerOffBackup, agentapi.PowerOffRestoreTest:
return "poweroff.refused.backup"
case agentapi.PowerOffOSUpdate, agentapi.PowerOffCtrlUpdate:
return "poweroff.refused.update"
case agentapi.PowerOffDrive:
return "poweroff.refused.drive"
case agentapi.PowerOffOtherGuests:
return "poweroff.refused.other_guests"
default:
return "poweroff.refused.generic"
}
}
// HandleServerPowerOff is POST /api/server/poweroff {"confirm": "<the word>"}.
func (s *Server) HandleServerPowerOff(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeDiskJSON(w, http.StatusMethodNotAllowed, false, "POST only", nil)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
var req struct {
Confirm string `json:"confirm"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if !powerOffConfirmed(req.Confirm) {
writeDiskJSON(w, http.StatusBadRequest, false, s.msg(r, "poweroff.confirm_required"), map[string]any{"code": "confirm_required"})
return
}
if busy, why := s.powerOffLocalBusy(); busy {
s.logger.Printf("[INFO] [web] power-off refused: %s", why)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, "poweroff.refused.backup"), map[string]any{"code": "controller_busy"})
return
}
agent, err := s.powerOffAgentFor()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.msg(r, "poweroff.agent_down"), nil)
return
}
if agent.Supports(r.Context(), agentapi.FeatureHostPowerOff) == agentapi.SupportNo {
writeDiskJSON(w, http.StatusPreconditionFailed, false, s.msg(r, "poweroff.agent_outdated"), map[string]any{"code": "agent_outdated"})
return
}
err = agent.HostPowerOff(r.Context())
if ref, ok := agentapi.IsPowerOffRefused(err); ok {
s.logger.Printf("[INFO] [web] power-off refused by the agent: %s (%s)", ref.Reason, ref.Detail)
writeDiskJSON(w, http.StatusConflict, false, s.msg(r, powerOffRefusalKey(ref.Reason)), map[string]any{"code": ref.Reason})
return
}
var se *agentapi.StatusError
if errors.As(err, &se) && se.Code == http.StatusNotFound {
writeDiskJSON(w, http.StatusPreconditionFailed, false, s.msg(r, "poweroff.agent_outdated"), map[string]any{"code": "agent_outdated"})
return
}
if err != nil {
s.logger.Printf("[ERROR] [web] power-off request failed: %v", err)
writeDiskJSON(w, http.StatusBadGateway, false, s.msg(r, "poweroff.agent_down"), nil)
return
}
s.logger.Printf("[WARN] [web] MACHINE POWER-OFF accepted (household request from the dashboard)")
writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"powering_off": true})
}
@@ -0,0 +1,144 @@
package web
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-998 — „A gép kikapcsolása". The consequence is asserted: was the agent asked to switch the machine off, or not?
type fakePowerAgent struct {
support agentapi.SupportState
err error
calls int
}
func (f *fakePowerAgent) HostPowerOff(context.Context) error { f.calls++; return f.err }
func (f *fakePowerAgent) Supports(context.Context, agentapi.Feature) agentapi.SupportState {
return f.support
}
func powerServer(t *testing.T, a *fakePowerAgent) *Server {
t.Helper()
s := testPageServer(t)
s.powerOffAgentFn = func() (powerOffAgent, error) { return a, nil }
s.powerOffBusyFn = func() (bool, string) { return false, "" }
return s
}
func postPowerOff(s *Server, method, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/api/server/poweroff", strings.NewReader(body))
w := httptest.NewRecorder()
s.HandleServerPowerOff(w, r)
return w
}
// Without the typed word nothing is switched off; with it (either language, either spelling) the agent is asked once.
func TestPowerOff_ConfirmationWordRequired(t *testing.T) {
for _, body := range []string{``, `{}`, `{"confirm":""}`, `{"confirm":"igen"}`, `{"confirm":"KIKAPCS"}`} {
a := &fakePowerAgent{support: agentapi.SupportYes}
w := postPowerOff(powerServer(t, a), http.MethodPost, body)
if w.Code != http.StatusBadRequest || a.calls != 0 {
t.Fatalf("body %q: got %d, agent asked %d times — want 400 and no call", body, w.Code, a.calls)
}
if !strings.Contains(w.Body.String(), "KIKAPCSOLÁS") {
t.Fatalf("the refusal does not name the word: %s", w.Body.String())
}
}
for _, word := range []string{"KIKAPCSOLÁS", "kikapcsolás", " KIKAPCSOLAS ", "TURN OFF", "turn off"} {
a := &fakePowerAgent{support: agentapi.SupportYes}
w := postPowerOff(powerServer(t, a), http.MethodPost, `{"confirm":"`+word+`"}`)
if w.Code != http.StatusAccepted || a.calls != 1 {
t.Fatalf("word %q: got %d (%s), calls %d — want 202 and one call", word, w.Code, w.Body.String(), a.calls)
}
}
}
// POST only: a GET (a link, an image tag) must never switch the machine off.
func TestPowerOff_PostOnly(t *testing.T) {
a := &fakePowerAgent{support: agentapi.SupportYes}
w := postPowerOff(powerServer(t, a), http.MethodGet, `{"confirm":"KIKAPCSOLÁS"}`)
if w.Code != http.StatusMethodNotAllowed || a.calls != 0 {
t.Fatalf("GET: %d, calls %d", w.Code, a.calls)
}
}
// The agent's refusal reaches the household as a sentence; the controller's own busy work refuses before the agent.
func TestPowerOff_RefusalsShown(t *testing.T) {
a := &fakePowerAgent{support: agentapi.SupportYes, err: &agentapi.PowerOffRefused{Reason: agentapi.PowerOffBackup}}
w := postPowerOff(powerServer(t, a), http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`)
if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "Most mentés vagy visszaállítás fut") ||
!strings.Contains(w.Body.String(), `"code":"backup_running"`) {
t.Fatalf("agent refusal: %d %s", w.Code, w.Body.String())
}
a2 := &fakePowerAgent{support: agentapi.SupportYes}
s := powerServer(t, a2)
s.powerOffBusyFn = func() (bool, string) { return true, "a backup or restore is running" }
if w := postPowerOff(s, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusConflict || a2.calls != 0 {
t.Fatalf("controller busy: %d, agent calls %d — want 409 and no call", w.Code, a2.calls)
}
}
// An agent older than the route: the card is HIDDEN with a one-line note, and the route answers agent_outdated
// without asking the agent. An agent that 404s anyway (version unknown) gets the same answer.
func TestPowerOff_HiddenForOldAgent(t *testing.T) {
old := &fakePowerAgent{support: agentapi.SupportNo}
s := powerServer(t, old)
page := getPage(t, s, "/settings").Body.String()
if strings.Contains(page, `id="btn-poweroff"`) || !strings.Contains(page, "data-poweroff-unavailable") {
t.Fatal("an old agent: the power-off button must be hidden, with the note")
}
if w := postPowerOff(s, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusPreconditionFailed || old.calls != 0 {
t.Fatalf("old agent: %d, calls %d", w.Code, old.calls)
}
cur := &fakePowerAgent{support: agentapi.SupportYes}
page = getPage(t, powerServer(t, cur), "/settings").Body.String()
if !strings.Contains(page, `id="btn-poweroff"`) || !strings.Contains(page, "A gép kikapcsolása") ||
!strings.Contains(page, "nyomd meg a gép bekapcsológombját") {
t.Fatal("a current agent: the card, its title and the power-button sentence must be on the page")
}
unknown := &fakePowerAgent{support: agentapi.SupportUnknown, err: &agentapi.StatusError{Method: "POST", Path: "/host/poweroff", Code: 404}}
if w := postPowerOff(powerServer(t, unknown), http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`); w.Code != http.StatusPreconditionFailed {
t.Fatalf("404 from an unknown-version agent: %d, want 412", w.Code)
}
}
// R-983 (one of seven): the restart route must not act on a GET.
func TestServerReboot_PostOnly(t *testing.T) {
s := testServer(t)
w := httptest.NewRecorder()
s.HandleServerReboot(w, httptest.NewRequest(http.MethodGet, "/api/server/reboot", nil))
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET /api/server/reboot = %d, want 405", w.Code)
}
}
// Every agent refusal reason maps to a household sentence that exists in BOTH languages (an unknown reason → the
// generic one), and an unreachable agent answers with its own sentence and asks nobody to switch anything off.
func TestPowerOff_EveryReasonHasASentence(t *testing.T) {
s := testPageServer(t)
b := s.bundle()
for _, reason := range []string{agentapi.PowerOffTooSoon, agentapi.PowerOffAlreadyAsked, agentapi.PowerOffBackup,
agentapi.PowerOffRestoreTest, agentapi.PowerOffOSUpdate, agentapi.PowerOffCtrlUpdate, agentapi.PowerOffDrive,
agentapi.PowerOffEscrow, agentapi.PowerOffBusy, agentapi.PowerOffOtherGuests, agentapi.PowerOffUnknown, "a_reason_from_the_future"} {
key := powerOffRefusalKey(reason)
for _, lang := range []string{"hu", "en"} {
if got := b.Msg(lang, key); got == "" || got == key {
t.Errorf("reason %q → key %q has no %s text", reason, key, lang)
}
}
}
down := testPageServer(t)
down.powerOffAgentFn = func() (powerOffAgent, error) { return nil, errors.New("agent not configured") }
down.powerOffBusyFn = func() (bool, string) { return false, "" }
w := postPowerOff(down, http.MethodPost, `{"confirm":"KIKAPCSOLÁS"}`)
if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), "nem éri el a gép kezelőjét") {
t.Fatalf("agent down: %d %s", w.Code, w.Body.String())
}
}
+4
View File
@@ -201,6 +201,10 @@ type Server struct {
memFeatures agentapi.SupportCache
memAgentFn func() (memAgent, error)
// R-998: test seams for the power-off route (nil in production → the real agent client / busy checks).
powerOffAgentFn func() (powerOffAgent, error)
powerOffBusyFn func() (bool, string)
// Asset syncer for Hub-managed assets (optional)
assetsSyncer *assets.Syncer
@@ -61,7 +61,7 @@ func TestSettingsSplitPagesRender(t *testing.T) {
mustNot []string
}{
{"/settings",
[]string{"Rendszer konfiguráció", "Verzió és frissítés", "Vezérlő újraindítása", "Kiszolgáló újraindítása"},
[]string{"Rendszer konfiguráció", "Verzió és frissítés", "Vezérlő újraindítása", "A Felhom újraindítása", "A gép kikapcsolása"},
[]string{"Adattárolók", "Jelszó módosítás", "Értesítési szünet"}},
{"/settings/notifications",
[]string{"Beállítások — Értesítések"},
@@ -110,7 +110,7 @@ func TestSettingsSectionInventory(t *testing.T) {
"Alkalmazás-email",
"Vészhelyzeti információk", // renamed from the misspelled "Veszhelyzeti informaciok"
"Vezérlő újraindítása",
"Kiszolgáló újraindítása",
"A Felhom újraindítása", // R-999: was „Kiszolgáló újraindítása" — it restarts the Felhom part, not the machine
}
var union strings.Builder
for _, f := range []string{"settings_system.html", "settings_notifications.html", "settings_security.html", "storage.html", "storage_network.html"} {
@@ -817,6 +817,12 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
// 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) {
// R-983 (one of its seven): CsrfProtect lets a GET through, so a link could restart the guest. POST only.
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeDiskJSON(w, http.StatusMethodNotAllowed, false, "POST only", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, s.errText(r, err), nil)
@@ -240,10 +240,15 @@ function openDialog(opts){
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)document.getElementById(\'dialog-cancel\').click()"><div class="confirm-box">'
+'<h3>'+escText(opts.title||'{{T "settings_system.megerosites"}}')+'</h3>'
+'<p style="white-space:pre-line">'+escText(opts.message||'')+'</p>'
+'<div class="form-actions"><button id="dialog-go" class="btn btn-primary">'+escText(opts.confirmLabel||'{{T "settings_system.megerosites"}}')+'</button>'
// R-998: typeToConfirm — the button stays off until the household types the word (the storage page's pattern).
+(opts.typeToConfirm?'<div class="confirm-input"><label>{{T "settings_system.megerositeshez_ird_be"}} <strong class="mono">'+escText(opts.typeToConfirm)+'</strong></label>'
+'<input type="text" id="dialog-type" class="form-control" autocomplete="off" placeholder="'+escText(opts.typeToConfirm)+'"></div>':'')
+'<div class="form-actions"><button id="dialog-go" class="btn '+(opts.typeToConfirm?'btn-danger-outline':'btn-primary')+'"'+(opts.typeToConfirm?' disabled':'')+'>'+escText(opts.confirmLabel||'{{T "settings_system.megerosites"}}')+'</button>'
+'<button type="button" class="btn btn-outline" id="dialog-cancel">{{T "common.megsem"}}</button></div>'
+'</div></div>';
document.getElementById('dialog-go').onclick=function(){ closeDialog(); if(opts.onConfirm) opts.onConfirm(); };
var typed=document.getElementById('dialog-type');
if(typed){ typed.oninput=function(){ document.getElementById('dialog-go').disabled=(this.value.trim().toUpperCase()!==opts.typeToConfirm.toUpperCase()); }; }
document.getElementById('dialog-go').onclick=function(){ var v=typed?typed.value:''; closeDialog(); if(opts.onConfirm) opts.onConfirm(v); };
document.getElementById('dialog-cancel').onclick=function(){ closeDialog(); if(opts.onCancel) opts.onCancel(); };
}
@@ -395,6 +400,20 @@ pollUntilBack();
<button type="button" class="btn btn-outline" id="btn-restart-server" onclick="restartServer()">{{T "settings_system.kiszolgalo_ujrainditasa"}}</button>
</div>
<!-- Section: R-998 — „A gép kikapcsolása": the whole machine off (agent POST /host/poweroff, doc 14). Dashboard login
only; the household types the word; the agent may still refuse (its reason is shown). Hidden for an old agent. -->
<div class="settings-card" id="poweroff-card">
<h3>{{T "settings_system.a_gep_kikapcsolasa"}}</h3>
{{if .PowerOffSupported}}
<p class="settings-card-desc">{{T "settings_system.a_gep_teljesen_kikapcsol"}}</p>
<p class="settings-card-desc"><strong>{{T "settings_system.ujra_bekapcsolni_csak_otthon"}}</strong></p>
<div id="poweroff-status"></div>
<button type="button" class="btn btn-danger-outline" id="btn-poweroff" onclick="powerOffMachine()">{{T "settings_system.a_gep_kikapcsolasa"}}</button>
{{else}}
<p class="settings-card-desc" data-poweroff-unavailable>{{T "settings_system.kikapcsolas_frissites_utan"}}</p>
{{end}}
</div>
<script>
function restartController() {
openDialog({title:'{{T "settings_system.vezerlo_ujrainditasa"}}', confirmLabel:'{{T "common.ujrainditas"}}',
@@ -424,6 +443,32 @@ function doRestartServer() {
.then(function(){ pollRestart(0); })
.catch(function(){ pollRestart(0); }); // the guest reboot drops the connection — poll regardless
}
function powerOffMachine() {
var word = '{{T "settings_system.kikapcsolas_szo"}}';
openDialog({title:'{{T "settings_system.a_gep_kikapcsolasa"}}', confirmLabel:'{{T "settings_system.kikapcsolas"}}',
message:'{{T "settings_system.biztosan_kikapcsolod_a_gepet"}}', typeToConfirm: word,
onConfirm:doPowerOffMachine});
}
function doPowerOffMachine(typed) {
var btn = document.getElementById('btn-poweroff');
var status = document.getElementById('poweroff-status');
if (btn) btn.disabled = true;
var h = csrfHeaders(); h['Content-Type'] = 'application/json';
fetch('/api/server/poweroff', { method: 'POST', headers: h, body: JSON.stringify({confirm: typed}) })
.then(function(r){ return r.json().catch(function(){ return {ok:false}; }); })
.then(function(d){
if (d && d.ok) {
status.innerHTML = '<div class="alert alert-info">' + escText('{{T "settings_system.a_gep_kikapcsol_folyamatban"}}') + '</div>';
return; // the box goes down — nothing to poll
}
status.innerHTML = '<div class="alert alert-error">' + escText((d && d.error) || '{{T "common.hiba_tortent"}}') + '</div>';
if (btn) btn.disabled = false;
})
.catch(function(){
status.innerHTML = '<div class="alert alert-error">' + escText('{{T "settings_system.kapcsolodasi_hiba"}}') + '</div>';
if (btn) btn.disabled = false;
});
}
function pollRestart(attempt) {
if (attempt > 60) { // ~2 min cap — never leave the user on a dead page silently
document.getElementById('restart-status').innerHTML =
@@ -333,10 +333,15 @@ function openDialog(opts){
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)document.getElementById(\'dialog-cancel\').click()"><div class="confirm-box">'
+'<h3>'+escText(opts.title||'Megerősítés')+'</h3>'
+'<p style="white-space:pre-line">'+escText(opts.message||'')+'</p>'
+'<div class="form-actions"><button id="dialog-go" class="btn btn-primary">'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+(opts.typeToConfirm?'<div class="confirm-input"><label>Megerősítéshez írd be: <strong class="mono">'+escText(opts.typeToConfirm)+'</strong></label>'
+'<input type="text" id="dialog-type" class="form-control" autocomplete="off" placeholder="'+escText(opts.typeToConfirm)+'"></div>':'')
+'<div class="form-actions"><button id="dialog-go" class="btn '+(opts.typeToConfirm?'btn-danger-outline':'btn-primary')+'"'+(opts.typeToConfirm?' disabled':'')+'>'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+'<button type="button" class="btn btn-outline" id="dialog-cancel">Mégsem</button></div>'
+'</div></div>';
document.getElementById('dialog-go').onclick=function(){ closeDialog(); if(opts.onConfirm) opts.onConfirm(); };
var typed=document.getElementById('dialog-type');
if(typed){ typed.oninput=function(){ document.getElementById('dialog-go').disabled=(this.value.trim().toUpperCase()!==opts.typeToConfirm.toUpperCase()); }; }
document.getElementById('dialog-go').onclick=function(){ var v=typed?typed.value:''; closeDialog(); if(opts.onConfirm) opts.onConfirm(v); };
document.getElementById('dialog-cancel').onclick=function(){ closeDialog(); if(opts.onCancel) opts.onCancel(); };
}
@@ -479,12 +484,23 @@ function pollUntilBack() {
<div class="settings-card">
<h3>Kiszolgáló újraindítása</h3>
<h3>A Felhom ú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áld, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.
Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. Ha csak a vezérlőpult akad, a fenti „Vezérlő újraindítása” elég.
</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>
<button type="button" class="btn btn-outline" id="btn-restart-server" onclick="restartServer()">A Felhom újraindítása</button>
</div>
<div class="settings-card" id="poweroff-card">
<h3>A gép kikapcsolása</h3>
<p class="settings-card-desc">A gép teljesen kikapcsol. Az alkalmazásaid és a mentések leállnak.</p>
<p class="settings-card-desc"><strong>Újra bekapcsolni csak otthon lehet: nyomd meg a gép bekapcsológombját.</strong></p>
<div id="poweroff-status"></div>
<button type="button" class="btn btn-danger-outline" id="btn-poweroff" onclick="powerOffMachine()">A gép kikapcsolása</button>
</div>
<script>
@@ -503,8 +519,8 @@ function doRestartController() {
.catch(function(){ pollRestart(0); });
}
function restartServer() {
openDialog({title:'Kiszolgáló újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.',
openDialog({title:'A Felhom újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a Felhomot? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak. A gép bekapcsolva marad.',
onConfirm:doRestartServer});
}
function doRestartServer() {
@@ -516,6 +532,32 @@ function doRestartServer() {
.then(function(){ pollRestart(0); })
.catch(function(){ pollRestart(0); });
}
function powerOffMachine() {
var word = 'KIKAPCSOLÁS';
openDialog({title:'A gép kikapcsolása', confirmLabel:'Kikapcsolás',
message:'Biztosan kikapcsolod a gépet? A Felhom leáll, és a gép kikapcsol. Távolról senki nem tudja újra bekapcsolni: csak otthon, a gép bekapcsológombjával.', typeToConfirm: word,
onConfirm:doPowerOffMachine});
}
function doPowerOffMachine(typed) {
var btn = document.getElementById('btn-poweroff');
var status = document.getElementById('poweroff-status');
if (btn) btn.disabled = true;
var h = csrfHeaders(); h['Content-Type'] = 'application/json';
fetch('/api/server/poweroff', { method: 'POST', headers: h, body: JSON.stringify({confirm: typed}) })
.then(function(r){ return r.json().catch(function(){ return {ok:false}; }); })
.then(function(d){
if (d && d.ok) {
status.innerHTML = '<div class="alert alert-info">' + escText('A gép kikapcsol. Néhány perc múlva teljesen leáll, és ez az oldal nem válaszol többé. Újra bekapcsolni: nyomd meg a gép bekapcsológombját.') + '</div>';
return;
}
status.innerHTML = '<div class="alert alert-error">' + escText((d && d.error) || 'Hiba történt') + '</div>';
if (btn) btn.disabled = false;
})
.catch(function(){
status.innerHTML = '<div class="alert alert-error">' + escText('Kapcsolódási hiba') + '</div>';
if (btn) btn.disabled = false;
});
}
function pollRestart(attempt) {
if (attempt > 60) {
document.getElementById('restart-status').innerHTML =
@@ -395,10 +395,15 @@ function openDialog(opts){
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)document.getElementById(\'dialog-cancel\').click()"><div class="confirm-box">'
+'<h3>'+escText(opts.title||'Megerősítés')+'</h3>'
+'<p style="white-space:pre-line">'+escText(opts.message||'')+'</p>'
+'<div class="form-actions"><button id="dialog-go" class="btn btn-primary">'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+(opts.typeToConfirm?'<div class="confirm-input"><label>Megerősítéshez írd be: <strong class="mono">'+escText(opts.typeToConfirm)+'</strong></label>'
+'<input type="text" id="dialog-type" class="form-control" autocomplete="off" placeholder="'+escText(opts.typeToConfirm)+'"></div>':'')
+'<div class="form-actions"><button id="dialog-go" class="btn '+(opts.typeToConfirm?'btn-danger-outline':'btn-primary')+'"'+(opts.typeToConfirm?' disabled':'')+'>'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+'<button type="button" class="btn btn-outline" id="dialog-cancel">Mégsem</button></div>'
+'</div></div>';
document.getElementById('dialog-go').onclick=function(){ closeDialog(); if(opts.onConfirm) opts.onConfirm(); };
var typed=document.getElementById('dialog-type');
if(typed){ typed.oninput=function(){ document.getElementById('dialog-go').disabled=(this.value.trim().toUpperCase()!==opts.typeToConfirm.toUpperCase()); }; }
document.getElementById('dialog-go').onclick=function(){ var v=typed?typed.value:''; closeDialog(); if(opts.onConfirm) opts.onConfirm(v); };
document.getElementById('dialog-cancel').onclick=function(){ closeDialog(); if(opts.onCancel) opts.onCancel(); };
}
@@ -543,12 +548,23 @@ pollUntilBack();
<div class="settings-card">
<h3>Kiszolgáló újraindítása</h3>
<h3>A Felhom ú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áld, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.
Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. Ha csak a vezérlőpult akad, a fenti „Vezérlő újraindítása” elég.
</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>
<button type="button" class="btn btn-outline" id="btn-restart-server" onclick="restartServer()">A Felhom újraindítása</button>
</div>
<div class="settings-card" id="poweroff-card">
<h3>A gép kikapcsolása</h3>
<p class="settings-card-desc">A gép teljesen kikapcsol. Az alkalmazásaid és a mentések leállnak.</p>
<p class="settings-card-desc"><strong>Újra bekapcsolni csak otthon lehet: nyomd meg a gép bekapcsológombját.</strong></p>
<div id="poweroff-status"></div>
<button type="button" class="btn btn-danger-outline" id="btn-poweroff" onclick="powerOffMachine()">A gép kikapcsolása</button>
</div>
<script>
@@ -567,8 +583,8 @@ function doRestartController() {
.catch(function(){ pollRestart(0); });
}
function restartServer() {
openDialog({title:'Kiszolgáló újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.',
openDialog({title:'A Felhom újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a Felhomot? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak. A gép bekapcsolva marad.',
onConfirm:doRestartServer});
}
function doRestartServer() {
@@ -580,6 +596,32 @@ function doRestartServer() {
.then(function(){ pollRestart(0); })
.catch(function(){ pollRestart(0); });
}
function powerOffMachine() {
var word = 'KIKAPCSOLÁS';
openDialog({title:'A gép kikapcsolása', confirmLabel:'Kikapcsolás',
message:'Biztosan kikapcsolod a gépet? A Felhom leáll, és a gép kikapcsol. Távolról senki nem tudja újra bekapcsolni: csak otthon, a gép bekapcsológombjával.', typeToConfirm: word,
onConfirm:doPowerOffMachine});
}
function doPowerOffMachine(typed) {
var btn = document.getElementById('btn-poweroff');
var status = document.getElementById('poweroff-status');
if (btn) btn.disabled = true;
var h = csrfHeaders(); h['Content-Type'] = 'application/json';
fetch('/api/server/poweroff', { method: 'POST', headers: h, body: JSON.stringify({confirm: typed}) })
.then(function(r){ return r.json().catch(function(){ return {ok:false}; }); })
.then(function(d){
if (d && d.ok) {
status.innerHTML = '<div class="alert alert-info">' + escText('A gép kikapcsol. Néhány perc múlva teljesen leáll, és ez az oldal nem válaszol többé. Újra bekapcsolni: nyomd meg a gép bekapcsológombját.') + '</div>';
return;
}
status.innerHTML = '<div class="alert alert-error">' + escText((d && d.error) || 'Hiba történt') + '</div>';
if (btn) btn.disabled = false;
})
.catch(function(){
status.innerHTML = '<div class="alert alert-error">' + escText('Kapcsolódási hiba') + '</div>';
if (btn) btn.disabled = false;
});
}
function pollRestart(attempt) {
if (attempt > 60) {
document.getElementById('restart-status').innerHTML =
@@ -282,10 +282,15 @@ function openDialog(opts){
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)document.getElementById(\'dialog-cancel\').click()"><div class="confirm-box">'
+'<h3>'+escText(opts.title||'Megerősítés')+'</h3>'
+'<p style="white-space:pre-line">'+escText(opts.message||'')+'</p>'
+'<div class="form-actions"><button id="dialog-go" class="btn btn-primary">'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+(opts.typeToConfirm?'<div class="confirm-input"><label>Megerősítéshez írd be: <strong class="mono">'+escText(opts.typeToConfirm)+'</strong></label>'
+'<input type="text" id="dialog-type" class="form-control" autocomplete="off" placeholder="'+escText(opts.typeToConfirm)+'"></div>':'')
+'<div class="form-actions"><button id="dialog-go" class="btn '+(opts.typeToConfirm?'btn-danger-outline':'btn-primary')+'"'+(opts.typeToConfirm?' disabled':'')+'>'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+'<button type="button" class="btn btn-outline" id="dialog-cancel">Mégsem</button></div>'
+'</div></div>';
document.getElementById('dialog-go').onclick=function(){ closeDialog(); if(opts.onConfirm) opts.onConfirm(); };
var typed=document.getElementById('dialog-type');
if(typed){ typed.oninput=function(){ document.getElementById('dialog-go').disabled=(this.value.trim().toUpperCase()!==opts.typeToConfirm.toUpperCase()); }; }
document.getElementById('dialog-go').onclick=function(){ var v=typed?typed.value:''; closeDialog(); if(opts.onConfirm) opts.onConfirm(v); };
document.getElementById('dialog-cancel').onclick=function(){ closeDialog(); if(opts.onCancel) opts.onCancel(); };
}
@@ -428,12 +433,20 @@ function pollUntilBack() {
<div class="settings-card">
<h3>Kiszolgáló újraindítása</h3>
<h3>A Felhom ú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áld, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.
Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. Ha csak a vezérlőpult akad, a fenti „Vezérlő újraindítása” elég.
</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>
<button type="button" class="btn btn-outline" id="btn-restart-server" onclick="restartServer()">A Felhom újraindítása</button>
</div>
<div class="settings-card" id="poweroff-card">
<h3>A gép kikapcsolása</h3>
<p class="settings-card-desc" data-poweroff-unavailable>A gép kikapcsolásához a gép szoftverének frissítése kell. Ez magától megtörténik, addig ez a gomb nem látszik.</p>
</div>
<script>
@@ -452,8 +465,8 @@ function doRestartController() {
.catch(function(){ pollRestart(0); });
}
function restartServer() {
openDialog({title:'Kiszolgáló újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.',
openDialog({title:'A Felhom újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a Felhomot? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak. A gép bekapcsolva marad.',
onConfirm:doRestartServer});
}
function doRestartServer() {
@@ -465,6 +478,32 @@ function doRestartServer() {
.then(function(){ pollRestart(0); })
.catch(function(){ pollRestart(0); });
}
function powerOffMachine() {
var word = 'KIKAPCSOLÁS';
openDialog({title:'A gép kikapcsolása', confirmLabel:'Kikapcsolás',
message:'Biztosan kikapcsolod a gépet? A Felhom leáll, és a gép kikapcsol. Távolról senki nem tudja újra bekapcsolni: csak otthon, a gép bekapcsológombjával.', typeToConfirm: word,
onConfirm:doPowerOffMachine});
}
function doPowerOffMachine(typed) {
var btn = document.getElementById('btn-poweroff');
var status = document.getElementById('poweroff-status');
if (btn) btn.disabled = true;
var h = csrfHeaders(); h['Content-Type'] = 'application/json';
fetch('/api/server/poweroff', { method: 'POST', headers: h, body: JSON.stringify({confirm: typed}) })
.then(function(r){ return r.json().catch(function(){ return {ok:false}; }); })
.then(function(d){
if (d && d.ok) {
status.innerHTML = '<div class="alert alert-info">' + escText('A gép kikapcsol. Néhány perc múlva teljesen leáll, és ez az oldal nem válaszol többé. Újra bekapcsolni: nyomd meg a gép bekapcsológombját.') + '</div>';
return;
}
status.innerHTML = '<div class="alert alert-error">' + escText((d && d.error) || 'Hiba történt') + '</div>';
if (btn) btn.disabled = false;
})
.catch(function(){
status.innerHTML = '<div class="alert alert-error">' + escText('Kapcsolódási hiba') + '</div>';
if (btn) btn.disabled = false;
});
}
function pollRestart(attempt) {
if (attempt > 60) {
document.getElementById('restart-status').innerHTML =
@@ -323,10 +323,15 @@ function openDialog(opts){
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)document.getElementById(\'dialog-cancel\').click()"><div class="confirm-box">'
+'<h3>'+escText(opts.title||'Megerősítés')+'</h3>'
+'<p style="white-space:pre-line">'+escText(opts.message||'')+'</p>'
+'<div class="form-actions"><button id="dialog-go" class="btn btn-primary">'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+(opts.typeToConfirm?'<div class="confirm-input"><label>Megerősítéshez írd be: <strong class="mono">'+escText(opts.typeToConfirm)+'</strong></label>'
+'<input type="text" id="dialog-type" class="form-control" autocomplete="off" placeholder="'+escText(opts.typeToConfirm)+'"></div>':'')
+'<div class="form-actions"><button id="dialog-go" class="btn '+(opts.typeToConfirm?'btn-danger-outline':'btn-primary')+'"'+(opts.typeToConfirm?' disabled':'')+'>'+escText(opts.confirmLabel||'Megerősítés')+'</button>'
+'<button type="button" class="btn btn-outline" id="dialog-cancel">Mégsem</button></div>'
+'</div></div>';
document.getElementById('dialog-go').onclick=function(){ closeDialog(); if(opts.onConfirm) opts.onConfirm(); };
var typed=document.getElementById('dialog-type');
if(typed){ typed.oninput=function(){ document.getElementById('dialog-go').disabled=(this.value.trim().toUpperCase()!==opts.typeToConfirm.toUpperCase()); }; }
document.getElementById('dialog-go').onclick=function(){ var v=typed?typed.value:''; closeDialog(); if(opts.onConfirm) opts.onConfirm(v); };
document.getElementById('dialog-cancel').onclick=function(){ closeDialog(); if(opts.onCancel) opts.onCancel(); };
}
@@ -469,12 +474,23 @@ function pollUntilBack() {
<div class="settings-card">
<h3>Kiszolgáló újraindítása</h3>
<h3>A Felhom ú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áld, ha a teljes rendszer újraindítására van szükség — egyébként a fenti „Vezérlő újraindítása” elegendő.
Újraindítja a Felhom részt a gépen: a vezérlőpultot és az összes alkalmazást. A gép maga bekapcsolva marad. Kb. 30 másodpercig semmi nem érhető el, utána minden magától visszajön. Ha csak a vezérlőpult akad, a fenti „Vezérlő újraindítása” elég.
</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>
<button type="button" class="btn btn-outline" id="btn-restart-server" onclick="restartServer()">A Felhom újraindítása</button>
</div>
<div class="settings-card" id="poweroff-card">
<h3>A gép kikapcsolása</h3>
<p class="settings-card-desc">A gép teljesen kikapcsol. Az alkalmazásaid és a mentések leállnak.</p>
<p class="settings-card-desc"><strong>Újra bekapcsolni csak otthon lehet: nyomd meg a gép bekapcsológombját.</strong></p>
<div id="poweroff-status"></div>
<button type="button" class="btn btn-danger-outline" id="btn-poweroff" onclick="powerOffMachine()">A gép kikapcsolása</button>
</div>
<script>
@@ -493,8 +509,8 @@ function doRestartController() {
.catch(function(){ pollRestart(0); });
}
function restartServer() {
openDialog({title:'Kiszolgáló újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a kiszolgálót? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak.',
openDialog({title:'A Felhom újraindítása', confirmLabel:'Újraindítás',
message:'Biztosan újraindítod a Felhomot? Az alkalmazások és a vezérlőpult kb. 30 másodpercre elérhetetlenné válnak. A gép bekapcsolva marad.',
onConfirm:doRestartServer});
}
function doRestartServer() {
@@ -506,6 +522,32 @@ function doRestartServer() {
.then(function(){ pollRestart(0); })
.catch(function(){ pollRestart(0); });
}
function powerOffMachine() {
var word = 'KIKAPCSOLÁS';
openDialog({title:'A gép kikapcsolása', confirmLabel:'Kikapcsolás',
message:'Biztosan kikapcsolod a gépet? A Felhom leáll, és a gép kikapcsol. Távolról senki nem tudja újra bekapcsolni: csak otthon, a gép bekapcsológombjával.', typeToConfirm: word,
onConfirm:doPowerOffMachine});
}
function doPowerOffMachine(typed) {
var btn = document.getElementById('btn-poweroff');
var status = document.getElementById('poweroff-status');
if (btn) btn.disabled = true;
var h = csrfHeaders(); h['Content-Type'] = 'application/json';
fetch('/api/server/poweroff', { method: 'POST', headers: h, body: JSON.stringify({confirm: typed}) })
.then(function(r){ return r.json().catch(function(){ return {ok:false}; }); })
.then(function(d){
if (d && d.ok) {
status.innerHTML = '<div class="alert alert-info">' + escText('A gép kikapcsol. Néhány perc múlva teljesen leáll, és ez az oldal nem válaszol többé. Újra bekapcsolni: nyomd meg a gép bekapcsológombját.') + '</div>';
return;
}
status.innerHTML = '<div class="alert alert-error">' + escText((d && d.error) || 'Hiba történt') + '</div>';
if (btn) btn.disabled = false;
})
.catch(function(){
status.innerHTML = '<div class="alert alert-error">' + escText('Kapcsolódási hiba') + '</div>';
if (btn) btn.disabled = false;
});
}
function pollRestart(attempt) {
if (attempt > 60) {
document.getElementById('restart-status').innerHTML =