From 3fc7c1b5461697aa9959b59d407961afae138795 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sat, 20 Jun 2026 17:40:03 +0200 Subject: [PATCH] initial config manager --- cmd/uopi/main.go | 9 +- internal/api/api.go | 29 +- internal/api/api_test.go | 8 +- internal/api/confmgr.go | 446 ++++++++++++++++++ internal/api/confmgr_test.go | 107 +++++ internal/confmgr/apply.go | 55 +++ internal/confmgr/apply_test.go | 77 ++++ internal/confmgr/diff.go | 140 ++++++ internal/confmgr/diff_test.go | 55 +++ internal/confmgr/model.go | 225 +++++++++ internal/confmgr/store.go | 550 ++++++++++++++++++++++ internal/confmgr/store_test.go | 200 ++++++++ internal/server/server.go | 5 +- web/src/ConfigManager.tsx | 816 +++++++++++++++++++++++++++++++++ web/src/ViewMode.tsx | 14 + web/src/styles.css | 282 ++++++++++++ 16 files changed, 3013 insertions(+), 5 deletions(-) create mode 100644 internal/api/confmgr.go create mode 100644 internal/api/confmgr_test.go create mode 100644 internal/confmgr/apply.go create mode 100644 internal/confmgr/apply_test.go create mode 100644 internal/confmgr/diff.go create mode 100644 internal/confmgr/diff_test.go create mode 100644 internal/confmgr/model.go create mode 100644 internal/confmgr/store.go create mode 100644 internal/confmgr/store_test.go create mode 100644 web/src/ConfigManager.tsx diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 30cb1a9..4bbeba8 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -17,6 +17,7 @@ import ( "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/config" + "github.com/uopi/uopi/internal/confmgr" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/epics" "github.com/uopi/uopi/internal/datasource/pva" @@ -72,6 +73,12 @@ func main() { os.Exit(1) } + cfgStore, err := confmgr.New(cfg.Server.StorageDir) + if err != nil { + log.Error("failed to open configuration store", "err", err) + os.Exit(1) + } + webFS, err := fs.Sub(web.FS, "dist") if err != nil { log.Error("failed to sub web dist", "err", err) @@ -194,7 +201,7 @@ func main() { ctrlEngine.SetNotifier(dialogs) ctrlEngine.Reload() - srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log) + srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log) if err := srv.Start(ctx); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) diff --git a/internal/api/api.go b/internal/api/api.go index c0f1491..72de759 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -18,6 +18,7 @@ import ( "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/confmgr" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource" "github.com/uopi/uopi/internal/datasource/synthetic" @@ -30,6 +31,7 @@ type Handler struct { broker *broker.Broker synthetic *synthetic.Synthetic // nil if not enabled store *storage.Store + cfg *confmgr.Store policy *access.Policy acl *panelacl.Store ctrlLogic *controllogic.Store @@ -42,7 +44,7 @@ type Handler struct { // New creates an API Handler. synth may be nil if the synthetic DS is disabled. // rec records system-affecting mutations; pass audit.Nop() to disable auditing. -func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { +func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { if rec == nil { rec = audit.Nop() } @@ -50,6 +52,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol broker: b, synthetic: synth, store: store, + cfg: cfg, policy: policy, acl: acl, ctrlLogic: ctrlLogic, @@ -109,6 +112,30 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic) mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic) mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic) + // Configuration manager — config sets (schemas) and instances (values), + // both versioned git-style. Mutations are write-gated by the access + // middleware; "apply" writes each value to its target signal. + mux.HandleFunc("GET "+prefix+"/config/sets", h.listConfigSets) + mux.HandleFunc("POST "+prefix+"/config/sets", h.createConfigSet) + mux.HandleFunc("GET "+prefix+"/config/sets/{id}", h.getConfigSet) + mux.HandleFunc("PUT "+prefix+"/config/sets/{id}", h.updateConfigSet) + mux.HandleFunc("DELETE "+prefix+"/config/sets/{id}", h.deleteConfigSet) + mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions", h.listConfigSetVersions) + mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions/{version}", h.getConfigSetVersion) + mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/promote", h.promoteConfigSet) + mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/fork", h.forkConfigSet) + mux.HandleFunc("GET "+prefix+"/config/sets/diff", h.diffConfigSets) + mux.HandleFunc("GET "+prefix+"/config/instances", h.listConfigInstances) + mux.HandleFunc("POST "+prefix+"/config/instances", h.createConfigInstance) + mux.HandleFunc("GET "+prefix+"/config/instances/{id}", h.getConfigInstance) + mux.HandleFunc("PUT "+prefix+"/config/instances/{id}", h.updateConfigInstance) + mux.HandleFunc("DELETE "+prefix+"/config/instances/{id}", h.deleteConfigInstance) + mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions", h.listConfigInstanceVersions) + mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions/{version}", h.getConfigInstanceVersion) + mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/promote", h.promoteConfigInstance) + mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/fork", h.forkConfigInstance) + mux.HandleFunc("POST "+prefix+"/config/instances/{id}/apply", h.applyConfigInstance) + mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances) } // ── /me ───────────────────────────────────────────────────────────────────── diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 437e4f9..ed55274 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -16,6 +16,7 @@ import ( "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/confmgr" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/panelacl" @@ -53,8 +54,13 @@ func setup(t *testing.T) (*httptest.Server, func()) { } clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log) + cfgStore, err := confmgr.New(dir) + if err != nil { + t.Fatal("confmgr.New:", err) + } + mux := http.NewServeMux() - api.New(brk, nil, store, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1") + api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { diff --git a/internal/api/confmgr.go b/internal/api/confmgr.go new file mode 100644 index 0000000..80542f6 --- /dev/null +++ b/internal/api/confmgr.go @@ -0,0 +1,446 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/uopi/uopi/internal/confmgr" +) + +// configEnabled guards every config-manager handler; the store is always +// constructed today, but the nil check keeps the handlers safe if it is ever +// made optional. +func (h *Handler) configEnabled(w http.ResponseWriter) bool { + if h.cfg == nil { + jsonError(w, http.StatusServiceUnavailable, "configuration manager not enabled") + return false + } + return true +} + +func pathVersion(r *http.Request) (int, error) { + return strconv.Atoi(r.PathValue("version")) +} + +func configStatus(err error) int { + if errors.Is(err, confmgr.ErrNotFound) { + return http.StatusNotFound + } + return http.StatusBadRequest +} + +// ── config sets ───────────────────────────────────────────────────────────── + +func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) { + if !h.configEnabled(w) { + return + } + sets, err := h.cfg.List(confmgr.KindSet) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonOK(w, sets) +} + +func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + set, err := h.cfg.GetSet(r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, set) +} + +func (h *Handler) createConfigSet(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + var set confmgr.ConfigSet + if err := json.NewDecoder(r.Body).Decode(&set); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + set.ID = "" + set.Owner = caller(r) + out, err := h.cfg.CreateSet(set, r.URL.Query().Get("tag")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.set.create", out.ID+" "+out.Name) + w.WriteHeader(http.StatusCreated) + jsonOK(w, out) +} + +func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + id := r.PathValue("id") + var set confmgr.ConfigSet + if err := json.NewDecoder(r.Body).Decode(&set); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.set.update", out.ID+" v"+strconv.Itoa(out.Version)) + jsonOK(w, out) +} + +func (h *Handler) deleteConfigSet(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + id := r.PathValue("id") + if err := h.cfg.Delete(confmgr.KindSet, id); err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.set.delete", id) + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listConfigSetVersions(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + versions, err := h.cfg.Versions(confmgr.KindSet, r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, versions) +} + +func (h *Handler) getConfigSetVersion(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + set, err := h.cfg.GetSetVersion(r.PathValue("id"), version) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, set) +} + +func (h *Handler) promoteConfigSet(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + id := r.PathValue("id") + if err := h.cfg.Promote(confmgr.KindSet, id, version); err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.set.promote", id+" v"+strconv.Itoa(version)) + set, err := h.cfg.GetSet(id) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, set) +} + +func (h *Handler) forkConfigSet(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + id := r.PathValue("id") + newID, err := h.cfg.Fork(confmgr.KindSet, id, version) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.set.fork", id+" v"+strconv.Itoa(version)+" -> "+newID) + set, err := h.cfg.GetSet(newID) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + w.WriteHeader(http.StatusCreated) + jsonOK(w, set) +} + +// diffConfigSets compares two set revisions. Query params: a, b (ids); +// optional av, bv (versions, default current). +func (h *Handler) diffConfigSets(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + q := r.URL.Query() + left, err := h.resolveSet(q.Get("a"), q.Get("av")) + if err != nil { + jsonError(w, configStatus(err), "left set: "+err.Error()) + return + } + right, err := h.resolveSet(q.Get("b"), q.Get("bv")) + if err != nil { + jsonError(w, configStatus(err), "right set: "+err.Error()) + return + } + jsonOK(w, confmgr.DiffSets(left, right)) +} + +func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) { + if id == "" { + return confmgr.ConfigSet{}, errors.New("missing set id") + } + if version == "" { + return h.cfg.GetSet(id) + } + v, err := strconv.Atoi(version) + if err != nil { + return confmgr.ConfigSet{}, errors.New("invalid version") + } + return h.cfg.GetSetVersion(id, v) +} + +// ── config instances ──────────────────────────────────────────────────────── + +func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) { + if !h.configEnabled(w) { + return + } + insts, err := h.cfg.List(confmgr.KindInstance) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonOK(w, insts) +} + +func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + inst, err := h.cfg.GetInstance(r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, inst) +} + +func (h *Handler) createConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + var inst confmgr.ConfigInstance + if err := json.NewDecoder(r.Body).Decode(&inst); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + inst.ID = "" + inst.Owner = caller(r) + out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.instance.create", out.ID+" "+out.Name) + w.WriteHeader(http.StatusCreated) + jsonOK(w, out) +} + +func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + id := r.PathValue("id") + var inst confmgr.ConfigInstance + if err := json.NewDecoder(r.Body).Decode(&inst); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.instance.update", out.ID+" v"+strconv.Itoa(out.Version)) + jsonOK(w, out) +} + +func (h *Handler) deleteConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + id := r.PathValue("id") + if err := h.cfg.Delete(confmgr.KindInstance, id); err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.instance.delete", id) + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listConfigInstanceVersions(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + versions, err := h.cfg.Versions(confmgr.KindInstance, r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, versions) +} + +func (h *Handler) getConfigInstanceVersion(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + inst, err := h.cfg.GetInstanceVersion(r.PathValue("id"), version) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, inst) +} + +func (h *Handler) promoteConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + id := r.PathValue("id") + if err := h.cfg.Promote(confmgr.KindInstance, id, version); err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.instance.promote", id+" v"+strconv.Itoa(version)) + inst, err := h.cfg.GetInstance(id) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, inst) +} + +func (h *Handler) forkConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + id := r.PathValue("id") + newID, err := h.cfg.Fork(confmgr.KindInstance, id, version) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.instance.fork", id+" v"+strconv.Itoa(version)+" -> "+newID) + inst, err := h.cfg.GetInstance(newID) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + w.WriteHeader(http.StatusCreated) + jsonOK(w, inst) +} + +// applyConfigInstance writes every resolvable parameter value of an instance to +// its target signal via the broker. Per-parameter outcomes are returned so a +// partial apply is reported faithfully. +func (h *Handler) applyConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + id := r.PathValue("id") + inst, err := h.cfg.GetInstance(id) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + set, err := h.cfg.SetForInstance(inst) + if err != nil { + jsonError(w, configStatus(err), "load set: "+err.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + write := func(ds, signal string, value any) error { + src, ok := h.broker.Source(ds) + if !ok { + return errors.New("unknown data source: " + ds) + } + return src.Write(ctx, signal, value) + } + res := confmgr.Apply(set, inst, write) + h.recordMutation(r, "config.instance.apply", id+" applied="+strconv.Itoa(res.Applied)+" failed="+strconv.Itoa(res.Failed)) + jsonOK(w, res) +} + +// diffConfigInstances compares two instance revisions. Query params: a, b +// (ids); optional av, bv (versions, default current). +func (h *Handler) diffConfigInstances(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + q := r.URL.Query() + left, err := h.resolveInstance(q.Get("a"), q.Get("av")) + if err != nil { + jsonError(w, configStatus(err), "left instance: "+err.Error()) + return + } + right, err := h.resolveInstance(q.Get("b"), q.Get("bv")) + if err != nil { + jsonError(w, configStatus(err), "right instance: "+err.Error()) + return + } + jsonOK(w, confmgr.DiffInstances(left, right)) +} + +func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, error) { + if id == "" { + return confmgr.ConfigInstance{}, errors.New("missing instance id") + } + if version == "" { + return h.cfg.GetInstance(id) + } + v, err := strconv.Atoi(version) + if err != nil { + return confmgr.ConfigInstance{}, errors.New("invalid version") + } + return h.cfg.GetInstanceVersion(id, v) +} diff --git a/internal/api/confmgr_test.go b/internal/api/confmgr_test.go new file mode 100644 index 0000000..79b1ce4 --- /dev/null +++ b/internal/api/confmgr_test.go @@ -0,0 +1,107 @@ +package api_test + +import ( + "net/http" + "testing" +) + +// TestConfigSetCRUD exercises create → get → version → apply over HTTP, using +// the stub data source's writable "setpoint" signal as the apply target. +func TestConfigManagerEndToEnd(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + // Create a config set targeting the stub's writable setpoint. + setBody := map[string]any{ + "name": "Stub PSU", + "parameters": []map[string]any{ + {"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 42.0, "mandatory": true, "min": 0.0, "max": 100.0}, + }, + } + resp := postJSON(t, srv, "/api/v1/config/sets", setBody) + assertStatus(t, resp, http.StatusCreated) + var set struct { + ID string `json:"id"` + Version int `json:"version"` + } + readJSON(t, resp, &set) + if set.ID == "" || set.Version != 1 { + t.Fatalf("unexpected created set: %+v", set) + } + + // List sets includes it. + resp = get(t, srv, "/api/v1/config/sets") + assertStatus(t, resp, http.StatusOK) + var sets []map[string]any + readJSON(t, resp, &sets) + if len(sets) != 1 { + t.Fatalf("want 1 set, got %d", len(sets)) + } + + // Create an instance assigning a concrete value. + instBody := map[string]any{ + "name": "nominal", + "setId": set.ID, + "values": map[string]any{"sp": 73.0}, + } + resp = postJSON(t, srv, "/api/v1/config/instances", instBody) + assertStatus(t, resp, http.StatusCreated) + var inst struct { + ID string `json:"id"` + } + readJSON(t, resp, &inst) + if inst.ID == "" { + t.Fatal("instance missing id") + } + + // Apply writes the value to the target signal. + resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/apply", nil) + assertStatus(t, resp, http.StatusOK) + var res struct { + Applied int `json:"applied"` + Failed int `json:"failed"` + Entries []struct { + Signal string `json:"signal"` + OK bool `json:"ok"` + } `json:"entries"` + } + readJSON(t, resp, &res) + if res.Applied != 1 || res.Failed != 0 { + t.Fatalf("apply summary: applied=%d failed=%d", res.Applied, res.Failed) + } + if len(res.Entries) != 1 || res.Entries[0].Signal != "setpoint" || !res.Entries[0].OK { + t.Errorf("unexpected apply entries: %+v", res.Entries) + } + + // Delete the set soft-deletes it. + resp = deleteReq(t, srv, "/api/v1/config/sets/"+set.ID) + assertStatus(t, resp, http.StatusNoContent) + resp = get(t, srv, "/api/v1/config/sets/"+set.ID) + assertStatus(t, resp, http.StatusNotFound) +} + +// TestConfigInstanceRejectsInvalidValue verifies server-side validation against +// the bound set (value above the parameter's max). +func TestConfigInstanceRejectsInvalidValue(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{ + "name": "S", + "parameters": []map[string]any{ + {"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "max": 10.0}, + }, + }) + assertStatus(t, resp, http.StatusCreated) + var set struct { + ID string `json:"id"` + } + readJSON(t, resp, &set) + + resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{ + "name": "bad", + "setId": set.ID, + "values": map[string]any{"sp": 999.0}, + }) + assertStatus(t, resp, http.StatusBadRequest) +} diff --git a/internal/confmgr/apply.go b/internal/confmgr/apply.go new file mode 100644 index 0000000..e0690bc --- /dev/null +++ b/internal/confmgr/apply.go @@ -0,0 +1,55 @@ +package confmgr + +// WriteFunc writes a resolved value to a target signal. The API layer supplies +// a closure backed by the broker/datasource so confmgr stays decoupled from the +// transport. +type WriteFunc func(ds, signal string, value any) error + +// ApplyEntry records the outcome of writing one parameter. +type ApplyEntry struct { + Key string `json:"key"` + DS string `json:"ds"` + Signal string `json:"signal"` + Value any `json:"value,omitempty"` + OK bool `json:"ok"` + Skipped bool `json:"skipped,omitempty"` + Error string `json:"error,omitempty"` +} + +// ApplyResult summarises an apply run. +type ApplyResult struct { + InstanceID string `json:"instanceId"` + SetID string `json:"setId"` + Entries []ApplyEntry `json:"entries"` + Applied int `json:"applied"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` +} + +// Apply writes every resolvable parameter value of an instance to its target +// signal via write. Optional parameters with no value and no default are +// skipped. Individual write failures are recorded per-entry rather than +// aborting the whole apply, so a partial apply is reported faithfully. +func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult { + res := ApplyResult{InstanceID: inst.ID, SetID: set.ID, Entries: make([]ApplyEntry, 0, len(set.Parameters))} + for _, p := range set.Parameters { + e := ApplyEntry{Key: p.Key, DS: p.DS, Signal: p.Signal} + v, ok := inst.Resolve(p) + if !ok { + e.Skipped = true + res.Skipped++ + res.Entries = append(res.Entries, e) + continue + } + e.Value = v + if err := write(p.DS, p.Signal, v); err != nil { + e.Error = err.Error() + res.Failed++ + } else { + e.OK = true + res.Applied++ + } + res.Entries = append(res.Entries, e) + } + return res +} diff --git a/internal/confmgr/apply_test.go b/internal/confmgr/apply_test.go new file mode 100644 index 0000000..2de4be6 --- /dev/null +++ b/internal/confmgr/apply_test.go @@ -0,0 +1,77 @@ +package confmgr + +import ( + "errors" + "testing" +) + +func TestApplyWritesValues(t *testing.T) { + set := ConfigSet{ + ID: "set1", + Name: "s", + Parameters: []Parameter{ + {Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0}, + {Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool}, + {Key: "opt", DS: "epics", Signal: "PSU:OPT", Type: TypeFloat}, // no value, no default → skipped + }, + } + inst := ConfigInstance{ID: "i1", SetID: "set1", Values: map[string]any{"v": 24.0, "en": true}} + + type write struct { + ds, sig string + val any + } + var writes []write + res := Apply(set, inst, func(ds, signal string, value any) error { + writes = append(writes, write{ds, signal, value}) + return nil + }) + + if res.Applied != 2 || res.Skipped != 1 || res.Failed != 0 { + t.Fatalf("summary: applied=%d skipped=%d failed=%d", res.Applied, res.Skipped, res.Failed) + } + if len(writes) != 2 { + t.Fatalf("want 2 writes, got %d", len(writes)) + } + if writes[0].sig != "PSU:V" || writes[0].val != 24.0 { + t.Errorf("first write: %+v", writes[0]) + } +} + +func TestApplyUsesDefaultWhenNoValue(t *testing.T) { + set := ConfigSet{ + ID: "set1", Name: "s", + Parameters: []Parameter{{Key: "v", DS: "d", Signal: "S", Type: TypeFloat, Default: 7.0}}, + } + inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}} + var got any + res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil }) + if res.Applied != 1 || got != 7.0 { + t.Errorf("want default 7.0 applied, got %v (applied=%d)", got, res.Applied) + } +} + +func TestApplyRecordsFailures(t *testing.T) { + set := ConfigSet{ + ID: "set1", Name: "s", + Parameters: []Parameter{ + {Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}, + {Key: "b", DS: "d", Signal: "B", Type: TypeFloat, Default: 2.0}, + }, + } + inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}} + res := Apply(set, inst, func(_, signal string, _ any) error { + if signal == "B" { + return errors.New("write rejected") + } + return nil + }) + if res.Applied != 1 || res.Failed != 1 { + t.Fatalf("want applied=1 failed=1, got applied=%d failed=%d", res.Applied, res.Failed) + } + for _, e := range res.Entries { + if e.Key == "b" && (e.OK || e.Error == "") { + t.Errorf("entry b should record failure: %+v", e) + } + } +} diff --git a/internal/confmgr/diff.go b/internal/confmgr/diff.go new file mode 100644 index 0000000..d6e7f3c --- /dev/null +++ b/internal/confmgr/diff.go @@ -0,0 +1,140 @@ +package confmgr + +import ( + "fmt" + "sort" +) + +// ChangeStatus classifies a single diff entry. +type ChangeStatus string + +const ( + StatusAdded ChangeStatus = "added" + StatusRemoved ChangeStatus = "removed" + StatusChanged ChangeStatus = "changed" + StatusUnchanged ChangeStatus = "unchanged" +) + +// SetDiffEntry is one parameter-level difference between two config sets. +type SetDiffEntry struct { + Key string `json:"key"` + Status ChangeStatus `json:"status"` + Left *Parameter `json:"left,omitempty"` + Right *Parameter `json:"right,omitempty"` +} + +// InstanceDiffEntry is one value-level difference between two config instances. +type InstanceDiffEntry struct { + Key string `json:"key"` + Status ChangeStatus `json:"status"` + Left any `json:"left,omitempty"` + Right any `json:"right,omitempty"` +} + +// DiffSets compares two config sets parameter-by-parameter (keyed by Key), +// returning entries sorted by key. Both sides are included so the frontend can +// render either unified or side-by-side. +func DiffSets(left, right ConfigSet) []SetDiffEntry { + keys := map[string]bool{} + li := map[string]Parameter{} + ri := map[string]Parameter{} + for _, p := range left.Parameters { + li[p.Key] = p + keys[p.Key] = true + } + for _, p := range right.Parameters { + ri[p.Key] = p + keys[p.Key] = true + } + out := make([]SetDiffEntry, 0, len(keys)) + for k := range keys { + l, lok := li[k] + r, rok := ri[k] + e := SetDiffEntry{Key: k} + switch { + case lok && !rok: + e.Status = StatusRemoved + lp := l + e.Left = &lp + case !lok && rok: + e.Status = StatusAdded + rp := r + e.Right = &rp + default: + lp, rp := l, r + e.Left, e.Right = &lp, &rp + if paramEqual(l, r) { + e.Status = StatusUnchanged + } else { + e.Status = StatusChanged + } + } + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} + +// DiffInstances compares two config instances value-by-value (keyed by +// parameter key), returning entries sorted by key. +func DiffInstances(left, right ConfigInstance) []InstanceDiffEntry { + keys := map[string]bool{} + for k := range left.Values { + keys[k] = true + } + for k := range right.Values { + keys[k] = true + } + out := make([]InstanceDiffEntry, 0, len(keys)) + for k := range keys { + lv, lok := left.Values[k] + rv, rok := right.Values[k] + e := InstanceDiffEntry{Key: k, Left: lv, Right: rv} + switch { + case lok && !rok: + e.Status = StatusRemoved + case !lok && rok: + e.Status = StatusAdded + case valueEqual(lv, rv): + e.Status = StatusUnchanged + default: + e.Status = StatusChanged + } + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} + +func paramEqual(a, b Parameter) bool { + if a.Label != b.Label || a.Group != b.Group || a.Subgroup != b.Subgroup || + a.DS != b.DS || a.Signal != b.Signal || a.Type != b.Type || + a.Mandatory != b.Mandatory || a.Unit != b.Unit || a.Description != b.Description { + return false + } + if !valueEqual(a.Default, b.Default) || !floatPtrEqual(a.Min, b.Min) || !floatPtrEqual(a.Max, b.Max) { + return false + } + if len(a.EnumValues) != len(b.EnumValues) { + return false + } + for i := range a.EnumValues { + if a.EnumValues[i] != b.EnumValues[i] { + return false + } + } + return true +} + +func floatPtrEqual(a, b *float64) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// valueEqual compares two JSON-decoded scalar values for diff purposes by +// rendering them to a canonical string. +func valueEqual(a, b any) bool { + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) +} diff --git a/internal/confmgr/diff_test.go b/internal/confmgr/diff_test.go new file mode 100644 index 0000000..b716196 --- /dev/null +++ b/internal/confmgr/diff_test.go @@ -0,0 +1,55 @@ +package confmgr + +import "testing" + +func TestDiffSets(t *testing.T) { + left := ConfigSet{Parameters: []Parameter{ + {Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}, + {Key: "b", DS: "d", Signal: "B", Type: TypeFloat}, + }} + right := ConfigSet{Parameters: []Parameter{ + {Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 2.0}, // changed default + {Key: "c", DS: "d", Signal: "C", Type: TypeFloat}, // added + }} + diff := DiffSets(left, right) + got := map[string]ChangeStatus{} + for _, e := range diff { + got[e.Key] = e.Status + } + if got["a"] != StatusChanged { + t.Errorf("a: want changed, got %s", got["a"]) + } + if got["b"] != StatusRemoved { + t.Errorf("b: want removed, got %s", got["b"]) + } + if got["c"] != StatusAdded { + t.Errorf("c: want added, got %s", got["c"]) + } +} + +func TestDiffSetsUnchanged(t *testing.T) { + p := Parameter{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0} + diff := DiffSets(ConfigSet{Parameters: []Parameter{p}}, ConfigSet{Parameters: []Parameter{p}}) + if len(diff) != 1 || diff[0].Status != StatusUnchanged { + t.Errorf("want single unchanged entry, got %+v", diff) + } +} + +func TestDiffInstances(t *testing.T) { + left := ConfigInstance{Values: map[string]any{"a": 1.0, "b": "x"}} + right := ConfigInstance{Values: map[string]any{"a": 2.0, "c": true}} + diff := DiffInstances(left, right) + got := map[string]ChangeStatus{} + for _, e := range diff { + got[e.Key] = e.Status + } + if got["a"] != StatusChanged { + t.Errorf("a: want changed, got %s", got["a"]) + } + if got["b"] != StatusRemoved { + t.Errorf("b: want removed, got %s", got["b"]) + } + if got["c"] != StatusAdded { + t.Errorf("c: want added, got %s", got["c"]) + } +} diff --git a/internal/confmgr/model.go b/internal/confmgr/model.go new file mode 100644 index 0000000..c65267c --- /dev/null +++ b/internal/confmgr/model.go @@ -0,0 +1,225 @@ +// Package confmgr implements the configuration manager: versioned, git-style +// configuration Sets (schemas of parameters bound to target signals) and +// configuration Instances (concrete values for a set), persisted as versioned +// JSON files. Applying an instance writes each value to its target signal. +package confmgr + +import ( + "fmt" + "math" + "slices" + "strconv" + "strings" +) + +// ParamType enumerates the value kinds a parameter may hold. +type ParamType string + +const ( + TypeFloat ParamType = "float64" + TypeInt ParamType = "int64" + TypeBool ParamType = "bool" + TypeString ParamType = "string" + TypeEnum ParamType = "enum" +) + +func (t ParamType) valid() bool { + switch t { + case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum: + return true + } + return false +} + +// Parameter is one entry in a configuration set's schema. It names a target +// signal (DS + Signal), a value type, an optional default, and validation +// metadata. Parameters may be grouped for presentation via Group/Subgroup. +type Parameter struct { + Key string `json:"key"` // unique within the set + Label string `json:"label,omitempty"` // human-friendly name + Group string `json:"group,omitempty"` // top-level grouping + Subgroup string `json:"subgroup,omitempty"` + DS string `json:"ds"` // target data source + Signal string `json:"signal"` // target signal name + Type ParamType `json:"type"` // value kind + Default any `json:"default,omitempty"` + Mandatory bool `json:"mandatory,omitempty"` + Min *float64 `json:"min,omitempty"` // numeric lower bound + Max *float64 `json:"max,omitempty"` // numeric upper bound + EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum + Unit string `json:"unit,omitempty"` + Description string `json:"description,omitempty"` +} + +// ConfigSet is the schema half of the two-tier model: an ordered list of +// parameters bound to target signals. It is versioned git-style. +type ConfigSet struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version int `json:"version"` + Tag string `json:"tag,omitempty"` + Owner string `json:"owner,omitempty"` + Parameters []Parameter `json:"parameters"` +} + +// ConfigInstance is the value half: concrete values for a set's parameters, +// keyed by parameter key. SetID pins the schema it belongs to; SetVersion pins +// the schema revision (0 means "track current"). It is versioned git-style. +type ConfigInstance struct { + ID string `json:"id"` + Name string `json:"name"` + SetID string `json:"setId"` + SetVersion int `json:"setVersion,omitempty"` // 0 = current set version + Version int `json:"version"` + Tag string `json:"tag,omitempty"` + Owner string `json:"owner,omitempty"` + Values map[string]any `json:"values"` +} + +// Validate checks structural invariants of a config set: non-empty name, +// unique non-empty parameter keys, valid types, a target signal per parameter, +// and well-formed enum/default metadata. +func (s ConfigSet) Validate() error { + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("config set name must not be empty") + } + seen := make(map[string]bool, len(s.Parameters)) + for i, p := range s.Parameters { + if strings.TrimSpace(p.Key) == "" { + return fmt.Errorf("parameter %d: key must not be empty", i) + } + if seen[p.Key] { + return fmt.Errorf("duplicate parameter key %q", p.Key) + } + seen[p.Key] = true + if !p.Type.valid() { + return fmt.Errorf("parameter %q: invalid type %q", p.Key, p.Type) + } + if strings.TrimSpace(p.DS) == "" || strings.TrimSpace(p.Signal) == "" { + return fmt.Errorf("parameter %q: target ds and signal are required", p.Key) + } + if p.Type == TypeEnum && len(p.EnumValues) == 0 { + return fmt.Errorf("parameter %q: enum type requires enumValues", p.Key) + } + if p.Min != nil && p.Max != nil && *p.Min > *p.Max { + return fmt.Errorf("parameter %q: min %v greater than max %v", p.Key, *p.Min, *p.Max) + } + if p.Default != nil { + if err := p.checkValue(p.Default); err != nil { + return fmt.Errorf("parameter %q default: %w", p.Key, err) + } + } + } + return nil +} + +// param returns the parameter with the given key, or false. +func (s ConfigSet) param(key string) (Parameter, bool) { + for _, p := range s.Parameters { + if p.Key == key { + return p, true + } + } + return Parameter{}, false +} + +// ValidateAgainst checks an instance against its set: every value references a +// known parameter and is type/range valid; every mandatory parameter without a +// default has a value. +func (inst ConfigInstance) ValidateAgainst(set ConfigSet) error { + for key, v := range inst.Values { + p, ok := set.param(key) + if !ok { + return fmt.Errorf("value for unknown parameter %q", key) + } + if err := p.checkValue(v); err != nil { + return fmt.Errorf("parameter %q: %w", key, err) + } + } + for _, p := range set.Parameters { + if !p.Mandatory { + continue + } + if _, ok := inst.Values[p.Key]; ok { + continue + } + if p.Default == nil { + return fmt.Errorf("mandatory parameter %q has no value", p.Key) + } + } + return nil +} + +// Resolve returns the effective value for a parameter: the instance value when +// present, otherwise the parameter default. The second result is false when no +// value is available (optional parameter, no default). +func (inst ConfigInstance) Resolve(p Parameter) (any, bool) { + if v, ok := inst.Values[p.Key]; ok { + return v, true + } + if p.Default != nil { + return p.Default, true + } + return nil, false +} + +// checkValue verifies a value matches the parameter's type and constraints. +func (p Parameter) checkValue(v any) error { + switch p.Type { + case TypeFloat, TypeInt: + f, err := toFloat(v) + if err != nil { + return err + } + if p.Type == TypeInt && f != math.Trunc(f) { + return fmt.Errorf("value %v is not an integer", f) + } + if p.Min != nil && f < *p.Min { + return fmt.Errorf("value %v below minimum %v", f, *p.Min) + } + if p.Max != nil && f > *p.Max { + return fmt.Errorf("value %v above maximum %v", f, *p.Max) + } + case TypeBool: + if _, ok := v.(bool); !ok { + return fmt.Errorf("value %v is not a bool", v) + } + case TypeString: + if _, ok := v.(string); !ok { + return fmt.Errorf("value %v is not a string", v) + } + case TypeEnum: + s, ok := v.(string) + if !ok { + return fmt.Errorf("enum value %v is not a string", v) + } + if !slices.Contains(p.EnumValues, s) { + return fmt.Errorf("value %q is not an allowed enum value", s) + } + } + return nil +} + +// toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling +// yields float64 for numbers, but values may also arrive as int or string. +func toFloat(v any) (float64, error) { + switch n := v.(type) { + case float64: + return n, nil + case float32: + return float64(n), nil + case int: + return float64(n), nil + case int64: + return float64(n), nil + case string: + f, err := strconv.ParseFloat(n, 64) + if err != nil { + return 0, fmt.Errorf("value %q is not numeric", n) + } + return f, nil + default: + return 0, fmt.Errorf("value %v is not numeric", v) + } +} diff --git a/internal/confmgr/store.go b/internal/confmgr/store.go new file mode 100644 index 0000000..5fa90c6 --- /dev/null +++ b/internal/confmgr/store.go @@ -0,0 +1,550 @@ +package confmgr + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + "unicode" +) + +// ErrNotFound is returned when the requested set or instance does not exist. +var ErrNotFound = errors.New("config object not found") + +// Kind selects which collection a store operation targets. +type Kind int + +const ( + KindSet Kind = iota + KindInstance +) + +func (k Kind) sub() string { + if k == KindInstance { + return "instances" + } + return "sets" +} + +// Meta is the lightweight listing representation. +type Meta struct { + ID string `json:"id"` + Name string `json:"name"` + Version int `json:"version"` +} + +// VersionMeta describes a single persisted revision. +type VersionMeta struct { + Version int `json:"version"` + Name string `json:"name"` + Tag string `json:"tag,omitempty"` + Current bool `json:"current"` + SavedAt time.Time `json:"savedAt"` +} + +// Store persists configuration sets and instances as versioned JSON files +// under {storageDir}/configs/{sets,instances}/. Each object lives in +// {id}.json with prior revisions preserved as {id}.v{N}.json backups, exactly +// mirroring the panel storage versioning scheme. +type Store struct { + rootDir string + dirs map[Kind]string +} + +// New opens (and creates, if needed) the configs storage directories. +func New(storageDir string) (*Store, error) { + s := &Store{rootDir: storageDir, dirs: map[Kind]string{}} + for _, k := range []Kind{KindSet, KindInstance} { + dir := filepath.Join(storageDir, "configs", k.sub()) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("create %s dir: %w", k.sub(), err) + } + s.dirs[k] = dir + } + return s, nil +} + +// header parses the metadata fields shared by sets and instances. +type header struct { + ID string `json:"id"` + Name string `json:"name"` + Version int `json:"version"` + Tag string `json:"tag"` +} + +func validateID(id string) error { + if id == "" { + return fmt.Errorf("config ID must not be empty") + } + for _, r := range id { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' { + return fmt.Errorf("invalid character %q in config ID", r) + } + } + return nil +} + +func (s *Store) filePath(k Kind, id string) string { + return filepath.Join(s.dirs[k], id+".json") +} + +func (s *Store) backupPath(k Kind, id string, version int) string { + return filepath.Join(s.dirs[k], fmt.Sprintf("%s.v%d.json", id, version)) +} + +// isVersioned reports whether name matches .v.json. +func isVersioned(name string) bool { + parts := strings.Split(name, ".") + if len(parts) != 3 || parts[2] != "json" { + return false + } + if !strings.HasPrefix(parts[1], "v") { + return false + } + _, err := strconv.Atoi(parts[1][1:]) + return err == nil +} + +func readHeader(data []byte) (header, error) { + var h header + if err := json.Unmarshal(data, &h); err != nil { + return header{}, err + } + return h, nil +} + +// List returns metadata for every stored object of the given kind. +func (s *Store) List(k Kind) ([]Meta, error) { + entries, err := os.ReadDir(s.dirs[k]) + if err != nil { + return nil, err + } + out := []Meta{} + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasSuffix(name, ".json") || isVersioned(name) { + continue + } + id := strings.TrimSuffix(name, ".json") + data, err := os.ReadFile(s.filePath(k, id)) + if err != nil { + continue + } + h, err := readHeader(data) + if err != nil { + continue + } + out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version}) + } + return out, nil +} + +// get returns the raw JSON bytes for the current revision. +func (s *Store) get(k Kind, id string) ([]byte, error) { + if err := validateID(id); err != nil { + return nil, fmt.Errorf("%w: %v", ErrNotFound, err) + } + data, err := os.ReadFile(s.filePath(k, id)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return data, err +} + +// create writes a new object, deriving a unique ID from name when obj has none, +// stamping version=1 and the given tag. It returns the raw stored bytes. +func (s *Store) create(k Kind, obj map[string]any, tag string) (string, []byte, error) { + id, _ := obj["id"].(string) + if id == "" { + id = slugify(name(obj)) + } + if err := validateID(id); err != nil { + return "", nil, err + } + if _, err := os.Stat(s.filePath(k, id)); err == nil { + id = id + "-" + strconv.FormatInt(time.Now().UnixMilli(), 10) + } + obj["id"] = id + obj["version"] = 1 + if tag != "" { + obj["tag"] = tag + } + data, err := marshal(obj) + if err != nil { + return "", nil, err + } + return id, data, os.WriteFile(s.filePath(k, id), data, 0o644) +} + +// update replaces the current revision, preserving the prior one as a backup +// and incrementing the version. It returns the raw stored bytes. +func (s *Store) update(k Kind, id string, obj map[string]any, tag string) ([]byte, error) { + if err := validateID(id); err != nil { + return nil, ErrNotFound + } + oldData, err := os.ReadFile(s.filePath(k, id)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return nil, err + } + oldHdr, err := readHeader(oldData) + if err != nil { + return nil, fmt.Errorf("parse existing JSON: %w", err) + } + if oldHdr.Version < 1 { + oldHdr.Version = 1 + } + if err := os.WriteFile(s.backupPath(k, id, oldHdr.Version), oldData, 0o644); err != nil { + return nil, fmt.Errorf("create backup: %w", err) + } + obj["id"] = id + obj["version"] = oldHdr.Version + 1 + if tag != "" { + obj["tag"] = tag + } + data, err := marshal(obj) + if err != nil { + return nil, err + } + return data, os.WriteFile(s.filePath(k, id), data, 0o644) +} + +// Versions returns metadata for every persisted revision, newest-first. +func (s *Store) Versions(k Kind, id string) ([]VersionMeta, error) { + if err := validateID(id); err != nil { + return nil, ErrNotFound + } + curInfo, err := os.Stat(s.filePath(k, id)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } else if err != nil { + return nil, err + } + curData, err := os.ReadFile(s.filePath(k, id)) + if err != nil { + return nil, err + } + curHdr, err := readHeader(curData) + if err != nil { + return nil, err + } + out := []VersionMeta{{ + Version: curHdr.Version, + Name: curHdr.Name, + Tag: curHdr.Tag, + Current: true, + SavedAt: curInfo.ModTime(), + }} + + entries, err := os.ReadDir(s.dirs[k]) + if err != nil { + return nil, err + } + prefix := id + ".v" + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasPrefix(name, prefix) || !isVersioned(name) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + data, err := os.ReadFile(filepath.Join(s.dirs[k], name)) + if err != nil { + continue + } + h, err := readHeader(data) + if err != nil { + continue + } + out = append(out, VersionMeta{ + Version: h.Version, + Name: h.Name, + Tag: h.Tag, + SavedAt: info.ModTime(), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version }) + return out, nil +} + +// getVersion returns the raw JSON bytes for a specific revision. +func (s *Store) getVersion(k Kind, id string, version int) ([]byte, error) { + if err := validateID(id); err != nil { + return nil, ErrNotFound + } + curData, err := os.ReadFile(s.filePath(k, id)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return nil, err + } + if h, err := readHeader(curData); err == nil && h.Version == version { + return curData, nil + } + data, err := os.ReadFile(s.backupPath(k, id, version)) + if errors.Is(err, os.ErrNotExist) { + return nil, ErrNotFound + } + return data, err +} + +// Promote re-saves a past revision as a new revision on top of history. +func (s *Store) Promote(k Kind, id string, version int) error { + data, err := s.getVersion(k, id, version) + if err != nil { + return err + } + obj, err := unmarshal(data) + if err != nil { + return err + } + _, err = s.update(k, id, obj, fmt.Sprintf("restored from v%d", version)) + return err +} + +// Fork creates a brand-new object from a revision, with a fresh ID and version 1. +func (s *Store) Fork(k Kind, id string, version int) (string, error) { + data, err := s.getVersion(k, id, version) + if err != nil { + return "", err + } + obj, err := unmarshal(data) + if err != nil { + return "", err + } + newID := id + "-fork-" + strconv.FormatInt(time.Now().UnixMilli(), 10) + obj["id"] = newID + obj["version"] = 1 + delete(obj, "tag") + out, err := marshal(obj) + if err != nil { + return "", err + } + return newID, os.WriteFile(s.filePath(k, newID), out, 0o644) +} + +// Delete moves an object and all its backups to a timestamped trash folder so +// it can be recovered. Nothing is destroyed. +func (s *Store) Delete(k Kind, id string) error { + if err := validateID(id); err != nil { + return ErrNotFound + } + if _, err := os.Stat(s.filePath(k, id)); err != nil { + if errors.Is(err, os.ErrNotExist) { + return ErrNotFound + } + return err + } + trashDir := filepath.Join(s.rootDir, "trash", "configs", k.sub(), fmt.Sprintf("%s.%d", id, time.Now().UnixMilli())) + if err := os.MkdirAll(trashDir, 0o755); err != nil { + return fmt.Errorf("create trash dir: %w", err) + } + entries, err := os.ReadDir(s.dirs[k]) + if err != nil { + return err + } + for _, e := range entries { + name := e.Name() + if e.IsDir() { + continue + } + if name == id+".json" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) { + if err := os.Rename(filepath.Join(s.dirs[k], name), filepath.Join(trashDir, name)); err != nil { + return fmt.Errorf("move %s to trash: %w", name, err) + } + } + } + return nil +} + +// ── typed wrappers ────────────────────────────────────────────────────────── + +// GetSet returns the current revision of a config set. +func (s *Store) GetSet(id string) (ConfigSet, error) { + data, err := s.get(KindSet, id) + if err != nil { + return ConfigSet{}, err + } + var set ConfigSet + return set, json.Unmarshal(data, &set) +} + +// GetSetVersion returns a specific revision of a config set. +func (s *Store) GetSetVersion(id string, version int) (ConfigSet, error) { + data, err := s.getVersion(KindSet, id, version) + if err != nil { + return ConfigSet{}, err + } + var set ConfigSet + return set, json.Unmarshal(data, &set) +} + +// CreateSet validates and stores a new config set, returning it with its +// assigned ID and version. +func (s *Store) CreateSet(set ConfigSet, tag string) (ConfigSet, error) { + if err := set.Validate(); err != nil { + return ConfigSet{}, err + } + obj, err := toMap(set) + if err != nil { + return ConfigSet{}, err + } + _, data, err := s.create(KindSet, obj, tag) + if err != nil { + return ConfigSet{}, err + } + var out ConfigSet + return out, json.Unmarshal(data, &out) +} + +// UpdateSet validates and stores a new revision of an existing config set. +func (s *Store) UpdateSet(id string, set ConfigSet, tag string) (ConfigSet, error) { + if err := set.Validate(); err != nil { + return ConfigSet{}, err + } + obj, err := toMap(set) + if err != nil { + return ConfigSet{}, err + } + data, err := s.update(KindSet, id, obj, tag) + if err != nil { + return ConfigSet{}, err + } + var out ConfigSet + return out, json.Unmarshal(data, &out) +} + +// GetInstance returns the current revision of a config instance. +func (s *Store) GetInstance(id string) (ConfigInstance, error) { + data, err := s.get(KindInstance, id) + if err != nil { + return ConfigInstance{}, err + } + var inst ConfigInstance + return inst, json.Unmarshal(data, &inst) +} + +// GetInstanceVersion returns a specific revision of a config instance. +func (s *Store) GetInstanceVersion(id string, version int) (ConfigInstance, error) { + data, err := s.getVersion(KindInstance, id, version) + if err != nil { + return ConfigInstance{}, err + } + var inst ConfigInstance + return inst, json.Unmarshal(data, &inst) +} + +// setForInstance loads the schema an instance is bound to, honouring a pinned +// SetVersion when set. +func (s *Store) setForInstance(inst ConfigInstance) (ConfigSet, error) { + if inst.SetVersion > 0 { + return s.GetSetVersion(inst.SetID, inst.SetVersion) + } + return s.GetSet(inst.SetID) +} + +// CreateInstance validates an instance against its set and stores it. +func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance, error) { + set, err := s.setForInstance(inst) + if err != nil { + return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err) + } + if err := inst.ValidateAgainst(set); err != nil { + return ConfigInstance{}, err + } + obj, err := toMap(inst) + if err != nil { + return ConfigInstance{}, err + } + _, data, err := s.create(KindInstance, obj, tag) + if err != nil { + return ConfigInstance{}, err + } + var out ConfigInstance + return out, json.Unmarshal(data, &out) +} + +// UpdateInstance validates and stores a new revision of an existing instance. +func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (ConfigInstance, error) { + set, err := s.setForInstance(inst) + if err != nil { + return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err) + } + if err := inst.ValidateAgainst(set); err != nil { + return ConfigInstance{}, err + } + obj, err := toMap(inst) + if err != nil { + return ConfigInstance{}, err + } + data, err := s.update(KindInstance, id, obj, tag) + if err != nil { + return ConfigInstance{}, err + } + var out ConfigInstance + return out, json.Unmarshal(data, &out) +} + +// SetForInstance is the exported loader used by apply/diff callers. +func (s *Store) SetForInstance(inst ConfigInstance) (ConfigSet, error) { + return s.setForInstance(inst) +} + +// ── JSON helpers ──────────────────────────────────────────────────────────── + +func name(obj map[string]any) string { + n, _ := obj["name"].(string) + return n +} + +func marshal(obj map[string]any) ([]byte, error) { + return json.MarshalIndent(obj, "", " ") +} + +func unmarshal(data []byte) (map[string]any, error) { + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + return nil, err + } + return obj, nil +} + +// toMap round-trips a typed value through JSON into a generic map so the store +// can stamp id/version/tag without knowing the concrete type. +func toMap(v any) (map[string]any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + return unmarshal(data) +} + +// slugify converts a human-readable name into a URL-safe identifier. +func slugify(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + default: + if b.Len() > 0 && b.String()[b.Len()-1] != '-' { + b.WriteByte('-') + } + } + } + result := strings.Trim(b.String(), "-") + if result == "" { + return "config" + } + return result +} diff --git a/internal/confmgr/store_test.go b/internal/confmgr/store_test.go new file mode 100644 index 0000000..9e7d5af --- /dev/null +++ b/internal/confmgr/store_test.go @@ -0,0 +1,200 @@ +package confmgr + +import ( + "testing" +) + +func fptr(f float64) *float64 { return &f } + +func sampleSet() ConfigSet { + return ConfigSet{ + Name: "PSU", + Description: "power supply config", + Parameters: []Parameter{ + {Key: "voltage", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0, Mandatory: true, Min: fptr(0), Max: fptr(48)}, + {Key: "enabled", DS: "epics", Signal: "PSU:EN", Type: TypeBool, Default: false}, + }, + } +} + +func TestCreateAndGetSet(t *testing.T) { + s, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + out, err := s.CreateSet(sampleSet(), "initial") + if err != nil { + t.Fatal(err) + } + if out.ID == "" { + t.Fatal("expected generated ID") + } + if out.Version != 1 { + t.Errorf("version: want 1, got %d", out.Version) + } + got, err := s.GetSet(out.ID) + if err != nil { + t.Fatal(err) + } + if got.Name != "PSU" || len(got.Parameters) != 2 { + t.Errorf("round-trip mismatch: %+v", got) + } +} + +func TestSetVersioning(t *testing.T) { + s, _ := New(t.TempDir()) + created, _ := s.CreateSet(sampleSet(), "") + id := created.ID + + // Update -> v2. + upd := sampleSet() + upd.Description = "edited" + v2, err := s.UpdateSet(id, upd, "edit") + if err != nil { + t.Fatal(err) + } + if v2.Version != 2 { + t.Fatalf("version after update: want 2, got %d", v2.Version) + } + + versions, err := s.Versions(KindSet, id) + if err != nil { + t.Fatal(err) + } + if len(versions) != 2 { + t.Fatalf("want 2 versions, got %d", len(versions)) + } + if !versions[0].Current || versions[0].Version != 2 { + t.Errorf("newest should be current v2: %+v", versions[0]) + } + + // Old version still retrievable. + old, err := s.GetSetVersion(id, 1) + if err != nil { + t.Fatal(err) + } + if old.Description != "power supply config" { + t.Errorf("v1 description changed: %q", old.Description) + } + + // Promote v1 -> becomes v3 with original content. + if err := s.Promote(KindSet, id, 1); err != nil { + t.Fatal(err) + } + cur, _ := s.GetSet(id) + if cur.Version != 3 || cur.Description != "power supply config" { + t.Errorf("after promote: want v3 original desc, got v%d %q", cur.Version, cur.Description) + } +} + +func TestForkSet(t *testing.T) { + s, _ := New(t.TempDir()) + created, _ := s.CreateSet(sampleSet(), "") + _, _ = s.UpdateSet(created.ID, sampleSet(), "") + + newID, err := s.Fork(KindSet, created.ID, 1) + if err != nil { + t.Fatal(err) + } + forked, err := s.GetSet(newID) + if err != nil { + t.Fatal(err) + } + if forked.ID == created.ID { + t.Error("fork should have a new ID") + } + if forked.Version != 1 { + t.Errorf("fork version: want 1, got %d", forked.Version) + } + if forked.Tag != "" { + t.Errorf("fork tag should be cleared, got %q", forked.Tag) + } +} + +func TestDeleteSetSoftDeletes(t *testing.T) { + s, _ := New(t.TempDir()) + created, _ := s.CreateSet(sampleSet(), "") + if err := s.Delete(KindSet, created.ID); err != nil { + t.Fatal(err) + } + if _, err := s.GetSet(created.ID); err == nil { + t.Error("expected set to be gone after delete") + } + // Deleting again is a not-found. + if err := s.Delete(KindSet, created.ID); err != ErrNotFound { + t.Errorf("want ErrNotFound, got %v", err) + } +} + +func TestSetValidation(t *testing.T) { + s, _ := New(t.TempDir()) + bad := ConfigSet{Name: "", Parameters: nil} + if _, err := s.CreateSet(bad, ""); err == nil { + t.Error("expected empty-name validation error") + } + dup := ConfigSet{Name: "x", Parameters: []Parameter{ + {Key: "a", DS: "d", Signal: "s", Type: TypeFloat}, + {Key: "a", DS: "d", Signal: "s2", Type: TypeFloat}, + }} + if _, err := s.CreateSet(dup, ""); err == nil { + t.Error("expected duplicate-key validation error") + } + defaultOOR := ConfigSet{Name: "x", Parameters: []Parameter{ + {Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Default: 100.0, Max: fptr(10)}, + }} + if _, err := s.CreateSet(defaultOOR, ""); err == nil { + t.Error("expected out-of-range default validation error") + } +} + +func TestInstanceLifecycle(t *testing.T) { + s, _ := New(t.TempDir()) + set, _ := s.CreateSet(sampleSet(), "") + + inst := ConfigInstance{ + Name: "nominal", + SetID: set.ID, + Values: map[string]any{"voltage": 24.0, "enabled": true}, + } + out, err := s.CreateInstance(inst, "") + if err != nil { + t.Fatal(err) + } + if out.Version != 1 { + t.Errorf("instance version: want 1, got %d", out.Version) + } + + // Update value -> v2. + out.Values["voltage"] = 36.0 + v2, err := s.UpdateInstance(out.ID, out, "bump") + if err != nil { + t.Fatal(err) + } + if v2.Version != 2 { + t.Errorf("instance version after update: want 2, got %d", v2.Version) + } +} + +func TestInstanceValidation(t *testing.T) { + s, _ := New(t.TempDir()) + set, _ := s.CreateSet(sampleSet(), "") + + // voltage is mandatory with a default, so omitting it is OK; but an + // out-of-range value must fail. + bad := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"voltage": 999.0}} + if _, err := s.CreateInstance(bad, ""); err == nil { + t.Error("expected out-of-range value error") + } + + // Unknown parameter key must fail. + unknown := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"nope": 1.0}} + if _, err := s.CreateInstance(unknown, ""); err == nil { + t.Error("expected unknown-parameter error") + } + + // Unknown set must fail. + missing := ConfigInstance{Name: "x", SetID: "does-not-exist", Values: map[string]any{}} + if _, err := s.CreateInstance(missing, ""); err == nil { + t.Error("expected missing-set error") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 73f70dc..77c1e3e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/confmgr" "github.com/uopi/uopi/internal/controllogic" "github.com/uopi/uopi/internal/datasource/synthetic" "github.com/uopi/uopi/internal/metrics" @@ -28,7 +29,7 @@ type Server struct { // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. -func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { +func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { if rec == nil { rec = audit.Nop() } @@ -49,7 +50,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti // REST API — registered on a dedicated mux so it can be wrapped with the // access-control middleware (identity resolution + global level enforcement). apiMux := http.NewServeMux() - api.New(brk, synth, store, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) + api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux)) // Embedded frontend — must be last (catch-all) diff --git a/web/src/ConfigManager.tsx b/web/src/ConfigManager.tsx new file mode 100644 index 0000000..c3db27f --- /dev/null +++ b/web/src/ConfigManager.tsx @@ -0,0 +1,816 @@ +import { h, Fragment } from 'preact'; +import { useState, useEffect, useCallback } from 'preact/hooks'; +import SignalPicker, { SignalOption } from './SignalPicker'; + +// ── Types (mirror internal/confmgr) ────────────────────────────────────────── + +type ParamType = 'float64' | 'int64' | 'bool' | 'string' | 'enum'; + +interface Parameter { + key: string; + label?: string; + group?: string; + subgroup?: string; + ds: string; + signal: string; + type: ParamType; + default?: any; + mandatory?: boolean; + min?: number; + max?: number; + enumValues?: string[]; + unit?: string; + description?: string; +} + +interface ConfigSet { + id: string; + name: string; + description?: string; + version: number; + tag?: string; + owner?: string; + parameters: Parameter[]; +} + +interface ConfigInstance { + id: string; + name: string; + setId: string; + setVersion?: number; + version: number; + tag?: string; + owner?: string; + values: Record; +} + +interface Meta { id: string; name: string; version: number; } +interface VersionMeta { version: number; name: string; tag?: string; current: boolean; savedAt: string; } + +interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; } +interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; } + +type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged'; +interface SetDiffEntry { key: string; status: DiffStatus; left?: Parameter; right?: Parameter; } +interface InstanceDiffEntry { key: string; status: DiffStatus; left?: any; right?: any; } + +const PARAM_TYPES: ParamType[] = ['float64', 'int64', 'bool', 'string', 'enum']; + +// ── API helper ─────────────────────────────────────────────────────────────── + +async function apiJSON(url: string, opts?: RequestInit): Promise { + const res = await fetch(url, opts); + if (!res.ok && res.status !== 204) { + let msg = `HTTP ${res.status}`; + try { const e = await res.json(); if (e && e.error) msg = e.error; } catch { /* ignore */ } + throw new Error(msg); + } + if (res.status === 204) return null; + return res.json(); +} + +// Lazily-loaded data-source + signal options powering the SignalPicker. +function useSignals() { + const [dataSources, setDataSources] = useState([]); + const [dsSignals, setDsSignals] = useState>({}); + + useEffect(() => { + fetch('/api/v1/datasources') + .then(r => (r.ok ? r.json() : [])) + .then((ds: { name: string }[]) => setDataSources(ds.map(d => d.name))) + .catch(() => {}); + }, []); + + const loadSignals = useCallback((ds: string) => { + if (!ds) return; + setDsSignals(prev => { + if (prev[ds]) return prev; + fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`) + .then(r => (r.ok ? r.json() : [])) + .then((sigs: { name: string }[]) => setDsSignals(p => ({ ...p, [ds]: sigs.map(s => s.name) }))) + .catch(() => {}); + return prev; + }); + }, []); + + function allOptions(): SignalOption[] { + const out: SignalOption[] = []; + for (const ds of dataSources) for (const name of dsSignals[ds] ?? []) out.push({ ds, name }); + return out; + } + function openAll() { dataSources.forEach(loadSignals); } + + return { allOptions, openAll }; +} + +function splitRef(ref: string): { ds: string; signal: string } { + const i = ref.indexOf(':'); + if (i < 0) return { ds: '', signal: ref }; + return { ds: ref.slice(0, i), signal: ref.slice(i + 1) }; +} + +function fmtTime(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +// ── Top-level modal ─────────────────────────────────────────────────────────── + +interface Props { onClose: () => void; } + +export default function ConfigManager({ onClose }: Props) { + const [tab, setTab] = useState<'sets' | 'instances'>('sets'); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+ Configuration manager + Versioned configuration sets (schemas) and instances (values) that apply to signals. +
+ +
+
+ +
+ + +
+ + {tab === 'sets' ? : } +
+
+ ); +} + +// ── Sets manager ────────────────────────────────────────────────────────────── + +function SetsManager() { + const [sets, setSets] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [working, setWorking] = useState(null); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null); + const { allOptions, openAll } = useSignals(); + + const reload = useCallback(async () => { + try { setSets((await apiJSON('/api/v1/config/sets')) ?? []); } + catch (err) { setError(`Failed to load sets: ${msg(err)}`); } + }, []); + useEffect(() => { reload(); }, [reload]); + + async function select(id: string) { + if (dirty && !confirm('Discard unsaved changes?')) return; + setError(null); + try { + const s = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`); + setSelectedId(id); + setWorking(s); + setDirty(false); + } catch (err) { setError(msg(err)); } + } + + async function createSet() { + setBusy(true); setError(null); + try { + const body = { name: 'New config set', description: '', parameters: [] }; + const created = await apiJSON('/api/v1/config/sets', jsonPost(body)); + await reload(); + setSelectedId(created!.id); + setWorking(created); + setDirty(false); + } catch (err) { setError(`Create failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + async function save() { + if (!working) return; + setBusy(true); setError(null); + try { + const saved = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working)); + setWorking(saved); + setDirty(false); + await reload(); + } catch (err) { setError(`Save failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + async function remove(id: string) { + if (!confirm('Delete this config set? A server-side copy is kept in trash.')) return; + setBusy(true); setError(null); + try { + await apiJSON(`/api/v1/config/sets/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (selectedId === id) { setSelectedId(null); setWorking(null); setDirty(false); } + await reload(); + } catch (err) { setError(`Delete failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + function patch(p: Partial) { + if (!working) return; + setWorking({ ...working, ...p }); + setDirty(true); + } + function patchParam(i: number, p: Partial) { + if (!working) return; + const params = working.parameters.map((x, idx) => (idx === i ? { ...x, ...p } : x)); + patch({ parameters: params }); + } + function addParam() { + if (!working) return; + const n = working.parameters.length + 1; + patch({ parameters: [...working.parameters, { key: `param${n}`, ds: '', signal: '', type: 'float64' }] }); + } + function removeParam(i: number) { + if (!working) return; + patch({ parameters: working.parameters.filter((_, idx) => idx !== i) }); + } + + async function showDiff(av: number, bv: number) { + if (!working) return; + setError(null); + try { + const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) }); + const entries = (await apiJSON(`/api/v1/config/sets/diff?${q}`)) ?? []; + setDiff({ title: `${working.name}: v${av} → v${bv}`, entries }); + } catch (err) { setError(`Diff failed: ${msg(err)}`); } + } + + return ( +
+
+
+ Config sets + +
+ {sets.length === 0 &&
No config sets yet.
} + {sets.map(s => ( +
select(s.id)}> + {s.name || '(unnamed)'} + v{s.version} + +
+ ))} +
+ +
+ {error &&
{error}
} + {!working &&
Select a config set on the left, or create a new one.
} + {working && ( +
+
+ patch({ name: (e.target as HTMLInputElement).value })} /> + v{working.version} + {dirty && unsaved} + +
+ patch({ description: (e.target as HTMLInputElement).value })} /> + +
+ Parameters + +
+ {working.parameters.length === 0 &&
No parameters. Each parameter binds a target signal with a default and constraints.
} +
+ {working.parameters.map((p, i) => ( + patchParam(i, patch2)} onRemove={() => removeParam(i)} /> + ))} +
+ + { reload(); select(id); }} + onPromoted={() => select(working.id)} + onCompare={showDiff} onError={setError} /> +
+ )} +
+ + {diff && setDiff(null)}> + + } +
+ ); +} + +function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: { + param: Parameter; + allOptions: SignalOption[]; + onOpenSignals: () => void; + onChange: (p: Partial) => void; + onRemove: () => void; +}) { + const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : ''; + const isNum = param.type === 'float64' || param.type === 'int64'; + return ( +
+
+
+ + onChange({ key: (e.target as HTMLInputElement).value })} /> +
+
+ + onChange({ label: (e.target as HTMLInputElement).value })} /> +
+
+ + +
+ +
+ +
+
+ + { const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} /> +
+
+ + {param.type === 'bool' ? ( + + ) : ( + onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} /> + )} +
+ +
+ +
+
+ + onChange({ group: (e.target as HTMLInputElement).value })} /> +
+
+ + onChange({ unit: (e.target as HTMLInputElement).value })} /> +
+ {isNum && ( + +
+ + onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} /> +
+
+ + onChange({ max: numOrUndef((e.target as HTMLInputElement).value) })} /> +
+
+ )} + {param.type === 'enum' && ( +
+ + onChange({ enumValues: (e.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean) })} /> +
+ )} +
+
+ ); +} + +// ── Instances manager ───────────────────────────────────────────────────────── + +function InstancesManager() { + const [instances, setInstances] = useState([]); + const [sets, setSets] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [working, setWorking] = useState(null); + const [boundSet, setBoundSet] = useState(null); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [applyResult, setApplyResult] = useState(null); + const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null); + const [showNew, setShowNew] = useState(false); + + const reload = useCallback(async () => { + try { + setInstances((await apiJSON('/api/v1/config/instances')) ?? []); + setSets((await apiJSON('/api/v1/config/sets')) ?? []); + } catch (err) { setError(`Failed to load: ${msg(err)}`); } + }, []); + useEffect(() => { reload(); }, [reload]); + + async function loadSet(setId: string, setVersion?: number): Promise { + const path = setVersion && setVersion > 0 + ? `/api/v1/config/sets/${encodeURIComponent(setId)}/versions/${setVersion}` + : `/api/v1/config/sets/${encodeURIComponent(setId)}`; + return apiJSON(path); + } + + async function select(id: string) { + if (dirty && !confirm('Discard unsaved changes?')) return; + setError(null); setApplyResult(null); + try { + const inst = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`); + const set = await loadSet(inst!.setId, inst!.setVersion); + setSelectedId(id); + setWorking(inst); + setBoundSet(set); + setDirty(false); + } catch (err) { setError(msg(err)); } + } + + async function createInstance(name: string, setId: string) { + setBusy(true); setError(null); + try { + const body = { name, setId, values: {} }; + const created = await apiJSON('/api/v1/config/instances', jsonPost(body)); + const set = await loadSet(created!.setId, created!.setVersion); + await reload(); + setSelectedId(created!.id); + setWorking(created); + setBoundSet(set); + setDirty(false); + setShowNew(false); + } catch (err) { setError(`Create failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + async function save() { + if (!working) return; + setBusy(true); setError(null); + try { + const saved = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working)); + setWorking(saved); + setDirty(false); + await reload(); + } catch (err) { setError(`Save failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + async function remove(id: string) { + if (!confirm('Delete this config instance? A server-side copy is kept in trash.')) return; + setBusy(true); setError(null); + try { + await apiJSON(`/api/v1/config/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (selectedId === id) { setSelectedId(null); setWorking(null); setBoundSet(null); setDirty(false); } + await reload(); + } catch (err) { setError(`Delete failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + async function apply() { + if (!working) return; + if (!confirm('Apply this configuration? Each value is written to its target signal.')) return; + setBusy(true); setError(null); setApplyResult(null); + try { + const res = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}/apply`, { method: 'POST' }); + setApplyResult(res); + } catch (err) { setError(`Apply failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + function setValue(key: string, value: any) { + if (!working) return; + const values = { ...working.values }; + if (value === undefined) delete values[key]; + else values[key] = value; + setWorking({ ...working, values }); + setDirty(true); + } + + async function showDiff(av: number, bv: number) { + if (!working) return; + setError(null); + try { + const q = new URLSearchParams({ a: working.id, av: String(av), b: working.id, bv: String(bv) }); + const entries = (await apiJSON(`/api/v1/config/instances/diff?${q}`)) ?? []; + setDiff({ title: `${working.name}: v${av} → v${bv}`, entries }); + } catch (err) { setError(`Diff failed: ${msg(err)}`); } + } + + const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id; + + return ( +
+
+
+ Instances + +
+ {sets.length === 0 &&
Create a config set first.
} + {sets.length > 0 && instances.length === 0 &&
No instances yet.
} + {instances.map(s => ( +
select(s.id)}> + {s.name || '(unnamed)'} + v{s.version} + +
+ ))} +
+ +
+ {error &&
{error}
} + {!working &&
Select an instance on the left, or create a new one.
} + {working && ( +
+
+ { setWorking({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} /> + v{working.version} + {dirty && unsaved} + + +
+
Based on set {setName(working.setId)}{working.setVersion ? ` (v${working.setVersion})` : ''}.
+ + {!boundSet &&
Bound set could not be loaded.
} + {boundSet && ( +
+ {boundSet.parameters.length === 0 &&
The bound set has no parameters.
} + {boundSet.parameters.map(p => ( + setValue(p.key, v)} /> + ))} +
+ )} + + {applyResult && } + + { reload(); select(id); }} + onPromoted={() => select(working.id)} + onCompare={showDiff} onError={setError} /> +
+ )} +
+ + {showNew && setShowNew(false)} onCreate={createInstance} />} + + {diff && setDiff(null)}> + + } +
+ ); +} + +function ValueRow({ param, value, onChange }: { param: Parameter; value: any; onChange: (v: any) => void }) { + const label = param.label || param.key; + const isNum = param.type === 'float64' || param.type === 'int64'; + const defText = param.default !== undefined && param.default !== null ? String(param.default) : ''; + return ( +
+
+ {param.mandatory && *} + {label} + {param.unit && {param.unit}} +
+
+ {param.type === 'bool' ? ( + + ) : param.type === 'enum' ? ( + + ) : ( + { const raw = (e.target as HTMLInputElement).value; onChange(raw === '' ? undefined : coerceVal(raw, param.type)); }} /> + )} +
+ {param.ds}:{param.signal} +
+ ); +} + +function NewInstanceDialog({ sets, busy, onCancel, onCreate }: { + sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void; +}) { + const [name, setName] = useState('New instance'); + const [setId, setSetId] = useState(sets[0]?.id ?? ''); + return ( +
{ if (e.target === e.currentTarget) onCancel(); }}> +
+
New config instance
+
+ + setName((e.target as HTMLInputElement).value)} /> +
+
+ + +
+
+ + +
+
+
+ ); +} + +function ApplyResultView({ result }: { result: ApplyResult }) { + return ( +
+
+ Applied {result.applied}, failed {result.failed}, skipped {result.skipped} +
+ + + + {result.entries.map(e => ( + + + + + + + ))} + +
ParameterTargetValueResult
{e.key}{e.ds}:{e.signal}{e.value !== undefined ? String(e.value) : '—'} + {e.skipped ? skipped + : e.ok ? ok + : failed} +
+
+ ); +} + +// ── Versions panel (shared) ─────────────────────────────────────────────────── + +function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: { + kind: 'sets' | 'instances'; + id: string; + onReload: () => void; + onForked: (newId: string) => void; + onPromoted: () => void; + onCompare: (av: number, bv: number) => void; + onError: (m: string) => void; +}) { + const [versions, setVersions] = useState([]); + const [open, setOpen] = useState(false); + const [a, setA] = useState(null); + const [b, setB] = useState(null); + + const base = `/api/v1/config/${kind}`; + + const load = useCallback(async () => { + try { + const v = (await apiJSON(`${base}/${encodeURIComponent(id)}/versions`)) ?? []; + setVersions(v); + if (v.length >= 2) { setA(v[1].version); setB(v[0].version); } + else if (v.length === 1) { setA(v[0].version); setB(v[0].version); } + } catch (err) { onError(`Versions failed: ${msg(err)}`); } + }, [base, id, onError]); + + useEffect(() => { if (open) load(); }, [open, load]); + // Reset when switching object. + useEffect(() => { setOpen(false); setVersions([]); }, [id]); + + async function fork(version: number) { + try { + const obj = await apiJSON<{ id: string }>(`${base}/${encodeURIComponent(id)}/versions/${version}/fork`, { method: 'POST' }); + onForked(obj!.id); + } catch (err) { onError(`Fork failed: ${msg(err)}`); } + } + async function promote(version: number) { + if (!confirm(`Promote v${version} to a new current version?`)) return; + try { + await apiJSON(`${base}/${encodeURIComponent(id)}/versions/${version}/promote`, { method: 'POST' }); + onPromoted(); + } catch (err) { onError(`Promote failed: ${msg(err)}`); } + } + + return ( +
+ + {open && ( +
+ {versions.length === 0 &&
No versions.
} + {versions.map(v => ( +
+ v{v.version}{v.current && current} + {v.tag || ''} + {fmtTime(v.savedAt)} + + {!v.current && } +
+ ))} + {versions.length >= 2 && ( +
+ Compare + + + + +
+ )} +
+ )} +
+ ); +} + +// ── Diff views ──────────────────────────────────────────────────────────────── + +function DiffModal({ title, onClose, children }: { title: string; onClose: () => void; children: any }) { + return ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+ Diff — {title} + +
+ {children} +
+
+ ); +} + +function paramSummary(p?: Parameter): string { + if (!p) return '—'; + const parts = [`${p.ds}:${p.signal}`, p.type]; + if (p.default !== undefined) parts.push(`default=${p.default}`); + if (p.mandatory) parts.push('mandatory'); + if (p.min !== undefined) parts.push(`min=${p.min}`); + if (p.max !== undefined) parts.push(`max=${p.max}`); + return parts.join(', '); +} + +function SetDiffTable({ entries }: { entries: SetDiffEntry[] }) { + if (entries.length === 0) return
No parameters.
; + return ( + + + + {entries.map(e => ( + + + + + + ))} + +
ParameterLeftRight
{e.key} {e.status}{paramSummary(e.left)}{paramSummary(e.right)}
+ ); +} + +function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) { + if (entries.length === 0) return
No values.
; + return ( + + + + {entries.map(e => ( + + + + + + ))} + +
ParameterLeftRight
{e.key} {e.status}{e.left !== undefined ? String(e.left) : '—'}{e.right !== undefined ? String(e.right) : '—'}
+ ); +} + +// ── small helpers ───────────────────────────────────────────────────────────── + +function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function jsonPost(body: any): RequestInit { return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; } +function jsonPut(body: any): RequestInit { return { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; } +function numOrUndef(v: string): number | undefined { if (v.trim() === '') return undefined; const n = Number(v); return isNaN(n) ? undefined : n; } +function coerceVal(v: string, type: ParamType): any { + if (type === 'float64' || type === 'int64') { const n = Number(v); return isNaN(n) ? v : n; } + return v; +} diff --git a/web/src/ViewMode.tsx b/web/src/ViewMode.tsx index f5f2f5d..acde01a 100644 --- a/web/src/ViewMode.tsx +++ b/web/src/ViewMode.tsx @@ -11,6 +11,7 @@ import HelpModal from './HelpModal'; import ZoomControl from './ZoomControl'; import ControlLogicEditor from './ControlLogicEditor'; import AuditViewer from './AuditViewer'; +import ConfigManager from './ConfigManager'; interface Props { onEdit?: (iface?: Interface) => void; @@ -34,6 +35,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { const [showTimeNav, setShowTimeNav] = useState(false); const [showControlLogic, setShowControlLogic] = useState(false); const [showAudit, setShowAudit] = useState(false); + const [showConfig, setShowConfig] = useState(false); const [leftW, setLeftW] = useState(220); const [listCollapsed, setListCollapsed] = useState(false); const me = useAuth(); @@ -179,6 +181,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) { 🛡 Audit )} + {writable && ( + + )} {writable && (