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
+187 -7
View File
@@ -15,6 +15,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
@@ -28,19 +29,23 @@ type Handler struct {
store *storage.Store
policy *access.Policy
acl *panelacl.Store
ctrlLogic *controllogic.Store
ctrlEngine *controllogic.Engine
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
return &Handler{
broker: b,
synthetic: synth,
store: store,
policy: policy,
acl: acl,
ctrlLogic: ctrlLogic,
ctrlEngine: ctrlEngine,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
log: log,
@@ -87,6 +92,13 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
// Server-side control logic CRUD (mutations are write-gated by the access
// middleware; each mutation reloads the running engine).
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
mux.HandleFunc("POST "+prefix+"/controllogic", h.createControlLogic)
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
}
// ── /me ─────────────────────────────────────────────────────────────────────
@@ -102,9 +114,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
groups = []string{}
}
jsonOK(w, map[string]any{
"user": user,
"level": h.policy.Level(user).String(),
"groups": groups,
"user": user,
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
})
}
@@ -420,9 +433,10 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
// render sharing affordances and filter the visible set.
type interfaceListItem struct {
storage.InterfaceMeta
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Perm string `json:"perm"`
Owner string `json:"owner,omitempty"`
Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
Perm string `json:"perm"`
}
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
@@ -442,6 +456,7 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
if acl := h.acl.GetPanel(m.ID); acl != nil {
item.Owner = acl.Owner
item.Folder = acl.Folder
item.Order = acl.Order
}
out = append(out, item)
}
@@ -455,6 +470,12 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
// Editing panel logic may be restricted to an allowlist. A brand-new panel
// carrying a non-empty <logic> block requires that permission.
if !h.policy.CanEditLogic(caller(r)) && extractLogicBlock(body) != "" {
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
return
}
id, err := h.store.Create(body, r.URL.Query().Get("tag"))
if err != nil {
h.log.Error("create interface", "err", err)
@@ -474,6 +495,44 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
}
// reorderInterfaces places a set of panels into a folder (or the root when folder
// is empty) in the given order, stamping each id with order=index. The caller must
// have write access to the destination folder (if any) and to each panel.
func (h *Handler) reorderInterfaces(w http.ResponseWriter, r *http.Request) {
var req struct {
Folder string `json:"folder"`
IDs []string `json:"ids"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "decode body: "+err.Error())
return
}
if req.Folder != "" {
if _, err := h.acl.GetFolder(req.Folder); err != nil {
jsonError(w, http.StatusNotFound, "folder not found: "+req.Folder)
return
}
if h.folderPerm(r, req.Folder) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to this folder")
return
}
}
for _, id := range req.IDs {
if h.panelPerm(r, id) < panelacl.PermWrite {
jsonError(w, http.StatusForbidden, "you do not have write access to panel: "+id)
return
}
}
for i, id := range req.IDs {
if err := h.acl.PlacePanel(id, req.Folder, float64(i)); err != nil {
h.log.Error("reorder interface", "id", id, "err", err)
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) getInterface(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if h.panelPerm(r, id) < panelacl.PermRead {
@@ -504,6 +563,14 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
// Editing panel logic may be restricted to an allowlist. Permit unrestricted
// edits to the rest of the panel as long as the <logic> block is unchanged.
if !h.policy.CanEditLogic(caller(r)) {
if cur, err := h.store.Get(id); err != nil || extractLogicBlock(body) != extractLogicBlock(cur) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit panel logic")
return
}
}
if err := h.store.Update(id, body, r.URL.Query().Get("tag")); err != nil {
if errors.Is(err, storage.ErrNotFound) {
jsonError(w, http.StatusNotFound, "interface not found: "+id)
@@ -1009,8 +1076,121 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ── Control logic ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
if h.ctrlLogic == nil {
jsonOK(w, []any{})
return
}
graphs := h.ctrlLogic.List()
if graphs == nil {
graphs = []controllogic.Graph{}
}
jsonOK(w, graphs)
}
func (h *Handler) getControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
g, err := h.ctrlLogic.Get(r.PathValue("id"))
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
jsonOK(w, g)
}
func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
var g controllogic.Graph
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
g.ID = genID("cl")
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.ctrlEngine.Reload()
w.WriteHeader(http.StatusCreated)
jsonOK(w, g)
}
func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
id := r.PathValue("id")
if _, err := h.ctrlLogic.Get(id); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
var g controllogic.Graph
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
g.ID = id
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.ctrlEngine.Reload()
jsonOK(w, g)
}
func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
return
}
if !h.policy.CanEditLogic(caller(r)) {
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
return
}
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
return
}
h.ctrlEngine.Reload()
w.WriteHeader(http.StatusNoContent)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
// interface XML document, or "" when absent. The frontend serializes the block
// only when it holds nodes/wires and uses a stable format, so comparing the
// extracted substrings reliably detects whether the panel logic changed.
func extractLogicBlock(xml []byte) string {
s := string(xml)
start := strings.Index(s, "<logic>")
if start < 0 {
return ""
}
end := strings.Index(s[start:], "</logic>")
if end < 0 {
return ""
}
return s[start : start+end+len("</logic>")]
}
func metaToSignalInfo(m datasource.Metadata) signalInfo {
return signalInfo{
Name: m.Name,
+8 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
@@ -45,8 +46,14 @@ func setup(t *testing.T) (*httptest.Server, func()) {
t.Fatal("panelacl.New:", err)
}
clStore, err := controllogic.NewStore(dir)
if err != nil {
t.Fatal("controllogic.NewStore:", err)
}
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
mux := http.NewServeMux()
api.New(brk, nil, store, access.New("", nil, nil), acl, "", "", log).Register(mux, "/api/v1")
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {