Files
nilsandClaude Sonnet 5.5 eda7080d94 Zone-based firewall page, per-sensor temperatures, 15 minute default
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
2026-10-30 09:26:25 +01:00

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
}