hub v0.45.0: floor-UI separation + effective-floor source + per-box MinAgent conditional floor
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -745,6 +745,48 @@ func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, c
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// countBoxesBelowFloor counts reporting boxes whose EFFECTIVE floor (per-customer override else the
|
||||
// proposed global) would exceed their reported controller version — i.e. how many boxes a proposed
|
||||
// global-floor save would immediately push into an update. Boxes with a per-customer override are
|
||||
// governed by that override, not the proposed global, so they are excluded from the global-save
|
||||
// blast radius (the confirm dialog is about the GLOBAL knob). Reused by the confirm-count endpoint.
|
||||
func (s *Server) countBoxesBelowFloor(proposedGlobal string) int {
|
||||
customers, err := s.store.GetCustomers()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
configs, _ := s.store.ListCustomerConfigs()
|
||||
override := make(map[string]string, len(configs))
|
||||
for _, c := range configs {
|
||||
if c.MinControllerVersion != "" {
|
||||
override[c.CustomerID] = c.MinControllerVersion
|
||||
}
|
||||
}
|
||||
n := 0
|
||||
for _, c := range customers {
|
||||
floor := proposedGlobal
|
||||
if ov, ok := override[c.CustomerID]; ok {
|
||||
floor = ov // an overridden box is not moved by the global knob
|
||||
}
|
||||
if floor != "" && c.ControllerVersion != "" && compareVersions(floor, c.ControllerVersion) > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// handleGlobalFloorImpact answers the confirm dialog's "how many boxes are below <v>?" probe
|
||||
// (GET /configuration/global-floor/impact?v=X.Y.Z). Read-only JSON; blank v = 0.
|
||||
func (s *Server) handleGlobalFloorImpact(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := normalizeFloorInput(r.URL.Query().Get("v"))
|
||||
count := 0
|
||||
if ok && v != "" {
|
||||
count = s.countBoxesBelowFloor(v)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "below": count})
|
||||
}
|
||||
|
||||
// handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed
|
||||
// updates). Empty clears the hub_settings override, falling back to the config/env default.
|
||||
func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -796,7 +838,8 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
|
||||
goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
|
||||
if !okAV || !okGV {
|
||||
minAgent, okMA := normalizeFloorInput(r.FormValue("min_agent")) // Part D: empty = uncoupled release
|
||||
if !okAV || !okGV || !okMA {
|
||||
http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
@@ -811,12 +854,13 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
AgentSHA256: agentSHA,
|
||||
GoldenVersion: goldenVer,
|
||||
GoldenSHA256: goldenSHA,
|
||||
MinAgent: minAgent,
|
||||
}); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer)
|
||||
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q", agentVer, goldenVer, minAgent)
|
||||
http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// seedReport writes a latest report carrying a controller version for customerID.
|
||||
func seedReport(t *testing.T, st *store.Store, customerID, ctrlVer string) {
|
||||
t.Helper()
|
||||
if err := st.SaveReport(customerID, []byte(`{"controller_version":"`+ctrlVer+`"}`)); err != nil {
|
||||
t.Fatalf("SaveReport(%s): %v", customerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManifestSave_DoesNotTouchFloor is Part C's central guarantee: saving the Day-0 artifact
|
||||
// manifest must NOT write hub_settings.min_controller_version (the publish-train footgun). Companion
|
||||
// red-proof: add a SetGlobalMinControllerVersion call into handleSetArtifacts → the floor changes →
|
||||
// this fails.
|
||||
func TestManifestSave_DoesNotTouchFloor(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
st.SetDefaultMinControllerVersion("0.87.0")
|
||||
if err := st.SetGlobalMinControllerVersion("0.113.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := st.ResolveGlobalFloor()
|
||||
|
||||
form := url.Values{"agent_version": {"0.82.0"}, "golden_version": {"0.115.0"}}
|
||||
// No Gitea client in the test → resolveArtifactSHA takes the manual path; provide valid shas.
|
||||
form.Set("agent_sha256", strings.Repeat("a", 64))
|
||||
form.Set("golden_sha256", strings.Repeat("b", 64))
|
||||
r := httptest.NewRequest(http.MethodPost, "/configuration/artifacts", strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
s.handleSetArtifacts(w, r)
|
||||
|
||||
if m := st.GetArtifactManifest(); m.AgentVersion != "0.82.0" || m.GoldenVersion != "0.115.0" {
|
||||
t.Fatalf("manifest not saved: %+v", m)
|
||||
}
|
||||
after := st.ResolveGlobalFloor()
|
||||
if after.Effective != before.Effective || after.Source != before.Source || after.DBValue != before.DBValue {
|
||||
t.Errorf("manifest save MUST NOT change the floor: before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGlobalFloorImpact_Count: the confirm-dialog probe counts boxes below a proposed floor,
|
||||
// honoring per-customer overrides (an overridden box is governed by its own floor, not the proposed
|
||||
// global). Companion red-proof: drop the override exclusion → the overridden box is miscounted.
|
||||
func TestGlobalFloorImpact_Count(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
// Three reporting boxes.
|
||||
seedReport(t, st, "below-a", "0.110.0") // below a 0.113 proposal
|
||||
seedReport(t, st, "below-b", "0.112.0") // below
|
||||
seedReport(t, st, "current", "0.113.0") // at the proposal (not below)
|
||||
seedReport(t, st, "overridden", "0.90.0") // below the global BUT has its own override at 0.90.0
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "overridden", RetrievalPassword: "x", APIKey: "y"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetMinControllerVersion("overridden", "0.90.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=0.113.0", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleGlobalFloorImpact(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("impact: %d", w.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Version string `json:"version"`
|
||||
Valid bool `json:"valid"`
|
||||
Below int `json:"below"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !resp.Valid || resp.Version != "0.113.0" {
|
||||
t.Fatalf("resp = %+v", resp)
|
||||
}
|
||||
// below-a + below-b = 2; current is AT the floor; overridden is governed by its own 0.90.0.
|
||||
if resp.Below != 2 {
|
||||
t.Errorf("below-floor count = %d, want 2 (overridden box excluded, at-floor excluded)", resp.Below)
|
||||
}
|
||||
|
||||
// Invalid version → valid:false, below:0.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/configuration/global-floor/impact?v=notaversion", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
s.handleGlobalFloorImpact(w2, req2)
|
||||
var resp2 struct {
|
||||
Valid bool `json:"valid"`
|
||||
Below int `json:"below"`
|
||||
}
|
||||
_ = json.Unmarshal(w2.Body.Bytes(), &resp2)
|
||||
if resp2.Valid || resp2.Below != 0 {
|
||||
t.Errorf("invalid version → valid=false below=0, got %+v", resp2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigurationPage_RendersFloorSource: the effective-floor source line renders both the
|
||||
// DB-wins and env-fallback states through the production template.
|
||||
func TestConfigurationPage_RendersFloorSource(t *testing.T) {
|
||||
render := func(setup func(st *store.Store)) string {
|
||||
t.Helper()
|
||||
s, st := newTestServer(t)
|
||||
setup(st)
|
||||
req := httptest.NewRequest(http.MethodGet, "/configuration", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleConfiguration(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("configuration page: %d", w.Code)
|
||||
}
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
dbWins := render(func(st *store.Store) {
|
||||
st.SetDefaultMinControllerVersion("0.87.0")
|
||||
_ = st.SetGlobalMinControllerVersion("0.113.0")
|
||||
})
|
||||
if !strings.Contains(dbWins, "DB (hub_settings)") || !strings.Contains(dbWins, "v0.113.0") {
|
||||
t.Errorf("db-wins source line missing:\n%s", excerpt(dbWins))
|
||||
}
|
||||
if !strings.Contains(dbWins, "env fallback would be") || !strings.Contains(dbWins, "v0.87.0") {
|
||||
t.Errorf("db-wins must also surface the env fallback value:\n%s", excerpt(dbWins))
|
||||
}
|
||||
|
||||
envOnly := render(func(st *store.Store) { st.SetDefaultMinControllerVersion("0.87.0") })
|
||||
if !strings.Contains(envOnly, "env fallback (DEFAULT_MIN_CONTROLLER_VERSION)") {
|
||||
t.Errorf("env-fallback source line missing:\n%s", excerpt(envOnly))
|
||||
}
|
||||
// The type-to-confirm wiring must be present (no bare submit button).
|
||||
if !strings.Contains(envOnly, "confirmGlobalFloor()") || !strings.Contains(envOnly, "global-floor/impact") {
|
||||
t.Errorf("type-to-confirm dialog wiring missing")
|
||||
}
|
||||
}
|
||||
|
||||
func excerpt(s string) string {
|
||||
if i := strings.Index(s, "Managed updates"); i >= 0 {
|
||||
end := i + 900
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
return s[i:end]
|
||||
}
|
||||
if len(s) > 600 {
|
||||
return s[:600]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -9,6 +10,15 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// agentOrUnknown renders an agent version for operator text, mapping the empty (never-reported)
|
||||
// value to a readable token.
|
||||
func agentOrUnknown(v string) string {
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// hostStatus computes the host's liveness state from its last-report recency, using the
|
||||
// SAME thresholds as the HostStalenessChecker (s.staleThreshold; "down" at 2×). This keeps
|
||||
// the GUI badge in agreement with the alerting — there is no second definition of "stale".
|
||||
@@ -177,6 +187,10 @@ type hostListRow struct {
|
||||
WorstFillPct float64
|
||||
WorstFillName string
|
||||
HasStorage bool
|
||||
// FloorHeld (Part D): the managed controller-version floor is being WITHHELD because this box's
|
||||
// agent is below the current golden's MinAgent. HeldReason carries the operator-facing text.
|
||||
FloorHeld bool
|
||||
HeldReason string
|
||||
}
|
||||
|
||||
// customerName resolves a display name for a customer id (config first, then the last
|
||||
@@ -224,6 +238,13 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
|
||||
HasReport: h.LastReportAt != nil,
|
||||
}
|
||||
|
||||
// Part D: surface a held managed floor (agent below the golden's MinAgent) so a held box is
|
||||
// never silently stale.
|
||||
if fd := s.store.ResolveManagedFloor(h.CustomerID); fd.Held {
|
||||
row.FloorHeld = true
|
||||
row.HeldReason = fmt.Sprintf("held: agent %s < MinAgent %s", agentOrUnknown(fd.AgentVersion), fd.MinAgent)
|
||||
}
|
||||
|
||||
// Guest counts from the reality table (per-host accurate).
|
||||
guests, _ := s.store.ListGuestsForHost(h.HostID)
|
||||
row.GuestTotal = len(guests)
|
||||
|
||||
+11
-23
@@ -11,7 +11,6 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -19,6 +18,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -332,6 +332,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleConfigNewForm(w, r)
|
||||
}
|
||||
// Global settings live under the Configuration tab (moved from Customers).
|
||||
case path == "/configuration/global-floor/impact":
|
||||
if r.Method == http.MethodGet {
|
||||
s.handleGlobalFloorImpact(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configuration/global-floor":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleSetGlobalFloor(w, r)
|
||||
@@ -618,28 +624,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b.
|
||||
// Accepts "X.Y.Z" format. Returns 0 on parse error.
|
||||
func compareVersions(a, b string) int {
|
||||
a = strings.TrimPrefix(a, "v")
|
||||
b = strings.TrimPrefix(b, "v")
|
||||
aParts := strings.SplitN(a, ".", 3)
|
||||
bParts := strings.SplitN(b, ".", 3)
|
||||
if len(aParts) != 3 || len(bParts) != 3 {
|
||||
return 0
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
ai, e1 := strconv.Atoi(aParts[i])
|
||||
bi, e2 := strconv.Atoi(bParts[i])
|
||||
if e1 != nil || e2 != nil {
|
||||
return 0
|
||||
}
|
||||
if ai != bi {
|
||||
return ai - bi
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
// compareVersions delegates to THE hub comparator (internal/semver). Kept as a thin wrapper so the
|
||||
// web package's many call sites are unchanged.
|
||||
func compareVersions(a, b string) int { return semver.Compare(a, b) }
|
||||
|
||||
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := templateFS.ReadFile("templates/style.css")
|
||||
@@ -716,6 +703,7 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
"AssetCount": assetCount,
|
||||
"AssetLastSync": assetLastSync,
|
||||
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
|
||||
"FloorRes": s.store.ResolveGlobalFloor(),
|
||||
"Artifacts": s.store.GetArtifactManifest(),
|
||||
"AgentChoices": s.artifactChoices(ctx, pkgAgent, fileAgent),
|
||||
"GoldenChoices": s.artifactChoices(ctx, pkgGolden, fileGolden),
|
||||
|
||||
@@ -48,20 +48,77 @@
|
||||
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Phase 2 managed updates: global controller-version floor (moved from the Customers page —
|
||||
it is a global setting). -->
|
||||
<!-- Phase 2 managed updates: global controller-version floor. ITS OWN card, separate from the
|
||||
Day-0 artifact manifest below (a manifest save must NEVER touch the live floor — the
|
||||
publish-train 0.81/0.113 incident). Saving acts IMMEDIATELY, so it is behind a
|
||||
type-to-confirm dialog that states the live below-floor blast radius first. -->
|
||||
<section class="card">
|
||||
<h3 style="margin-top: 0;">Managed updates — global floor</h3>
|
||||
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
||||
The minimum controller version every box auto-updates to (unless a per-customer override is set).
|
||||
Boxes below the floor update on their next report — no customer action. Blank = no global floor.
|
||||
<strong>Saving takes effect immediately</strong> — boxes below the floor update on their next
|
||||
report, no customer action. Blank = no global floor. This setting is independent of the Day-0
|
||||
artifact manifest below.
|
||||
</p>
|
||||
<form method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<!-- Effective-floor-and-source line: makes the DB-override-vs-env-fallback precedence
|
||||
permanently visible (the 9-minute-skew incident's root cause). -->
|
||||
<p style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
||||
Effective floor:
|
||||
{{if .FloorRes.Effective}}<code>v{{.FloorRes.Effective}}</code>{{else}}<span class="text-muted">none</span>{{end}}
|
||||
{{if eq .FloorRes.Source "db"}}
|
||||
<span style="color: #cbd5e1;">— source: <strong>DB (hub_settings)</strong>{{if .FloorRes.EnvValue}}; env fallback would be <code>v{{.FloorRes.EnvValue}}</code>{{end}}</span>
|
||||
{{else if eq .FloorRes.Source "env"}}
|
||||
<span style="color: #cbd5e1;">— source: <strong>env fallback (DEFAULT_MIN_CONTROLLER_VERSION)</strong>; no DB override set</span>
|
||||
{{else}}
|
||||
<span class="text-muted">— no floor from either source</span>
|
||||
{{end}}
|
||||
</p>
|
||||
<form id="global-floor-form" method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
{{.CSRFField}}
|
||||
<input type="text" name="min_controller_version" value="{{.GlobalFloor}}" placeholder="e.g. 0.86.0 (blank = none)" style="padding: 0.3em 0.5em; width: 14em;">
|
||||
<button class="btn btn-sm" type="submit">Save global floor</button>
|
||||
<span style="font-size: 0.85em; color: #cbd5e1;">Current: {{if .GlobalFloor}}<code>v{{.GlobalFloor}}</code>{{else}}<span class="text-muted">unset</span>{{end}}</span>
|
||||
<input type="text" id="global-floor-input" name="min_controller_version" value="{{.FloorRes.DBValue}}" placeholder="e.g. 0.86.0 (blank = clear DB override)" style="padding: 0.3em 0.5em; width: 16em;">
|
||||
<button class="btn btn-sm" type="button" onclick="confirmGlobalFloor()">Save global floor…</button>
|
||||
<span style="font-size: 0.85em; color: #cbd5e1;">DB override: {{if .FloorRes.DBValue}}<code>v{{.FloorRes.DBValue}}</code>{{else}}<span class="text-muted">none</span>{{end}}</span>
|
||||
</form>
|
||||
<div id="global-floor-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid #7c3f00; background: #241a0a; border-radius: 6px; max-width: 44em;">
|
||||
<p id="global-floor-impact" style="margin: 0 0 0.5rem; font-size: 0.9em;">…</p>
|
||||
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: #cbd5e1;">Type the version again to confirm (or <code>CLEAR</code> to remove the DB override):</p>
|
||||
<input type="text" id="global-floor-confirm-input" placeholder="retype the version…" style="padding: 0.3em 0.5em; width: 16em;">
|
||||
<button class="btn btn-sm" type="button" onclick="submitGlobalFloor()">Confirm & apply</button>
|
||||
<button class="btn btn-sm btn-ghost" type="button" onclick="document.getElementById('global-floor-confirm').style.display='none';">Cancel</button>
|
||||
<p id="global-floor-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: #f87171;"></p>
|
||||
</div>
|
||||
<script>
|
||||
function confirmGlobalFloor() {
|
||||
var v = document.getElementById('global-floor-input').value.trim();
|
||||
var box = document.getElementById('global-floor-confirm');
|
||||
var impact = document.getElementById('global-floor-impact');
|
||||
document.getElementById('global-floor-confirm-input').value = '';
|
||||
document.getElementById('global-floor-confirm-err').textContent = '';
|
||||
box.style.display = 'block';
|
||||
if (v === '') {
|
||||
impact.textContent = 'This will CLEAR the DB floor override (the box falls back to the env default). Type CLEAR to confirm.';
|
||||
return;
|
||||
}
|
||||
impact.textContent = 'Checking blast radius…';
|
||||
fetch('/configuration/global-floor/impact?v=' + encodeURIComponent(v))
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (!d.valid) { impact.textContent = 'Invalid version — use X.Y.Z.'; return; }
|
||||
impact.textContent = 'Saving the minimum version v' + d.version +
|
||||
' takes effect immediately — currently ' + d.below +
|
||||
' box(es) are below this version and would update on their next report.';
|
||||
})
|
||||
.catch(function(){ impact.textContent = 'Could not compute the blast radius; proceed with caution.'; });
|
||||
}
|
||||
function submitGlobalFloor() {
|
||||
var v = document.getElementById('global-floor-input').value.trim();
|
||||
var typed = document.getElementById('global-floor-confirm-input').value.trim();
|
||||
var err = document.getElementById('global-floor-confirm-err');
|
||||
var expected = (v === '') ? 'CLEAR' : v;
|
||||
if (typed !== expected) { err.textContent = 'Confirmation does not match (' + expected + ').'; return; }
|
||||
document.getElementById('global-floor-form').submit();
|
||||
}
|
||||
</script>
|
||||
</section>
|
||||
|
||||
<!-- BUNDLE slice: Day-0 artifact manifest (agent binary + golden archive). The hub is the
|
||||
@@ -102,6 +159,9 @@
|
||||
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
|
||||
{{end}}
|
||||
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
|
||||
<label style="font-size: 0.9em; color: #cbd5e1;">Min agent</label>
|
||||
<input type="text" name="min_agent" value="{{.Artifacts.MinAgent}}" placeholder="e.g. 0.81.0 (blank = uncoupled)" style="padding: 0.3em 0.5em;">
|
||||
<span style="font-size: 0.8em; color: #94a6bf;">The golden's controller CHANGELOG <code>MinAgent:</code>. The hub HOLDS the floor for any box whose agent is below this — blank = uncoupled release, no gating.</span>
|
||||
<span></span><span></span>
|
||||
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
|
||||
</form>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<tr onclick="window.location='/hosts/{{.HostID}}'" style="cursor: pointer;">
|
||||
<td><a href="/hosts/{{.HostID}}">{{.HostID}}</a></td>
|
||||
<td>{{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}}</td>
|
||||
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</td>
|
||||
<td>{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}{{if .FloorHeld}} <span class="status-badge status-warn" title="{{.HeldReason}}">floor held</span>{{end}}</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>
|
||||
|
||||
Reference in New Issue
Block a user