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
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user