Hub v0.149.0: a box the household switched off is not an outage for 14 days (R-998)
gates / gates (push) Successful in 6m40s

hosts.deliberate_off_at follows the agent's deliberate_off field (set, then cleared by the first
report of a new boot; one info event each way). While held, every silence alarm is held: host and
node staleness, the 05:00 backup deadlines, off-site and restore-test freshness, the OS alarms and
the kernel notice. After 14 days the usual alarms fire once, naming the power-off. Pages show
KIKAPCSOLVA and the time. Consequence tests with controls; each guard red-proofed.
This commit is contained in:
2026-10-13 19:47:02 +02:00
parent ed59bf3cbd
commit 50e47e29af
24 changed files with 638 additions and 17 deletions
@@ -0,0 +1,63 @@
package web
import (
"database/sql"
"io"
"log"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-998: a box the household switched off reads KIKAPCSOLVA with the power-off line on the hosts page — not DOWN —
// exactly while the alarms are held; after the 14-day hold it reads DOWN again.
func TestHostsPage_DeliberatelyOffBox(t *testing.T) {
path := filepath.Join(t.TempDir(), "t.db")
st, err := store.New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ck", RetrievalPassword: "p"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"})
st.SaveHostReport("h1", "c1", []byte(`{}`), store.HostReportDenorm{})
age := func(d time.Duration) {
db, _ := sql.Open("sqlite", path)
defer db.Close()
db.Exec(`UPDATE hosts SET last_report_at = ? WHERE host_id = 'h1'`, time.Now().UTC().Add(-d).Format("2006-01-02 15:04:05"))
}
page := func() string {
rr := httptest.NewRecorder()
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/hosts", nil))
if rr.Code != 200 {
t.Fatalf("/hosts: %d", rr.Code)
}
return rr.Body.String()
}
age(3 * time.Hour)
if p := page(); !strings.Contains(p, ">DOWN<") || strings.Contains(p, "data-deliberate-off") {
t.Fatal("CONTROL: a silent box without a power-off must read DOWN")
}
at := time.Now().UTC().Add(-3 * time.Hour).Truncate(time.Second)
st.SetHostDeliberateOff("h1", &at)
p := page()
if !strings.Contains(p, ">KIKAPCSOLVA<") || strings.Contains(p, ">DOWN<") {
t.Fatal("a box switched off by the household must read KIKAPCSOLVA, not DOWN")
}
if !strings.Contains(p, "Kikapcsolva a háztartás által, "+at.Format("2006-01-02 15:04")) {
t.Fatal("the hosts page does not name the household power-off and its time")
}
old := time.Now().UTC().Add(-15 * 24 * time.Hour)
st.SetHostDeliberateOff("h1", &old)
age(15 * 24 * time.Hour)
if p := page(); !strings.Contains(p, ">DOWN<") {
t.Fatal("after the 14-day hold the box must read DOWN again")
}
}
+31 -4
View File
@@ -44,6 +44,26 @@ func (s *Server) hostStatus(lastReport *time.Time) string {
}
}
// hostDisplayStatus is hostStatus for the PAGES (R-998): a silent host the household switched off on purpose, inside
// the 14-day hold, reads "off" — not stale/down — exactly while the checkers hold its alarms (the same
// store.DeliberateOffHeld). Delete logic keeps reading hostStatus. Pinned by deliberate_off_view_test.go.
func (s *Server) hostDisplayStatus(h *store.Host) string {
st := s.hostStatus(h.LastReportAt)
if (st == "stale" || st == "down") && store.DeliberateOffHeld(h.DeliberateOffAt, time.Now()) {
return "off"
}
return st
}
// deliberateOffLine is the operator line for a household power-off ("" when none is held).
func deliberateOffLine(h *store.Host) string {
if h == nil || !store.DeliberateOffHeld(h.DeliberateOffAt, time.Now()) {
return ""
}
return "Kikapcsolva a háztartás által, " + h.DeliberateOffAt.UTC().Format("2006-01-02 15:04") + " UTC — riasztás szünetel eddig: " +
h.DeliberateOffAt.Add(store.DeliberateOffHold).UTC().Format("2006-01-02 15:04") + " UTC"
}
// hostStatusClass maps the internal status to the existing status-badge-* CSS class.
// "stale" reuses the amber -warn class (there is no dedicated -stale class), keeping the
// styling consistent with the rest of the console.
@@ -55,6 +75,8 @@ func hostStatusClass(status string) string {
return "status-badge-warn"
case "down":
return "status-badge-down"
case "off":
return "status-badge-pending" // grey: deliberate, not an incident (R-998)
default:
return "status-badge-pending"
}
@@ -69,6 +91,8 @@ func hostStatusLabel(status string) string {
return "STALE"
case "down":
return "DOWN"
case "off":
return "KIKAPCSOLVA" // R-998: switched off by the household
default:
return "NO REPORT"
}
@@ -511,8 +535,9 @@ type hostListRow struct {
CustomerID string
CustomerName string
AgentVersion string
Status string // ok | stale | down | pending
Status string // ok | stale | down | pending | off (R-998)
StatusLabel string
OffLine string // R-998: the household power-off line while the hold lasts
StatusClass string
LastReportAt *time.Time
HasReport bool
@@ -563,8 +588,9 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
rows := make([]hostListRow, 0, len(hosts))
for _, h := range hosts {
status := s.hostStatus(h.LastReportAt)
status := s.hostDisplayStatus(&h)
row := hostListRow{
OffLine: deliberateOffLine(&h),
HostID: h.HostID,
CustomerID: h.CustomerID,
CustomerName: s.customerName(h.CustomerID),
@@ -826,8 +852,9 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
"AgentVersion": host.AgentVersion,
"CreatedAt": host.CreatedAt,
"Status": status,
"StatusLabel": hostStatusLabel(status),
"StatusClass": hostStatusClass(status),
"StatusLabel": hostStatusLabel(s.hostDisplayStatus(host)),
"StatusClass": hostStatusClass(s.hostDisplayStatus(host)),
"OffLine": deliberateOffLine(host),
"LastReportAt": host.LastReportAt,
"HasReport": host.LastReportAt != nil,
"RecoveryMode": host.InRecoveryMode(time.Now()),
+4
View File
@@ -66,6 +66,10 @@ func (s *Server) foldHostStatus(customerID, base string, customerHasReported boo
}
worst, worstHost := "", ""
for i := range hosts {
// R-998: a host the household switched off (silent, inside the hold) is the customer's state, not an outage.
if s.hostDisplayStatus(&hosts[i]) == "off" {
return "off", deliberateOffLine(&hosts[i])
}
hs := s.hostStatus(hosts[i].LastReportAt)
if hs == "pending" && !customerHasReported {
continue
+1 -1
View File
@@ -1154,7 +1154,7 @@ func statusColor(status string) string {
return "crit"
case "blocked":
return "warn"
case "disabled", "pending":
case "disabled", "pending", "off":
return "neutral"
default:
return "neutral"
+1 -1
View File
@@ -65,7 +65,7 @@
<span class="status-badge status-badge-{{.OverallStatus}}">
{{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else if eq .OverallStatus "down"}}DOWN{{else if eq .OverallStatus "disabled"}}PAUSED{{else if eq .OverallStatus "pending"}}PENDING{{else}}{{.OverallStatus}}{{end}}
</span>
{{if .HostCause}}<span class="status-badge status-badge-warn">{{.HostCause}}</span>{{end}}
{{if .HostCause}}<span class="status-badge {{if eq .OverallStatus "off"}}status-badge-pending{{else}}status-badge-warn{{end}}">{{.HostCause}}</span>{{end}}
{{else}}
<span class="text-muted">—</span>
{{end}}
@@ -26,7 +26,7 @@
<h1>
<span class="status-dot status-dot-{{statusColor .OverallStatus}}"></span>
{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}
{{if .HostCause}}<span class="status-badge status-badge-warn">{{.HostCause}}</span>{{end}}
{{if .HostCause}}<span class="status-badge {{if eq .OverallStatus "off"}}status-badge-pending{{else}}status-badge-warn{{end}}">{{.HostCause}}</span>{{end}}
</h1>
{{if .HasReports}}
<p class="subtitle">
@@ -107,7 +107,7 @@
<div class="summary-strip">
<span class="strip-name">{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</span>
<span class="strip-item"><span class="status-dot status-dot-{{statusColor .OverallStatus}}"></span> {{.OverallStatus}}</span>
{{if .HostCause}}<span class="strip-item"><span class="status-badge status-badge-warn">{{.HostCause}}</span></span>{{end}}
{{if .HostCause}}<span class="strip-item"><span class="status-badge {{if eq .OverallStatus "off"}}status-badge-pending{{else}}status-badge-warn{{end}}">{{.HostCause}}</span></span>{{end}}
{{if .HasReports}}
<span class="strip-item">Controller <code>{{.Customer.ControllerVersion}}</code></span>
<span class="strip-item">Last report {{timeAgo .Customer.ReceivedAt}}</span>
+1 -1
View File
@@ -75,7 +75,7 @@
<span class="status-badge status-badge-{{.OverallStatus}}">
{{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else if eq .OverallStatus "disabled"}}PAUSED{{else if eq .OverallStatus "pending"}}PENDING{{else}}DOWN{{end}}
</span>
{{if .HostCause}}<span class="status-badge status-badge-warn">{{.HostCause}}</span>{{end}}
{{if .HostCause}}<span class="status-badge {{if eq .OverallStatus "off"}}status-badge-pending{{else}}status-badge-warn{{end}}">{{.HostCause}}</span>{{end}}
</td>
<td>{{if eq .OverallStatus "pending"}}—{{else}}{{if gt (add (add .EventCriticals .EventErrors) .EventWarnings) 0}}{{if gt .EventCriticals 0}}<span class="severity-badge severity-critical">{{.EventCriticals}}</span>{{end}}{{if gt .EventErrors 0}}<span class="severity-badge severity-error">{{.EventErrors}}</span>{{end}}{{if gt .EventWarnings 0}}<span class="severity-badge severity-warning">{{.EventWarnings}}</span>{{end}}{{else}}<span class="text-muted">—</span>{{end}}{{end}}</td>
<td>{{if eq .OverallStatus "pending"}}—{{else}}{{timeAgo .ReceivedAt}}{{end}}</td>
@@ -52,6 +52,10 @@
<span class="label">Last Report</span>
<span class="value">{{if .HasReport}}{{timeAgoPtr .LastReportAt}}{{else}}waiting for first report{{end}}</span>
</div>
{{if .OffLine}}<div class="info-item">
<span class="label">Power</span>
<span class="value" data-deliberate-off>{{.OffLine}}</span>
</div>{{end}}
<!-- R-30 slice 1: the box's wait-channel presence (in memory; ~4-min cadence), beside the
15-min report clock. Every branch pinned by r30_presence_delete_test.go. -->
<div class="info-item">
+1 -1
View File
@@ -104,7 +104,7 @@
<td>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</td>
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}{{if .FloorHeld}} <span class="status-badge status-warn" title="{{.HeldReason}}">floor held</span>{{else if eq .FloorSource "declared"}} <span class="status-badge" title="served above the vouched golden: the agent requirement was declared with the floor (R-472)">floor: declared MinAgent</span>{{end}}</td>
<td style="font-size: 0.85em;">{{.PVEVersion}}<br><span class="text-muted">{{.KernelRunning}}</span></td>
<td><span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span></td>
<td><span class="status-badge {{.StatusClass}}">{{.StatusLabel}}</span>{{if .OffLine}}<br><span class="text-muted" style="font-size: 0.85em;" data-deliberate-off>{{.OffLine}}</span>{{end}}</td>
<td>{{if .HasReport}}{{.GuestRunning}}/{{.GuestTotal}}{{else}}—{{end}}</td>
<td>{{if .HasReport}}{{formatFloat .Vitals.CPUPercent}}%{{else}}—{{end}}</td>
<td>{{if .HasReport}}{{formatFloat .Vitals.MemoryPercent}}%{{else}}—{{end}}</td>