hub v0.132.0: the System page (versions + OS updates with the ring/switch/approve buttons, R-852), the Hosts Proxmox/kernel column, the operator-approved Docker engine release (2 healthy ring-0 nights), the crash-guard events (R-851); evidence audits/os-docker-crash-2026-10-04
gates / gates (push) Successful in 30s
gates / gates (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts"
|
||||
)
|
||||
|
||||
// agentOrUnknown renders an agent version for operator text, mapping the empty (never-reported)
|
||||
@@ -502,6 +503,8 @@ func parseHostStorageTargets(reportJSON string) []storageTargetView {
|
||||
|
||||
// hostListRow is the per-host view model for the fleet list.
|
||||
type hostListRow struct {
|
||||
PVEVersion string // R-852 (hub v0.132.0): from the report's system stanza; "unknown" when not reported
|
||||
KernelRunning string
|
||||
HostID string
|
||||
CustomerID string
|
||||
CustomerName string
|
||||
@@ -590,8 +593,14 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Vitals from the latest report body.
|
||||
row.PVEVersion, row.KernelRunning = sysfacts.Unknown, sysfacts.Unknown
|
||||
if reportJSON, _ := s.store.GetLatestHostReportJSON(h.CustomerID); reportJSON != "" {
|
||||
row.Vitals = parseHostVitals(reportJSON)
|
||||
sf := sysfacts.Parse(reportJSON)
|
||||
row.PVEVersion, row.KernelRunning = sysfacts.ShortPVE(sf.PVEVersion), sf.Host.KernelRunning
|
||||
if row.KernelRunning == sysfacts.Unknown {
|
||||
row.KernelRunning = sysfacts.ShortKernel(sf.KernelVersion)
|
||||
}
|
||||
}
|
||||
|
||||
if wf, ok := worstFill[h.HostID]; ok {
|
||||
|
||||
@@ -2,7 +2,9 @@ package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -12,13 +14,27 @@ import (
|
||||
// POST /os/ring/<host_id> ring=0|1
|
||||
// POST /os/enabled/<host_id> on=1|0
|
||||
// POST /os/approve-now approve the current ring-0 set at once (an operator event)
|
||||
// POST /os/approve-docker approve the Docker engine set ring 0 ran 2 healthy nights (`11` §5.8)
|
||||
// (a form field return=/system makes any POST answer with a redirect to the System page)
|
||||
// GET /os/fleet one line per box (JSON)
|
||||
func (s *Server) handleOSAdmin(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if s.osUpdates == nil {
|
||||
http.Error(w, "os updates not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
// A button on the System page posts return=/system: answer with a redirect and a flash, never JSON.
|
||||
fromPage := r.Method == http.MethodPost && r.FormValue("return") == "/system"
|
||||
reply := func(v any, err error) {
|
||||
if fromPage {
|
||||
q := "flash=done"
|
||||
if err != nil {
|
||||
q = "err=" + url.QueryEscape(err.Error())
|
||||
} else if m, ok := v.(map[string]string); ok && m["release_id"] != "" {
|
||||
q = "flash=" + url.QueryEscape("approved "+m["release_id"])
|
||||
}
|
||||
http.Redirect(w, r, "/system?"+q, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -46,6 +62,14 @@ func (s *Server) handleOSAdmin(w http.ResponseWriter, r *http.Request, path stri
|
||||
case r.Method == http.MethodPost && path == "/os/approve-now":
|
||||
id, err := s.osUpdates.ApproveNow()
|
||||
reply(map[string]string{"release_id": id}, err)
|
||||
case r.Method == http.MethodPost && path == "/os/approve-docker":
|
||||
view, ok := s.osUpdates.(OSSystemView)
|
||||
if !ok {
|
||||
reply(nil, fmt.Errorf("docker approval not available"))
|
||||
return
|
||||
}
|
||||
id, err := view.ApproveDocker()
|
||||
reply(map[string]string{"release_id": id}, err)
|
||||
default:
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -457,6 +457,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
|
||||
case path == "/hosts" || path == "/hosts/":
|
||||
s.handleHostsList(w, r)
|
||||
// System — every box's versions + OS updates, with the operator's buttons (hub v0.132.0, R-852).
|
||||
case path == "/system":
|
||||
s.handleSystem(w, r)
|
||||
// R-21 slice C — unclaimed-appliance operator actions (bind/discard). POST only.
|
||||
case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/bind"):
|
||||
if id, ok := parseApplianceID(path, "bind"); ok && r.Method == http.MethodPost {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/osupdates"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/sysfacts"
|
||||
)
|
||||
|
||||
// The System page (hub v0.132.0; R-852, `09` decision 89, `11` §5.7): every box's real versions and its OS-update
|
||||
// state, with the operator's buttons (ring, switch, approve now, approve the Docker set). Read from the hub's own
|
||||
// records only: the newest host report's `system` stanza (agent ≥ v0.142.0) and the OS fleet lines.
|
||||
|
||||
// cell is one value with its colour: "" plain, "warn" amber, "bad" red (red = an alarm would fire).
|
||||
type cell struct {
|
||||
Text string
|
||||
Class string
|
||||
Title string
|
||||
}
|
||||
|
||||
type systemRow struct {
|
||||
HostID, CustomerName string
|
||||
Ring int
|
||||
Enabled bool
|
||||
Tunnel cell
|
||||
HasFacts bool
|
||||
FactsNote string
|
||||
// host
|
||||
PVE, KernelRunning, KernelNextBoot, HostDebian cell
|
||||
HostRelease, HostPending, HostNotCovered cell
|
||||
Held, RebootSince, KernelPanic, Oops cell
|
||||
CrashRestarts24h, Guard cell
|
||||
// guest
|
||||
GuestDebian, GuestRelease, GuestPending, GuestRestart cell
|
||||
// docker
|
||||
Engine, Containerd, LiveRestore, DockerRelease cell
|
||||
// last leg
|
||||
LastLeg cell
|
||||
}
|
||||
|
||||
// OSSystemView is what the System page needs beyond OSUpdateAdmin (implemented by *osupdates.Service).
|
||||
type OSSystemView interface {
|
||||
Fleet() ([]osupdates.FleetLine, error)
|
||||
Releases() []osupdates.ReleaseInfo
|
||||
Candidates() []osupdates.Status
|
||||
Thresholds() (stale, reboot, notCovered time.Duration)
|
||||
ApproveDocker() (string, error)
|
||||
}
|
||||
|
||||
func plain(s string) cell { return cell{Text: s} }
|
||||
|
||||
func unknownCell(s string) cell {
|
||||
if s == "" || s == sysfacts.Unknown {
|
||||
return cell{Text: "unknown", Class: "warn", Title: "the box could not read it (agent older than v0.142.0, or the guest is down)"}
|
||||
}
|
||||
return cell{Text: s}
|
||||
}
|
||||
|
||||
func ago(t time.Time, now time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
d := now.Sub(t)
|
||||
switch {
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d min ago", int(d.Minutes()))
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%d h ago", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||||
}
|
||||
|
||||
// buildSystemRows is pure (the render test feeds it directly).
|
||||
func buildSystemRows(lines []osupdates.FleetLine, facts map[string]sysfacts.System, names map[string]string,
|
||||
stale, reboot, notCov time.Duration, now time.Time) []systemRow {
|
||||
var rows []systemRow
|
||||
for _, l := range lines {
|
||||
f := facts[l.HostID]
|
||||
r := systemRow{HostID: l.HostID, CustomerName: names[l.HostID], Ring: l.Ring, Enabled: l.Enabled, HasFacts: f.Present}
|
||||
switch l.Tunnel {
|
||||
case "running":
|
||||
r.Tunnel = plain("running")
|
||||
case "not_running", "inactive":
|
||||
r.Tunnel = cell{Text: l.Tunnel, Class: "bad"}
|
||||
default:
|
||||
r.Tunnel = cell{Text: l.Tunnel, Class: "warn"}
|
||||
}
|
||||
if !f.Present {
|
||||
r.FactsNote = "no versions reported (agent older than v0.142.0)"
|
||||
} else if f.FactsError != "" {
|
||||
r.FactsNote = "partial: " + f.FactsError
|
||||
}
|
||||
r.PVE = unknownCell(sysfacts.ShortPVE(f.PVEVersion))
|
||||
r.KernelRunning = unknownCell(f.Host.KernelRunning)
|
||||
r.KernelNextBoot = unknownCell(f.Host.KernelNextBoot)
|
||||
if f.NextBootDiffers() {
|
||||
r.KernelNextBoot.Class, r.KernelNextBoot.Title = "warn", "the next boot changes the kernel ("+f.Host.KernelNextBootSource+")"
|
||||
}
|
||||
r.HostDebian = unknownCell(f.Host.Debian)
|
||||
r.HostRelease = plain(orDash(l.Host.ReleaseID))
|
||||
r.HostPending = plain(fmt.Sprint(l.Host.Pending))
|
||||
r.HostNotCovered = plain(fmt.Sprint(l.Host.NotCoveredFast))
|
||||
if l.Host.NotCoveredFast > 0 {
|
||||
r.HostNotCovered.Class = "warn"
|
||||
}
|
||||
switch {
|
||||
case f.Host.Held == nil:
|
||||
r.Held = unknownCell("")
|
||||
case len(f.Host.Held) == 0:
|
||||
r.Held = plain("none")
|
||||
default:
|
||||
r.Held = cell{Text: strings.Join(f.Host.Held, ", "), Class: "warn", Title: "held by hand (an undo) — the hub cannot see it otherwise (R-848)"}
|
||||
}
|
||||
if l.Host.RebootNeededSince.IsZero() {
|
||||
r.RebootSince = plain("no")
|
||||
} else {
|
||||
r.RebootSince = cell{Text: "since " + l.Host.RebootNeededSince.UTC().Format("2006-01-02"), Class: "warn"}
|
||||
if now.Sub(l.Host.RebootNeededSince) >= reboot {
|
||||
r.RebootSince.Class = "bad"
|
||||
}
|
||||
}
|
||||
if f.Host.KernelPanic != nil {
|
||||
r.KernelPanic = plain(fmt.Sprintf("%d s", *f.Host.KernelPanic))
|
||||
if *f.Host.KernelPanic == 0 {
|
||||
r.KernelPanic = cell{Text: "0 (stays off)", Class: "warn"}
|
||||
}
|
||||
} else {
|
||||
r.KernelPanic = unknownCell("")
|
||||
}
|
||||
r.Oops = plain("no")
|
||||
if f.Host.OopsThisBoot != nil && *f.Host.OopsThisBoot {
|
||||
r.Oops = cell{Text: "yes", Class: "warn", Title: "a kernel oops this boot"}
|
||||
}
|
||||
if cg := f.Host.CrashGuard; cg != nil {
|
||||
r.CrashRestarts24h = plain(fmt.Sprint(cg.In24h))
|
||||
if cg.In24h > 0 {
|
||||
r.CrashRestarts24h.Class = "warn"
|
||||
}
|
||||
if cg.Tripped {
|
||||
r.Guard = cell{Text: "TRIPPED " + cg.TrippedAt, Class: "bad", Title: cg.TrippedReason}
|
||||
} else {
|
||||
r.Guard = plain("armed")
|
||||
}
|
||||
} else {
|
||||
r.CrashRestarts24h, r.Guard = unknownCell(""), cell{Text: "not installed", Class: "warn"}
|
||||
}
|
||||
r.GuestDebian = unknownCell(f.Guest.Debian)
|
||||
r.GuestRelease = plain(orDash(l.Guest.ReleaseID))
|
||||
r.GuestPending = plain(fmt.Sprint(l.Guest.Pending))
|
||||
r.GuestRestart = plain(fmt.Sprint(l.Guest.RestartNeeded))
|
||||
r.Engine, r.Containerd = unknownCell(f.Guest.DockerEngine), unknownCell(f.Guest.Containerd)
|
||||
r.LiveRestore = unknownCell(f.Guest.LiveRestore)
|
||||
if f.Guest.LiveRestore == "off" {
|
||||
r.LiveRestore.Class, r.LiveRestore.Title = "warn", "a Docker step is refused until it is on (decision 87)"
|
||||
}
|
||||
r.DockerRelease = plain(orDash(l.Docker.ReleaseID))
|
||||
last := l.Guest
|
||||
if l.Host.LastAt.After(last.LastAt) {
|
||||
last = l.Host
|
||||
}
|
||||
if l.Docker.LastAt.After(last.LastAt) {
|
||||
last = l.Docker
|
||||
}
|
||||
ok := l.Guest.LastSuccessfulLeg
|
||||
r.LastLeg = cell{Text: fmt.Sprintf("%s · %s · %.0f s", ago(last.LastAt, now), orDash(last.LastOutcome), last.WrapperPassSeconds),
|
||||
Title: "last successful leg: " + ago(ok, now)}
|
||||
if l.Enabled && !ok.IsZero() && now.Sub(ok) >= stale {
|
||||
r.LastLeg.Class = "bad"
|
||||
} else if last.LastOutcome == "health_failed" || last.LastOutcome == "failed" || last.LastOutcome == "refused" {
|
||||
r.LastLeg.Class = "warn"
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].HostID < rows[j].HostID })
|
||||
return rows
|
||||
}
|
||||
|
||||
func orDash(s string) string {
|
||||
if s == "" {
|
||||
return "—"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) handleSystem(w http.ResponseWriter, r *http.Request) {
|
||||
view, ok := s.osUpdates.(OSSystemView)
|
||||
if s.osUpdates == nil || !ok {
|
||||
http.Error(w, "os updates not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
lines, err := view.Fleet()
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] system page: fleet: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hosts, _ := s.store.ListHosts()
|
||||
facts, names := map[string]sysfacts.System{}, map[string]string{}
|
||||
for _, h := range hosts {
|
||||
names[h.HostID] = s.customerName(h.CustomerID)
|
||||
if rj, _ := s.store.GetLatestHostReportJSON(h.CustomerID); rj != "" {
|
||||
facts[h.HostID] = sysfacts.Parse(rj)
|
||||
}
|
||||
}
|
||||
stale, reboot, notCov := view.Thresholds()
|
||||
data := map[string]interface{}{
|
||||
"Rows": buildSystemRows(lines, facts, names, stale, reboot, notCov, time.Now()),
|
||||
"Releases": view.Releases(),
|
||||
"Candidates": view.Candidates(),
|
||||
"Flash": r.URL.Query().Get("flash"),
|
||||
"FlashErr": r.URL.Query().Get("err"),
|
||||
"CSRFToken": s.getCSRFToken(r),
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "system.html", data); err != nil {
|
||||
s.logger.Printf("[ERROR] system.html template: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/osupdates"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const sysFull = `{"host":{"cpu_percent":1},"cloudflared":{"status":"running"},"system":{"pve_version":"pve-manager/9.0.11/abc",
|
||||
"kernel_version":"Linux 7.0.14-20-pve #1","vmid":9201,"facts":{"host":{"debian":"13.7","kernel_running":"7.0.14-20-pve",
|
||||
"kernel_next_boot":"7.0.2-6-pve","kernel_next_boot_source":"saved default","held":["tzdata"],"kernel_panic":0,"oops_this_boot":false,
|
||||
"crash_guard":{"armed":false,"tripped":true,"tripped_at":"2026-10-04T16:00:00Z","tripped_reason":"2 unclean boots within 60 minutes","unclean_boots_24h":2}},
|
||||
"guest":{"debian":"13.7","docker_engine":"29.8.2","containerd":"2.3.6-1~debian.13~trixie","live_restore":"on"}}}}`
|
||||
const sysPartial = `{"host":{"cpu_percent":1},"system":{"pve_version":"pve-manager/9.0.10/x","kernel_version":"Linux 7.0.2-6-pve #1","facts_error":"no running customer guest"}}`
|
||||
const sysOld = `{"host":{"cpu_percent":1}}`
|
||||
|
||||
func systemServer(t *testing.T) (*Server, *store.Store, *osupdates.Service) {
|
||||
s, st := newTestServer(t)
|
||||
for _, h := range []struct{ id, cust, body string }{{"full-1", "c-full", sysFull}, {"part-1", "c-part", sysPartial}, {"old-1", "c-old", sysOld}} {
|
||||
if err := st.UpsertHost(&store.Host{HostID: h.id, CustomerID: h.cust, APIKey: "k-" + h.id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostReport(h.id, h.cust, []byte(h.body), store.HostReportDenorm{AgentVersion: "0.142.0", CloudflaredStatus: "running"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
svc := &osupdates.Service{Store: st, ApproveAfter: 24 * time.Hour, NightsRequired: 1}
|
||||
s.SetOSUpdateAdmin(svc)
|
||||
return s, st, svc
|
||||
}
|
||||
|
||||
func getSystem(t *testing.T, s *Server) string {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/system", nil))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("GET /system = %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
return rr.Body.String()
|
||||
}
|
||||
|
||||
// Full, partial and old-agent boxes render; an unreadable value says "unknown", never empty or guessed.
|
||||
// Red-proof: render KernelRunning with no unknownCell and the partial box shows an empty cell.
|
||||
func TestSystemPage_FullPartialUnknown(t *testing.T) {
|
||||
s, _, _ := systemServer(t)
|
||||
b := getSystem(t, s)
|
||||
for _, want := range []string{"9.0.11", "7.0.14-20-pve", "29.8.2", "tzdata", "TRIPPED", "0 (stays off)",
|
||||
"9.0.10", "partial: no running customer guest", "no versions reported (agent older than v0.142.0)",
|
||||
`action="/os/ring/full-1"`, `action="/os/enabled/part-1"`, `action="/os/approve-now"`} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Errorf("System page lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Count(b, ">unknown<") < 6 {
|
||||
t.Errorf("the partial and old boxes must read unknown, got %d unknown cells", strings.Count(b, ">unknown<"))
|
||||
}
|
||||
if strings.Count(b, `class="c-warn" title="the box could not read it`) < 6 {
|
||||
t.Errorf("an unknown value must be shown amber with its reason, got %d", strings.Count(b, `class="c-warn" title="the box could not read it`))
|
||||
}
|
||||
if !strings.Contains(b, `class="c-bad" title="2 unclean boots`) {
|
||||
t.Error("a tripped crash guard must be red")
|
||||
}
|
||||
}
|
||||
|
||||
// The "Approve Docker set" button appears ONLY when the rule allows it (seam rule: one render test per branch).
|
||||
func TestSystemPage_DockerButtonOnlyWhenReady(t *testing.T) {
|
||||
s, st, svc := systemServer(t)
|
||||
if strings.Contains(getSystem(t, s), `action="/os/approve-docker"`) {
|
||||
t.Fatal("button shown with no Docker candidate")
|
||||
}
|
||||
_ = st.SetOSRing("full-1", 0)
|
||||
night := func() {
|
||||
if err := svc.Ingest("full-1", osupdates.Report{RunID: time.Now().String(), Layer: "docker", Trigger: "night", Mode: "apply",
|
||||
Outcome: "nothing", Healthy: true, Installed: []osupdates.Package{{Name: "docker-ce", Version: "5:29.8.2-1", Origin: "Docker"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
night()
|
||||
if strings.Contains(getSystem(t, s), `action="/os/approve-docker"`) {
|
||||
t.Fatal("button shown after ONE night")
|
||||
}
|
||||
night()
|
||||
if !strings.Contains(getSystem(t, s), `action="/os/approve-docker"`) {
|
||||
t.Fatal("button missing after two healthy nights")
|
||||
}
|
||||
}
|
||||
|
||||
// Every button needs the operator login; a box's own API key is not one. Red-proof: route /os/ outside RequireAuth.
|
||||
func TestSystemButtons_NeedTheOperatorLogin(t *testing.T) {
|
||||
s, st, _ := systemServer(t)
|
||||
h, _ := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
|
||||
s.configPasswordHash = string(h)
|
||||
for _, tc := range []struct{ method, path, body string }{
|
||||
{http.MethodGet, "/system", ""},
|
||||
{http.MethodPost, "/os/ring/full-1", "ring=0&return=%2Fsystem"},
|
||||
{http.MethodPost, "/os/enabled/full-1", "on=0&return=%2Fsystem"},
|
||||
{http.MethodPost, "/os/approve-now", "return=%2Fsystem"},
|
||||
{http.MethodPost, "/os/approve-docker", "return=%2Fsystem"},
|
||||
} {
|
||||
for _, auth := range []string{"", "Bearer k-full-1"} {
|
||||
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
if auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req)
|
||||
if rr.Code == http.StatusOK || rr.Code == http.StatusSeeOther {
|
||||
t.Errorf("%s %s with auth %q = %d — must be refused", tc.method, tc.path, auth, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.GetOSHostSettings("full-1").Enabled != true || st.GetOSHostSettings("full-1").Ring != 1 {
|
||||
t.Fatal("an unauthenticated POST changed a box")
|
||||
}
|
||||
}
|
||||
|
||||
// With the operator's session a page button changes the box and returns to the page.
|
||||
func TestSystemButtons_RedirectBackToThePage(t *testing.T) {
|
||||
s, st, _ := systemServer(t)
|
||||
h, _ := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
|
||||
s.configPasswordHash = string(h)
|
||||
cookie, csrf := newRevealSession(t, s)
|
||||
form := url.Values{"on": {"0"}, "return": {"/system"}, "_csrf": {csrf}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/os/enabled/full-1", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusSeeOther || !strings.HasPrefix(rr.Header().Get("Location"), "/system?flash=") {
|
||||
t.Fatalf("= %d %q", rr.Code, rr.Header().Get("Location"))
|
||||
}
|
||||
if st.GetOSHostSettings("full-1").Enabled {
|
||||
t.Fatal("the switch did not change")
|
||||
}
|
||||
}
|
||||
|
||||
// The Hosts page shows the Proxmox version and the running kernel (unknown for an old agent).
|
||||
func TestHostsPage_ProxmoxKernelColumn(t *testing.T) {
|
||||
s, _, _ := systemServer(t)
|
||||
rr := httptest.NewRecorder()
|
||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/hosts", nil))
|
||||
b := rr.Body.String()
|
||||
if !strings.Contains(b, "Proxmox / kernel") || !strings.Contains(b, "9.0.11") || !strings.Contains(b, "7.0.2-6-pve") {
|
||||
t.Fatalf("hosts column missing:\n%s", b[:min(len(b), 400)])
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link active">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link active">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link active">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link active">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link active">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
@@ -85,6 +86,7 @@
|
||||
<th>Host</th>
|
||||
<th>Customer</th>
|
||||
<th>Agent</th>
|
||||
<th>Proxmox / kernel</th>
|
||||
<th>Status</th>
|
||||
<th>Guests</th>
|
||||
<th>CPU</th>
|
||||
@@ -100,6 +102,7 @@
|
||||
<td><a href="/hosts/{{.HostID}}">{{.HostID}}</a></td>
|
||||
<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>{{if .HasReport}}{{.GuestRunning}}/{{.GuestTotal}}{{else}}—{{end}}</td>
|
||||
<td>{{if .HasReport}}{{formatFloat .Vitals.CPUPercent}}%{{else}}—{{end}}</td>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link">System</a>
|
||||
<a href="/offsite" class="nav-link active">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System — Felhom Hub</title>
|
||||
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
|
||||
<style>
|
||||
.sys td, .sys th { white-space: nowrap; font-size: 0.82em; vertical-align: top; }
|
||||
.sys .grp { border-left: 2px solid var(--border, #444); }
|
||||
.c-warn { color: var(--warn); font-weight: 600; }
|
||||
.c-bad { color: var(--danger, #e5534b); font-weight: 700; }
|
||||
.sys form { display: inline; }
|
||||
.rel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 0.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "icon_sprite"}}
|
||||
{{template "inline_confirm_js"}}
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom <span>Hub</span></h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/hosts" class="nav-link">Hosts</a>
|
||||
<a href="/system" class="nav-link active">System</a>
|
||||
<a href="/offsite" class="nav-link">Offsite</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<h2 style="margin-bottom: 1rem;">System — versions and OS updates</h2>
|
||||
|
||||
{{if .Flash}}<div class="flash flash-success" style="margin-bottom: 1rem;">{{.Flash}}</div>{{end}}
|
||||
{{if .FlashErr}}<div class="flash flash-error" style="margin-bottom: 1rem;">{{.FlashErr}}</div>{{end}}
|
||||
|
||||
<section class="card" style="margin-bottom: 1.5rem;">
|
||||
<h3 style="margin-top: 0;">Approved releases</h3>
|
||||
<div class="rel-grid">
|
||||
{{range .Releases}}
|
||||
<div><strong>{{.Layer}}</strong>: <code>{{.ID}}</code><br>
|
||||
<span class="text-muted">{{.Packages}} packages · {{.ApprovedAt.UTC.Format "2006-01-02 15:04"}} UTC · by {{.ApprovedBy}}</span></div>
|
||||
{{else}}<div class="text-muted">No release approved yet.</div>{{end}}
|
||||
</div>
|
||||
<h3>What ring 0 runs now</h3>
|
||||
<div class="rel-grid">
|
||||
{{range .Candidates}}
|
||||
<div><strong>{{.Layer}}</strong>:
|
||||
{{if .Fingerprint}}{{.Packages}} packages, first seen {{.FirstSeen.UTC.Format "2006-01-02 15:04"}} UTC{{else}}<span class="text-muted">—</span>{{end}}<br>
|
||||
{{if .Approved}}<span class="text-muted">approved as {{.Approved}}</span>
|
||||
{{else if .Waiting}}<span class="text-muted">{{.Waiting}}</span>{{end}}
|
||||
{{if and (eq .Layer "docker") .Fingerprint (not .Approved) (eq .Waiting "")}}
|
||||
<form method="POST" action="/os/approve-docker" style="margin-top: 0.3rem;">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
|
||||
<button type="submit" class="btn btn-sm" data-confirm="Approve this Docker engine set? Ring-1 boxes take it only through a signed operator job.">Approve Docker set</button>
|
||||
</form>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<form method="POST" action="/os/approve-now" style="margin-top: 0.8rem;">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
|
||||
<button type="submit" class="btn btn-sm btn-danger" data-confirm="Approve the guest and host sets ring 0 runs NOW, without the 24 h + 1 night wait? Every ring-1 box installs them at its next night run.">Approve now (guest + host)</button>
|
||||
<span class="text-muted" style="font-size: 0.85em;">An urgent fix only — normally the hub approves after 24 h and one night.</span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{if .Rows}}
|
||||
<section class="card" style="padding: 0; overflow-x: auto;">
|
||||
<table class="data-table sys">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Box</th><th>Ring / updates</th><th>Tunnel</th>
|
||||
<th class="grp">Proxmox</th><th>Kernel (running)</th><th>Kernel (next boot)</th><th>Debian</th><th>Felhom release</th><th>Pending</th><th>Not covered</th><th>Held</th><th>Reboot needed</th><th>kernel.panic</th><th>Oops</th><th>Crash restarts 24 h</th><th>Crash guard</th>
|
||||
<th class="grp">Guest Debian</th><th>Felhom release</th><th>Pending</th><th>Restart needed</th>
|
||||
<th class="grp">Docker</th><th>containerd</th><th>live-restore</th><th>Docker release</th>
|
||||
<th class="grp">Last OS leg</th>
|
||||
</tr>
|
||||
<tr class="text-muted"><th></th><th></th><th></th><th class="grp" colspan="13">host</th><th class="grp" colspan="4">guest</th><th class="grp" colspan="4">Docker engine</th><th class="grp"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
<td><a href="/hosts/{{.HostID}}">{{.HostID}}</a>{{if .CustomerName}}<br><span class="text-muted">{{.CustomerName}}</span>{{end}}
|
||||
{{if .FactsNote}}<br><span class="c-warn" style="font-weight: normal;">{{.FactsNote}}</span>{{end}}</td>
|
||||
<td>
|
||||
ring {{.Ring}}
|
||||
<form method="POST" action="/os/ring/{{.HostID}}">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
|
||||
{{if eq .Ring 0}}<input type="hidden" name="ring" value="1"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Make {{.HostID}} a normal (ring 1) box? It then installs only approved releases.">→ normal</button>
|
||||
{{else}}<input type="hidden" name="ring" value="0"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Make {{.HostID}} a DEMO (ring 0) box? It then installs every new fix first and takes unsigned Docker steps if its root-owned ring-0 mark allows.">→ demo</button>{{end}}
|
||||
</form><br>
|
||||
updates {{if .Enabled}}<strong>ON</strong>{{else}}<span class="c-warn">OFF</span>{{end}}
|
||||
<form method="POST" action="/os/enabled/{{.HostID}}">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}"><input type="hidden" name="return" value="/system">
|
||||
{{if .Enabled}}<input type="hidden" name="on" value="0"><button type="submit" class="btn btn-sm btn-outline" data-confirm="Switch OS updates OFF for {{.HostID}}? It keeps reporting and installs nothing.">switch off</button>
|
||||
{{else}}<input type="hidden" name="on" value="1"><button type="submit" class="btn btn-sm btn-outline">switch on</button>{{end}}
|
||||
</form>
|
||||
</td>
|
||||
{{template "sys_cell" .Tunnel}}
|
||||
<td class="grp {{if .PVE.Class}}c-{{.PVE.Class}}{{end}}">{{.PVE.Text}}</td>
|
||||
{{template "sys_cell" .KernelRunning}}{{template "sys_cell" .KernelNextBoot}}{{template "sys_cell" .HostDebian}}
|
||||
{{template "sys_cell" .HostRelease}}{{template "sys_cell" .HostPending}}{{template "sys_cell" .HostNotCovered}}
|
||||
{{template "sys_cell" .Held}}{{template "sys_cell" .RebootSince}}{{template "sys_cell" .KernelPanic}}{{template "sys_cell" .Oops}}
|
||||
{{template "sys_cell" .CrashRestarts24h}}{{template "sys_cell" .Guard}}
|
||||
<td class="grp {{if .GuestDebian.Class}}c-{{.GuestDebian.Class}}{{end}}">{{.GuestDebian.Text}}</td>
|
||||
{{template "sys_cell" .GuestRelease}}{{template "sys_cell" .GuestPending}}{{template "sys_cell" .GuestRestart}}
|
||||
<td class="grp {{if .Engine.Class}}c-{{.Engine.Class}}{{end}}">{{.Engine.Text}}</td>
|
||||
{{template "sys_cell" .Containerd}}{{template "sys_cell" .LiveRestore}}{{template "sys_cell" .DockerRelease}}
|
||||
<td class="grp {{if .LastLeg.Class}}c-{{.LastLeg.Class}}{{end}}" title="{{.LastLeg.Title}}">{{.LastLeg.Text}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<p class="text-muted" style="font-size: 0.85em;">Amber: worth a look. Red: an operator alarm fires (`08` §6.3). "unknown": the box could not read the value — never a guess.</p>
|
||||
{{else}}
|
||||
<div class="empty-state"><p>No boxes yet.</p></div>
|
||||
{{end}}
|
||||
|
||||
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
||||
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{define "sys_cell"}}<td{{if .Class}} class="c-{{.Class}}"{{end}}{{if .Title}} title="{{.Title}}"{{end}}>{{.Text}}</td>{{end}}
|
||||
Reference in New Issue
Block a user