Files
3x-ui-agent/internal/web/service/probe/meta.go
T
Muse 93c1620c15 v221 ⑥ probe traffic: binary GB convention + one-shot calibration
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).
2026-10-23 15:02:11 +00:00

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, &notify, &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()
}