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:
Martino Ferrari
2026-06-19 07:27:35 +02:00
parent aba394b84d
commit afefba3184
35 changed files with 4633 additions and 467 deletions
+92
View File
@@ -0,0 +1,92 @@
package controllogic
import (
"testing"
"time"
)
func mustSched(t *testing.T, spec string) *Schedule {
t.Helper()
s, err := ParseSchedule(spec)
if err != nil {
t.Fatalf("ParseSchedule(%q): %v", spec, err)
}
return s
}
func TestCronEveryMinute(t *testing.T) {
s := mustSched(t, "* * * * *")
if !s.Match(time.Date(2026, 6, 18, 12, 34, 0, 0, time.UTC)) {
t.Error("* * * * * should match any time")
}
}
func TestCronSpecificTime(t *testing.T) {
s := mustSched(t, "30 9 * * *")
if !s.Match(time.Date(2026, 6, 18, 9, 30, 0, 0, time.UTC)) {
t.Error("should match 09:30")
}
if s.Match(time.Date(2026, 6, 18, 9, 31, 0, 0, time.UTC)) {
t.Error("should not match 09:31")
}
}
func TestCronStep(t *testing.T) {
s := mustSched(t, "*/15 * * * *")
for _, m := range []int{0, 15, 30, 45} {
if !s.Match(time.Date(2026, 6, 18, 1, m, 0, 0, time.UTC)) {
t.Errorf("*/15 should match minute %d", m)
}
}
if s.Match(time.Date(2026, 6, 18, 1, 7, 0, 0, time.UTC)) {
t.Error("*/15 should not match minute 7")
}
}
func TestCronRangeAndList(t *testing.T) {
s := mustSched(t, "0 9-17 * * 1,2,3,4,5")
// 2026-06-18 is a Thursday (weekday 4).
if !s.Match(time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC)) {
t.Error("should match Thursday 10:00")
}
if s.Match(time.Date(2026, 6, 18, 18, 0, 0, 0, time.UTC)) {
t.Error("should not match 18:00 (out of 9-17)")
}
// 2026-06-20 is a Saturday (weekday 6).
if s.Match(time.Date(2026, 6, 20, 10, 0, 0, 0, time.UTC)) {
t.Error("should not match Saturday")
}
}
func TestCronDOWSunday7(t *testing.T) {
s := mustSched(t, "0 0 * * 7")
// 2026-06-21 is a Sunday.
if !s.Match(time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC)) {
t.Error("7 should mean Sunday")
}
}
func TestCronDomOrDow(t *testing.T) {
// When both day fields restricted, match either (Vixie semantics).
s := mustSched(t, "0 0 1 * 5")
// 2026-06-01 is a Monday — matches via day-of-month=1.
if !s.Match(time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)) {
t.Error("should match day-of-month 1")
}
// 2026-06-19 is a Friday (weekday 5) — matches via day-of-week.
if !s.Match(time.Date(2026, 6, 19, 0, 0, 0, 0, time.UTC)) {
t.Error("should match Friday")
}
// 2026-06-18 Thursday, not the 1st — no match.
if s.Match(time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)) {
t.Error("should not match Thursday the 18th")
}
}
func TestCronInvalid(t *testing.T) {
for _, bad := range []string{"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "a * * * *", "*/0 * * * *"} {
if _, err := ParseSchedule(bad); err == nil {
t.Errorf("ParseSchedule(%q) should error", bad)
}
}
}