Unit unification: GB entries converted at 1e9 while every byte formatter is binary (1024^3), so a 3000 GB limit rendered 2.73 TB instead of 2.93 TB. calBytes and the form's limit conversion now use 1024^3. Stored traffic_limits (bytes, typed x1e9) are rescaled x1.073741824 exactly once at store open, guarded by the probe_setting mark gb_1024_migrated_v1; calibration values are stored in GB (no rescale — only their conversion changed) and price/sort/reset-day are unitless. One-shot calibration: the form's cal fields are deltas to apply now. probe.MergeCalDelta folds a non-zero submitted value into the stored standing correction and keeps the standing value on a zero/absent submission; putMeta merges against the stored row (SaveMeta/UpsertMeta keep plain replace — the notify sweep relies on it). GET no longer echoes the standing correction into the form, and the frontend keeps the cal inputs as local state that starts empty and clears on successful save, so a later unrelated save resubmits nothing. Tests: calBytes(3000)=3000x2^30; migration 3.0e12 -> 3000x2^30 with no second rescale on reopen; one-shot flow (save delta, save unrelated with zero cal -> calibrated traffic unmoved, second delta accumulates once). Existing TestTrafficCalibrationAppliedAndFloored expectation updated to the binary figure. No vitest covers ProbeMetaSection (disclosed in the report).
204 lines
7.1 KiB
Go
204 lines
7.1 KiB
Go
package probe
|
|
|
|
import (
|
|
"database/sql"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Meta is one agent's probe display/operations metadata (hub node row's
|
|
// operator-owned half, ITEM7-SCOPE §5.2): what the public page shows
|
|
// beyond the machine's own reports — price/expiry/plan figures, the
|
|
// public switch and ordering, grouping and the public remark, the
|
|
// notify opt-in and the hand-set address/country pins. Rows are
|
|
// ensured with hub defaults on the agent's first report; the editing
|
|
// surface (B3) reads and writes them here.
|
|
type Meta struct {
|
|
AgentID int64
|
|
Price float64
|
|
Currency string
|
|
BillingCycle string
|
|
ExpiresAt *string // "2006-01-02" or nil
|
|
TrafficLimit int64
|
|
TrafficMode string
|
|
TrafficResetDay int64
|
|
Public bool
|
|
Sort int64
|
|
GroupName string
|
|
PublicRemark string
|
|
Notify bool
|
|
CountryPin string
|
|
IPv4Pin string
|
|
IPv6Pin string
|
|
// TrafficCalUpGB / TrafficCalDownGB are the operator's ledger
|
|
// corrections in GB (V2.0.1), either sign: added to the counted
|
|
// uplink (tx) / downlink (rx) figures wherever the ledger is
|
|
// read — panel statistics, the public page and traffic alerts.
|
|
// The stored values are the STANDING accumulated correction;
|
|
// the edit form submits one-shot deltas that MergeCalDelta folds
|
|
// into them (V2.2.1 ⑥b).
|
|
TrafficCalUpGB float64
|
|
TrafficCalDownGB float64
|
|
// StatIface pins the one interface whose counters the agent
|
|
// books (V2.0.1); "" keeps the automatic counted-set selection.
|
|
StatIface string
|
|
}
|
|
|
|
// DefaultMeta returns the metadata of an agent that has no row yet —
|
|
// the same values the row would carry once ensured (the probe_meta
|
|
// DDL defaults).
|
|
func DefaultMeta(agentID int64) Meta {
|
|
return Meta{
|
|
AgentID: agentID,
|
|
Currency: "USD",
|
|
BillingCycle: "monthly",
|
|
TrafficMode: "sum",
|
|
TrafficResetDay: 1,
|
|
Public: true,
|
|
}
|
|
}
|
|
|
|
const metaColumns = `agent_id, price, currency, billing_cycle, expires_at,
|
|
traffic_limit, traffic_mode, traffic_reset_day, public, sort,
|
|
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin,
|
|
traffic_cal_up_gb, traffic_cal_down_gb, stat_iface`
|
|
|
|
func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
|
|
var m Meta
|
|
var expires sql.NullString
|
|
var public, notify int64
|
|
if err := sc.Scan(&m.AgentID, &m.Price, &m.Currency, &m.BillingCycle, &expires,
|
|
&m.TrafficLimit, &m.TrafficMode, &m.TrafficResetDay, &public, &m.Sort,
|
|
&m.GroupName, &m.PublicRemark, ¬ify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin,
|
|
&m.TrafficCalUpGB, &m.TrafficCalDownGB, &m.StatIface); err != nil {
|
|
return Meta{}, err
|
|
}
|
|
if expires.Valid && strings.TrimSpace(expires.String) != "" {
|
|
v := expires.String
|
|
m.ExpiresAt = &v
|
|
}
|
|
m.Public = public != 0
|
|
m.Notify = notify != 0
|
|
return m, nil
|
|
}
|
|
|
|
// Meta reads one agent's metadata row. ok is false when the agent has
|
|
// no row yet (it never reported); callers then use DefaultMeta.
|
|
func (s *Store) Meta(agentID int64) (Meta, bool, error) {
|
|
row := s.db.QueryRow(`SELECT `+metaColumns+` FROM probe_meta WHERE agent_id = ?`, agentID)
|
|
m, err := scanMeta(row)
|
|
if err == sql.ErrNoRows {
|
|
return Meta{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Meta{}, false, err
|
|
}
|
|
return m, true, nil
|
|
}
|
|
|
|
// AllMeta reads every metadata row, keyed by agent id.
|
|
func (s *Store) AllMeta() (map[int64]Meta, error) {
|
|
rows, err := s.db.Query(`SELECT ` + metaColumns + ` FROM probe_meta`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make(map[int64]Meta)
|
|
for rows.Next() {
|
|
m, err := scanMeta(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[m.AgentID] = m
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// UpsertMeta writes one agent's metadata row (insert or full replace).
|
|
func (s *Store) UpsertMeta(m Meta) error {
|
|
var expires any
|
|
if m.ExpiresAt != nil && strings.TrimSpace(*m.ExpiresAt) != "" {
|
|
expires = *m.ExpiresAt
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(agent_id) DO UPDATE SET
|
|
price=excluded.price, currency=excluded.currency,
|
|
billing_cycle=excluded.billing_cycle, expires_at=excluded.expires_at,
|
|
traffic_limit=excluded.traffic_limit, traffic_mode=excluded.traffic_mode,
|
|
traffic_reset_day=excluded.traffic_reset_day, public=excluded.public,
|
|
sort=excluded.sort, group_name=excluded.group_name,
|
|
public_remark=excluded.public_remark, notify=excluded.notify,
|
|
country_pin=excluded.country_pin, ipv4_pin=excluded.ipv4_pin,
|
|
ipv6_pin=excluded.ipv6_pin,
|
|
traffic_cal_up_gb=excluded.traffic_cal_up_gb,
|
|
traffic_cal_down_gb=excluded.traffic_cal_down_gb,
|
|
stat_iface=excluded.stat_iface`,
|
|
m.AgentID, m.Price, m.Currency, m.BillingCycle, expires,
|
|
m.TrafficLimit, m.TrafficMode, m.TrafficResetDay, boolToInt(m.Public), m.Sort,
|
|
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin,
|
|
m.TrafficCalUpGB, m.TrafficCalDownGB, m.StatIface)
|
|
return err
|
|
}
|
|
|
|
// MergeCalDelta folds a probe-meta form submission into the stored
|
|
// row under the one-shot calibration semantics (V2.2.1 ⑥b): the
|
|
// form's calibration fields are a correction TO APPLY NOW, not the
|
|
// new standing value. A non-zero submitted value adds onto the stored
|
|
// standing correction; a zero field (an absent field is the same
|
|
// zero — the wire type cannot distinguish them, and both mean
|
|
// "nothing to apply") keeps the stored value untouched. Every other
|
|
// field takes the submitted value, as UpsertMeta's full replace
|
|
// does. Paired with the form clearing its calibration inputs after a
|
|
// successful save, a later unrelated save can never re-apply an old
|
|
// correction.
|
|
func MergeCalDelta(existing, submitted Meta) Meta {
|
|
out := submitted
|
|
out.TrafficCalUpGB = existing.TrafficCalUpGB
|
|
if submitted.TrafficCalUpGB != 0 {
|
|
out.TrafficCalUpGB += submitted.TrafficCalUpGB
|
|
}
|
|
out.TrafficCalDownGB = existing.TrafficCalDownGB
|
|
if submitted.TrafficCalDownGB != 0 {
|
|
out.TrafficCalDownGB += submitted.TrafficCalDownGB
|
|
}
|
|
return out
|
|
}
|
|
|
|
func boolToInt(b bool) int64 {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// DeleteAgentData removes every probe row of an agent that no longer
|
|
// exists: metadata, the traffic ledger and both history tiers. The
|
|
// hub keeps history inside its own node table; here probe.db is a
|
|
// separate file, so the panel-side agent deletion calls this to keep
|
|
// the two stores in step (B2 report §6 cleanup point).
|
|
func (s *Store) DeleteAgentData(agentID int64) error {
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for _, table := range []string{
|
|
"probe_meta", "probe_traffic", "probe_metric",
|
|
"probe_metric_hour", "probe_ping_record", "probe_ping_hour",
|
|
"probe_ping_node",
|
|
} {
|
|
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE agent_id = ?`, agentID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// The B6 offline mark is keyed per agent in probe_setting; the
|
|
// traffic steps are one shared JSON value the sweep prunes to
|
|
// live agents on its next write, so only the mark is struck here.
|
|
if _, err := tx.Exec(`DELETE FROM probe_setting WHERE key = ?`,
|
|
"notify_down:"+strconv.FormatInt(agentID, 10)); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|