diff --git a/docs/design/route-visualizer.md b/docs/design/route-visualizer.md new file mode 100644 index 0000000000..add7ac19d2 --- /dev/null +++ b/docs/design/route-visualizer.md @@ -0,0 +1,168 @@ +# Route Visualizer + Route-Selection UI + +Status: **design + first-increment scaffold**. This document describes a +ROUTE-oriented view for skywire — distinct from, but modeled on, the existing +network (transport-graph) visualizer — and lands a minimal working slice that +proves the live per-leg data pipeline into a browser. + +## 1. Motivation + +The network visualizer (`pkg/tpviz`, mounted at `/tp-viz`, and the Angular +`network-visualizer` host that embeds its bundle) renders the **transport +graph**: undirected edges between visor nodes, keyed by transport type. It is a +topology view. It does *not* render **routes** — the actual multi-hop +forward/reverse paths a session uses to reach a destination. + +We now have rich per-route, per-leg data that nothing visual consumes: + +- The adaptive mux runs **multiple parallel legs** (routes) inside one route + group, each with its own transport, latency, and live byte/packet counters. +- Legs carry a **gate state**: `alive` (transport up) and `standby` (a warm + standby — rules kept, not currently sending; see + `docs/warm_standby_legs_rfc.md`). The routing-policy engine promotes/demotes + these adaptively. +- Operators already actuate route sets from the CLI + (`cli proxy mux add/rm/set/grow`, `cli route calc|find`), and the + routing-policy `ForwardMux`/`ReverseMux` surfaces pick legs by policy. + +The goal is a view that, for a chosen destination/app, shows the route group's +legs as paths — per-leg next-hop/intermediate PKs, transport kind, latency, +bandwidth, and active/standby/dead state — updating live, with a seam for a +future route-**selection** UI (pick/pin which route(s) a session uses, exclude +hops). + +## 2. Available data (what already exists) + +The data plane is essentially complete; the gap is *exposure* and *rendering*. + +| Data | Type | Reachable via | Notes | +|------|------|---------------|-------| +| Per-leg live telemetry | `router.MuxInfo` / `visor.MuxRouteGroupInfo` + `MuxLegInfo` | `Visor.RouteGroupMuxInfo(app)` RPC | per-leg tp id/type, next-hop `remote_pk`, `latency_ms`, sent/recv bytes+packets, `retransmits`, `alive`, `standby` | +| Active routes by app | `visor.AppRouteStatus` / `router.RouteStatus` | `Visor.ActiveRoutes()` RPC | per-app; rg bandwidth up/down, `RouteTransport[]` (per-leg fwd/rev rule IDs + latency), `Hops[]` | +| Route groups + hop path | `visor.RouteGroupInfo` + `RouteHopInfo` | `Visor.RouteGroups()` RPC → **HTTP** `/api/visors/{pk}/routegroups` | descriptor, initiator, fwd/consume rule IDs, forward hop path | +| Route candidates | route-finder / local BFS | HTTP `/api/visors/{pk}/route-find`, `/route-calc` | hop lists ready to draw (candidate routes) | +| Routing policy state | `RoutingPoliciesSummary` | HTTP `/api/visors/{pk}/routing-policies` | default + per-app policy | +| Actuation | — | RPC `AddMuxRoute` / `RemoveMuxRoute` / `GrowMuxRoute`; `cli proxy mux add/rm/grow`; policy `ForwardMux`/`ReverseMux` | the seam for route-selection | + +Key source files: + +- `pkg/router/route_group.go` — `MuxInfo`, `MuxLeg`, `MuxStats()`, + `RouteHopDetails()`. +- `pkg/router/route_status.go` — `RouteStatus`, `RouteTransport`, + `ActiveRouteStatuses()`. +- `pkg/visor/api_routing.go` — `RouteGroupMuxInfo`, `ActiveRoutes`, + `RouteGroups`, `AddMuxRoute`, `GrowMuxRoute`, `RemoveMuxRoute`. +- `pkg/visor/rpc.go` / `api.go` — wire types (`MuxRouteGroupInfo`, + `MuxLegInfo`, `RouteGroupInfo`, `RouteHopInfo`, `AppRouteStatus`). +- `pkg/visor/hypervisor.go` + `hypervisor_handlers_routes.go` — HTTP surface. +- `cmd/skywire-cli/commands/proxy/mux_info.go` — the CLI's per-leg render + + byte-delta rate tracker (reference model for the page). + +### Two data gaps + +1. **No HTTP seam for live per-leg telemetry.** `RouteGroupMuxInfo` and + `ActiveRoutes` were RPC-only; `/routegroups` gives the static hop path but + not the live mux legs. → **Closed by this increment** (new + `/api/visors/{pk}/route-mux`). +2. **No per-leg full path.** `RouteHopDetails()` records only the *primary* + leg's `forwardHops`; secondary mux legs expose only their **next-hop** + `remote_pk`, not their full intermediate-PK chain. A true per-leg + path-over-graph render needs the router to record each leg's hop list. + This lives in `pkg/router` and is **designed-only / deferred** here. + +## 3. Design + +### 3.1 The route view + +For a selected **(visor, app)** — and, when disambiguation is needed, a +specific route group by src-port: + +- **Route-group cards.** One card per active rg: `src:port → dst:port`, mux + on/off, SACK on/off, leg count. +- **Per-leg rows.** leg index · transport-type badge + short tp id · next-hop + PK · latency · recv/s · sent/s · **share bar** (this leg's fraction of rg + throughput) · retransmits · **gate-state pill** (active / warm-standby / + dead) · cumulative recv/sent. Rates are derived from byte deltas between + polls, keyed by `rg-descriptor#leg-index` (the model already proven in + `mux_info.go`). +- **Per-leg mini-path.** `SRC ──(tp)──▶ next-hop ┄┄▶ DST`. Upgrades to a full + intermediate-PK chain once gap #2 is closed. + +**Full feature (designed):** a route-centric *graph* layout — the rg's legs +drawn as coloured paths over the node graph (active solid, standby dashed, +dead greyed), reusing tpviz's node-positioning. Candidate routes from +`/route-find` and `/route-calc` overlay as selectable ghost paths. Live +recolour from the same poll. + +### 3.2 The route-selection seam (designed) + +The view is the read side of a select/actuate loop whose write side already +exists: + +- **Pin / add a leg:** `AddMuxRoute(app, fwd, rev, srcPort)` — hop lists have + the exact shape `cli route calc --json` emits. UI: pick a candidate path + from the `/route-find|/route-calc` overlay → POST it as a new leg. +- **Grow for redundancy:** `GrowMuxRoute(app, target, minHops, srcPort)`. +- **Drop / exclude a leg or hop:** `RemoveMuxRoute(app, tpID, srcPort)`; + hop-exclusion is expressed to the route planner as a constraint (future). +- **Policy-level pin:** the routing-policy `ForwardMux`/`ReverseMux` primitives + express "prefer/require these legs" declaratively; the UI can emit a policy + rather than one-shot mux edits. (Policy authoring is owned by another effort + — this view only *reads* policy state and offers the imperative mux edits.) + +New HTTP writes needed for the full selection UI (thin wrappers over existing +RPC, mirroring `postMinHops`): `POST /api/visors/{pk}/route-mux` (add leg), +`DELETE …/route-mux/{tpID}` (drop leg), `POST …/route-mux/grow`. Not in this +increment. + +### 3.3 Where it belongs + +| Option | Fit | Cost | +|--------|-----|------| +| **Angular HV UI** (`routing.component.ts` already models `groups/find/calc/policies`) | Natural long-term home; where routes are managed and where selection/actuation belongs | Heavy: `make build-ui` (minutes) + committed bundle gated by `make check-ui`; eager (non-lazy) modules | +| **tpviz bundle** (`pkg/tpviz/ui`) | Owns node-graph rendering → best for the path-over-graph layout | Large TS/esbuild SPA; separate committed bundle | +| **Standalone visor-served page** (`/route-viz`) | Zero build step, self-contained, instantly iterable | Not integrated into the SPA chrome | +| wasm-visor UI | Browser-native visor context | wasm build/size overhead | + +**Recommendation.** Land the **standalone `/route-viz` page** first (this +increment) to prove the pipeline with no build cost, then graduate the +route-**selection** actuation + the path-over-graph layout into the **Angular +`routing` page** (its `RoutingView` already has a slot; add a `'mux'` / +`'routeviz'` view and reuse `route.service.ts`), embedding the tpviz bundle for +the graph exactly as `network-visualizer.component.ts` does. The standalone +page remains the lightweight/headless diagnostic surface. + +## 4. First increment (this PR) — what is scaffolded vs designed + +**Scaffolded (working):** + +- `GET /api/visors/{pk}/route-mux?app=` — new hypervisor HTTP handler + (`getRouteMux`, `pkg/visor/hypervisor_handlers_routes.go`) wrapping + `RouteGroupMuxInfo`. First browser-reachable seam for live per-leg route + telemetry. Read-only. +- `/route-viz` — self-contained embedded page (`pkg/visor/routeviz/routeviz.html`, + served by `getRouteViz`, `pkg/visor/routeviz_embed.go`). Visor + app pickers, + live poll, per-leg rows with rate/share/gate-state, per-leg mini-path. No + Angular/tpviz rebuild. + +**Designed-only (deferred):** + +- Route-over-graph layout (needs tpviz node positioning + gap #2). +- Per-leg full intermediate-PK path (needs `pkg/router` to record each leg's + hop list — out of scope; that package is owned elsewhere). +- Route-**selection** write actions (add/drop/grow legs, hop exclusion) and + their HTTP wrappers. +- Candidate-route overlay from `/route-find` + `/route-calc`. +- Promotion into the Angular `routing` page. + +## 5. Try it + +``` +# on a hypervisor/visor with the manager UI: +# open http://:8000/route-viz +# pick a visor + app (default skysocks-client), watch legs update live +# raw data: +curl -s http:///api/visors//route-mux?app=skysocks-client | jq +# CLI equivalent (the render model this page mirrors): +skywire-cli proxy mux info -n skysocks-client --watch 1s +``` diff --git a/pkg/visor/hypervisor.go b/pkg/visor/hypervisor.go index 3396530415..228c481f06 100644 --- a/pkg/visor/hypervisor.go +++ b/pkg/visor/hypervisor.go @@ -854,6 +854,7 @@ func (hv *Hypervisor) makeMux() chi.Router { r.Delete("/visors/{pk}/routes/{rid}", hv.deleteRoute()) r.Delete("/visors/{pk}/routes/", hv.deleteRoutes()) r.Get("/visors/{pk}/routegroups", hv.getRouteGroups()) + r.Get("/visors/{pk}/route-mux", hv.getRouteMux()) r.Get("/visors/{pk}/routing-policies", hv.getRoutingPolicies()) r.Post("/visors/{pk}/shutdown", hv.shutdown()) r.Post("/visors/{pk}/restart", hv.restart()) @@ -1074,6 +1075,14 @@ func (hv *Hypervisor) makeMux() chi.Router { r.Get("/api/dmsg/health", tpvHandler.ServeHTTP) } + // Route visualizer: a self-contained static page (no Angular build) + // that live-renders an app's active route group(s) per-leg from the + // /api/visors/{pk}/route-mux endpoint. Mounted before the SPA catch-all + // so /route-viz is claimed here; the page fetches the authed /api same + // origin. See docs/design/route-visualizer.md. + r.Get("/route-viz", hv.getRouteViz()) + r.Get("/route-viz/", hv.getRouteViz()) + // HV-served skycoin wallet: embedded /wallet/ static + node API proxied // over the visor's dmsg client — no skycoin-web process/port, the native // equivalent of the wasm visor's /wallet/. Mounted before the UI diff --git a/pkg/visor/hypervisor_handlers_routes.go b/pkg/visor/hypervisor_handlers_routes.go index 1caa451496..0c7e10fb3b 100644 --- a/pkg/visor/hypervisor_handlers_routes.go +++ b/pkg/visor/hypervisor_handlers_routes.go @@ -192,6 +192,36 @@ func (hv *Hypervisor) getRouteGroups() http.HandlerFunc { }) } +// getRouteMux surfaces live per-mux-leg route telemetry for one app's +// active route group(s) over HTTP — the browser-reachable seam behind the +// route visualizer (/route-viz). It wraps the visor's RouteGroupMuxInfo RPC, +// which until now was only reachable from `cli proxy mux info`. +// +// GET /api/visors/{pk}/route-mux?app= (app defaults to skysocks-client) +// +// Each returned route group carries its descriptor plus a per-leg list with +// transport id/type, next-hop PK, smoothed latency, rg-scoped byte/packet +// counters, SACK retransmits, and the leg's alive/standby gate state — enough +// for the page to render the legs live and derive throughput from the byte +// deltas between polls. +func (hv *Hypervisor) getRouteMux() http.HandlerFunc { + return hv.withCtx(hv.visorCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { + app := r.URL.Query().Get("app") + if app == "" { + app = "skysocks-client" + } + infos, err := ctx.API.RouteGroupMuxInfo(app) + if err != nil { + httputil.WriteJSON(w, r, http.StatusInternalServerError, err) + return + } + if infos == nil { + infos = []MuxRouteGroupInfo{} + } + httputil.WriteJSON(w, r, http.StatusOK, infos) + }) +} + // getRoutingPolicies surfaces the installed routing-policy state // for the hypervisor UI's routing tab. Returns // {default?, per_app: {appName: PolicyInfo}}. diff --git a/pkg/visor/routeviz/routeviz.html b/pkg/visor/routeviz/routeviz.html new file mode 100644 index 0000000000..5bab31b157 --- /dev/null +++ b/pkg/visor/routeviz/routeviz.html @@ -0,0 +1,214 @@ + + + + + +Skywire Route Visualizer + + + +
+

