The firewall page now manages zones (assign each LAN or tunnel, add and remove zones) and rules with zone, address and port on both sides plus the protocol. router.firewall.zones declares zones without members and rules take sourcePorts. The temperature chart draws every thermal zone and hwmon sensor; the overview defaults to 15 minutes, 1h stays an option. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
266 lines
6.0 KiB
Go
266 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/gob"
|
|
"math"
|
|
"os"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Metric history: every series keeps a ring of averages per resolution. A
|
|
// slot's index follows from its time, so writing needs no head pointer and
|
|
// gaps (the UI was not running) simply stay NaN.
|
|
//
|
|
// 5 s for 1 h, 1 min for 24 h, 15 min for 7 days, 1 h for 30 days.
|
|
var tiers = []struct {
|
|
Step int64 // seconds
|
|
Slots int
|
|
}{
|
|
{5, 720},
|
|
{60, 1440},
|
|
{900, 672},
|
|
{3600, 720},
|
|
}
|
|
|
|
// Ranges the dashboard offers: the tier each one reads and how many of its
|
|
// newest slots it shows (0: all of them). The first one is the default.
|
|
var ranges = []timeRange{
|
|
{"15m", 0, 180},
|
|
{"1h", 0, 0},
|
|
{"24h", 1, 0},
|
|
{"7d", 2, 0},
|
|
{"30d", 3, 0},
|
|
}
|
|
|
|
type timeRange struct {
|
|
Name string
|
|
Tier int
|
|
Slots int
|
|
}
|
|
|
|
// slots is how many slots of its tier the range shows.
|
|
func (r timeRange) slots() int {
|
|
if r.Slots > 0 {
|
|
return r.Slots
|
|
}
|
|
return tiers[r.Tier].Slots
|
|
}
|
|
|
|
func findRange(name string) timeRange {
|
|
for _, r := range ranges {
|
|
if r.Name == name {
|
|
return r
|
|
}
|
|
}
|
|
return ranges[0]
|
|
}
|
|
|
|
type Tier struct {
|
|
Step int64
|
|
Values []float64 // NaN: no data
|
|
Last int64 // start of the newest written bucket (unix seconds)
|
|
// The bucket being filled.
|
|
Bucket int64
|
|
Sum float64
|
|
Count int
|
|
}
|
|
|
|
func newTier(step int64, slots int) *Tier {
|
|
t := &Tier{Step: step, Values: make([]float64, slots)}
|
|
for i := range t.Values {
|
|
t.Values[i] = math.NaN()
|
|
}
|
|
return t
|
|
}
|
|
|
|
func (t *Tier) slot(b int64) int { return int((b / t.Step) % int64(len(t.Values))) }
|
|
|
|
func (t *Tier) add(at int64, v float64) {
|
|
b := at - at%t.Step
|
|
if b != t.Bucket {
|
|
t.flush()
|
|
t.Bucket, t.Sum, t.Count = b, 0, 0
|
|
}
|
|
t.Sum += v
|
|
t.Count++
|
|
}
|
|
|
|
// flush writes the bucket being filled into the ring.
|
|
func (t *Tier) flush() {
|
|
if t.Count == 0 || t.Bucket < t.Last {
|
|
return
|
|
}
|
|
n := int64(len(t.Values))
|
|
// Clear the slots skipped since the last write (at most one full turn).
|
|
for b, i := t.Last+t.Step, int64(0); b < t.Bucket && i < n; b, i = b+t.Step, i+1 {
|
|
t.Values[t.slot(b)] = math.NaN()
|
|
}
|
|
t.Values[t.slot(t.Bucket)] = t.Sum / float64(t.Count)
|
|
t.Last = t.Bucket
|
|
t.Count = 0
|
|
}
|
|
|
|
// read returns the buckets of the last full turn of the ring up to now, the
|
|
// one being filled included, oldest first, and the first bucket's start.
|
|
func (t *Tier) read(now int64) (int64, []float64) {
|
|
n := int64(len(t.Values))
|
|
end := now - now%t.Step
|
|
start := end - (n-1)*t.Step
|
|
out := make([]float64, n)
|
|
for i := range out {
|
|
b := start + int64(i)*t.Step
|
|
switch {
|
|
case b == t.Bucket && t.Count > 0:
|
|
out[i] = t.Sum / float64(t.Count)
|
|
case b > t.Last || b <= t.Last-n*t.Step:
|
|
out[i] = math.NaN()
|
|
default:
|
|
out[i] = t.Values[t.slot(b)]
|
|
}
|
|
}
|
|
return start, out
|
|
}
|
|
|
|
type Series struct {
|
|
Tiers []*Tier
|
|
}
|
|
|
|
func newSeries() *Series {
|
|
s := &Series{}
|
|
for _, t := range tiers {
|
|
s.Tiers = append(s.Tiers, newTier(t.Step, t.Slots))
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Event is a change of the uplink's state.
|
|
type Event struct {
|
|
Time time.Time
|
|
Connected bool
|
|
Detail string
|
|
}
|
|
|
|
// Store is the history that survives restarts.
|
|
type Store struct {
|
|
mu sync.Mutex
|
|
Series map[string]*Series
|
|
// The uplink's state as last seen, and its recent changes.
|
|
Connected bool
|
|
Since time.Time
|
|
Events []Event
|
|
}
|
|
|
|
const maxEvents = 50
|
|
|
|
func newStore() *Store { return &Store{Series: map[string]*Series{}} }
|
|
|
|
func (s *Store) Add(name string, at time.Time, v float64) {
|
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
ser := s.Series[name]
|
|
if ser == nil {
|
|
ser = newSeries()
|
|
s.Series[name] = ser
|
|
}
|
|
for _, t := range ser.Tiers {
|
|
t.add(at.Unix(), v)
|
|
}
|
|
}
|
|
|
|
// Read returns a series at the resolution of tier: the start, step and values.
|
|
func (s *Store) Read(name string, tier int, now time.Time) (time.Time, time.Duration, []float64) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
t := tiers[tier]
|
|
ser := s.Series[name]
|
|
if ser == nil {
|
|
ser = newSeries() // all NaN
|
|
}
|
|
start, vals := ser.Tiers[tier].read(now.Unix())
|
|
return time.Unix(start, 0), time.Duration(t.Step) * time.Second, vals
|
|
}
|
|
|
|
func (s *Store) Names() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
names := make([]string, 0, len(s.Series))
|
|
for n := range s.Series {
|
|
names = append(names, n)
|
|
}
|
|
slices.Sort(names)
|
|
return names
|
|
}
|
|
|
|
// SetConnected records the uplink's state; a change becomes an event.
|
|
func (s *Store) SetConnected(connected bool, at time.Time, detail string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if connected == s.Connected && !s.Since.IsZero() {
|
|
return
|
|
}
|
|
s.Connected, s.Since = connected, at
|
|
s.Events = append(s.Events, Event{Time: at, Connected: connected, Detail: detail})
|
|
if len(s.Events) > maxEvents {
|
|
s.Events = s.Events[len(s.Events)-maxEvents:]
|
|
}
|
|
}
|
|
|
|
func (s *Store) Uplink() (bool, time.Time, []Event) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.Connected, s.Since, slices.Clone(s.Events)
|
|
}
|
|
|
|
// The on-disk format; bump storeVersion when it changes incompatibly (an old
|
|
// file is then ignored).
|
|
const storeVersion = 1
|
|
|
|
type storeFile struct {
|
|
Version int
|
|
Series map[string]*Series
|
|
Connected bool
|
|
Since time.Time
|
|
Events []Event
|
|
}
|
|
|
|
func (s *Store) Save(path string) error {
|
|
s.mu.Lock()
|
|
f := storeFile{storeVersion, s.Series, s.Connected, s.Since, s.Events}
|
|
var buf bytes.Buffer
|
|
err := gob.NewEncoder(&buf).Encode(f)
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeFileAtomic(path, buf.Bytes(), 0o600)
|
|
}
|
|
|
|
func loadStore(path string) (*Store, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return newStore(), err
|
|
}
|
|
var f storeFile
|
|
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&f); err != nil || f.Version != storeVersion {
|
|
return newStore(), err
|
|
}
|
|
s := &Store{Series: map[string]*Series{}, Connected: f.Connected, Since: f.Since, Events: f.Events}
|
|
for name, ser := range f.Series {
|
|
// Keep only series whose shape matches the current tiers.
|
|
ok := len(ser.Tiers) == len(tiers)
|
|
for i := 0; ok && i < len(tiers); i++ {
|
|
ok = ser.Tiers[i].Step == tiers[i].Step && len(ser.Tiers[i].Values) == tiers[i].Slots
|
|
}
|
|
if ok {
|
|
s.Series[name] = ser
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|