Add control logic engine, panel logic dialogs, logic-edit restriction
Introduce server-side control-logic flow graphs (cron/alarm triggers, Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and user-interaction dialog nodes, and a synthetic node-graph editor. Add an optional logic-editor allowlist (server.logic_editors) gating who may add/edit panel logic and control logic, surfaced via /api/v1/me and enforced in the API; hide logic affordances in the UI accordingly. Update README, example config, and functional/technical specs to cover all current features (plot panels, panel/control logic, local variables, access control) and refresh the in-app manual and contextual help. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
// Minimal 5-field cron parser for control-logic cron triggers.
|
||||
//
|
||||
// Fields, in order: minute hour day-of-month month day-of-week.
|
||||
// Each field supports:
|
||||
//
|
||||
// * any value
|
||||
// */n every n (step over the whole range)
|
||||
// a-b inclusive range
|
||||
// a-b/n range with step
|
||||
// a,b,c comma-separated list of the above
|
||||
// N a single value
|
||||
//
|
||||
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
|
||||
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
|
||||
// matches when EITHER matches, following Vixie cron semantics.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Schedule is a parsed 5-field cron specification.
|
||||
type Schedule struct {
|
||||
minute uint64 // bitmask 0..59
|
||||
hour uint64 // bitmask 0..23
|
||||
dom uint64 // bitmask 1..31
|
||||
month uint64 // bitmask 1..12
|
||||
dow uint64 // bitmask 0..6
|
||||
domStar bool // day-of-month field was "*"
|
||||
dowStar bool // day-of-week field was "*"
|
||||
}
|
||||
|
||||
// ParseSchedule parses a 5-field cron spec. Extra whitespace is tolerated.
|
||||
func ParseSchedule(spec string) (*Schedule, error) {
|
||||
fields := strings.Fields(spec)
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("cron spec must have 5 fields, got %d", len(fields))
|
||||
}
|
||||
s := &Schedule{}
|
||||
var err error
|
||||
if s.minute, err = parseField(fields[0], 0, 59); err != nil {
|
||||
return nil, fmt.Errorf("minute: %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(fields[1], 0, 23); err != nil {
|
||||
return nil, fmt.Errorf("hour: %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(fields[2], 1, 31); err != nil {
|
||||
return nil, fmt.Errorf("day-of-month: %w", err)
|
||||
}
|
||||
if s.month, err = parseField(fields[3], 1, 12); err != nil {
|
||||
return nil, fmt.Errorf("month: %w", err)
|
||||
}
|
||||
if s.dow, err = parseDOW(fields[4]); err != nil {
|
||||
return nil, fmt.Errorf("day-of-week: %w", err)
|
||||
}
|
||||
s.domStar = strings.TrimSpace(fields[2]) == "*"
|
||||
s.dowStar = strings.TrimSpace(fields[4]) == "*"
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Match reports whether t (truncated to the minute) satisfies the schedule.
|
||||
func (s *Schedule) Match(t time.Time) bool {
|
||||
if s.minute&bit(t.Minute()) == 0 {
|
||||
return false
|
||||
}
|
||||
if s.hour&bit(t.Hour()) == 0 {
|
||||
return false
|
||||
}
|
||||
if s.month&bit(int(t.Month())) == 0 {
|
||||
return false
|
||||
}
|
||||
domMatch := s.dom&bit(t.Day()) != 0
|
||||
dowMatch := s.dow&bit(int(t.Weekday())) != 0
|
||||
// Vixie semantics: when both day fields are restricted, match either.
|
||||
switch {
|
||||
case s.domStar && s.dowStar:
|
||||
return true
|
||||
case s.domStar:
|
||||
return dowMatch
|
||||
case s.dowStar:
|
||||
return domMatch
|
||||
default:
|
||||
return domMatch || dowMatch
|
||||
}
|
||||
}
|
||||
|
||||
func bit(n int) uint64 { return uint64(1) << uint(n) }
|
||||
|
||||
func parseDOW(field string) (uint64, error) {
|
||||
// Normalise 7 → 0 (both mean Sunday) by parsing then folding.
|
||||
mask, err := parseField(field, 0, 7)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if mask&bit(7) != 0 {
|
||||
mask = (mask &^ bit(7)) | bit(0)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parseField(field string, lo, hi int) (uint64, error) {
|
||||
var mask uint64
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
m, err := parsePart(strings.TrimSpace(part), lo, hi)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
mask |= m
|
||||
}
|
||||
if mask == 0 {
|
||||
return 0, fmt.Errorf("empty field %q", field)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
|
||||
func parsePart(part string, lo, hi int) (uint64, error) {
|
||||
if part == "" {
|
||||
return 0, fmt.Errorf("empty term")
|
||||
}
|
||||
step := 1
|
||||
rangePart := part
|
||||
if idx := strings.IndexByte(part, '/'); idx >= 0 {
|
||||
rangePart = part[:idx]
|
||||
st, err := strconv.Atoi(part[idx+1:])
|
||||
if err != nil || st < 1 {
|
||||
return 0, fmt.Errorf("bad step %q", part[idx+1:])
|
||||
}
|
||||
step = st
|
||||
}
|
||||
|
||||
start, end := lo, hi
|
||||
if rangePart != "*" {
|
||||
if idx := strings.IndexByte(rangePart, '-'); idx >= 0 {
|
||||
a, err1 := strconv.Atoi(rangePart[:idx])
|
||||
b, err2 := strconv.Atoi(rangePart[idx+1:])
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, fmt.Errorf("bad range %q", rangePart)
|
||||
}
|
||||
start, end = a, b
|
||||
} else {
|
||||
v, err := strconv.Atoi(rangePart)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bad value %q", rangePart)
|
||||
}
|
||||
start, end = v, v
|
||||
}
|
||||
}
|
||||
|
||||
if start < lo || end > hi || start > end {
|
||||
return 0, fmt.Errorf("value out of range [%d,%d] in %q", lo, hi, part)
|
||||
}
|
||||
|
||||
var mask uint64
|
||||
for v := start; v <= end; v += step {
|
||||
mask |= bit(v)
|
||||
}
|
||||
return mask, nil
|
||||
}
|
||||
Reference in New Issue
Block a user