Route Visualizer scaffold

+
+
+
+
s
+
+
+
+
+
+ starting… + +
+
+
+ What this shows. Each card is one active route group for the selected app; each row is a + mux leg — a parallel route to the same destination. Rates are derived from the byte deltas between + polls (rg-scoped, this visor's view). Gate state: active + warm standby dead.
+ Scaffold scope. Data source is the live /api/visors/{pk}/route-mux endpoint + (RouteGroupMuxInfo). Per-leg rows show the leg's next-hop PK + transport; full multi-hop + per-leg paths and a route-over-graph layout are designed but not yet wired + (see docs/design/route-visualizer.md). +
+
+ + + diff --git a/pkg/visor/routeviz_embed.go b/pkg/visor/routeviz_embed.go new file mode 100644 index 0000000000..96e52e02d2 --- /dev/null +++ b/pkg/visor/routeviz_embed.go @@ -0,0 +1,25 @@ +// Package visor pkg/visor/routeviz_embed.go c3-vis-core +package visor + +import ( + _ "embed" + "net/http" +) + +// routeVizHTML is the self-contained route-visualizer page (vanilla JS, no +// build step). Served at /route-viz; it fetches /api/visors and +// /api/visors/{pk}/route-mux same-origin to live-render an app's active +// route group(s) per leg. See docs/design/route-visualizer.md. +// +//go:embed routeviz/routeviz.html +var routeVizHTML []byte + +// getRouteViz serves the embedded route-visualizer page. Static, no auth of +// its own — the data it fetches (/api/...) carries the hypervisor's auth. +func (hv *Hypervisor) getRouteViz() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(routeVizHTML) + } +} diff --git a/pkg/visor/routeviz_embed_test.go b/pkg/visor/routeviz_embed_test.go new file mode 100644 index 0000000000..ead45d0e23 --- /dev/null +++ b/pkg/visor/routeviz_embed_test.go @@ -0,0 +1,37 @@ +//go:build !mobile + +package visor + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestGetRouteViz checks the embedded route-visualizer page is served with the +// right content-type and carries its data-endpoint + telemetry field wiring, so +// a rename of the /route-mux seam or the MuxLegInfo JSON tags is caught here. +func TestGetRouteViz(t *testing.T) { + hv := &Hypervisor{} + w := httptest.NewRecorder() + hv.getRouteViz()(w, httptest.NewRequest(http.MethodGet, "/route-viz", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d, want 200", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type=%q, want text/html", ct) + } + body := w.Body.String() + for _, want := range []string{ + "/api/visors/", // visor list fetch + "/route-mux?app=", // the live per-leg data endpoint + "remote_pk", "latency_ms", // MuxLegInfo JSON contract the page renders + "standby", "retransmits", // gate-state + loss signal + } { + if !strings.Contains(body, want) { + t.Errorf("route-viz page missing %q", want) + } + } +}