From 7c607ba3ac87377f2f2a1bd4febb09d9e5c46143 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 5 Jul 2026 17:28:16 -0400 Subject: [PATCH] Add region config to console page --- internal/core/confirm.go | 85 +++-- internal/core/console.go | 44 ++- internal/core/console_config.go | 171 +++++++++ internal/core/console_config_test.go | 194 ++++++++++ internal/core/console_integration_test.go | 8 + internal/core/templates/console.html | 50 ++- internal/core/web.go | 2 + internal/e2e/console_config_test.go | 123 ++++++ internal/store/config_profiles_test.go | 80 ++++ internal/store/org_repeaters.go | 43 +++ internal/web/static/console-config.js | 441 ++++++++++++++++++++++ internal/web/static/console.js | 66 +++- internal/web/static/regionmap.js | 3 + 13 files changed, 1253 insertions(+), 57 deletions(-) create mode 100644 internal/core/console_config.go create mode 100644 internal/core/console_config_test.go create mode 100644 internal/e2e/console_config_test.go create mode 100644 internal/web/static/console-config.js diff --git a/internal/core/confirm.go b/internal/core/confirm.go index 27b9504..4488c95 100644 --- a/internal/core/confirm.go +++ b/internal/core/confirm.go @@ -223,49 +223,56 @@ func (s *Handlers) wsConfirm(w http.ResponseWriter, r *http.Request) { } _ = bridge.Status("confirmed", "Repeater reached with admin access. ✓") - // Fetch and store the repeater's location. Each coordinate is a separate query - // so progress (and retries) are visible. - { - fetchCoord := func(label, cmd string, accept func(text string) bool) (float64, bool) { - reply, err := ex.CommandAccept(ctx, cmd, accept, func(attempt, max int) { - if attempt == 1 { - _ = bridge.Status("info", "Fetching "+label+"…") - } else { - _ = bridge.Status("info", fmt.Sprintf("Fetching %s — retry %d/%d…", label, attempt, max)) - } - }) - if err != nil { - return 0, false - } - return parseLocationFloat(reply) - } - lat, okLat := fetchCoord("latitude", "get lat", nil) - // A slow latitude fetch is retried, which makes the repeater re-run "get - // lat" and emit duplicate replies; one can straggle in during the "get - // lon" wait and be misread as the longitude (storing lat,lat). Since the - // two coordinates differ, reject a longitude reply whose value equals the - // latitude we just read and keep waiting for the genuine reply. - lon, okLon := fetchCoord("longitude", "get lon", func(text string) bool { - f, ok := parseLocationFloat(text) - if ok && okLat && f == lat { - if debug { - _ = bridge.Status("debug", "ignored a stale 'get lat' reply while awaiting longitude") - } - return false - } - return true - }) - if okLat && okLon { - if err := s.Store.SetRepeaterLocation(ctx, id, lat, lon); err != nil { - web.LogError(r, "confirm: store location", err, "repeater_id", id) - _ = bridge.Status("error", "could not store location: "+err.Error()) + s.fetchAndStoreLocation(ctx, r, ex, bridge, id, debug) +} + +// fetchAndStoreLocation queries the connected repeater for its coordinates +// ("get lat"/"get lon", each retried) and persists them. It reports progress via +// bridge.Status and returns the stored coordinates (ok=false if either read +// failed). Shared by the confirm flow and the console's confirm-on-connect so +// both capture location the same way. Requires admin access (guests can't run the +// get commands) — callers must gate on that first. +func (s *Handlers) fetchAndStoreLocation(ctx context.Context, r *http.Request, ex *mesh.Exchanger, bridge *wsbridge.Conn, id int64, debug bool) (lat, lon float64, ok bool) { + fetchCoord := func(label, cmd string, accept func(text string) bool) (float64, bool) { + reply, err := ex.CommandAccept(ctx, cmd, accept, func(attempt, max int) { + if attempt == 1 { + _ = bridge.Status("info", "Fetching "+label+"…") } else { - _ = bridge.Status("info", fmt.Sprintf("Stored location: %.5f, %.5f", lat, lon)) + _ = bridge.Status("info", fmt.Sprintf("Fetching %s — retry %d/%d…", label, attempt, max)) } - } else { - _ = bridge.Status("warning", "Could not read a location from the repeater.") + }) + if err != nil { + return 0, false } + return parseLocationFloat(reply) } + lat, okLat := fetchCoord("latitude", "get lat", nil) + // A slow latitude fetch is retried, which makes the repeater re-run "get lat" + // and emit duplicate replies; one can straggle in during the "get lon" wait and + // be misread as the longitude (storing lat,lat). Since the two coordinates + // differ, reject a longitude reply whose value equals the latitude we just read + // and keep waiting for the genuine reply. + lon, okLon := fetchCoord("longitude", "get lon", func(text string) bool { + f, ok := parseLocationFloat(text) + if ok && okLat && f == lat { + if debug { + _ = bridge.Status("debug", "ignored a stale 'get lat' reply while awaiting longitude") + } + return false + } + return true + }) + if !okLat || !okLon { + _ = bridge.Status("warning", "Could not read a location from the repeater.") + return 0, 0, false + } + if err := s.Store.SetRepeaterLocation(ctx, id, lat, lon); err != nil { + web.LogError(r, "confirm: store location", err, "repeater_id", id) + _ = bridge.Status("error", "could not store location: "+err.Error()) + return 0, 0, false + } + _ = bridge.Status("info", fmt.Sprintf("Stored location: %.5f, %.5f", lat, lon)) + return lat, lon, true } // parseLocationFloat parses a "get lat"/"get lon" reply like "> 37.7749". diff --git a/internal/core/console.go b/internal/core/console.go index f428f27..0e7adfe 100644 --- a/internal/core/console.go +++ b/internal/core/console.go @@ -152,9 +152,17 @@ func (s *Handlers) pageConsole(w http.ResponseWriter, r *http.Request) { } allowed := s.allowedCommands(r.Context(), rep, uid, catalog) sort.Slice(allowed, func(i, j int) bool { return allowed[i].Template < allowed[j].Template }) + // Only offer the "Apply organization configuration" action when this repeater + // actually participates in an org that has a saved configuration. + configOrgs, err := s.Store.ListRepeaterConfigOrgs(r.Context(), rep.ID) + if err != nil { + s.ServerError(w, r, "could not load organizations", err) + return + } s.Render(w, r, "console.html", map[string]any{ - "Repeater": rep, - "Commands": allowed, + "Repeater": rep, + "Commands": allowed, + "ShowConfig": len(configOrgs) > 0, }) } @@ -224,6 +232,7 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { ready := make(chan struct{}, 1) cmdCh := make(chan string, 8) + locCh := make(chan struct{}, 1) // "getloc" requests: fetch the device's coordinates go func() { for { @@ -256,6 +265,11 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { default: _ = bridge.Status("error", "busy — wait for the previous command") } + case "getloc": + select { + case locCh <- struct{}{}: + default: + } } } } @@ -293,8 +307,22 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { _ = bridge.Status("warning", "Couldn't reach the repeater to establish a session — commands will still be attempted (flood), but may not work if it doesn't recognize MeshTender.") case err != nil: return // context cancelled - case userPathSet: - reportPathOutcome(bridge, lr) + default: + if userPathSet { + reportPathOutcome(bridge, lr) + } + // A successful admin login proves we reached the repeater, so treat connecting + // from the console as a confirmation (the same as the dedicated confirm flow). + // This is cheap — no extra packets. Fetching the location is deferred to an + // explicit "getloc" request (below) so a plain console session doesn't pay for + // a location round-trip it doesn't need. + if lr.IsAdmin { + if err := s.Store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil { + web.LogError(r, "console: save confirmation", err, "repeater_id", id) + } else { + _ = bridge.Status("confirmed", "Repeater confirmed with admin access. ✓") + } + } } _ = bridge.Status("info", "Connected. Ready for commands.") @@ -353,6 +381,14 @@ func (s *Handlers) wsConsole(w http.ResponseWriter, r *http.Request) { return case text := <-cmdCh: runCommand(text) + case <-locCh: + // Handled in the same loop as commands so it never drives the exchanger + // concurrently with a command. Emits a "location" status on success so an + // open config panel refreshes its region commands. + idle.Reset(consoleIdleTimeout) + if _, _, ok := s.fetchAndStoreLocation(ctx, r, ex, bridge, id, false); ok { + _ = bridge.Status("location", "Location updated from the repeater.") + } } } } diff --git a/internal/core/console_config.go b/internal/core/console_config.go new file mode 100644 index 0000000..019dd5d --- /dev/null +++ b/internal/core/console_config.go @@ -0,0 +1,171 @@ +package core + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/jleight/meshtender/internal/store" + "github.com/jleight/meshtender/internal/web" +) + +// consoleConfig is the JSON payload behind the console's "Apply organization +// configuration" modal: the orgs whose config applies to this repeater, the +// selected org/profile, the full recommended-command list (profile base settings +// + region commands), and the location state that drives the region commands. +type consoleConfig struct { + Orgs []store.RepeaterConfigOrg `json:"orgs"` + SelectedOrg int64 `json:"selectedOrg"` + SelectedProfile string `json:"selectedProfile"` + Commands []consoleConfigCommand `json:"commands"` + Location consoleConfigLocation `json:"location"` +} + +// consoleConfigCommand is one line of the recommended configuration. Every line +// is shown; Runnable=false marks a line the user can't send (a note, or a command +// they lack permission for), with Reason explaining why. +type consoleConfigCommand struct { + Line string `json:"line"` // CLI text; empty for a comment-only note + Comment string `json:"comment,omitempty"` // optional note text + Kind string `json:"kind"` // "profile" | "region" + Runnable bool `json:"runnable"` + Reason string `json:"reason,omitempty"` // why it isn't runnable +} + +// consoleConfigLocation reports what we know about the repeater's location, which +// the region commands depend on. +type consoleConfigLocation struct { + Known bool `json:"known"` + Lat *float64 `json:"lat,omitempty"` + Lon *float64 `json:"lon,omitempty"` + NeedsLocation bool `json:"needsLocation"` // the org has regions but we have no coords + RegionsCover bool `json:"regionsCover"` // the location falls inside some org region +} + +// consoleConfigJSON serves the recommended configuration for a repeater under a +// chosen org/profile. Location defaults to the repeater's stored coordinates; a +// ?lat=&lon= override lets the client preview a picked location before saving it. +func (s *Handlers) consoleConfigJSON(w http.ResponseWriter, r *http.Request) { + rep, id, ok := s.requireRepeaterAccess(w, r) + if !ok { + return + } + ctx := r.Context() + uid := s.Auth.CurrentUserID(ctx) + + orgs, err := s.Store.ListRepeaterConfigOrgs(ctx, id) + if err != nil { + s.ServerError(w, r, "could not load organizations", err) + return + } + resp := consoleConfig{Orgs: orgs} + if len(orgs) == 0 { + writeConfigJSON(w, resp) // no config-bearing orgs for this repeater + return + } + + // Selected org: ?org= when it's one this repeater participates in, else the first. + resp.SelectedOrg = orgs[0].OrgID + if v := r.URL.Query().Get("org"); v != "" { + if oid, perr := strconv.ParseInt(v, 10, 64); perr == nil { + for _, o := range orgs { + if o.OrgID == oid { + resp.SelectedOrg = oid + break + } + } + } + } + + // Location: a ?lat=&lon= preview wins, otherwise the repeater's stored coords. + lat, lon := rep.Latitude, rep.Longitude + if qLat, qLon, okq := web.PreviewLatLon(r); okq { + lat, lon = &qLat, &qLon + } + + cv, err := web.BuildConfigView(ctx, s.Store, resp.SelectedOrg, r.URL.Query().Get("profile"), lat, lon) + if err != nil { + s.ServerError(w, r, "could not load configuration", err) + return + } + resp.SelectedProfile = cv.Selected + + // Resolve each recommended line against the catalog + the user's sendable set so + // the UI can mark which lines they may actually run. + catalog, err := s.Store.ListCommands(ctx) + if err != nil { + s.ServerError(w, r, "could not load commands", err) + return + } + sendable, err := s.Store.ListSendableCommandIDs(ctx, uid, id) + if err != nil { + s.ServerError(w, r, "could not load permissions", err) + return + } + allowed := make(map[int64]bool, len(sendable)) + for _, cid := range sendable { + allowed[cid] = true + } + runnable := func(line string) (bool, string) { + cmd := resolveCommand(line, catalog) + if cmd == nil { + return false, "not a recognized command" + } + if !allowed[cmd.ID] { + return false, "you don't have permission to run this" + } + return true, "" + } + + // Profile base settings (verbatim; comment-only steps are notes, not commands). + for _, step := range cv.SelectedSteps { + c := consoleConfigCommand{Kind: "profile", Line: step.CommandLine, Comment: step.Comment} + if step.IsComment() { + c.Reason = "note" + } else { + c.Runnable, c.Reason = runnable(step.CommandLine) + } + resp.Commands = append(resp.Commands, c) + } + // Region commands derived from the location. + for _, line := range cv.RegionDef { + c := consoleConfigCommand{Kind: "region", Line: line} + c.Runnable, c.Reason = runnable(line) + resp.Commands = append(resp.Commands, c) + } + + resp.Location = consoleConfigLocation{ + Known: lat != nil && lon != nil, + Lat: lat, + Lon: lon, + NeedsLocation: cv.HasRegions && (lat == nil || lon == nil), + RegionsCover: len(cv.RegionDef) > 0, + } + writeConfigJSON(w, resp) +} + +func writeConfigJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// handleSetRepeaterLocation persists a location picked on the console map when the +// repeater has none (or the user is correcting it). Access-gated the same way as +// confirming — anyone who can operate the repeater can set its location. +func (s *Handlers) handleSetRepeaterLocation(w http.ResponseWriter, r *http.Request) { + _, id, ok := s.requireRepeaterAccess(w, r) + if !ok { + return + } + lat, errLat := strconv.ParseFloat(r.FormValue("lat"), 64) + lon, errLon := strconv.ParseFloat(r.FormValue("lon"), 64) + if errLat != nil || errLon != nil || lat < -90 || lat > 90 || lon < -180 || lon > 180 { + http.Error(w, "invalid coordinates", http.StatusBadRequest) + return + } + if err := s.Store.SetRepeaterLocation(r.Context(), id, lat, lon); err != nil { + s.ServerError(w, r, "could not save location", err) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/core/console_config_test.go b/internal/core/console_config_test.go new file mode 100644 index 0000000..fb0bcb5 --- /dev/null +++ b/internal/core/console_config_test.go @@ -0,0 +1,194 @@ +package core + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/jleight/meshtender/internal/geo" + "github.com/jleight/meshtender/internal/store" +) + +// cmdIDByKey resolves a catalog command's id by its key (test helper, via the +// public ListCommands API since the test package can't reach the store's pool). +func cmdIDByKey(t *testing.T, st *store.Store, key string) int64 { + t.Helper() + cmds, err := st.ListCommands(t.Context()) + if err != nil { + t.Fatalf("list commands: %v", err) + } + for _, c := range cmds { + if c.Key == key { + return c.ID + } + } + t.Fatalf("command %q not in catalog", key) + return 0 +} + +func getConsoleConfig(t *testing.T, ts *httptest.Server, host, path string, cookie *http.Cookie) consoleConfig { + t.Helper() + resp := do(t, ts, host, path, cookie) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s = %d, want 200", path, resp.StatusCode) + } + var cc consoleConfig + if err := json.NewDecoder(resp.Body).Decode(&cc); err != nil { + t.Fatalf("decode config.json: %v", err) + } + return cc +} + +// TestConsoleConfigJSON covers the recommended-configuration endpoint: it lists +// the repeater's config-bearing orgs, returns the selected profile's steps plus +// the location-derived region commands, and marks every line runnable or not per +// the caller's permissions (owner: all; plain member: not the admin-tier region +// commands). The whole list is always returned — nothing is hidden. +func TestConsoleConfigJSON(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + + owner, ownerCookie := appLogin(t, ts, st, ctx, h.app, "cc-owner") + rep, err := st.CreateRepeater(ctx, &store.Repeater{ + OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("e", 64), + RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatal(err) + } + org, err := st.CreateOrg(ctx, "CfgOrg", owner.ID) // owner is an admin member + if err != nil { + t.Fatal(err) + } + + // Ceiling: set.tx is member-tier (everyone in the org can run it); the region + // write commands stay admin-tier (their catalog default). + if err := st.UpdateCommandFlags(ctx, cmdIDByKey(t, st, "set.tx"), false, false, true, false); err != nil { + t.Fatal(err) + } + + profiles := []store.ProfileInput{{Name: "ESP32", Steps: []store.ConfigStep{ + {CommandLine: "set tx 22"}, + {Comment: "tune antenna later"}, + }}} + regions := []store.RegionInput{{ + Token: "buf", DisplayName: "Buffalo", Layer: 0, AllowFlood: true, + GeofenceJSON: geo.Rectangle(40, -80, 45, -75), + }} + if err := st.ReplaceOrgConfig(ctx, org.ID, profiles, regions); err != nil { + t.Fatal(err) + } + + base := "/repeaters/" + rep.PublicID + "/config.json" + + // Owner, with a preview location inside the Buffalo box. + cc := getConsoleConfig(t, ts, h.app, base+"?lat=42&lon=-78", ownerCookie) + if len(cc.Orgs) != 1 || cc.Orgs[0].OrgID != org.ID { + t.Fatalf("orgs = %+v, want just CfgOrg", cc.Orgs) + } + if cc.SelectedProfile != "ESP32" { + t.Fatalf("selected profile = %q, want ESP32", cc.SelectedProfile) + } + if !cc.Location.Known || !cc.Location.RegionsCover { + t.Fatalf("location = %+v, want known & covered", cc.Location) + } + + byLine := map[string]consoleConfigCommand{} + var regionLines, noteCount int + for _, c := range cc.Commands { + byLine[c.Line] = c + if c.Kind == "region" { + regionLines++ + } + if c.Line == "" && c.Comment != "" { + noteCount++ + } + } + // Profile command + the note both appear. + if cmd, ok := byLine["set tx 22"]; !ok || cmd.Kind != "profile" || !cmd.Runnable { + t.Fatalf("set tx 22 = %+v, want profile+runnable", cmd) + } + if noteCount != 1 { + t.Fatalf("note count = %d, want 1 (the comment step)", noteCount) + } + // Region commands present and runnable for the owner. + if regionLines == 0 { + t.Fatal("no region commands emitted for a covered location") + } + for _, c := range cc.Commands { + if c.Kind == "region" && !c.Runnable { + t.Fatalf("region line %q not runnable for owner", c.Line) + } + } + + // A plain member of the org reaches the repeater via org access. They may run + // the member-tier set.tx but NOT the admin-tier region commands — and every + // line is still returned, just marked not-runnable. + member, memberCookie := appLogin(t, ts, st, ctx, h.app, "cc-member") + if err := st.AddOrgMember(ctx, org.ID, member.ID, "member"); err != nil { + t.Fatal(err) + } + mc := getConsoleConfig(t, ts, h.app, base+"?lat=42&lon=-78", memberCookie) + var sawRegion bool + for _, c := range mc.Commands { + switch { + case c.Line == "set tx 22" && !c.Runnable: + t.Error("member should be allowed to run member-tier set tx 22") + case c.Kind == "region": + sawRegion = true + if c.Runnable { + t.Errorf("member should NOT be allowed to run admin-tier %q", c.Line) + } + if c.Reason == "" { + t.Errorf("region line %q missing a not-runnable reason", c.Line) + } + } + } + if !sawRegion { + t.Fatal("member did not receive the region commands (should be shown, just not runnable)") + } +} + +// TestSetRepeaterLocation covers the map-pick persistence endpoint used when the +// repeater has no location: valid coordinates save and surface via config.json; +// invalid coordinates are rejected. +func TestSetRepeaterLocation(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + + owner, cookie := appLogin(t, ts, st, ctx, h.app, "loc-owner") + rep, err := st.CreateRepeater(ctx, &store.Repeater{ + OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("f", 64), + RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatal(err) + } + + // Invalid coordinates → 400, nothing stored. + bad := post(t, ts, h.app, "/repeaters/"+rep.PublicID+"/location", + url.Values{"lat": {"999"}, "lon": {"0"}}, cookie) + bad.Body.Close() + if bad.StatusCode != http.StatusBadRequest { + t.Fatalf("bad coords = %d, want 400", bad.StatusCode) + } + + // Valid coordinates → 204 and persisted. + ok := post(t, ts, h.app, "/repeaters/"+rep.PublicID+"/location", + url.Values{"lat": {"42.5"}, "lon": {"-78.5"}}, cookie) + ok.Body.Close() + if ok.StatusCode != http.StatusNoContent { + t.Fatalf("good coords = %d, want 204", ok.StatusCode) + } + got, err := st.GetRepeaterForUser(ctx, owner.ID, rep.ID) + if err != nil { + t.Fatal(err) + } + if got.Latitude == nil || got.Longitude == nil || *got.Latitude != 42.5 || *got.Longitude != -78.5 { + t.Fatalf("stored location = %v,%v, want 42.5,-78.5", got.Latitude, got.Longitude) + } +} diff --git a/internal/core/console_integration_test.go b/internal/core/console_integration_test.go index df8d847..de644ba 100644 --- a/internal/core/console_integration_test.go +++ b/internal/core/console_integration_test.go @@ -179,4 +179,12 @@ func TestConsoleRoundTrip(t *testing.T) { if e.CommandText != "ver" || !e.AckReceived || e.ResponseText == nil || *e.ResponseText != replyText { t.Fatalf("log entry = %+v (response=%v)", e, e.ResponseText) } + + // Connecting from the console with a successful admin login confirms the + // repeater — the same as running the dedicated confirm flow. + confirmed, err := st.GetRepeaterForUser(ctx, user.ID, rep.ID) + must(err, "reload repeater") + if !confirmed.Confirmed { + t.Fatal("console connect (admin login) did not confirm the repeater") + } } diff --git a/internal/core/templates/console.html b/internal/core/templates/console.html index 9460fe9..6b3ab25 100644 --- a/internal/core/templates/console.html +++ b/internal/core/templates/console.html @@ -26,7 +26,12 @@ Hex hops from your modem to the repeater, in order, same length each (e.g. 11, 22, 33). Leave blank to reach it by flood. - +
+ + {{if .ShowConfig}} + + {{end}} +
+ {{if .ShowConfig}} +
+
+

