From 824b6ff833029eecb00b2fd15786e91e9cdee02b Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 26 Apr 2026 11:01:58 +0200 Subject: [PATCH] phase 10 --- internal/api/api_test.go | 430 +++++++++++++++++++++++++++ internal/broker/broker_bench_test.go | 101 +++++++ internal/metrics/metrics.go | 82 +++++ web/src/SyntheticWizard.tsx | 182 ++++++++++++ 4 files changed, 795 insertions(+) create mode 100644 internal/api/api_test.go create mode 100644 internal/broker/broker_bench_test.go create mode 100644 internal/metrics/metrics.go create mode 100644 web/src/SyntheticWizard.tsx diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..4701f53 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,430 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/uopi/uopi/internal/api" + "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/datasource/stub" + "github.com/uopi/uopi/internal/storage" +) + +// ── Test helpers ───────────────────────────────────────────────────────────── + +func setup(t *testing.T) (*httptest.Server, func()) { + t.Helper() + + ctx, cancel := context.WithCancel(context.Background()) + + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + brk := broker.New(ctx, log) + ds := stub.New() + if err := ds.Connect(ctx); err != nil { + t.Fatal("stub connect:", err) + } + brk.Register(ds) + + dir := t.TempDir() + store, err := storage.New(dir) + if err != nil { + t.Fatal("storage.New:", err) + } + + mux := http.NewServeMux() + api.New(brk, nil, store, "", log).Register(mux, "/api/v1") + + srv := httptest.NewServer(mux) + return srv, func() { + srv.Close() + cancel() + } +} + +func get(t *testing.T, srv *httptest.Server, path string) *http.Response { + t.Helper() + resp, err := http.Get(srv.URL + path) + if err != nil { + t.Fatal("GET", path, err) + } + return resp +} + +func postJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatal("json.Marshal:", err) + } + resp, err := http.Post(srv.URL+path, "application/json", bytes.NewReader(b)) + if err != nil { + t.Fatal("POST", path, err) + } + return resp +} + +func postRaw(t *testing.T, srv *httptest.Server, path, ct string, body []byte) *http.Response { + t.Helper() + resp, err := http.Post(srv.URL+path, ct, bytes.NewReader(body)) + if err != nil { + t.Fatal("POST", path, err) + } + return resp +} + +func putRaw(t *testing.T, srv *httptest.Server, path, ct string, body []byte) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodPut, srv.URL+path, bytes.NewReader(body)) + if err != nil { + t.Fatal("NewRequest PUT:", err) + } + req.Header.Set("Content-Type", ct) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal("PUT", path, err) + } + return resp +} + +func deleteReq(t *testing.T, srv *httptest.Server, path string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodDelete, srv.URL+path, nil) + if err != nil { + t.Fatal("NewRequest DELETE:", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal("DELETE", path, err) + } + return resp +} + +func readJSON(t *testing.T, resp *http.Response, v any) { + t.Helper() + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatal("decode response:", err) + } +} + +func assertStatus(t *testing.T, resp *http.Response, want int) { + t.Helper() + if resp.StatusCode != want { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("expected HTTP %d, got %d: %s", want, resp.StatusCode, bytes.TrimSpace(body)) + } +} + +// ── /api/v1/datasources ─────────────────────────────────────────────────────── + +func TestListDataSources(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := get(t, srv, "/api/v1/datasources") + assertStatus(t, resp, http.StatusOK) + + var sources []struct { + Name string `json:"name"` + Status string `json:"status"` + } + readJSON(t, resp, &sources) + + if len(sources) == 0 { + t.Fatal("expected at least one data source") + } + found := false + for _, s := range sources { + if s.Name == "stub" { + found = true + if s.Status != "connected" { + t.Errorf("stub status = %q, want connected", s.Status) + } + } + } + if !found { + t.Error("stub data source not in list") + } +} + +// ── /api/v1/signals ─────────────────────────────────────────────────────────── + +func TestListSignalsMissingDs(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := get(t, srv, "/api/v1/signals") + assertStatus(t, resp, http.StatusBadRequest) +} + +func TestListSignalsUnknownDs(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := get(t, srv, "/api/v1/signals?ds=nonexistent") + assertStatus(t, resp, http.StatusNotFound) +} + +func TestListSignalsStub(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := get(t, srv, "/api/v1/signals?ds=stub") + assertStatus(t, resp, http.StatusOK) + + var signals []struct { + Name string `json:"name"` + Type string `json:"type"` + } + readJSON(t, resp, &signals) + + if len(signals) == 0 { + t.Fatal("expected signals from stub data source") + } +} + +// ── /api/v1/signals/search ──────────────────────────────────────────────────── + +func TestSearchSignals(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := get(t, srv, "/api/v1/signals/search?q=sine") + assertStatus(t, resp, http.StatusOK) + + var signals []struct{ Name string `json:"name"` } + readJSON(t, resp, &signals) + + for _, s := range signals { + if !strings.Contains(s.Name, "sine") { + t.Errorf("signal %q does not match query 'sine'", s.Name) + } + } +} + +func TestSearchSignalsEmpty(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + // Empty query should return all signals. + resp := get(t, srv, "/api/v1/signals/search") + assertStatus(t, resp, http.StatusOK) + + var signals []any + readJSON(t, resp, &signals) + if len(signals) == 0 { + t.Error("expected signals for empty query") + } +} + +// ── /api/v1/interfaces ──────────────────────────────────────────────────────── + +const sampleXML = `` + +func TestInterfaceCRUD(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + // List — initially empty + resp := get(t, srv, "/api/v1/interfaces") + assertStatus(t, resp, http.StatusOK) + var list []any + readJSON(t, resp, &list) + if len(list) != 0 { + t.Fatalf("expected empty interface list, got %d", len(list)) + } + + // Create + resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML)) + assertStatus(t, resp, http.StatusCreated) + var created struct{ ID string `json:"id"` } + readJSON(t, resp, &created) + if created.ID == "" { + t.Fatal("expected non-empty ID from create") + } + + // Get + resp = get(t, srv, "/api/v1/interfaces/"+created.ID) + assertStatus(t, resp, http.StatusOK) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !bytes.Contains(body, []byte("Test Panel")) { + t.Errorf("GET response missing interface name: %s", body) + } + + // Update + updated := `` + resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID, "application/xml", []byte(updated)) + assertStatus(t, resp, http.StatusNoContent) + + // Verify update + resp = get(t, srv, "/api/v1/interfaces/"+created.ID) + assertStatus(t, resp, http.StatusOK) + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if !bytes.Contains(body, []byte("Updated Panel")) { + t.Errorf("GET after update missing new name: %s", body) + } + + // List — now one entry + resp = get(t, srv, "/api/v1/interfaces") + assertStatus(t, resp, http.StatusOK) + readJSON(t, resp, &list) + if len(list) != 1 { + t.Fatalf("expected 1 interface in list, got %d", len(list)) + } + + // Clone + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/interfaces/"+created.ID+"/clone", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal("clone:", err) + } + assertStatus(t, resp, http.StatusCreated) + var cloned struct{ ID string `json:"id"` } + readJSON(t, resp, &cloned) + if cloned.ID == created.ID { + t.Error("clone produced same ID as original") + } + + // Delete original + resp = deleteReq(t, srv, "/api/v1/interfaces/"+created.ID) + assertStatus(t, resp, http.StatusNoContent) + + // Get after delete → 404 + resp = get(t, srv, "/api/v1/interfaces/"+created.ID) + assertStatus(t, resp, http.StatusNotFound) + + // Delete non-existent → 404 + resp = deleteReq(t, srv, "/api/v1/interfaces/"+created.ID) + assertStatus(t, resp, http.StatusNotFound) +} + +func TestInterfaceInvalidXML(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte("not xml at all")) + assertStatus(t, resp, http.StatusBadRequest) +} + +func TestInterfacePathTraversal(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + // Attempt to read a file outside the storage dir via path traversal in the ID. + resp := get(t, srv, "/api/v1/interfaces/../etc/passwd") + // Should be 404 (invalid ID), not 200 or 500. + if resp.StatusCode == http.StatusOK { + t.Error("path traversal succeeded — expected 404") + } +} + +// ── Synthetic (disabled — synth is nil) ────────────────────────────────────── + +func TestSyntheticDisabled(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := get(t, srv, "/api/v1/synthetic") + assertStatus(t, resp, http.StatusOK) + var out []any + readJSON(t, resp, &out) + if len(out) != 0 { + t.Errorf("expected empty list when synthetic is nil, got %v", out) + } +} + +func TestSyntheticCreateDisabled(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := postJSON(t, srv, "/api/v1/synthetic", map[string]any{"name": "test"}) + assertStatus(t, resp, http.StatusServiceUnavailable) +} + +// ── Channel Finder (not configured) ────────────────────────────────────────── + +func TestChannelFinderNotConfigured(t *testing.T) { + srv, teardown := setup(t) + defer teardown() + + resp := get(t, srv, "/api/v1/channel-finder?q=test") + assertStatus(t, resp, http.StatusNotImplemented) +} + +// ── /healthz ────────────────────────────────────────────────────────────────── + +func TestHealthz(t *testing.T) { + // healthz is registered in server.go, not api.go, so test it directly. + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + assertStatus(t, resp, http.StatusOK) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "ok" { + t.Errorf("healthz body = %q, want ok", body) + } +} + +// ── Storage ID validation ───────────────────────────────────────────────────── + +func TestStorageValidateID(t *testing.T) { + dir := t.TempDir() + store, err := storage.New(dir) + if err != nil { + t.Fatal(err) + } + + // Create a real interface first to get a valid ID. + id, err := store.Create([]byte(sampleXML)) + if err != nil { + t.Fatal("create:", err) + } + + // Valid ID works. + data, err := store.Get(id) + if err != nil { + t.Fatalf("Get(%q): %v", id, err) + } + if len(data) == 0 { + t.Error("expected data, got empty") + } + + // Malicious IDs must be rejected. + malicious := []string{"../etc/passwd", "../../secret", "a/b", "a\x00b", ""} + for _, bad := range malicious { + if _, err := store.Get(bad); err == nil { + t.Errorf("Get(%q) should have failed, got nil error", bad) + } + if err := store.Update(bad, []byte(sampleXML)); err == nil { + t.Errorf("Update(%q) should have failed", bad) + } + if err := store.Delete(bad); err == nil { + t.Errorf("Delete(%q) should have failed", bad) + } + } +} + +// ── Ensure os is used (blank import guard) ───────────────────────────────────── + +var _ = os.DevNull diff --git a/internal/broker/broker_bench_test.go b/internal/broker/broker_bench_test.go new file mode 100644 index 0000000..6652262 --- /dev/null +++ b/internal/broker/broker_bench_test.go @@ -0,0 +1,101 @@ +package broker_test + +import ( + "testing" + "time" + + "github.com/uopi/uopi/internal/broker" +) + +// BenchmarkFanOut measures the end-to-end latency and throughput of the broker +// fan-out with varying numbers of downstream clients. + +func BenchmarkFanOut1Client(b *testing.B) { benchFanOut(b, 1) } +func BenchmarkFanOut10Clients(b *testing.B) { benchFanOut(b, 10) } +func BenchmarkFanOut20Clients(b *testing.B) { benchFanOut(b, 20) } +func BenchmarkFanOut100Clients(b *testing.B) { benchFanOut(b, 100) } + +func benchFanOut(b *testing.B, nClients int) { + b.Helper() + + brk, cancel := newBroker(b) + defer cancel() + + ref := broker.SignalRef{DS: "stub", Name: "counter_fast"} + + // Subscribe N downstream clients. + type sub struct { + ch chan broker.Update + cancel func() + } + subs := make([]sub, nClients) + for i := range subs { + ch := make(chan broker.Update, 512) + cancelFn, err := brk.Subscribe(ref, ch) + if err != nil { + b.Skip("signal not available:", err) + } + subs[i] = sub{ch, cancelFn} + } + defer func() { + for _, s := range subs { + s.cancel() + } + }() + + // Warm-up: wait for the first update to arrive on every client. + timeout := time.After(3 * time.Second) + for _, s := range subs { + select { + case <-s.ch: + case <-timeout: + b.Skip("timed out waiting for first update — signal may not exist in stub") + } + } + + b.ResetTimer() + b.ReportAllocs() + + for range b.N { + // Drain one update from every client channel. + for _, s := range subs { + select { + case <-s.ch: + case <-time.After(2 * time.Second): + b.Fatal("timed out waiting for update") + } + } + } +} + +// newBroker is defined in broker_test.go (same package broker_test) and shared +// across test files. It returns a broker pre-populated with the stub DS. + +// BenchmarkSubscribeUnsubscribe measures the overhead of adding and removing +// a subscription (the fast path — signal is already active). +func BenchmarkSubscribeUnsubscribe(b *testing.B) { + brk, cancel := newBroker(b) + defer cancel() + + ref := broker.SignalRef{DS: "stub", Name: "sine_1hz"} + + // Prime the signal so the fanOut goroutine exists. + ch0 := make(chan broker.Update, 256) + unsub0, err := brk.Subscribe(ref, ch0) + if err != nil { + b.Fatal(err) + } + defer unsub0() + + b.ResetTimer() + b.ReportAllocs() + + for range b.N { + ch := make(chan broker.Update, 16) + unsub, err := brk.Subscribe(ref, ch) + if err != nil { + b.Fatal(err) + } + unsub() + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..91b3a15 --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,82 @@ +// Package metrics provides in-process Prometheus-format instrumentation. +// All counters and gauges use atomic operations and are safe for concurrent use. +package metrics + +import ( + "fmt" + "net/http" + "sync/atomic" + "time" +) + +var startTime = time.Now() + +// Counters and gauges — updated by callers in ws.go and api.go. +var ( + wsConns atomic.Int64 // current open WebSocket connections (gauge) + msgIn atomic.Int64 // total WS messages received (counter) + msgOut atomic.Int64 // total WS messages sent (counter) + writeOps atomic.Int64 // total signal write operations (counter) + historyReqs atomic.Int64 // total history requests served (counter) +) + +// IncWsConns increments the active WebSocket connection gauge. +func IncWsConns() { wsConns.Add(1) } + +// DecWsConns decrements the active WebSocket connection gauge. +func DecWsConns() { wsConns.Add(-1) } + +// IncMsgIn increments the total inbound WS message counter. +func IncMsgIn() { msgIn.Add(1) } + +// IncMsgOut increments the total outbound WS message counter. +func IncMsgOut() { msgOut.Add(1) } + +// IncWrites increments the signal write operation counter. +func IncWrites() { writeOps.Add(1) } + +// IncHistoryReqs increments the history request counter. +func IncHistoryReqs() { historyReqs.Add(1) } + +// Handler returns an http.HandlerFunc that renders Prometheus-format metrics. +// activeSubs is called each request to read the current number of unique signal +// subscriptions from the broker; pass nil to omit the metric. +func Handler(activeSubs func() int) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + + uptime := time.Since(startTime).Seconds() + subs := 0 + if activeSubs != nil { + subs = activeSubs() + } + + fmt.Fprintf(w, "# HELP uopi_uptime_seconds Seconds elapsed since the server started\n") + fmt.Fprintf(w, "# TYPE uopi_uptime_seconds counter\n") + fmt.Fprintf(w, "uopi_uptime_seconds %.3f\n\n", uptime) + + fmt.Fprintf(w, "# HELP uopi_ws_connections Current number of open WebSocket connections\n") + fmt.Fprintf(w, "# TYPE uopi_ws_connections gauge\n") + fmt.Fprintf(w, "uopi_ws_connections %d\n\n", wsConns.Load()) + + fmt.Fprintf(w, "# HELP uopi_signal_subscriptions Current number of unique upstream signal subscriptions\n") + fmt.Fprintf(w, "# TYPE uopi_signal_subscriptions gauge\n") + fmt.Fprintf(w, "uopi_signal_subscriptions %d\n\n", subs) + + fmt.Fprintf(w, "# HELP uopi_ws_messages_in_total Total WebSocket messages received from clients\n") + fmt.Fprintf(w, "# TYPE uopi_ws_messages_in_total counter\n") + fmt.Fprintf(w, "uopi_ws_messages_in_total %d\n\n", msgIn.Load()) + + fmt.Fprintf(w, "# HELP uopi_ws_messages_out_total Total WebSocket messages sent to clients\n") + fmt.Fprintf(w, "# TYPE uopi_ws_messages_out_total counter\n") + fmt.Fprintf(w, "uopi_ws_messages_out_total %d\n\n", msgOut.Load()) + + fmt.Fprintf(w, "# HELP uopi_write_ops_total Total signal write operations\n") + fmt.Fprintf(w, "# TYPE uopi_write_ops_total counter\n") + fmt.Fprintf(w, "uopi_write_ops_total %d\n\n", writeOps.Load()) + + fmt.Fprintf(w, "# HELP uopi_history_requests_total Total historical data requests served\n") + fmt.Fprintf(w, "# TYPE uopi_history_requests_total counter\n") + fmt.Fprintf(w, "uopi_history_requests_total %d\n\n", historyReqs.Load()) + } +} diff --git a/web/src/SyntheticWizard.tsx b/web/src/SyntheticWizard.tsx new file mode 100644 index 0000000..da976a7 --- /dev/null +++ b/web/src/SyntheticWizard.tsx @@ -0,0 +1,182 @@ +import { h } from 'preact'; +import { useState } from 'preact/hooks'; + +interface Props { + onClose: () => void; + onCreated: () => void; +} + +interface NodeParam { + label: string; + key: string; + type: 'number' | 'text'; + default: string; +} + +const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [ + { type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'factor', type: 'number', default: '1' }] }, + { type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] }, + { type: 'moving_avg', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] }, + { type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'n', type: 'number', default: '10' }] }, + { type: 'lowpass', label: 'Lowpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] }, + { type: 'highpass', label: 'Highpass Filter', params: [{ label: 'Cutoff Hz', key: 'cutoff', type: 'number', default: '1' }, { label: 'Sample Hz', key: 'sample_rate', type: 'number', default: '10' }] }, + { type: 'derivative', label: 'Derivative', params: [] }, + { type: 'integral', label: 'Integral', params: [] }, + { type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] }, + { type: 'formula', label: 'Formula', params: [{ label: 'Expression (use "x")', key: 'expr', type: 'text', default: 'x * 1.0' }] }, +]; + +export default function SyntheticWizard({ onClose, onCreated }: Props) { + const [name, setName] = useState(''); + const [inputDs, setInputDs] = useState('stub'); + const [inputSig, setInputSig] = useState('sine_1hz'); + const [nodeType, setNodeType] = useState('gain'); + const [params, setParams] = useState>({}); + const [unit, setUnit] = useState(''); + const [desc, setDesc] = useState(''); + const [dispLow, setDispLow] = useState('0'); + const [dispHigh, setDispHigh] = useState('100'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!; + + function getParam(key: string, def: string): string { + return params[key] ?? def; + } + + function setParam(key: string, val: string) { + setParams(p => ({ ...p, [key]: val })); + } + + async function handleCreate() { + if (!name.trim()) { setError('Name is required'); return; } + if (!inputSig.trim()) { setError('Input signal is required'); return; } + + const nodeParams: Record = {}; + for (const p of nodeDef.params) { + const raw = getParam(p.key, p.default); + nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw; + } + + const def = { + name: name.trim(), + ds: inputDs, + signal: inputSig.trim(), + pipeline: [{ type: nodeType, params: nodeParams }], + meta: { + unit: unit || undefined, + description: desc || undefined, + displayLow: parseFloat(dispLow) || 0, + displayHigh: parseFloat(dispHigh) || 100, + }, + }; + + setSaving(true); + setError(''); + try { + const res = await fetch('/api/v1/synthetic', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(def), + }); + if (!res.ok) { + const j = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(j.error ?? res.statusText); + } + onCreated(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + return ( +
+
e.stopPropagation()}> +
+ New Synthetic Signal + +
+ +
+ {error &&

{error}

} + +
Signal identity
+
+ + setName((e.target as HTMLInputElement).value)} /> +
+ +
Input signal
+
+
+ + setInputDs((e.target as HTMLInputElement).value)} /> +
+
+ + setInputSig((e.target as HTMLInputElement).value)} /> +
+
+ +
Processing
+
+ + +
+ {nodeDef.params.map(p => ( +
+ + setParam(p.key, (e.target as HTMLInputElement).value)} /> +
+ ))} + +
Metadata (optional)
+
+
+ + setUnit((e.target as HTMLInputElement).value)} /> +
+
+ + setDispLow((e.target as HTMLInputElement).value)} /> +
+
+ + setDispHigh((e.target as HTMLInputElement).value)} /> +
+
+
+ + setDesc((e.target as HTMLInputElement).value)} /> +
+
+ + +
+
+ ); +}