Fix onboard WebKit preview and archive board telemetry locally

This commit is contained in:
DCCONSTRUCTIONS
2026-09-08 01:37:52 +03:00
parent d8afb61229
commit f0802d2713
46 changed files with 2623 additions and 40 deletions
@@ -0,0 +1,73 @@
package node
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
"time"
)
// Local WebKit requires completed bounded HTTP responses. The same plugin
// delivery owns RRD/fMP4 framing, backpressure, resume and acquisition isolation.
func (s *Server) localPreviewRoutes(mux *http.ServeMux) {
capacity := make(chan struct{}, 2)
for _, route := range []string{"/api/devices/preview", "/api/devices/preview/read"} {
mux.HandleFunc("POST "+route, func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var value json.RawMessage
if !decode(w, r, &value) {
return
}
if s.DeviceEnrollment == nil {
http.Error(w, "Preview unavailable", 503)
return
}
path := "/local-preview/read"
if r.URL.Path == "/api/devices/preview" {
var command SensorCommand
if json.Unmarshal(value, &command) != nil || command.Action != "offer" || !sensorID.MatchString(command.Session.DeviceID) || !strings.HasPrefix(command.Session.DeviceID, "k1_") {
http.Error(w, "Invalid preview", 400)
return
}
path = "/local-preview"
}
select {
case capacity <- struct{}{}:
defer func() { <-capacity }()
default:
http.Error(w, "Close another viewer", 429)
return
}
request, err := http.NewRequestWithContext(r.Context(), "POST", "http://k1"+path, bytes.NewReader(value))
if err != nil {
http.Error(w, "Invalid preview", 400)
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Node-Id", s.DeviceEnrollment.nodeID)
client := &http.Client{Transport: s.DeviceEnrollment.client.Transport, Timeout: 5 * time.Second}
response, err := client.Do(request)
if err != nil {
http.Error(w, "Preview unavailable", 503)
return
}
defer response.Body.Close()
if response.StatusCode != 200 {
http.Error(w, "Preview unavailable", response.StatusCode)
return
}
data, err := io.ReadAll(io.LimitReader(response.Body, 196609))
if err != nil || len(data) > 196608 {
http.Error(w, "Preview unavailable", 503)
return
}
w.Header().Set("Content-Type", "application/vnd.missioncore.preview")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
})
}
}
+159
View File
@@ -0,0 +1,159 @@
package node
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strconv"
"sync"
"time"
)
// The collector owns sampling/storage. This bounded cache keeps monitoring I/O
// out of the paired control heartbeat and out of acquisition lifecycles.
type Monitor struct {
mu sync.Mutex
client *http.Client
source string
after int64
sent int64
value map[string]any
}
func NewMonitor() *Monitor {
return &Monitor{client: &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-monitor/monitor.sock")
}}}}
}
func (m *Monitor) read(ctx context.Context, path string, body []byte) (map[string]any, error) {
method := "GET"
if body != nil {
method = "POST"
}
r, e := http.NewRequestWithContext(ctx, method, "http://monitor"+path, bytes.NewReader(body))
if e != nil {
return nil, e
}
r.Header.Set("Content-Type", "application/json")
response, e := m.client.Do(r)
if e != nil {
return nil, e
}
defer response.Body.Close()
if response.StatusCode != 200 {
return nil, errors.New("monitor unavailable")
}
data, e := io.ReadAll(io.LimitReader(response.Body, 262145))
if e != nil || len(data) > 262144 {
return nil, errors.New("monitor response budget")
}
var value map[string]any
e = json.Unmarshal(data, &value)
return value, e
}
func (m *Monitor) Run(ctx context.Context) {
defer m.client.CloseIdleConnections()
timer := time.NewTicker(2 * time.Second)
defer timer.Stop()
for {
status, e := m.read(ctx, "/status", nil)
if e == nil {
source, _ := status["source_id"].(string)
m.mu.Lock()
if source != m.source {
m.source = source
m.after = 0
m.sent = 0
}
after := m.after
m.mu.Unlock()
batch, be := m.read(ctx, "/batch?after="+strconv.FormatInt(after, 10), nil)
if be == nil && batch["source_id"] == source {
status["batch"] = batch
}
m.mu.Lock()
m.value = status
m.mu.Unlock()
}
select {
case <-ctx.Done():
return
case <-timer.C:
}
}
}
func (m *Monitor) Snapshot() map[string]any {
m.mu.Lock()
defer m.mu.Unlock()
if m.value == nil {
return map[string]any{"storage": "unavailable"}
}
if batch, ok := m.value["batch"].(map[string]any); ok {
if rows, ok := batch["samples"].([]any); ok {
for _, raw := range rows {
if row, ok := raw.(map[string]any); ok {
if seq, ok := row["seq"].(float64); ok && int64(seq) > m.sent {
m.sent = int64(seq)
}
}
}
}
}
return m.value
}
func (m *Monitor) Acknowledge(raw json.RawMessage) {
var ack struct {
Source string `json:"source_id"`
After int64 `json:"after"`
}
if json.Unmarshal(raw, &ack) != nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
// A receiver may already have a later cursor from before the Node restarted.
// Its authenticated ACK is bounded by the collector's durable latest record.
latest := m.sent
if row, ok := m.value["latest"].(map[string]any); ok {
if seq, ok := row["seq"].(float64); ok {
latest = int64(seq)
}
}
if ack.Source == m.source && ack.After >= m.after && ack.After <= latest {
m.after = ack.After
}
}
func (s *Server) monitorRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/monitor", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
if s.Monitor == nil {
reply(w, 503, map[string]string{"error": "Мониторинг недоступен"})
return
}
reply(w, 200, s.Monitor.Snapshot())
})
mux.HandleFunc("POST /api/monitor/events", func(w http.ResponseWriter, r *http.Request) {
if !s.authorized(w, r) {
return
}
var value json.RawMessage
if !decode(w, r, &value) {
return
}
if len(value) > 2048 || s.Monitor == nil {
http.Error(w, "Monitoring unavailable", 503)
return
}
if _, e := s.Monitor.read(r.Context(), "/events", value); e != nil {
http.Error(w, "Monitoring unavailable", 503)
return
}
reply(w, 200, map[string]bool{"ok": true})
})
}
@@ -0,0 +1,50 @@
package node
import (
"encoding/json"
"testing"
)
func TestMonitorAcknowledgementsRequireSameArchiveAndDurableBounds(t *testing.T) {
m := NewMonitor()
m.source = "archive-one"
m.value = map[string]any{"latest": map[string]any{"seq": float64(100)}}
for _, raw := range []string{`{"source_id":"another","after":50}`, `{"source_id":"archive-one","after":101}`, `{"source_id":"archive-one","after":-1}`} {
m.Acknowledge(json.RawMessage(raw))
if m.after != 0 {
t.Fatal("accepted invalid cursor")
}
}
m.Acknowledge(json.RawMessage(`{"source_id":"archive-one","after":80}`))
if m.after != 80 {
t.Fatal("did not restore committed receiver cursor after restart")
}
m.Acknowledge(json.RawMessage(`{"source_id":"archive-one","after":10}`))
if m.after != 80 {
t.Fatal("regressed cursor")
}
}
func TestMonitorAndPreviewCannotBypassNodeSession(t *testing.T) {
s := newTestServer(t)
for _, path := range []string{"/api/devices/preview", "/api/devices/preview/read", "/api/monitor/events"} {
if got := call(s, "POST", path, `{}`, nil).Code; got != 401 {
t.Fatalf("%s: %d", path, got)
}
}
if got := call(s, "GET", "/api/monitor", "", nil).Code; got != 401 {
t.Fatal(got)
}
cookie := login(t, s)
if got := call(s, "POST", "/api/devices/preview", `{}`, cookie).Code; got != 503 {
t.Fatal(got)
}
s.Monitor = NewMonitor()
if got := call(s, "GET", "/api/monitor", "", cookie).Code; got != 200 {
t.Fatal(got)
}
// An unavailable collector remains independent from the authenticated UI.
if got := call(s, "GET", "/api/status", "", cookie).Code; got != 200 {
t.Fatal(got)
}
}
+1
View File
@@ -39,6 +39,7 @@ type PairState struct {
Revocations []CoreBinding `json:"revocations,omitempty"`
}
type Pairing struct {
Monitor *Monitor
Sensors *Sensors
DeviceEnrollment *DeviceEnrollment
mu sync.Mutex
@@ -286,10 +286,16 @@ func (p *Pairing) channel(ctx context.Context) {
payload["device_enrollment"] = p.DeviceEnrollment.Status()
payload["enrollment_results"] = p.DeviceEnrollment.RemoteResults()
}
if p.Monitor != nil {
payload["monitor"] = p.Monitor.Snapshot()
}
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
p.mu.Lock()
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
if e == nil && status == 200 {
if p.Monitor != nil {
p.Monitor.Acknowledge(result["monitor_ack"])
}
p.connection = "online"
p.lastSeen = p.now().Unix()
if p.DeviceEnrollment != nil {
+3
View File
@@ -13,6 +13,7 @@ import (
)
type Server struct {
Monitor *Monitor
Store *Store
Pairing *Pairing
Sensors *Sensors
@@ -81,6 +82,8 @@ func reply(w http.ResponseWriter, status int, v any) {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
s.localPreviewRoutes(mux)
s.monitorRoutes(mux)
if s.Presentation != nil {
s.presentationRoutes(mux)
}