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.
-
+
+ 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. +
+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; }; })();