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,111 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const definitionsFile = "controllogic.json"
|
||||
|
||||
// ErrNotFound is returned when a graph id does not exist.
|
||||
var ErrNotFound = errors.New("control logic graph not found")
|
||||
|
||||
// Store persists control-logic graphs as a single JSON file in the storage dir.
|
||||
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
// NewStore opens (or initialises) the control-logic store under storageDir.
|
||||
func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
items: map[string]Graph{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var graphs []Graph
|
||||
if err := json.Unmarshal(data, &graphs); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
for _, g := range graphs {
|
||||
s.items[g.ID] = g
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked writes the current set atomically. Caller must hold s.mu.
|
||||
func (s *Store) saveLocked() error {
|
||||
graphs := make([]Graph, 0, len(s.items))
|
||||
for _, g := range s.items {
|
||||
graphs = append(graphs, g)
|
||||
}
|
||||
data, err := json.MarshalIndent(graphs, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
// List returns all graphs.
|
||||
func (s *Store) List() []Graph {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]Graph, 0, len(s.items))
|
||||
for _, g := range s.items {
|
||||
out = append(out, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Get returns a single graph by id.
|
||||
func (s *Store) Get(id string) (Graph, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
g, ok := s.items[id]
|
||||
if !ok {
|
||||
return Graph{}, ErrNotFound
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Save inserts or replaces a graph and persists the store.
|
||||
func (s *Store) Save(g Graph) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items[g.ID] = g
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// Delete removes a graph by id.
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.items[id]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(s.items, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
Reference in New Issue
Block a user