New internal/web/service/probe package keeps probe history in its own probe.db beside the panel DB (single connection, WAL), so history writes never contend with the panel write lock. - Minute rows per hub semantics: a held-reading rhythm books traffic against each reading own arrival; minute buckets merge (means, rounded int means, peaks) and the stored net rate derives from the kernel counters between row marks, falling back to agent rates at epoch breaks. - probe_traffic ledger: accumulate is a line port of hub traffic.rs (baseline alignment, same-boot shrink books 0, epoch change books 0, unseen-boot exception books the whole counters-since-boot measured against the master last-seen stamp, per-direction period resets, saturating adds); reads gate stale day/month stamps to zero. - probe_meta rows are ensured for every reporting agent (B3 edits them in place); probe_ping_* tables are created now, written by B4. - Hourly roll_up behind a watermark (hour-aligned, late grace, pre-retention hours skipped unfoldered) and watermark-guarded chunked prune (minute tier 7 days or the retention window when shorter; hourly tier to retention_days, default 7, cap 365). - Wiring: heartbeat files probe samples after the snapshot update (last-seen captured before touch); housekeeping hangs off the existing robfig/cron in web.startTask (@hourly + one pass a minute after startup, mirroring the hub boot pass); Server.stop books held readings. Every failure path logs and degrades - a broken probe.db never fails a heartbeat or the panel.
292 lines
9.0 KiB
Go
292 lines
9.0 KiB
Go
package probe
|
|
|
|
import (
|
|
"math"
|
|
"time"
|
|
)
|
|
|
|
// Sample is one heartbeat's probe metrics block, as the master received
|
|
// it (item 7 B1 wire shape). A Sample is only built when the heartbeat
|
|
// carried a metrics block at all; individual zero values mirror the
|
|
// agent's softened reads, exactly like a hub report frame.
|
|
type Sample struct {
|
|
BootID string
|
|
Uptime uint64 // seconds; 0 = unreadable, i.e. no boot time
|
|
CPU float32
|
|
MemUsed uint64
|
|
SwapUsed uint64
|
|
DiskUsed uint64
|
|
NetRxTotal uint64 // kernel lifetime counters of the counted ifaces
|
|
NetTxTotal uint64
|
|
NetRx uint64 // agent-reported per-second rates (peaks, fallback)
|
|
NetTx uint64
|
|
TCP uint32
|
|
UDP uint32
|
|
Procs uint32
|
|
}
|
|
|
|
// reading is one report's pair of kernel byte counters, the boot epoch
|
|
// they belong to, and when the master received them (hub Reading).
|
|
type reading struct {
|
|
epoch string
|
|
rx, tx int64
|
|
at time.Time // wall clock: stamps and calendar dates
|
|
tick time.Time // monotonic anchor for rate durations
|
|
booted *int64 // unix seconds the machine booted, by master's clock
|
|
booked bool
|
|
}
|
|
|
|
// continuedBy reports whether next continues this reading's span. A new
|
|
// epoch or a counter that moved backwards leaves nothing to subtract.
|
|
func (r *reading) continuedBy(next *reading) bool {
|
|
return r.epoch == next.epoch && next.rx >= r.rx && next.tx >= r.tx
|
|
}
|
|
|
|
// minuteAcc is the running mean/peak accumulator for the minute in
|
|
// progress (hub Minute): mean fields are summed, peaks are maxed.
|
|
type minuteAcc struct {
|
|
n float64
|
|
cpuSum float64
|
|
memSum float64
|
|
swapSum float64
|
|
diskSum float64
|
|
tcpSum float64
|
|
udpSum float64
|
|
procsSum float64
|
|
cpuPeak float64
|
|
rxPeak uint64
|
|
txPeak uint64
|
|
}
|
|
|
|
func (a *minuteAcc) add(s *Sample) {
|
|
a.n++
|
|
a.cpuSum += float64(s.CPU)
|
|
a.memSum += float64(s.MemUsed)
|
|
a.swapSum += float64(s.SwapUsed)
|
|
a.diskSum += float64(s.DiskUsed)
|
|
a.tcpSum += float64(s.TCP)
|
|
a.udpSum += float64(s.UDP)
|
|
a.procsSum += float64(s.Procs)
|
|
if float64(s.CPU) > a.cpuPeak {
|
|
a.cpuPeak = float64(s.CPU)
|
|
}
|
|
if s.NetRx > a.rxPeak {
|
|
a.rxPeak = s.NetRx
|
|
}
|
|
if s.NetTx > a.txPeak {
|
|
a.txPeak = s.NetTx
|
|
}
|
|
}
|
|
|
|
// counterMark is where a history row's network rate starts: the kernel
|
|
// counters as an earlier report carried them (hub Mark).
|
|
type counterMark struct {
|
|
tick time.Time
|
|
epoch string
|
|
rx int64
|
|
tx int64
|
|
}
|
|
|
|
// nodeState is one agent's in-memory ingest state (hub Agent + the held
|
|
// reading of file()). It is rebuilt within one heartbeat of a master
|
|
// restart; nothing here is the only copy of anything booked.
|
|
type nodeState struct {
|
|
lastMinute *int64 // minute bucket the stored-row cadence is on
|
|
acc minuteAcc
|
|
mark *counterMark
|
|
held *reading
|
|
}
|
|
|
|
func u64toI64(v uint64) (int64, bool) {
|
|
if v > math.MaxInt64 {
|
|
return 0, false
|
|
}
|
|
return int64(v), true
|
|
}
|
|
|
|
// Record files one heartbeat sample: it books the traffic reading (hub
|
|
// file()) and, on a minute change, stores the aggregated minute row
|
|
// (hub report()). at is the arrival time on the master's clock;
|
|
// prevLastSeenSec is the agent's last-seen stamp from before this
|
|
// heartbeat (seconds), which the unseen-boot rule measures against.
|
|
//
|
|
// Errors are returned for tests; the facade swallows them.
|
|
func (s *Store) Record(agentID int64, smp Sample, at time.Time, prevLastSeenSec int64) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
st := s.nodes[agentID]
|
|
if st == nil {
|
|
st = &nodeState{}
|
|
s.nodes[agentID] = st
|
|
}
|
|
|
|
// The traffic reading exists only with an epoch and a valid pair of
|
|
// counters: no reading is not a reading of zero — booking zero would
|
|
// align the baseline to it and book the next report's lifetime
|
|
// counter as one delta.
|
|
var rd *reading
|
|
if smp.BootID != "" {
|
|
if rx, ok1 := u64toI64(smp.NetRxTotal); ok1 {
|
|
if tx, ok2 := u64toI64(smp.NetTxTotal); ok2 {
|
|
rd = &reading{epoch: smp.BootID, rx: rx, tx: tx, at: at, tick: at}
|
|
if smp.Uptime > 0 {
|
|
b := at.Unix() - int64(smp.Uptime)
|
|
rd.booted = &b
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if rd != nil {
|
|
if err := s.file(st, agentID, rd, prevLastSeenSec); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Minute row: the accumulator takes every sample; a sample arriving
|
|
// in a new minute flushes a row stamped with that minute, summarizing
|
|
// the interval since the previous row (hub semantics — the means
|
|
// include the flushing sample).
|
|
minute := at.Unix() / 60
|
|
st.acc.add(&smp)
|
|
store := false
|
|
if st.lastMinute == nil {
|
|
st.lastMinute = &minute
|
|
} else if *st.lastMinute != minute {
|
|
store = true
|
|
}
|
|
if store {
|
|
if err := s.storeRow(st, agentID, smp, rd, at, minute); err != nil {
|
|
return err
|
|
}
|
|
st.acc = minuteAcc{}
|
|
st.lastMinute = &minute
|
|
}
|
|
if rd != nil {
|
|
if store || st.mark == nil || st.mark.epoch != rd.epoch {
|
|
st.mark = &counterMark{tick: rd.tick, epoch: rd.epoch, rx: rd.rx, tx: rd.tx}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// file books readings into the traffic row about once a minute rather
|
|
// than with every report (hub file()): each booking is a DB write, and
|
|
// skipping the readings in between loses nothing because the kernel
|
|
// counter keeps counting — provided every break is booked on both
|
|
// sides:
|
|
//
|
|
// - no reading held: book this one;
|
|
// - a break (epoch change / counter backwards): book the held reading
|
|
// as the last of its span, then this one as the first of the next;
|
|
// - a new minute: book the held reading, dated by its own arrival, so
|
|
// bytes moved before midnight count toward that day; where it was
|
|
// already booked, book this one instead.
|
|
func (s *Store) file(st *nodeState, agentID int64, rd *reading, prevLastSeenSec int64) error {
|
|
book := func(r *reading) error {
|
|
_, err := s.accumulate(agentID, r.epoch, r.rx, r.tx, r.at, r.booted, prevLastSeenSec)
|
|
return err
|
|
}
|
|
var bookLast, bookThis bool
|
|
switch {
|
|
case st.held == nil:
|
|
bookThis = true
|
|
case !st.held.continuedBy(rd):
|
|
bookLast, bookThis = !st.held.booked, true
|
|
case st.held.at.Unix()/60 != rd.at.Unix()/60:
|
|
bookLast, bookThis = !st.held.booked, st.held.booked
|
|
default:
|
|
bookLast, bookThis = false, false
|
|
}
|
|
if bookLast {
|
|
if err := book(st.held); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if bookThis {
|
|
if err := book(rd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
rd.booked = bookThis
|
|
st.held = rd
|
|
return nil
|
|
}
|
|
|
|
// storeRow writes the flushed minute row (hub insert_metric via
|
|
// Minute.write_into): mean fields from the accumulator (integer means
|
|
// rounded), peaks from the accumulator, and the network rates derived
|
|
// from the counter mark when the span is unbroken — so the stored
|
|
// rates integrate to the booked traffic — else the agent's own rates
|
|
// from the flushing sample.
|
|
func (s *Store) storeRow(st *nodeState, agentID int64, smp Sample, rd *reading, at time.Time, minute int64) error {
|
|
acc := &st.acc
|
|
row := struct {
|
|
cpu, cpuMax float64
|
|
mem, swap, disk int64
|
|
netRx, netTx int64
|
|
netRxMax, netTxMax int64
|
|
tcp, udp, procs int64
|
|
}{
|
|
cpu: float64(smp.CPU), cpuMax: float64(smp.CPU),
|
|
mem: int64(smp.MemUsed), swap: int64(smp.SwapUsed), disk: int64(smp.DiskUsed),
|
|
netRx: int64(smp.NetRx), netTx: int64(smp.NetTx),
|
|
netRxMax: int64(smp.NetRx), netTxMax: int64(smp.NetTx),
|
|
tcp: int64(smp.TCP), udp: int64(smp.UDP), procs: int64(smp.Procs),
|
|
}
|
|
if acc.n > 0 {
|
|
row.cpu = acc.cpuSum / acc.n
|
|
row.mem = int64(math.Round(acc.memSum / acc.n))
|
|
row.swap = int64(math.Round(acc.swapSum / acc.n))
|
|
row.disk = int64(math.Round(acc.diskSum / acc.n))
|
|
row.tcp = int64(math.Round(acc.tcpSum / acc.n))
|
|
row.udp = int64(math.Round(acc.udpSum / acc.n))
|
|
row.procs = int64(math.Round(acc.procsSum / acc.n))
|
|
row.cpuMax = acc.cpuPeak
|
|
row.netRxMax = int64(acc.rxPeak)
|
|
row.netTxMax = int64(acc.txPeak)
|
|
}
|
|
if rd != nil && st.mark != nil && st.mark.epoch == rd.epoch {
|
|
elapsed := at.Sub(st.mark.tick).Seconds()
|
|
if elapsed < 1.0 {
|
|
elapsed = 1.0
|
|
}
|
|
if d := rd.rx - st.mark.rx; d > 0 {
|
|
row.netRx = int64(float64(d) / elapsed)
|
|
} else {
|
|
row.netRx = 0
|
|
}
|
|
if d := rd.tx - st.mark.tx; d > 0 {
|
|
row.netTx = int64(float64(d) / elapsed)
|
|
} else {
|
|
row.netTx = 0
|
|
}
|
|
}
|
|
_, err := s.db.Exec(`
|
|
INSERT OR REPLACE INTO probe_metric
|
|
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
|
|
tcp, udp, procs, net_rx_max, net_tx_max, cpu_max)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
agentID, minute*60, row.cpu, row.mem, row.swap, row.disk, row.netRx, row.netTx,
|
|
row.tcp, row.udp, row.procs, row.netRxMax, row.netTxMax, row.cpuMax)
|
|
return err
|
|
}
|
|
|
|
// BookHeld books every node's held, not-yet-booked reading (hub
|
|
// book_held): a node that reboots before reporting again would otherwise
|
|
// take the bytes since its last booking with it. Called on master
|
|
// shutdown; best-effort. prevLastSeen is no longer meaningful at
|
|
// shutdown, so the unseen-boot exception cannot fire from here (the
|
|
// readings being booked continue spans the master already saw).
|
|
func (s *Store) BookHeld() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for id, st := range s.nodes {
|
|
if st.held != nil && !st.held.booked {
|
|
r := st.held
|
|
if _, err := s.accumulate(id, r.epoch, r.rx, r.tx, r.at, r.booted, 0); err == nil {
|
|
r.booked = true
|
|
}
|
|
}
|
|
}
|
|
}
|