phase 10
This commit is contained in:
@@ -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 = `<interface id="" name="Test Panel" version="1" w="800" h="600"></interface>`
|
||||||
|
|
||||||
|
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 := `<interface id="" name="Updated Panel" version="2" w="1024" h="768"></interface>`
|
||||||
|
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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Record<string, string>>({});
|
||||||
|
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<string, any> = {};
|
||||||
|
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 (
|
||||||
|
<div class="wizard-backdrop" onClick={onClose}>
|
||||||
|
<div class="wizard" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="wizard-header">
|
||||||
|
<span>New Synthetic Signal</span>
|
||||||
|
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-body">
|
||||||
|
{error && <p class="wizard-error">{error}</p>}
|
||||||
|
|
||||||
|
<div class="wizard-section-title">Signal identity</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Name</label>
|
||||||
|
<input class="prop-input" value={name}
|
||||||
|
placeholder="e.g. smoothed_pressure"
|
||||||
|
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-section-title">Input signal</div>
|
||||||
|
<div class="wizard-field wizard-field-row">
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Data source</label>
|
||||||
|
<input class="prop-input" value={inputDs}
|
||||||
|
placeholder="stub"
|
||||||
|
onInput={(e) => setInputDs((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field" style="flex:2;">
|
||||||
|
<label>Signal name</label>
|
||||||
|
<input class="prop-input" value={inputSig}
|
||||||
|
placeholder="sine_1hz"
|
||||||
|
onInput={(e) => setInputSig((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-section-title">Processing</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Node type</label>
|
||||||
|
<select class="prop-select" value={nodeType}
|
||||||
|
onChange={(e) => { setNodeType((e.target as HTMLSelectElement).value); setParams({}); }}>
|
||||||
|
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{nodeDef.params.map(p => (
|
||||||
|
<div key={p.key} class="wizard-field">
|
||||||
|
<label>{p.label}</label>
|
||||||
|
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
|
||||||
|
value={getParam(p.key, p.default)}
|
||||||
|
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div class="wizard-section-title">Metadata (optional)</div>
|
||||||
|
<div class="wizard-field wizard-field-row">
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Unit</label>
|
||||||
|
<input class="prop-input" value={unit} placeholder="m/s"
|
||||||
|
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Display low</label>
|
||||||
|
<input class="prop-input" type="number" value={dispLow}
|
||||||
|
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Display high</label>
|
||||||
|
<input class="prop-input" type="number" value={dispHigh}
|
||||||
|
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wizard-field">
|
||||||
|
<label>Description</label>
|
||||||
|
<input class="prop-input" value={desc} placeholder="Optional description"
|
||||||
|
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wizard-footer">
|
||||||
|
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||||
|
<button class="toolbar-btn toolbar-btn-primary" onClick={handleCreate} disabled={saving}>
|
||||||
|
{saving ? 'Creating…' : 'Create Signal'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user