Apply organization configuration

+
+

+ Pick an organization and profile to see its recommended configuration for this repeater. Every command is + listed; the ones you're allowed to send show a Run button. Connect the modem to run them — results appear in + the console log above and next to each command. +

+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ {{end}}
@@ -55,6 +96,13 @@ {{template "icon-arrow-left" "me-1"}}Back to dashboard +{{if .ShowConfig}} + + + + +{{end}} + diff --git a/internal/core/web.go b/internal/core/web.go index ddf8ff5..52cd9ca 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -142,6 +142,8 @@ func (s *Handlers) appRouter() chi.Router { r.Get("/repeaters/{id}/ws", s.wsConfirm) r.Get("/repeaters/{id}/console", s.pageConsole) r.Get("/repeaters/{id}/console/ws", s.wsConsole) + r.Get("/repeaters/{id}/config.json", s.consoleConfigJSON) + r.Post("/repeaters/{id}/location", s.handleSetRepeaterLocation) r.Get("/repeaters/{id}/log", s.pageCommandLog) r.Get("/repeaters/{id}/docs", s.pageRepeaterDocs) r.Post("/repeaters/{id}/docs", s.handleRepeaterDocs) diff --git a/internal/e2e/console_config_test.go b/internal/e2e/console_config_test.go new file mode 100644 index 0000000..3ac3737 --- /dev/null +++ b/internal/e2e/console_config_test.go @@ -0,0 +1,123 @@ +//go:build browser + +package e2e + +import ( + "testing" + + cdplog "github.com/chromedp/cdproto/log" + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" + + "github.com/jleight/meshtender/internal/geo" + "github.com/jleight/meshtender/internal/store" +) + +// TestE2EConsoleConfigPanel drives the "Apply organization configuration" panel: +// the button expands it, the recommended commands (profile base settings + region +// commands for the repeater's location) render, and — with no modem connected — +// the Run controls are disabled with a prompt to connect. Also asserts the page +// (Bootstrap collapse + Leaflet) runs clean under the strict CSP. +func TestE2EConsoleConfigPanel(t *testing.T) { + srv := newE2EServer(t) + owner, cookie := srv.login(t, "cfg-owner") + rep := srv.newRepeater(t, owner.ID, "Owner Rep") + + // Give the repeater a location inside the region below so region commands + // resolve (there's no modem in headless to fetch it live). + if err := srv.store.SetRepeaterLocation(srv.ctx, rep.ID, 42.0, -78.0); err != nil { + t.Fatalf("set location: %v", err) + } + + org, err := srv.store.CreateOrg(srv.ctx, "Mesh Org", owner.ID) // owner is an admin member + if err != nil { + t.Fatalf("create org: %v", err) + } + profiles := []store.ProfileInput{{Name: "ESP32", Steps: []store.ConfigStep{ + {CommandLine: "set tx 22"}, + {Comment: "tune antenna later"}, + }}} + regions := []store.RegionInput{{ + Token: "buf", DisplayName: "Buffalo", Layer: 0, AllowFlood: true, + GeofenceJSON: geo.Rectangle(40, -80, 45, -75), + }} + if err := srv.store.ReplaceOrgConfig(srv.ctx, org.ID, profiles, regions); err != nil { + t.Fatalf("replace config: %v", err) + } + + bctx, cancel, watch := startBrowser(t) + defer cancel() + + url := srv.appURL + "/repeaters/" + rep.PublicID + "/console" + + // After the panel expands and config.json loads, count rendered command rows, + // Run buttons, region badges, and whether the Run controls are disabled. + const summarize = `(function () { + var box = document.querySelector('[data-testid="config-commands"]'); + var rows = box ? box.querySelectorAll('.list-group-item') : []; + var runBtns = box ? box.querySelectorAll('button[data-run]') : []; + var anyRunEnabled = false; + Array.prototype.forEach.call(runBtns, function (b) { if (!b.disabled) anyRunEnabled = true; }); + var hasSetTx = box ? /set tx 22/.test(box.textContent) : false; + var hasRegionCmd = box ? /region save/.test(box.textContent) : false; + return { + rows: rows.length, + hasRegionCmd: hasRegionCmd, + runButtons: runBtns.length, + anyRunEnabled: anyRunEnabled, + hasSetTx: hasSetTx, + runAllDisabled: !!document.querySelector('[data-testid="config-run-all"]').disabled, + hasConnect: !!document.querySelector('[data-testid="config-connect"]'), + hint: (document.querySelector('[data-testid="config-hint"]') || {}).textContent || "", + }; + })()` + + var out struct { + Rows int `json:"rows"` + HasRegionCmd bool `json:"hasRegionCmd"` + RunButtons int `json:"runButtons"` + AnyRunEnabled bool `json:"anyRunEnabled"` + HasSetTx bool `json:"hasSetTx"` + RunAllDisabled bool `json:"runAllDisabled"` + HasConnect bool `json:"hasConnect"` + Hint string `json:"hint"` + } + if err := chromedp.Run(bctx, + network.Enable(), + cdplog.Enable(), + setSessionCookie(cookie), + chromedp.Navigate(url), + chromedp.WaitVisible(`[data-testid="apply-config"]`, chromedp.ByQuery), + chromedp.Click(`[data-testid="apply-config"]`, chromedp.ByQuery), + // The command rows render after config.json resolves. + chromedp.WaitVisible(`[data-testid="config-commands"] .list-group-item`, chromedp.ByQuery), + chromedp.Evaluate(summarize, &out), + ); err != nil { + t.Fatalf("browser run against %s: %v", url, err) + } + + if !out.HasSetTx { + t.Errorf("profile command 'set tx 22' not shown in the panel") + } + if !out.HasRegionCmd { + t.Errorf("no region commands shown despite a covered location") + } + if out.RunButtons == 0 { + t.Errorf("owner should see Run buttons for permitted commands") + } + // No modem is connected in headless, so Run must be disabled with a prompt. + if out.AnyRunEnabled { + t.Errorf("Run buttons should be disabled with no modem connected") + } + if !out.RunAllDisabled { + t.Errorf("Run all should be disabled with no modem connected") + } + if out.Hint == "" { + t.Errorf("expected a hint prompting to connect the modem") + } + if !out.HasConnect { + t.Errorf("config panel is missing a Connect button") + } + + watch.assertClean(t) +} diff --git a/internal/store/config_profiles_test.go b/internal/store/config_profiles_test.go index 739731a..2ec2931 100644 --- a/internal/store/config_profiles_test.go +++ b/internal/store/config_profiles_test.go @@ -1,6 +1,7 @@ package store import ( + "strings" "testing" "github.com/jleight/meshtender/internal/geo" @@ -277,3 +278,82 @@ func TestRegionDefFloodCommands(t *testing.T) { } } } + +// TestListRepeaterConfigOrgs covers which orgs surface in the console config +// picker: the repeater must participate (owner is a member, not excluded) and the +// org must have config. Profile names come back per org (empty for region-only). +func TestListRepeaterConfigOrgs(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + + owner, err := st.CreateUser(ctx, "rco-owner", "") + if err != nil { + t.Fatal(err) + } + stranger, err := st.CreateUser(ctx, "rco-stranger", "") + if err != nil { + t.Fatal(err) + } + rep, err := st.CreateRepeater(ctx, &Repeater{ + OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("d", 64), + RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatal(err) + } + + // A: owner is a member, has profiles → included, with profile names. + orgA, _ := st.CreateOrg(ctx, "Alpha", owner.ID) + if err := st.ReplaceOrgConfig(ctx, orgA.ID, + []ProfileInput{{Name: "ESP32"}, {Name: "nRF52"}}, nil); err != nil { + t.Fatal(err) + } + // B: owner is a member, region-only config (no profiles) → included, empty profiles. + orgB, _ := st.CreateOrg(ctx, "Bravo", owner.ID) + if err := st.ReplaceRegions(ctx, orgB.ID, + []RegionInput{{Token: "x", DisplayName: "X", Layer: 1}}, true); err != nil { + t.Fatal(err) + } + // C: owner is a member but the org has NO config → excluded. + st.CreateOrg(ctx, "Charlie", owner.ID) + // D: owner is a member, org has config, but the repeater is opted out → excluded. + orgD, _ := st.CreateOrg(ctx, "Delta", owner.ID) + if err := st.ReplaceOrgConfig(ctx, orgD.ID, []ProfileInput{{Name: "P"}}, nil); err != nil { + t.Fatal(err) + } + if err := st.SetRepeaterOrgExcluded(ctx, orgD.ID, rep.ID, true); err != nil { + t.Fatal(err) + } + // E: has config but the repeater's owner is NOT a member → excluded. + orgE, _ := st.CreateOrg(ctx, "Echo", stranger.ID) + if err := st.ReplaceOrgConfig(ctx, orgE.ID, []ProfileInput{{Name: "P"}}, nil); err != nil { + t.Fatal(err) + } + + got, err := st.ListRepeaterConfigOrgs(ctx, rep.ID) + if err != nil { + t.Fatalf("ListRepeaterConfigOrgs: %v", err) + } + byName := map[string]RepeaterConfigOrg{} + for _, o := range got { + byName[o.OrgName] = o + } + if len(got) != 2 { + t.Fatalf("orgs = %d %v, want 2 (Alpha, Bravo)", len(got), byName) + } + if a, ok := byName["Alpha"]; !ok { + t.Error("Alpha missing") + } else if len(a.Profiles) != 2 || a.Profiles[0] != "ESP32" || a.Profiles[1] != "nRF52" { + t.Errorf("Alpha profiles = %v, want [ESP32 nRF52]", a.Profiles) + } + if b, ok := byName["Bravo"]; !ok { + t.Error("Bravo (region-only) missing") + } else if len(b.Profiles) != 0 { + t.Errorf("Bravo profiles = %v, want empty", b.Profiles) + } + for _, absent := range []string{"Charlie", "Delta", "Echo"} { + if _, ok := byName[absent]; ok { + t.Errorf("%s should not be listed", absent) + } + } +} diff --git a/internal/store/org_repeaters.go b/internal/store/org_repeaters.go index 960e23e..976ed83 100644 --- a/internal/store/org_repeaters.go +++ b/internal/store/org_repeaters.go @@ -236,3 +236,46 @@ func (s *Store) ListRepeaterOrgMemberships(ctx context.Context, repeaterID int64 return m, err }) } + +// RepeaterConfigOrg is an org whose recommended configuration applies to a +// repeater: the repeater's owner is a member, the repeater isn't excluded, and +// the org has configuration (profiles and/or regions). Profiles lists that org's +// profile names (empty for a region-only org) for the console config picker. +type RepeaterConfigOrg struct { + OrgID int64 `json:"orgId"` + OrgSlug string `json:"orgSlug"` + OrgName string `json:"orgName"` + Profiles []string `json:"profiles"` +} + +// ListRepeaterConfigOrgs returns the orgs whose configuration applies to the +// repeater — those it participates in (owner is a member and it isn't excluded, +// the same predicate GetRepeaterForUser/CanSendCommand use) that have any config +// — each with its profile names. Ordered by org name. Used to populate the +// console's "Apply organization configuration" picker. +func (s *Store) ListRepeaterConfigOrgs(ctx context.Context, repeaterID int64) ([]RepeaterConfigOrg, error) { + rows, err := s.pool.Query(ctx, ` + SELECT o.id, o.slug, o.name, + COALESCE( + array_agg(p.name ORDER BY p.position, p.name) FILTER (WHERE p.id IS NOT NULL), + '{}') AS profiles + FROM repeaters r + JOIN org_members om ON om.user_id = r.owner_id + JOIN organizations o ON o.id = om.org_id + LEFT JOIN config_profiles p ON p.org_id = o.id + WHERE r.id = $1 + AND NOT EXISTS (SELECT 1 FROM org_repeater_excludes e + WHERE e.org_id = o.id AND e.repeater_id = r.id) + AND (EXISTS (SELECT 1 FROM config_profiles cp WHERE cp.org_id = o.id) + OR EXISTS (SELECT 1 FROM config_regions cr WHERE cr.org_id = o.id)) + GROUP BY o.id, o.slug, o.name + ORDER BY o.name`, repeaterID) + if err != nil { + return nil, fmt.Errorf("list repeater config orgs: %w", err) + } + return collectRows(rows, func(r pgx.Row) (RepeaterConfigOrg, error) { + var o RepeaterConfigOrg + err := r.Scan(&o.OrgID, &o.OrgSlug, &o.OrgName, &o.Profiles) + return o, err + }) +} diff --git a/internal/web/static/console-config.js b/internal/web/static/console-config.js new file mode 100644 index 0000000..65d471c --- /dev/null +++ b/internal/web/static/console-config.js @@ -0,0 +1,441 @@ +// Drives the "Apply organization configuration" panel on the repeater console. +// The panel is an inline Bootstrap collapse (hidden until the user opens it) so +// the console log above stays visible while commands run. It fetches the +// recommended configuration for a chosen org/profile from +// /repeaters/{id}/config.json, lists every command (marking which the user may +// run), and runs them over the live console session via window.MeshConsole +// (owned by console.js), showing a spinner/✓/✗ next to each as it runs. Location +// is handled here too: fetch-from-device (a getloc request over the session) or +// pick-on-map (POST to /location). + +(function () { + const panel = document.getElementById("config-panel"); + if (!panel) return; + + const configURL = panel.dataset.configUrl; + const locationURL = panel.dataset.locationUrl; + const q = (sel) => panel.querySelector(sel); + const orgSel = q('[data-cfg="org"]'); + const profileSel = q('[data-cfg="profile"]'); + const locBox = q('[data-cfg="location"]'); + const mapBox = q('[data-cfg="map"]'); + const cmdBox = q('[data-cfg="commands"]'); + const runAllBtn = q('[data-cfg="run-all"]'); + const connectBtn = q('[data-cfg="connect"]'); + const connectLabel = q('[data-cfg="connect-label"]'); + const hint = q('[data-cfg="hint"]'); + + let data = null; // last config payload + let rows = []; // per-command row state: { line, runnable, statusEl } + let mapView = null; // Leaflet map instance (created lazily when the picker is shown) + let running = false; // a run (single or batch) is in progress + + const consoleReady = () => !!(window.MeshConsole && window.MeshConsole.ready); + + function queryURL() { + const p = new URLSearchParams(); + if (orgSel.value) p.set("org", orgSel.value); + if (profileSel.value) p.set("profile", profileSel.value); + const qs = p.toString(); + return qs ? configURL + "?" + qs : configURL; + } + + async function load(useSelectors) { + try { + const resp = await fetch(useSelectors ? queryURL() : configURL, { + headers: { Accept: "application/json" }, + }); + if (!resp.ok) throw new Error("HTTP " + resp.status); + data = await resp.json(); + } catch (e) { + cmdBox.textContent = "Could not load the configuration."; + return; + } + render(); + } + + function render() { + const orgs = (data && data.orgs) || []; + if (!orgs.length) { + orgSel.innerHTML = ""; + profileSel.innerHTML = ""; + locBox.innerHTML = ""; + cmdBox.innerHTML = + '

This repeater isn\'t in an organization with a saved configuration.

'; + rows = []; + updateRunState(); + return; + } + + orgSel.innerHTML = ""; + orgs.forEach((o) => { + const opt = document.createElement("option"); + opt.value = String(o.orgId); + opt.textContent = o.orgName; + if (o.orgId === data.selectedOrg) opt.selected = true; + orgSel.appendChild(opt); + }); + + const org = orgs.find((o) => o.orgId === data.selectedOrg) || orgs[0]; + const profiles = (org && org.profiles) || []; + profileSel.innerHTML = ""; + if (!profiles.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "(regions only)"; + profileSel.appendChild(opt); + profileSel.disabled = true; + } else { + profileSel.disabled = false; + profiles.forEach((name) => { + const opt = document.createElement("option"); + opt.value = name; + opt.textContent = name; + if (name === data.selectedProfile) opt.selected = true; + profileSel.appendChild(opt); + }); + } + + renderLocation(); + renderCommands(); + refreshHint(); + } + + function renderLocation() { + locBox.innerHTML = ""; + const loc = (data && data.location) || {}; + if (loc.known) { + const el = document.createElement("div"); + el.className = "small text-secondary mb-2"; + el.textContent = "Location: " + fmt(loc.lat) + ", " + fmt(loc.lon); + locBox.appendChild(el); + if (!loc.regionsCover) { + locBox.appendChild( + alertEl( + "warning", + "This location isn't inside any of this organization's regions, so no regional settings will be applied — check the repeater's location.", + ), + ); + } + } else if (loc.needsLocation) { + locBox.appendChild( + alertEl( + "info", + "This organization's region settings depend on the repeater's location, which isn't known yet. Connect the modem and fetch it, or pick it on the map.", + ), + ); + } + + if (loc.needsLocation || !loc.known || (loc.known && !loc.regionsCover)) { + const actions = document.createElement("div"); + actions.className = "btn-list mb-3"; + + const fetchBtn = document.createElement("button"); + fetchBtn.type = "button"; + fetchBtn.className = "btn btn-sm"; + fetchBtn.dataset.loc = "fetch"; + fetchBtn.textContent = "Fetch from device"; + fetchBtn.disabled = !consoleReady(); + if (fetchBtn.disabled) fetchBtn.title = "Connect the modem first"; + fetchBtn.addEventListener("click", () => { + if (window.MeshConsole && window.MeshConsole.getLocation()) { + setHint("Fetching the location from the repeater…"); + } + }); + + const pickBtn = document.createElement("button"); + pickBtn.type = "button"; + pickBtn.className = "btn btn-sm"; + pickBtn.textContent = "Pick on map"; + pickBtn.addEventListener("click", showPicker); + + actions.appendChild(fetchBtn); + actions.appendChild(pickBtn); + locBox.appendChild(actions); + } + } + + function showPicker() { + mapBox.style.display = "block"; + if (!mapView && window.regionMapView) { + const loc = (data && data.location) || {}; + mapView = window.regionMapView("config-map", { + preview: loc.known ? { lat: loc.lat, lon: loc.lon } : undefined, + onPick: (lat, lon) => saveLocation(lat, lon), + }); + } else if (mapView && mapView.invalidateSize) { + mapView.invalidateSize(); + } + } + + async function saveLocation(lat, lon) { + const loc = (data && data.location) || {}; + if (loc.known && distKm(loc.lat, loc.lon, lat, lon) > 1) { + setHint( + "Note: that differs from the repeater's current location (" + + fmt(loc.lat) + ", " + fmt(loc.lon) + ").", + ); + } + try { + const resp = await fetch(locationURL, { + method: "POST", + body: new URLSearchParams({ lat: String(lat), lon: String(lon) }), + }); + if (!resp.ok && resp.status !== 204) throw new Error("HTTP " + resp.status); + } catch (e) { + setHint("Could not save the location."); + return; + } + mapBox.style.display = "none"; + load(true); // reload so region commands reflect the new location + } + + function renderCommands() { + cmdBox.innerHTML = ""; + rows = []; + const cmds = (data && data.commands) || []; + if (!cmds.length) { + cmdBox.innerHTML = + '

No recommended commands for this selection.

'; + updateRunState(); + return; + } + const list = document.createElement("div"); + list.className = "list-group"; + cmds.forEach((c) => { + const row = document.createElement("div"); + row.className = "list-group-item d-flex align-items-center gap-2 py-2"; + + const left = document.createElement("div"); + left.className = "flex-fill text-break"; + if (!c.line) { + const note = document.createElement("span"); + note.className = "text-secondary fst-italic"; + note.textContent = c.comment || ""; + left.appendChild(note); + } else { + const code = document.createElement("code"); + code.textContent = c.line; + left.appendChild(code); + } + row.appendChild(left); + + // Per-command status indicator (spinner while running, ✓/✗ on completion). + const status = document.createElement("span"); + status.className = "cfg-status"; + status.style.minWidth = "1.25rem"; + status.style.textAlign = "center"; + row.appendChild(status); + + const idx = rows.length; + if (c.line && c.runnable) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-sm"; + btn.textContent = "Run"; + btn.dataset.run = c.line; + btn.addEventListener("click", () => runLines([idx])); + row.appendChild(btn); + } else if (c.line) { + const badge = document.createElement("span"); + badge.className = "badge bg-secondary-lt"; + badge.textContent = c.reason === "note" ? "note" : "not permitted"; + if (c.reason && c.reason !== "note") badge.title = c.reason; + row.appendChild(badge); + } + + list.appendChild(row); + rows.push({ line: c.line, runnable: !!(c.line && c.runnable), statusEl: status }); + }); + cmdBox.appendChild(list); + updateRunState(); + } + + function setStatus(el, state) { + el.className = "cfg-status"; + el.textContent = ""; + if (state === "running") { + const sp = document.createElement("span"); + sp.className = "spinner-border spinner-border-sm text-secondary"; + sp.setAttribute("role", "status"); + el.appendChild(sp); + } else if (state === "ok") { + el.textContent = "✓"; + el.classList.add("text-success"); + } else if (state === "fail") { + el.textContent = "✗"; + el.classList.add("text-danger"); + } + } + + function runnableIndices() { + const out = []; + rows.forEach((r, i) => { + if (r.runnable) out.push(i); + }); + return out; + } + + // Run the given command rows in order, waiting for each to complete and + // updating its status icon. Serialized (running flag) so replies map to rows. + async function runLines(indices) { + if (running || !consoleReady()) return; + running = true; + updateRunState(); + indices.forEach((i) => rows[i] && setStatus(rows[i].statusEl, "idle")); + let failed = false; + for (const i of indices) { + const row = rows[i]; + if (!row || !row.runnable) continue; + setStatus(row.statusEl, "running"); + setHint("Running: " + row.line); + const done = waitForResult(); + if (!window.MeshConsole.send(row.line)) { + done.cancel(); + setStatus(row.statusEl, "fail"); + failed = true; + break; + } + const ok = await done.promise; + setStatus(row.statusEl, ok ? "ok" : "fail"); + if (!ok) { + failed = true; + break; + } + } + running = false; + updateRunState(); + setHint(failed ? "A command didn't succeed — see the console log." : ""); + } + + // Resolve true on the next successful reply, false on a failure/timeout. + function waitForResult() { + let timer; + let onStatus; + const promise = new Promise((resolve) => { + const finish = (ok) => { + clearTimeout(timer); + document.removeEventListener("mesh:status", onStatus); + resolve(ok); + }; + timer = setTimeout(() => finish(false), 60000); + onStatus = (ev) => { + const st = ev.detail && ev.detail.state; + if (st === "reply") finish(true); + else if (st === "noreply" || st === "error" || st === "denied") finish(false); + }; + document.addEventListener("mesh:status", onStatus); + }); + return { + promise, + cancel() { + clearTimeout(timer); + document.removeEventListener("mesh:status", onStatus); + }, + }; + } + + function updateConnectBtn() { + if (!connectBtn) return; + const supported = !!(window.MeshConsole && window.MeshConsole.supported); + const ready = consoleReady(); + if (!supported) { + connectBtn.disabled = true; + connectBtn.title = "This browser doesn't support WebSerial (use Chrome or Edge)."; + if (connectLabel) connectLabel.textContent = "Modem unsupported"; + } else if (ready) { + connectBtn.disabled = true; + connectBtn.title = ""; + if (connectLabel) connectLabel.textContent = "Modem connected"; + } else { + connectBtn.disabled = running; + connectBtn.title = ""; + if (connectLabel) connectLabel.textContent = "Connect modem"; + } + } + + function updateRunState() { + updateConnectBtn(); + const ready = consoleReady(); + runAllBtn.disabled = running || !runnableIndices().length || !ready; + cmdBox.querySelectorAll("button[data-run]").forEach((b) => { + b.disabled = running || !ready; + }); + const fetchBtn = locBox.querySelector('button[data-loc="fetch"]'); + if (fetchBtn) { + fetchBtn.disabled = running || !ready; + fetchBtn.title = ready ? "" : "Connect the modem first"; + } + } + + // Show the connect prompt only when idle and disconnected; never clobber an + // in-progress run's status message. + function refreshHint() { + if (running) return; + setHint(consoleReady() ? "" : "Connect the modem to run commands."); + } + + // helpers + function fmt(n) { + return typeof n === "number" ? n.toFixed(5) : "?"; + } + function alertEl(kind, text) { + const d = document.createElement("div"); + d.className = "alert alert-" + kind + " py-2 px-3 mb-2"; + d.textContent = text; + return d; + } + function setHint(t) { + hint.textContent = t; + } + function distKm(la1, lo1, la2, lo2) { + const R = 6371; + const rad = (d) => (d * Math.PI) / 180; + const dLa = rad(la2 - la1); + const dLo = rad(lo2 - lo1); + const a = + Math.sin(dLa / 2) ** 2 + + Math.cos(rad(la1)) * Math.cos(rad(la2)) * Math.sin(dLo / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); + } + + // wiring + orgSel.addEventListener("change", () => { + profileSel.value = ""; + load(true); + }); + profileSel.addEventListener("change", () => load(true)); + runAllBtn.addEventListener("click", () => runLines(runnableIndices())); + if (connectBtn) { + connectBtn.addEventListener("click", () => { + if (window.MeshConsole) window.MeshConsole.connect(); + if (connectLabel) connectLabel.textContent = "Connecting…"; + connectBtn.disabled = true; + }); + } + // Load the config the first time the panel is expanded (not before — it's opt-in). + panel.addEventListener("shown.bs.collapse", () => { + if (!data) load(false); + if (mapView && mapView.invalidateSize) mapView.invalidateSize(); + }); + document.addEventListener("mesh:ready", () => { + updateConnectBtn(); + if (data) { + updateRunState(); + refreshHint(); + } + }); + document.addEventListener("mesh:closed", () => { + updateConnectBtn(); + if (data) { + updateRunState(); + refreshHint(); + } + }); + document.addEventListener("mesh:status", (ev) => { + const st = ev.detail && ev.detail.state; + // The server confirms/updates location on connect or on a getloc request; when + // it does, refresh so region commands and the location banner reflect it. + if ((st === "location" || st === "confirmed") && data) load(true); + }); +})(); diff --git a/internal/web/static/console.js b/internal/web/static/console.js index 408eea3..7f91106 100644 --- a/internal/web/static/console.js +++ b/internal/web/static/console.js @@ -1,13 +1,50 @@ // Interactive repeater console. Bridges a WebSerial-connected KISS modem to the // server over a WebSocket (binary = raw KISS bytes), and exchanges JSON control // messages for commands and status. Mirrors serial.js's bridge, plus a CLI. +// +// It also exposes a small API (window.MeshConsole) and document events so the +// separate "apply org configuration" script (console-config.js) can send commands +// and react to session state without owning the socket: +// window.MeshConsole.ready — is the modem connected & the session live? +// window.MeshConsole.supported — does this browser have WebSerial at all? +// window.MeshConsole.connect() — open the modem + session (needs a user gesture) +// window.MeshConsole.send(text) — send a CLI command; returns false if not ready +// window.MeshConsole.getLocation() — ask the server to fetch the repeater's coords +// document "mesh:ready" event — fired when the session becomes ready +// document "mesh:closed" event — fired when the socket closes +// document "mesh:status" event — detail {state, message} for every server status (function () { const connectBtn = document.getElementById("connect"); const log = document.getElementById("log"); const form = document.getElementById("cmdform"); const input = document.getElementById("cmdinput"); - const sendBtn = document.getElementById("cmdsend"); + + let port, ws, reader, writer, keepReading = false; + + // The shared API other scripts use. `ready` is the single source of truth for + // whether a command can be sent right now. + const api = { + ready: false, + supported: "serial" in navigator, + connect() {}, // replaced with the real routine below when WebSerial is present + send(text) { + const t = String(text || "").trim(); + if (!t || !api.ready || !ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send(JSON.stringify({ type: "cmd", text: t })); + return true; + }, + getLocation() { + if (!api.ready || !ws || ws.readyState !== WebSocket.OPEN) return false; + ws.send(JSON.stringify({ type: "getloc" })); + return true; + }, + }; + window.MeshConsole = api; + + function emit(name, detail) { + document.dispatchEvent(new CustomEvent(name, { detail: detail })); + } // wsURL appends the optional user-entered path (#path) to the base ws URL so // the server routes login/commands directly (with flood fallback). @@ -24,7 +61,7 @@ const unsupportedEl = document.getElementById("unsupported"); if (unsupportedEl) unsupportedEl.hidden = false; if (connectBtn) connectBtn.disabled = true; - return; + return; // MeshConsole stays defined but never becomes ready (no modem here) } function addLog(state, message) { @@ -35,12 +72,13 @@ log.scrollTop = log.scrollHeight; } - let port, ws, reader, writer, keepReading = false, ready = false; - function setReady(on) { - ready = on; + api.ready = on; form.hidden = !on; // hide the command box until the modem is connected - if (on) input.focus(); + if (on) { + input.focus(); + emit("mesh:ready", null); + } } async function cleanup() { @@ -77,13 +115,11 @@ form.addEventListener("submit", (e) => { e.preventDefault(); - const text = input.value.trim(); - if (!text || !ready || !ws || ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify({ type: "cmd", text })); - input.value = ""; + if (api.send(input.value)) input.value = ""; }); - connectBtn.addEventListener("click", async () => { + async function connect() { + if (api.ready || connectBtn.disabled) return; // already connected / connecting connectBtn.disabled = true; log.innerHTML = ""; try { @@ -107,17 +143,21 @@ let msg = {}; try { msg = JSON.parse(ev.data); } catch (_) {} addLog(msg.state || "info", msg.message || ev.data); + emit("mesh:status", { state: msg.state || "info", message: msg.message || "" }); if (msg.state === "info" && /ready for commands/i.test(msg.message || "")) setReady(true); return; } try { await writer.write(new Uint8Array(ev.data)); } catch (e) { addLog("error", "Serial write error: " + e.message); } }; - ws.onclose = () => { addLog("info", "Disconnected."); cleanup(); }; + ws.onclose = () => { addLog("info", "Disconnected."); emit("mesh:closed", null); cleanup(); }; ws.onerror = () => { addLog("error", "WebSocket error."); }; } catch (e) { addLog("error", e.message); await cleanup(); } - }); + } + + connectBtn.addEventListener("click", connect); + api.connect = connect; // let the config modal (console-config.js) connect too })(); diff --git a/internal/web/static/regionmap.js b/internal/web/static/regionmap.js index 6345d45..e01cb90 100644 --- a/internal/web/static/regionmap.js +++ b/internal/web/static/regionmap.js @@ -302,5 +302,8 @@ } else { map.setView([20, 0], 2, { animate: false }); } + // Return the map so callers that show it inside an initially-hidden container + // (e.g. a modal) can invalidateSize() once it becomes visible. + return map; }; })();