initial config manager

This commit is contained in:
Martino Ferrari
2026-06-20 17:40:03 +02:00
parent 206d5b541d
commit 3fc7c1b546
16 changed files with 3013 additions and 5 deletions
+8 -1
View File
@@ -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)
+28 -1
View File
@@ -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 ─────────────────────────────────────────────────────────────────────
+7 -1
View File
@@ -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() {
+446
View File
@@ -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)
}
+107
View File
@@ -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)
}
+55
View File
@@ -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
}
+77
View File
@@ -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)
}
}
}
+140
View File
@@ -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)
}
+55
View File
@@ -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"])
}
}
+225
View File
@@ -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)
}
}
+550
View File
@@ -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 <id>.v<number>.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
}
+200
View File
@@ -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")
}
}
+3 -2
View File
@@ -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)
+816
View File
@@ -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<string, any>;
}
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<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
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<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
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 (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal">
<header class="cl-header">
<span class="cl-title">Configuration manager</span>
<span class="hint cl-subtitle">Versioned configuration sets (schemas) and instances (values) that apply to signals.</span>
<div class="cl-header-actions">
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="cfg-tabs">
<button class={`cfg-tab${tab === 'sets' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('sets')}>Sets</button>
<button class={`cfg-tab${tab === 'instances' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('instances')}>Instances</button>
</div>
{tab === 'sets' ? <SetsManager /> : <InstancesManager />}
</div>
</div>
);
}
// ── Sets manager ──────────────────────────────────────────────────────────────
function SetsManager() {
const [sets, setSets] = useState<Meta[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [working, setWorking] = useState<ConfigSet | null>(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null);
const { allOptions, openAll } = useSignals();
const reload = useCallback(async () => {
try { setSets((await apiJSON<Meta[]>('/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<ConfigSet>(`/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<ConfigSet>('/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<ConfigSet>(`/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<ConfigSet>) {
if (!working) return;
setWorking({ ...working, ...p });
setDirty(true);
}
function patchParam(i: number, p: Partial<Parameter>) {
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<SetDiffEntry[]>(`/api/v1/config/sets/diff?${q}`)) ?? [];
setDiff({ title: `${working.name}: v${av} → v${bv}`, entries });
} catch (err) { setError(`Diff failed: ${msg(err)}`); }
}
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<span>Config sets</span>
<button class="panel-btn" disabled={busy} onClick={createSet}>+ New</button>
</div>
{sets.length === 0 && <div class="hint cl-list-empty">No config sets yet.</div>}
{sets.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
<span class="cfg-badge" title="Current version">v{s.version}</span>
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button>
</div>
))}
</div>
<div class="cl-editor-wrap">
{error && <div class="cl-error">{error}</div>}
{!working && <div class="cl-empty hint">Select a config set on the left, or create a new one.</div>}
{working && (
<div class="cfg-editor">
<div class="cfg-edit-bar">
<input class="prop-input cl-name-input" value={working.name} placeholder="Set name"
onInput={(e) => patch({ name: (e.target as HTMLInputElement).value })} />
<span class="cfg-badge">v{working.version}</span>
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
</div>
<input class="prop-input" value={working.description ?? ''} placeholder="Description (optional)"
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
<div class="cfg-section-head">
<span>Parameters</span>
<button class="panel-btn" onClick={addParam}>+ Add parameter</button>
</div>
{working.parameters.length === 0 && <div class="hint">No parameters. Each parameter binds a target signal with a default and constraints.</div>}
<div class="cfg-params">
{working.parameters.map((p, i) => (
<ParamEditor key={i} param={p} allOptions={allOptions()} onOpenSignals={openAll}
onChange={(patch2) => patchParam(i, patch2)} onRemove={() => removeParam(i)} />
))}
</div>
<VersionPanel kind="sets" id={working.id} onReload={reload}
onForked={(id) => { reload(); select(id); }}
onPromoted={() => select(working.id)}
onCompare={showDiff} onError={setError} />
</div>
)}
</div>
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
<SetDiffTable entries={diff.entries} />
</DiffModal>}
</div>
);
}
function ParamEditor({ param, allOptions, onOpenSignals, onChange, onRemove }: {
param: Parameter;
allOptions: SignalOption[];
onOpenSignals: () => void;
onChange: (p: Partial<Parameter>) => void;
onRemove: () => void;
}) {
const ref = param.ds || param.signal ? `${param.ds}:${param.signal}` : '';
const isNum = param.type === 'float64' || param.type === 'int64';
return (
<div class="cfg-param">
<div class="cfg-param-row">
<div class="cfg-field cfg-field-key">
<label>Key</label>
<input class="prop-input" value={param.key} onInput={(e) => onChange({ key: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field cfg-field-label">
<label>Label</label>
<input class="prop-input" value={param.label ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ label: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field cfg-field-type">
<label>Type</label>
<select class="prop-select" value={param.type} onChange={(e) => onChange({ type: (e.target as HTMLSelectElement).value as ParamType })}>
{PARAM_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<button class="cl-mini-btn cfg-param-del" title="Remove parameter" onClick={onRemove}></button>
</div>
<div class="cfg-param-row">
<div class="cfg-field cfg-field-target">
<label>Target signal</label>
<SignalPicker value={ref} options={allOptions} onOpen={onOpenSignals} allowFreeform
onChange={(r) => { const s = splitRef(r); onChange({ ds: s.ds, signal: s.signal }); }} />
</div>
<div class="cfg-field cfg-field-default">
<label>Default</label>
{param.type === 'bool' ? (
<select class="prop-select" value={String(param.default ?? '')}
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange({ default: v === '' ? undefined : v === 'true' }); }}>
<option value="">(none)</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input class="prop-input" type={isNum ? 'number' : 'text'} value={param.default ?? ''}
onInput={(e) => onChange({ default: coerceVal((e.target as HTMLInputElement).value, param.type) })} />
)}
</div>
<label class="cfg-mandatory">
<input type="checkbox" checked={!!param.mandatory} onChange={(e) => onChange({ mandatory: (e.target as HTMLInputElement).checked })} />
Mandatory
</label>
</div>
<div class="cfg-param-row">
<div class="cfg-field">
<label>Group</label>
<input class="prop-input" value={param.group ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ group: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field">
<label>Unit</label>
<input class="prop-input" value={param.unit ?? ''} placeholder="(optional)"
onInput={(e) => onChange({ unit: (e.target as HTMLInputElement).value })} />
</div>
{isNum && (
<Fragment>
<div class="cfg-field cfg-field-num">
<label>Min</label>
<input class="prop-input" type="number" value={param.min ?? ''}
onInput={(e) => onChange({ min: numOrUndef((e.target as HTMLInputElement).value) })} />
</div>
<div class="cfg-field cfg-field-num">
<label>Max</label>
<input class="prop-input" type="number" value={param.max ?? ''}
onInput={(e) => onChange({ max: numOrUndef((e.target as HTMLInputElement).value) })} />
</div>
</Fragment>
)}
{param.type === 'enum' && (
<div class="cfg-field cfg-field-enum">
<label>Enum values (comma-separated)</label>
<input class="prop-input" value={(param.enumValues ?? []).join(', ')} placeholder="a, b, c"
onInput={(e) => onChange({ enumValues: (e.target as HTMLInputElement).value.split(',').map(s => s.trim()).filter(Boolean) })} />
</div>
)}
</div>
</div>
);
}
// ── Instances manager ─────────────────────────────────────────────────────────
function InstancesManager() {
const [instances, setInstances] = useState<Meta[]>([]);
const [sets, setSets] = useState<Meta[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [working, setWorking] = useState<ConfigInstance | null>(null);
const [boundSet, setBoundSet] = useState<ConfigSet | null>(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null);
const [showNew, setShowNew] = useState(false);
const reload = useCallback(async () => {
try {
setInstances((await apiJSON<Meta[]>('/api/v1/config/instances')) ?? []);
setSets((await apiJSON<Meta[]>('/api/v1/config/sets')) ?? []);
} catch (err) { setError(`Failed to load: ${msg(err)}`); }
}, []);
useEffect(() => { reload(); }, [reload]);
async function loadSet(setId: string, setVersion?: number): Promise<ConfigSet | null> {
const path = setVersion && setVersion > 0
? `/api/v1/config/sets/${encodeURIComponent(setId)}/versions/${setVersion}`
: `/api/v1/config/sets/${encodeURIComponent(setId)}`;
return apiJSON<ConfigSet>(path);
}
async function select(id: string) {
if (dirty && !confirm('Discard unsaved changes?')) return;
setError(null); setApplyResult(null);
try {
const inst = await apiJSON<ConfigInstance>(`/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<ConfigInstance>('/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<ConfigInstance>(`/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<ApplyResult>(`/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<InstanceDiffEntry[]>(`/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 (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<span>Instances</span>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
{sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
{instances.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
<span class="cfg-badge">v{s.version}</span>
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button>
</div>
))}
</div>
<div class="cl-editor-wrap">
{error && <div class="cl-error">{error}</div>}
{!working && <div class="cl-empty hint">Select an instance on the left, or create a new one.</div>}
{working && (
<div class="cfg-editor">
<div class="cfg-edit-bar">
<input class="prop-input cl-name-input" value={working.name} placeholder="Instance name"
onInput={(e) => { setWorking({ ...working, name: (e.target as HTMLInputElement).value }); setDirty(true); }} />
<span class="cfg-badge">v{working.version}</span>
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
<button class="panel-btn" disabled={busy || dirty} title={dirty ? 'Save before applying' : 'Write values to signals'} onClick={apply}>Apply</button>
</div>
<div class="hint">Based on set <b>{setName(working.setId)}</b>{working.setVersion ? ` (v${working.setVersion})` : ''}.</div>
{!boundSet && <div class="cl-error">Bound set could not be loaded.</div>}
{boundSet && (
<div class="cfg-values">
{boundSet.parameters.length === 0 && <div class="hint">The bound set has no parameters.</div>}
{boundSet.parameters.map(p => (
<ValueRow key={p.key} param={p} value={working.values[p.key]} onChange={(v) => setValue(p.key, v)} />
))}
</div>
)}
{applyResult && <ApplyResultView result={applyResult} />}
<VersionPanel kind="instances" id={working.id} onReload={reload}
onForked={(id) => { reload(); select(id); }}
onPromoted={() => select(working.id)}
onCompare={showDiff} onError={setError} />
</div>
)}
</div>
{showNew && <NewInstanceDialog sets={sets} busy={busy} onCancel={() => setShowNew(false)} onCreate={createInstance} />}
{diff && <DiffModal title={diff.title} onClose={() => setDiff(null)}>
<InstanceDiffTable entries={diff.entries} />
</DiffModal>}
</div>
);
}
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 (
<div class="cfg-value-row">
<div class="cfg-value-label">
{param.mandatory && <span class="cfg-req" title="Mandatory">*</span>}
<span title={`${param.ds}:${param.signal}`}>{label}</span>
{param.unit && <span class="hint cfg-unit">{param.unit}</span>}
</div>
<div class="cfg-value-input">
{param.type === 'bool' ? (
<select class="prop-select" value={value === undefined ? '' : String(value)}
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v === 'true'); }}>
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : param.type === 'enum' ? (
<select class="prop-select" value={value === undefined ? '' : String(value)}
onChange={(e) => { const v = (e.target as HTMLSelectElement).value; onChange(v === '' ? undefined : v); }}>
<option value="">{defText ? `default (${defText})` : '(unset)'}</option>
{(param.enumValues ?? []).map(ev => <option key={ev} value={ev}>{ev}</option>)}
</select>
) : (
<input class="prop-input" type={isNum ? 'number' : 'text'}
value={value === undefined ? '' : value}
placeholder={defText ? `default: ${defText}` : '(unset)'}
onInput={(e) => { const raw = (e.target as HTMLInputElement).value; onChange(raw === '' ? undefined : coerceVal(raw, param.type)); }} />
)}
</div>
<span class="cfg-value-target hint" title="Target signal">{param.ds}:{param.signal}</span>
</div>
);
}
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 (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div class="cl-confirm">
<div class="cl-confirm-title">New config instance</div>
<div class="cfg-field">
<label>Name</label>
<input class="prop-input" value={name} autoFocus onInput={(e) => setName((e.target as HTMLInputElement).value)} />
</div>
<div class="cfg-field">
<label>Config set</label>
<select class="prop-select" value={setId} onChange={(e) => setSetId((e.target as HTMLSelectElement).value)}>
{sets.map(s => <option key={s.id} value={s.id}>{s.name} (v{s.version})</option>)}
</select>
</div>
<div class="cl-confirm-actions">
<button class="panel-btn" onClick={onCancel}>Cancel</button>
<button class="panel-btn panel-btn-primary" disabled={busy || !setId || !name.trim()}
onClick={() => onCreate(name.trim(), setId)}>Create</button>
</div>
</div>
</div>
);
}
function ApplyResultView({ result }: { result: ApplyResult }) {
return (
<div class="cfg-apply-result">
<div class="cfg-apply-head">
Applied <b>{result.applied}</b>, failed <b class={result.failed ? 'cfg-fail' : ''}>{result.failed}</b>, skipped <b>{result.skipped}</b>
</div>
<table class="cfg-apply-table">
<thead><tr><th>Parameter</th><th>Target</th><th>Value</th><th>Result</th></tr></thead>
<tbody>
{result.entries.map(e => (
<tr key={e.key}>
<td>{e.key}</td>
<td class="hint">{e.ds}:{e.signal}</td>
<td>{e.value !== undefined ? String(e.value) : '—'}</td>
<td>
{e.skipped ? <span class="hint">skipped</span>
: e.ok ? <span class="cfg-ok">ok</span>
: <span class="cfg-fail" title={e.error}>failed</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ── 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<VersionMeta[]>([]);
const [open, setOpen] = useState(false);
const [a, setA] = useState<number | null>(null);
const [b, setB] = useState<number | null>(null);
const base = `/api/v1/config/${kind}`;
const load = useCallback(async () => {
try {
const v = (await apiJSON<VersionMeta[]>(`${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 (
<div class="cfg-versions">
<button class="cfg-versions-toggle" onClick={() => setOpen(o => !o)}>
{open ? '▾' : '▸'} Version history
</button>
{open && (
<div class="cfg-versions-body">
{versions.length === 0 && <div class="hint">No versions.</div>}
{versions.map(v => (
<div key={v.version} class={`cfg-version-row${v.current ? ' cfg-version-current' : ''}`}>
<span class="cfg-version-num">v{v.version}{v.current && <span class="cfg-badge">current</span>}</span>
<span class="cfg-version-tag hint" title={v.tag}>{v.tag || ''}</span>
<span class="cfg-version-time hint">{fmtTime(v.savedAt)}</span>
<button class="cl-mini-btn" title="Fork into a new object" onClick={() => fork(v.version)}>fork</button>
{!v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
</div>
))}
{versions.length >= 2 && (
<div class="cfg-compare">
<span class="hint">Compare</span>
<select class="prop-select" value={String(a ?? '')} onChange={(e) => setA(Number((e.target as HTMLSelectElement).value))}>
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
</select>
<span></span>
<select class="prop-select" value={String(b ?? '')} onChange={(e) => setB(Number((e.target as HTMLSelectElement).value))}>
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
</select>
<button class="panel-btn" disabled={a === null || b === null} onClick={() => onCompare(a!, b!)}>Diff</button>
</div>
)}
</div>
)}
</div>
);
}
// ── Diff views ────────────────────────────────────────────────────────────────
function DiffModal({ title, onClose, children }: { title: string; onClose: () => void; children: any }) {
return (
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-confirm cfg-diff-modal">
<div class="cfg-diff-head">
<span class="cl-confirm-title">Diff {title}</span>
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
{children}
</div>
</div>
);
}
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 <div class="hint">No parameters.</div>;
return (
<table class="cfg-diff-table">
<thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
<tbody>
{entries.map(e => (
<tr key={e.key} class={`cfg-diff-${e.status}`}>
<td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
<td>{paramSummary(e.left)}</td>
<td>{paramSummary(e.right)}</td>
</tr>
))}
</tbody>
</table>
);
}
function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) {
if (entries.length === 0) return <div class="hint">No values.</div>;
return (
<table class="cfg-diff-table">
<thead><tr><th>Parameter</th><th>Left</th><th>Right</th></tr></thead>
<tbody>
{entries.map(e => (
<tr key={e.key} class={`cfg-diff-${e.status}`}>
<td>{e.key} <span class="cfg-diff-status">{e.status}</span></td>
<td>{e.left !== undefined ? String(e.left) : '—'}</td>
<td>{e.right !== undefined ? String(e.right) : '—'}</td>
</tr>
))}
</tbody>
</table>
);
}
// ── 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;
}
+14
View File
@@ -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
</button>
)}
{writable && (
<button
class="toolbar-btn"
onClick={() => setShowConfig(true)}
title="Open configuration manager"
>
🗂 Config
</button>
)}
{writable && (
<button
class="btn-edit"
@@ -269,6 +280,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showAudit && (
<AuditViewer onClose={() => setShowAudit(false)} />
)}
{showConfig && (
<ConfigManager onClose={() => setShowConfig(false)} />
)}
</div>
);
}
+282
View File
@@ -3858,3 +3858,285 @@ kbd {
background: #2563eb;
}
.panel-btn-primary:hover { background: #3b82f6; }
/* ── Configuration manager ─────────────────────────────────────────────── */
.cfg-tabs {
display: flex;
gap: 0.25rem;
margin-left: 0.5rem;
}
.cfg-tab {
background: transparent;
border: 1px solid transparent;
color: #94a3b8;
font-size: 0.82rem;
padding: 0.25rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.cfg-tab:hover { color: #e2e8f0; background: #232a3a; }
.cfg-tab-active {
color: #e2e8f0;
background: #2d3748;
border-color: #3d4a63;
}
.cfg-badge {
display: inline-block;
min-width: 1.1rem;
text-align: center;
font-size: 0.7rem;
color: #94a3b8;
background: #232a3a;
border-radius: 8px;
padding: 0 0.35rem;
margin-left: 0.4rem;
}
.cfg-editor {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.75rem 1rem;
overflow-y: auto;
height: 100%;
}
.cfg-edit-bar {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cfg-edit-bar .panel-btn { flex: 0 0 auto; }
.cfg-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid #2d3748;
font-size: 0.82rem;
font-weight: 600;
color: #cbd5e1;
}
.cfg-section-head .panel-btn { flex: 0 0 auto; }
.cfg-params {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cfg-param {
background: #151a26;
border: 1px solid #232a3a;
border-radius: 6px;
padding: 0.5rem;
position: relative;
}
.cfg-param-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
}
.cfg-field {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.cfg-field > label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #64748b;
}
.cfg-field-key { width: 8rem; }
.cfg-field-label { flex: 1; min-width: 8rem; }
.cfg-field-type { width: 7rem; }
.cfg-field-target { flex: 2; min-width: 12rem; }
.cfg-field-default { width: 8rem; }
.cfg-field-enum { flex: 1; min-width: 10rem; }
.cfg-field-num { width: 6rem; }
.cfg-mandatory {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: #94a3b8;
white-space: nowrap;
padding-bottom: 0.25rem;
}
.cfg-param-del {
background: transparent;
border: none;
color: #64748b;
cursor: pointer;
font-size: 1rem;
padding: 0 0.25rem;
}
.cfg-param-del:hover { color: #f87171; }
.cfg-unit {
font-size: 0.72rem;
color: #64748b;
margin-left: 0.25rem;
}
/* ── Instance values ───────────────────────────────────────────────────── */
.cfg-values {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cfg-value-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.3rem 0.4rem;
border-radius: 5px;
}
.cfg-value-row:hover { background: #151a26; }
.cfg-value-label {
width: 14rem;
flex: 0 0 auto;
font-size: 0.82rem;
color: #cbd5e1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cfg-req { color: #f87171; margin-left: 0.15rem; }
.cfg-value-input { width: 10rem; flex: 0 0 auto; }
.cfg-value-target {
font-size: 0.72rem;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Apply result ──────────────────────────────────────────────────────── */
.cfg-apply-result {
border: 1px solid #232a3a;
border-radius: 6px;
margin-top: 0.5rem;
overflow: hidden;
}
.cfg-apply-head {
display: flex;
gap: 1rem;
padding: 0.4rem 0.6rem;
background: #151a26;
font-size: 0.8rem;
color: #cbd5e1;
}
.cfg-apply-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.cfg-apply-table th,
.cfg-apply-table td {
text-align: left;
padding: 0.25rem 0.6rem;
border-top: 1px solid #1e2433;
}
.cfg-apply-table th { color: #64748b; font-weight: 500; }
.cfg-ok { color: #4ade80; }
.cfg-fail { color: #f87171; }
/* ── Version panel ─────────────────────────────────────────────────────── */
.cfg-versions {
border-top: 1px solid #2d3748;
margin-top: 0.5rem;
padding-top: 0.4rem;
}
.cfg-versions-toggle {
background: transparent;
border: none;
color: #94a3b8;
font-size: 0.8rem;
cursor: pointer;
padding: 0.2rem 0;
}
.cfg-versions-toggle:hover { color: #e2e8f0; }
.cfg-versions-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.4rem;
}
.cfg-version-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.25rem 0.4rem;
border: 1px solid #232a3a;
border-radius: 5px;
font-size: 0.78rem;
}
.cfg-version-current {
border-color: #2563eb;
background: #15203a;
}
.cfg-version-num { font-weight: 600; color: #cbd5e1; min-width: 3rem; }
.cfg-version-tag { color: #94a3b8; flex: 1; }
.cfg-version-time { color: #64748b; font-size: 0.72rem; }
.cfg-compare {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.4rem;
font-size: 0.78rem;
color: #94a3b8;
}
/* ── Diff modal ────────────────────────────────────────────────────────── */
.cfg-diff-modal {
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 10px;
width: min(760px, 92vw);
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
}
.cfg-diff-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
border-bottom: 1px solid #2d3748;
font-size: 0.85rem;
color: #e2e8f0;
}
.cfg-diff-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
overflow-y: auto;
}
.cfg-diff-table th,
.cfg-diff-table td {
text-align: left;
padding: 0.3rem 0.7rem;
border-top: 1px solid #1e2433;
vertical-align: top;
}
.cfg-diff-table th { color: #64748b; font-weight: 500; }
.cfg-diff-status {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cfg-diff-added { background: rgba(34, 197, 94, 0.08); }
.cfg-diff-added .cfg-diff-status { color: #4ade80; }
.cfg-diff-removed { background: rgba(239, 68, 68, 0.08); }
.cfg-diff-removed .cfg-diff-status { color: #f87171; }
.cfg-diff-changed { background: rgba(234, 179, 8, 0.08); }
.cfg-diff-changed .cfg-diff-status { color: #facc15; }
.cfg-diff-unchanged .cfg-diff-status { color: #64748b; }