hub v0.137.0 source + burn-down round 2 in felhom.eu: R-277 R-581 R-600 R-544 R-855 R-134 R-92 R-292 R-599 R-725 R-728 (hub), R-819 R-857 R-555 R-364 R-587 (gates/tools), R-571 R-129 R-124-runbook (docs); 28 rows closed incl. catalog + agent v0.147.0 rows, R-350 merged into R-132, R-888 opened, R-887 mechanism (249 -> 222)
gates / gates (push) Successful in 2m3s
gates / gates (push) Successful in 2m3s
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:
+65
-19
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||||
"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"
|
||||
@@ -701,6 +703,20 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// R-728: one create per customer ID at a time. Held from before the duplicate check until the
|
||||
// handler returns, so a double submit cannot pass the check twice and mint twice.
|
||||
if _, busy := s.creating.LoadOrStore(customerID, struct{}{}); busy {
|
||||
s.logger.Printf("[WARN] customer create %s refused: a create for this ID is already in progress (double submit)", customerID)
|
||||
s.renderConfigForm(w, r, true, &store.CustomerConfig{
|
||||
CustomerID: customerID,
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
Domain: r.FormValue("domain"),
|
||||
Email: r.FormValue("email"),
|
||||
}, nil, fmt.Sprintf("Customer ID %q is already being created — this second submit was ignored. Open the customer page in a moment.", customerID))
|
||||
return
|
||||
}
|
||||
defer s.creating.Delete(customerID)
|
||||
|
||||
// Check for duplicates
|
||||
existing, _ := s.store.GetCustomerConfig(customerID)
|
||||
if existing != nil {
|
||||
@@ -764,6 +780,9 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.beforeCreateSave != nil {
|
||||
s.beforeCreateSave(customerID)
|
||||
}
|
||||
if err := s.store.SaveCustomerConfig(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
@@ -1315,11 +1334,11 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
// file whose 404 broke Friday. Probing some other path that merely exists is how that failure
|
||||
// stayed invisible.
|
||||
//
|
||||
// IT RUNS BEFORE resolveArtifactSHA, and that ordering is load-bearing. The sha lookup fails
|
||||
// with `artifact_sha_invalid`, whose text reads "version missing / Gitea unreachable / bad sha"
|
||||
// — three different facts in one message. If it ran first, an unreachable registry would be
|
||||
// reported to the operator as a possibly-missing artifact. Probing first means the operator is
|
||||
// told which of those it actually is. (Found by scenario E failing against the first draft.)
|
||||
// IT RUNS BEFORE resolveArtifactSHA. Until R-292 the sha lookup failed with ONE flash,
|
||||
// `artifact_sha_invalid`, covering "version missing / Gitea unreachable / bad sha", so the order
|
||||
// was load-bearing (found by scenario E failing against the first draft). Since R-292 the sha
|
||||
// lookup names its own cause too (TestArtifactSave_FlashNamesTheCause); the order is kept so the
|
||||
// tag and package legs are still reported as such.
|
||||
//
|
||||
// UNDETERMINED IS ALSO A REFUSAL, and it says something different. A warning beside a success is
|
||||
// read as a success, and this project has the scars; so an unreachable registry refuses too,
|
||||
@@ -1369,10 +1388,12 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
|
||||
goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
|
||||
if !okAS || !okGS {
|
||||
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
|
||||
// R-292: each failure names its own cause — version missing, registry unreachable, no sha listed, or a
|
||||
// bad typed sha — instead of one flash that conflated all of them.
|
||||
agentSHA, flashAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
|
||||
goldenSHA, flashGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
|
||||
if flash := firstNonEmpty(flashAS, flashGS); flash != "" {
|
||||
http.Redirect(w, r, "/configuration?flash="+flash, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// R-840: the vouched agent's CONFIG BUNDLE is resolved from the registry by exact name, like the binary. A version
|
||||
@@ -1450,23 +1471,48 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version
|
||||
// clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from
|
||||
// Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns
|
||||
// (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum.
|
||||
// Without a Gitea client it validates + uses the submitted sha (legacy manual path).
|
||||
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) {
|
||||
// clears the artifact (returns "",""). With a Gitea client it fetches the sha AUTHORITATIVELY from
|
||||
// Gitea (the submitted value is ignored — nothing hand-typed to trust); without one it validates + uses
|
||||
// the submitted sha (legacy manual path). The second value is "" on success, otherwise the flash that
|
||||
// names the cause (R-292), so the caller refuses the save with the right message:
|
||||
//
|
||||
// artifact_version_missing — the registry answered 404 for that version
|
||||
// artifact_sha_missing — the version exists but the registry listed no sha256
|
||||
// artifact_unverifiable — the registry did not answer usefully (network error, 5xx)
|
||||
// artifact_sha_invalid — a hand-typed sha is not 64 hex characters (manual path only)
|
||||
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, string) {
|
||||
if version == "" {
|
||||
return "", true
|
||||
return "", ""
|
||||
}
|
||||
if s.gitea != nil {
|
||||
sha, err := s.gitea.FileSHA256(ctx, pkg, version, file)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err)
|
||||
return "", false
|
||||
flash := "artifact_unverifiable"
|
||||
switch {
|
||||
case gitea.IsNotFound(err):
|
||||
flash = "artifact_version_missing"
|
||||
case errors.Is(err, gitea.ErrNoSHA):
|
||||
flash = "artifact_sha_missing"
|
||||
}
|
||||
s.logger.Printf("[WARN] artifact sha resolve (%s/%s) refused as %s: %v", pkg, version, flash, err)
|
||||
return "", flash
|
||||
}
|
||||
return sha, true
|
||||
return sha, ""
|
||||
}
|
||||
return normalizeSHA256(submittedSHA)
|
||||
if sha, ok := normalizeSHA256(submittedSHA); ok {
|
||||
return sha, ""
|
||||
}
|
||||
return "", "artifact_sha_invalid"
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string, or "".
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
|
||||
|
||||
@@ -229,7 +229,7 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu
|
||||
for i := range hosts {
|
||||
if s.hostStatus(hosts[i].LastReportAt) == "ok" {
|
||||
s.logger.Printf("[WARN] delete %s REFUSED: host %s is ONLINE", customerID, hosts[i].HostID)
|
||||
http.Error(w, "Delete refused: host "+hosts[i].HostID+" is ONLINE. Decommission the box first — the cascade never deletes a live host.", http.StatusConflict)
|
||||
http.Error(w, "Delete refused: host "+hosts[i].HostID+" is ONLINE. Decommission the box first — the cascade never deletes a live host."+s.deletionOpensAt(hosts[i].LastReportAt, time.Now()), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -307,7 +307,11 @@ func (s *Server) handleCustomerDelete(w http.ResponseWriter, r *http.Request, cu
|
||||
s.logger.Printf("[WARN] delete %s: save audit event: %v", customerID, eerr)
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] customer DELETE cascade COMPLETE for %s (journal #%d) — full teardown", customerID, journalID)
|
||||
// R-600: the WG peer was removed from the hub DB with the host rows (leg 1), but the endpoint only
|
||||
// drops it on the next peer-sync push. Ask for that push now, and say in the line that it is pending
|
||||
// — "full teardown" was logged while ep0 still held the peer for ~6 minutes.
|
||||
wgState := s.requestWGPeerPush()
|
||||
s.logger.Printf("[INFO] customer DELETE cascade COMPLETE for %s (journal #%d) — hub-side teardown done; %s", customerID, journalID, wgState)
|
||||
s.bumpIntent(customerID) // Direction-2: wake any still-holding wait so it completes promptly
|
||||
http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -621,3 +622,46 @@ func TestR688_PreviewNamesCloudflareByHand(t *testing.T) {
|
||||
t.Fatal("the dialog does not render the manual-removal list")
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-600: the cascade asks for the WG peer push at once, and its COMPLETE line says it is pending ───
|
||||
|
||||
func TestDeleteCascade_TriggersWGPeerPushAndSaysSo(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
seedDeletable(t, st, "acme")
|
||||
s.SetTenantSync(&orderTenancy{})
|
||||
var logBuf strings.Builder
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
triggers := 0
|
||||
s.SetWGPeerSync(func() { triggers++ })
|
||||
|
||||
rr := postDelete(t, s, "acme", cascadeForm("acme", 1))
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if triggers != 1 {
|
||||
t.Fatalf("WG peer-sync triggers = %d, want exactly 1 (the endpoint must drop the peer now, not on the next tick)", triggers)
|
||||
}
|
||||
out := logBuf.String()
|
||||
if strings.Contains(out, "full teardown") {
|
||||
t.Errorf("COMPLETE line still claims a full teardown while the endpoint push is pending:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "WG peer removal push requested") {
|
||||
t.Errorf("COMPLETE line must name the pending peer push:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Without peer-sync configured, the line says the endpoint was NOT updated (never "full teardown").
|
||||
func TestDeleteCascade_NoWGPeerSyncSaysEndpointNotUpdated(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
seedDeletable(t, st, "acme")
|
||||
s.SetTenantSync(&orderTenancy{})
|
||||
var logBuf strings.Builder
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
|
||||
if rr := postDelete(t, s, "acme", cascadeForm("acme", 1)); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, want 303", rr.Code)
|
||||
}
|
||||
if out := logBuf.String(); !strings.Contains(out, "endpoint's peer list was NOT updated") || strings.Contains(out, "full teardown") {
|
||||
t.Errorf("unwired peer-sync must be named in the COMPLETE line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -895,7 +896,7 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
}
|
||||
if status := s.hostStatus(host.LastReportAt); status == "ok" {
|
||||
s.logger.Printf("[WARN] host delete refused: %s is online", hostID)
|
||||
http.Error(w, "Host is ONLINE — deletion is refused (a live agent would receive 401s permanently).", http.StatusConflict)
|
||||
http.Error(w, "Host is ONLINE — deletion is refused (a live agent would receive 401s permanently)."+s.deletionOpensAt(host.LastReportAt, time.Now()), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if confirm := strings.TrimSpace(r.FormValue("confirm_host_id")); confirm != hostID {
|
||||
@@ -904,6 +905,8 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
return
|
||||
}
|
||||
deleteEscrow := r.FormValue("delete_escrow") == "1"
|
||||
// R-544: read whether an escrow exists BEFORE the delete, so the log can say what happened to it.
|
||||
escrowBefore, escErr := s.store.GetHostEscrow(hostID)
|
||||
if err := s.store.DeleteHost(hostID, deleteEscrow); err != nil {
|
||||
if errors.Is(err, store.ErrHostEscrowPresent) {
|
||||
s.logger.Printf("[WARN] host delete refused: %s has key escrow (acknowledgement missing)", hostID)
|
||||
@@ -914,7 +917,7 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] host deleted: %s (escrow deleted: %v)", hostID, deleteEscrow)
|
||||
s.logger.Printf("[INFO] host deleted: %s (%s)", hostID, hostDeleteEscrowEffect(escrowBefore != nil, escErr))
|
||||
// R-509: the customer record stays and now waits for a box → send the connect link.
|
||||
if host.CustomerID != "" {
|
||||
s.autoMintSelfBindIfWaiting(host.CustomerID, "host delete")
|
||||
@@ -922,6 +925,38 @@ func (s *Server) handleHostDelete(w http.ResponseWriter, r *http.Request, hostID
|
||||
http.Redirect(w, r, "/hosts", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// hostDeleteEscrowEffect states what a host delete did to the key escrow, in operator words (R-544).
|
||||
// A host delete NEVER destroys escrow: the store demotes it to retained custody, and only the customer
|
||||
// delete purges it. The old line printed the form flag ("escrow deleted: true"), which read as a
|
||||
// household's last key being destroyed.
|
||||
func hostDeleteEscrowEffect(hadEscrow bool, lookupErr error) string {
|
||||
switch {
|
||||
case lookupErr != nil:
|
||||
return "escrow state unknown before the delete; any escrow is demoted to retained custody, never destroyed"
|
||||
case hadEscrow:
|
||||
return "escrow custody demoted to retained (host delete); the customer delete is the only purge"
|
||||
default:
|
||||
return "no key escrow held"
|
||||
}
|
||||
}
|
||||
|
||||
// deletionOpensAt is the tail of an ONLINE refusal (R-599): "online" is a report-staleness window, not a
|
||||
// liveness probe, so a box that no longer exists still reads online until its last report is older than
|
||||
// the CONFIGURED stale threshold. Say when that was and when the refusal ends, so a teardown waits
|
||||
// instead of concluding the delete is broken. Empty when there is no last report.
|
||||
func (s *Server) deletionOpensAt(lastReport *time.Time, now time.Time) string {
|
||||
if lastReport == nil {
|
||||
return ""
|
||||
}
|
||||
age := now.Sub(*lastReport)
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
opens := lastReport.Add(s.staleThreshold).UTC()
|
||||
return fmt.Sprintf(" Its last report arrived %d min ago (%s UTC); deletion opens at %s UTC, once the host has been silent for the stale threshold (%s).",
|
||||
int(age.Minutes()), lastReport.UTC().Format("15:04"), opens.Format("15:04"), s.staleThreshold)
|
||||
}
|
||||
|
||||
// handleHostDetail renders the read-only per-host detail page (audit F-M1). GET only.
|
||||
func (s *Server) handleHostDetail(w http.ResponseWriter, r *http.Request, hostID string) {
|
||||
host, err := s.store.GetHost(hostID)
|
||||
|
||||
@@ -177,7 +177,9 @@ func (s *Server) offsiteCustomerRows() []offsiteCustomerRow {
|
||||
}
|
||||
if hasReport[cfg.CustomerID] {
|
||||
row.UsageBytes = usage[cfg.CustomerID]
|
||||
row.UsageStr = fmtBytesGB(row.UsageBytes)
|
||||
// R-277: fmtBytesAuto, not fmtBytesGB — a 162 KB repo rendered "0.0 GB" and was read as an
|
||||
// absent off-site tier. Above 1 GB both helpers print the same "%.1f GB".
|
||||
row.UsageStr = fmtBytesAuto(row.UsageBytes)
|
||||
if d.Type == "shared" && d.QuotaGB > 0 {
|
||||
row.UsagePercent = float64(row.UsageBytes) * 100 / float64(int64(d.QuotaGB)<<30)
|
||||
row.UsageBand = pctBand(row.UsagePercent, 90, 95) // matches the per-customer OffsiteChecker bands
|
||||
|
||||
@@ -5,6 +5,7 @@ package web
|
||||
// snapshot carries its own state (ok / unavailable / degraded) so the UI renders each honestly.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
@@ -20,11 +21,33 @@ type pbsdrBoxView struct {
|
||||
HasFill bool // numbers available (ok or degraded-with-last-known)
|
||||
CapacityStr string
|
||||
UsedStr string
|
||||
UsedExact string // R-92: exact bytes ("8 590 000 000 bytes") — UsedStr is 0.1 GB-granular
|
||||
FillPercent float64
|
||||
FillBand string
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
// fmtBytesExact renders the exact byte count grouped by spaces ("8 590 000 000 bytes"), so a
|
||||
// 50 MB delta on the PBS DR datastore is visible next to the 0.1 GB-granular UsedStr (R-92).
|
||||
func fmtBytesExact(b int64) string {
|
||||
neg := b < 0
|
||||
if neg {
|
||||
b = -b
|
||||
}
|
||||
digits := fmt.Sprintf("%d", b)
|
||||
var out []byte
|
||||
for i := range digits {
|
||||
if i > 0 && (len(digits)-i)%3 == 0 {
|
||||
out = append(out, ' ')
|
||||
}
|
||||
out = append(out, digits[i])
|
||||
}
|
||||
if neg {
|
||||
return "-" + string(out) + " bytes"
|
||||
}
|
||||
return string(out) + " bytes"
|
||||
}
|
||||
|
||||
// pbsdrTile is the compact Dashboard PBS gauge: fill %, band-colored; "n/a" when unavailable.
|
||||
type pbsdrTile struct {
|
||||
Unavailable bool
|
||||
@@ -55,6 +78,7 @@ func (s *Server) pbsdrBoxData() pbsdrBoxView {
|
||||
view.HasFill = true
|
||||
view.CapacityStr = fmtBytesGB(snap.CapacityBytes)
|
||||
view.UsedStr = fmtBytesGB(snap.UsedBytes)
|
||||
view.UsedExact = fmtBytesExact(snap.UsedBytes)
|
||||
view.FillPercent = snap.FillPercent
|
||||
view.FillBand = snap.FillBand
|
||||
}
|
||||
@@ -62,6 +86,7 @@ func (s *Server) pbsdrBoxData() pbsdrBoxView {
|
||||
view.HasFill = true
|
||||
view.CapacityStr = fmtBytesGB(snap.CapacityBytes)
|
||||
view.UsedStr = fmtBytesGB(snap.UsedBytes)
|
||||
view.UsedExact = fmtBytesExact(snap.UsedBytes)
|
||||
view.FillPercent = snap.FillPercent
|
||||
view.FillBand = snap.FillBand
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
)
|
||||
|
||||
// R-277(b): a small but non-empty off-site repo must not render as "0.0 GB" on the Offsite page — that
|
||||
// reading is how a healthy tier was reported to the operator as absent.
|
||||
func TestOffsiteRow_SmallRepoNotZeroGB(t *testing.T) {
|
||||
s, st := newRenderServer(t)
|
||||
s.SetOffsiteBox(func() (monitor.BoxSnapshot, bool) {
|
||||
return monitor.BoxSnapshot{CapacityBytes: 1 << 40, FillBand: "ok", OversubBand: "ok", FetchedAt: time.Now().UTC()}, true
|
||||
})
|
||||
saveCfg(t, st, "tiny", `{"offsite":{"enabled":true,"type":"shared","quota_gb":500}}`)
|
||||
if err := st.SaveReport("tiny", []byte(`{"customer_id":"tiny","offsite":{"enabled":true,"repo_size_bytes":165888}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := s.offsiteCustomerRows()
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("want 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].UsageStr == "0.0 GB" || rows[0].UsageStr != "162.0 KB" {
|
||||
t.Fatalf("162 KB repo rendered as %q, want \"162.0 KB\"", rows[0].UsageStr)
|
||||
}
|
||||
body := renderOffsite(t, s)
|
||||
if !strings.Contains(body, "162.0 KB") {
|
||||
t.Fatal("Offsite page must show the small repo's real size")
|
||||
}
|
||||
// Above 1 GB the unit stays GB (same as the quota column).
|
||||
if got := fmtBytesAuto(200 * gib); got != "200.0 GB" {
|
||||
t.Fatalf("200 GiB = %q, want 200.0 GB", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R-92: two PBS DR polls 50 MB apart read identically at 0.1 GB granularity; the page must show a value
|
||||
// that differs between them, so a small delta (a prune, a GC) is verifiable from the hub.
|
||||
func TestPBSDRPanel_ExactBytesShowsSmallDelta(t *testing.T) {
|
||||
render := func(used int64) string {
|
||||
s, _ := newRenderServer(t)
|
||||
s.SetPBSDRBox(func() (monitor.PBSBoxSnapshot, bool) {
|
||||
return monitor.PBSBoxSnapshot{
|
||||
CapacityBytes: 40 * gib, UsedBytes: used, FillPercent: 20, FillBand: "ok",
|
||||
State: monitor.PBSStateOK, FetchedAt: time.Now().UTC(),
|
||||
}, true
|
||||
})
|
||||
return pbsSection(renderOffsite(t, s))
|
||||
}
|
||||
a := render(8 * gib)
|
||||
b := render(8*gib + 50_000_000)
|
||||
if !strings.Contains(a, "8 589 934 592 bytes") {
|
||||
t.Fatalf("PBS DR panel must show exact bytes:\n%s", a)
|
||||
}
|
||||
if !strings.Contains(b, "8 639 934 592 bytes") {
|
||||
t.Fatalf("PBS DR panel must show the +50 MB exact value:\n%s", b)
|
||||
}
|
||||
if fmtBytesExact(999) != "999 bytes" || fmtBytesExact(1000) != "1 000 bytes" {
|
||||
t.Fatalf("grouping wrong: %q %q", fmtBytesExact(999), fmtBytesExact(1000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
)
|
||||
|
||||
// R-292: the artifact save names the cause of a checksum failure. One flash used to cover "version
|
||||
// missing", "registry unreachable" and "bad typed sha" — and the operator acts differently on each.
|
||||
//
|
||||
// These drive resolveArtifactSHA through the real handler. The installability gate is passed by a
|
||||
// registry that serves the tag and the download; ONLY the sha metadata route is varied.
|
||||
func shaRegistry(t *testing.T, metaStatus int, metaBody string) *gitea.Client {
|
||||
t.Helper()
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if strings.HasPrefix(p, "api/v1/packages/") && strings.HasSuffix(p, "/files") {
|
||||
if metaStatus != http.StatusOK {
|
||||
w.WriteHeader(metaStatus)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(metaBody))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK) // tag probe + package download: installable
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return gitea.New(srv.URL, "admin", "", "")
|
||||
}
|
||||
|
||||
func TestArtifactSave_FlashNamesTheCause(t *testing.T) {
|
||||
sha := strings.Repeat("c", 64)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
metaStatus int
|
||||
metaBody string
|
||||
want string
|
||||
}{
|
||||
{"version not found", http.StatusNotFound, "", "artifact_version_missing"},
|
||||
{"registry failing", http.StatusBadGateway, "", "artifact_unverifiable"},
|
||||
{"no sha listed", http.StatusOK, `[{"name":"x","sha256":""}]`, "artifact_sha_missing"},
|
||||
{"healthy", http.StatusOK, `[{"name":"x","sha256":"` + sha + `"}]`, "artifacts_set"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
s.SetGiteaClient(shaRegistry(t, tc.metaStatus, tc.metaBody))
|
||||
if got := flashOf(saveArtifacts(t, s)); got != tc.want {
|
||||
t.Fatalf("flash = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The manual path (no Gitea client): a malformed typed sha is the ONE case artifact_sha_invalid means.
|
||||
func TestArtifactSave_BadTypedSHAIsShaInvalid(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
form := url.Values{"agent_version": {"0.128.0"}, "agent_sha256": {"not-a-sha"}}
|
||||
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 got := flashOf(w); got != "artifact_sha_invalid" {
|
||||
t.Fatalf("flash = %q, want artifact_sha_invalid", got)
|
||||
}
|
||||
assertUnchanged(t, st, "bad typed sha")
|
||||
}
|
||||
|
||||
// Every flash the save can redirect to renders its own message (seam: a flash with no template branch
|
||||
// shows the operator nothing). The sha_invalid text no longer claims a lookup failure.
|
||||
func TestArtifactFlashes_EachRendersItsOwnText(t *testing.T) {
|
||||
s, _ := newRenderServer(t)
|
||||
for flash, want := range map[string]string{
|
||||
"artifact_version_missing": "the registry has no such version",
|
||||
"artifact_sha_missing": "no checksum for a chosen version",
|
||||
"artifact_unverifiable": "could not verify",
|
||||
"artifact_sha_invalid": "a checksum you typed is not valid",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/configuration?flash="+flash, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfiguration(rr, req)
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("flash %s: page does not render %q", flash, want)
|
||||
}
|
||||
if flash == "artifact_sha_invalid" && strings.Contains(body, "Gitea unreachable") {
|
||||
t.Error("artifact_sha_invalid still conflates an unreachable registry with a bad sha")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// R-544: an acknowledged host delete with an escrow logs the DEMOTION, never "escrow deleted: true" —
|
||||
// the line an operator would read as a household's last key being destroyed.
|
||||
func TestHostDelete_LogSaysEscrowDemotedNotDeleted(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
var logBuf strings.Builder
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
if err := st.UpsertHost(&store.Host{HostID: "esc-host", CustomerID: "c2", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.SaveHostEscrow("esc-host", []byte("blob"), "fp", "p", "2026-07-01T00:00:00Z", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := postHostDelete(t, s, "esc-host", url.Values{"confirm_host_id": {"esc-host"}, "delete_escrow": {"1"}})
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("acked delete = %d, want 303: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
out := logBuf.String()
|
||||
if strings.Contains(out, "escrow deleted") {
|
||||
t.Errorf("log still says the escrow was deleted:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "escrow custody demoted to retained") {
|
||||
t.Errorf("log must state the demotion:\n%s", out)
|
||||
}
|
||||
// And it is true: the blob survives as retained custody (a delete would be the bug the log implied).
|
||||
if n, err := st.CountSupersededEscrow("esc-host"); err != nil || n == 0 {
|
||||
t.Errorf("retained escrow count = 0 after a host delete — custody was destroyed, not demoted")
|
||||
}
|
||||
}
|
||||
|
||||
// A host with no escrow says so, not "demoted".
|
||||
func TestHostDelete_LogNoEscrow(t *testing.T) {
|
||||
if got := hostDeleteEscrowEffect(false, nil); got != "no key escrow held" {
|
||||
t.Fatalf("no-escrow effect = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R-599: an ONLINE refusal names how long ago the last report arrived and when deletion opens,
|
||||
// computed from the CONFIGURED stale threshold (45m here — not the 30m code default).
|
||||
func TestHostDelete_OnlineRefusalSaysWhenItOpens(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
s.staleThreshold = 45 * time.Minute
|
||||
if err := st.UpsertHost(&store.Host{HostID: "gone-vm", CustomerID: "c1", APIKey: "k"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostReport("gone-vm", "c1", []byte(`{}`), store.HostReportDenorm{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := st.GetHost("gone-vm")
|
||||
if err != nil || h == nil || h.LastReportAt == nil {
|
||||
t.Fatalf("host/last report: %v", err)
|
||||
}
|
||||
opens := h.LastReportAt.Add(45 * time.Minute).UTC().Format("15:04")
|
||||
|
||||
rr := postHostDelete(t, s, "gone-vm", url.Values{"confirm_host_id": {"gone-vm"}, "delete_escrow": {"1"}})
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Fatalf("online delete = %d, want 409", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"min ago", "deletion opens at " + opens + " UTC", "45m0s"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("host-delete 409 body missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
// The customer delete cascade's ONLINE refusal says the same.
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", CustomerName: "c1", APIKey: "a", RetrievalPassword: "p"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr = postDelete(t, s, "c1", cascadeForm("c1", 1))
|
||||
if rr.Code != http.StatusConflict || !strings.Contains(rr.Body.String(), "deletion opens at "+opens+" UTC") {
|
||||
t.Errorf("cascade ONLINE refusal = %d %q, want 409 naming the opening time", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The arithmetic, with a fixed clock: last report 10 minutes ago at 17:12 UTC, threshold 45m.
|
||||
func TestDeletionOpensAt_Arithmetic(t *testing.T) {
|
||||
s := &Server{staleThreshold: 45 * time.Minute}
|
||||
last := time.Date(2026, 9, 20, 17, 12, 0, 0, time.UTC)
|
||||
got := s.deletionOpensAt(&last, last.Add(10*time.Minute))
|
||||
for _, want := range []string{"10 min ago (17:12 UTC)", "deletion opens at 17:57 UTC", "(45m0s)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("deletionOpensAt = %q, missing %q", got, want)
|
||||
}
|
||||
}
|
||||
if s.deletionOpensAt(nil, last) != "" {
|
||||
t.Error("no last report → no timing sentence")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/i18n"
|
||||
)
|
||||
|
||||
// R-725: the expired bind page that SHOWS the „Új linket kérek" button must point the household at the
|
||||
// button, not send them to support („kérj újat az ügyfélszolgálattól") above it. The page without a
|
||||
// button (no token: rate-limited / not found) keeps the sentence that names another way.
|
||||
// ASCII fragment "szolg" is the support word; the no-button page is its positive control.
|
||||
func TestBindExpiredPage_PointsAtTheButton(t *testing.T) {
|
||||
b := i18n.Shared()
|
||||
s, st := newTestServer(t)
|
||||
seedForMint(t, st, "tester", "tester1@felhom.example")
|
||||
old := mintLink(t, st, "tester", -time.Hour)
|
||||
|
||||
withButton := bindGET(t, s, old).Body.String()
|
||||
if !strings.Contains(withButton, "/bind/"+old+"/resend") {
|
||||
t.Fatal("precondition: the expired page carries the resend button")
|
||||
}
|
||||
if !strings.Contains(withButton, b.Msg("hu", "bind.invalid.body_resend")) {
|
||||
t.Error("the expired page with a button does not carry the sentence that points at it")
|
||||
}
|
||||
if strings.Contains(withButton, "szolg") {
|
||||
t.Error("the expired page with a button still sends the household to support")
|
||||
}
|
||||
|
||||
// Positive control: the no-button page still carries the support sentence (the fragment is live).
|
||||
rr := httptest.NewRecorder()
|
||||
s.renderBind(rr, http.StatusNotFound, bindPageData{State: "expired", Lang: i18n.Default})
|
||||
noButton := rr.Body.String()
|
||||
if !strings.Contains(noButton, "szolg") || strings.Contains(noButton, "/resend") {
|
||||
t.Errorf("no-button page: want the support sentence and no resend form")
|
||||
}
|
||||
|
||||
// English twin: present, different, and pointing at the button.
|
||||
if en := b.Msg("en", "bind.invalid.body_resend"); !strings.Contains(en, "button below") {
|
||||
t.Errorf("English twin = %q, want it to point at the button", en)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func postCreate(s *Server, id string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"customer_id": {id}, "customer_name": {"Tester"}, "email": {"t@felhom.example"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/configs/new", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigCreate(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
// R-728: a second submit for the same customer ID while the first is still between its duplicate check
|
||||
// and its save must NOT create (and mint) a second time. The first create is held at the save by the
|
||||
// test seam while the second runs to completion.
|
||||
func TestConfigCreate_ConcurrentSubmitCreatesOnce(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
var logBuf syncBuf
|
||||
s.logger = log.New(&logBuf, "", 0)
|
||||
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var once sync.Once
|
||||
s.beforeCreateSave = func(string) {
|
||||
first := false
|
||||
once.Do(func() { first = true })
|
||||
if first {
|
||||
close(started)
|
||||
<-release
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan *httptest.ResponseRecorder)
|
||||
go func() { done <- postCreate(s, "tester-2") }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the first create never reached the save")
|
||||
}
|
||||
second := postCreate(s, "tester-2")
|
||||
close(release)
|
||||
first := <-done
|
||||
|
||||
if first.Code != http.StatusSeeOther {
|
||||
t.Fatalf("first create = %d, want 303", first.Code)
|
||||
}
|
||||
if n := strings.Count(logBuf.String(), "Customer config created: tester-2"); n != 1 {
|
||||
t.Fatalf("customer created %d times for one press, want exactly 1:\n%s", n, logBuf.String())
|
||||
}
|
||||
if !strings.Contains(second.Body.String(), "already being created") {
|
||||
t.Errorf("the second submit must be told a create is in progress; got %d", second.Code)
|
||||
}
|
||||
|
||||
// The guard is released: a later create of the SAME id is refused by the ordinary duplicate check,
|
||||
// not stuck behind the in-flight guard.
|
||||
s.beforeCreateSave = nil
|
||||
if body := postCreate(s, "tester-2").Body.String(); !strings.Contains(body, "already exists") {
|
||||
t.Error("after the first create returned, a re-submit must meet the ordinary 'already exists' check")
|
||||
}
|
||||
}
|
||||
|
||||
// syncBuf is a goroutine-safe log sink.
|
||||
type syncBuf struct {
|
||||
mu sync.Mutex
|
||||
b strings.Builder
|
||||
}
|
||||
|
||||
func (w *syncBuf) Write(p []byte) (int, error) { w.mu.Lock(); defer w.mu.Unlock(); return w.b.Write(p) }
|
||||
func (w *syncBuf) String() string { w.mu.Lock(); defer w.mu.Unlock(); return w.b.String() }
|
||||
@@ -342,8 +342,9 @@ const bindPageHTML = `<!DOCTYPE html>
|
||||
<p>{{T "bind.locked.body"}}</p>
|
||||
{{else}}
|
||||
<p class="lead">{{T "bind.invalid.lead"}}</p>
|
||||
<p>{{T "bind.invalid.body"}}</p>
|
||||
{{if .Token}}<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>{{end}}
|
||||
{{if .Token}}<p>{{T "bind.invalid.body_resend"}}</p>
|
||||
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
|
||||
{{else}}<p>{{T "bind.invalid.body"}}</p>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="foot">Felhom.eu</p>
|
||||
|
||||
@@ -111,6 +111,19 @@ type Server struct {
|
||||
// only — see the constant's comment for why this must never become a database row.
|
||||
artifactCache map[string]artifactChoiceCacheEntry
|
||||
artifactCacheMu sync.Mutex
|
||||
|
||||
// wgPeerSync asks the WG peer-sync reconciler for an immediate full-list push (R-600). nil when
|
||||
// peer-sync is not configured on this hub. Non-blocking: the push itself is asynchronous.
|
||||
wgPeerSync func()
|
||||
|
||||
// creating holds the customer IDs whose create is in flight (R-728): one press produced TWO creates
|
||||
// and two self-bind mints in the same second, and the first mail's link answered "expired". The
|
||||
// duplicate check reads the store, and the row is written only after the slow provisioning legs, so
|
||||
// a second submit inside that window passed it. Pinned by TestConfigCreate_ConcurrentSubmitCreatesOnce.
|
||||
creating sync.Map
|
||||
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
|
||||
// save. nil in production.
|
||||
beforeCreateSave func(customerID string)
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
@@ -247,6 +260,20 @@ func (s *Server) SetOffsiteBox(fn func() (monitor.BoxSnapshot, bool)) { s.offsit
|
||||
// carries its own state (ok/unavailable/degraded); the web layer never polls ep0.
|
||||
func (s *Server) SetPBSDRBox(fn func() (monitor.PBSBoxSnapshot, bool)) { s.pbsdrBox = fn }
|
||||
|
||||
// SetWGPeerSync wires the WG peer-sync reconciler's Trigger (R-600): the customer delete cascade asks
|
||||
// for an immediate push so the deleted box's peer leaves the endpoint now, not on the next 5-minute tick.
|
||||
func (s *Server) SetWGPeerSync(trigger func()) { s.wgPeerSync = trigger }
|
||||
|
||||
// requestWGPeerPush triggers the peer-sync push and returns the operator-facing phrase for the log:
|
||||
// the push is asynchronous, so the line says "requested", never "removed" (R-600).
|
||||
func (s *Server) requestWGPeerPush() string {
|
||||
if s.wgPeerSync == nil {
|
||||
return "WG peer-sync is not configured on this hub, so the endpoint's peer list was NOT updated"
|
||||
}
|
||||
s.wgPeerSync()
|
||||
return "WG peer removal push requested (asynchronous — the endpoint drops the peer on that push, check `wg show` before calling the box gone)"
|
||||
}
|
||||
|
||||
// SetClaimEngine wires the customer-claim code engine for the Setup-tab resend button (v0.50.0).
|
||||
func (s *Server) SetClaimEngine(e *claim.Engine) { s.claimEngine = e }
|
||||
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "artifact_sha_invalid"}}
|
||||
<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>
|
||||
<div class="flash flash-error"><strong>Refused: a checksum you typed is not valid.</strong> A sha256 is 64 characters, 0-9 and a-f. Manifest unchanged. Copy the checksum again and save.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "artifact_version_missing"}}
|
||||
<div class="flash flash-error"><strong>Refused: the registry has no such version.</strong> Gitea answered "not found" for that version, so there is no checksum to record. Manifest unchanged. Check the version number, or publish that version first, then vouch it here again.</div>
|
||||
{{end}}
|
||||
{{if eq .Flash "golden_behind_fleet"}}
|
||||
<div class="flash flash-error"><strong>Refused: that golden is older than the controller the fleet already runs.</strong> A fresh install would land on stale application code — which is R-120, where new boxes shipped a controller that told customers the wrong thing about a missing backup drive. Manifest unchanged. Re-bake the golden on the current controller, publish it, then vouch it here.</div>
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<table class="detail-table">
|
||||
<tr><th style="width: 12rem;">Datastore</th><td><code>felhom-offsite</code> (ep0)</td></tr>
|
||||
<tr><th>Capacity</th><td>{{.PBSBox.CapacityStr}}</td></tr>
|
||||
<tr><th>Used</th><td>{{.PBSBox.UsedStr}} · {{formatFloat .PBSBox.FillPercent}}% full</td></tr>
|
||||
<tr><th>Used</th><td>{{.PBSBox.UsedStr}} · {{formatFloat .PBSBox.FillPercent}}% full <span class="text-muted">({{.PBSBox.UsedExact}})</span></td></tr>
|
||||
</table>
|
||||
<div class="bar" style="margin: 0.4rem 0 0.9rem;"><div class="bar-fill bar-{{.PBSBox.FillBand}}" style="width: {{formatFloat .PBSBox.FillPercent}}%;"></div></div>
|
||||
<table class="detail-table">
|
||||
|
||||
Reference in New Issue
Block a user