Add region config to console page

This commit is contained in:
Jonathon Leight
2026-07-05 17:28:16 -04:00
parent cceae066c6
commit 7c607ba3ac
13 changed files with 1253 additions and 57 deletions
+46 -39
View File
@@ -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".
+40 -4
View File
@@ -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.")
}
}
}
}
+171
View File
@@ -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)
}
+194
View File
@@ -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)
}
}
@@ -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")
}
}
+49 -1
View File
@@ -26,7 +26,12 @@
<input type="text" class="form-control font-monospace" id="path" placeholder="e.g. 11, 22, 33" autocomplete="off" spellcheck="false">
<small class="form-hint">Hex hops from your modem to the repeater, in order, same length each (e.g. <code>11, 22, 33</code>). Leave blank to reach it by flood.</small>
</div>
<button id="connect" class="btn btn-primary" type="button">{{template "icon-plug" "me-1"}}Connect modem</button>
<div class="btn-list">
<button id="connect" class="btn btn-primary" type="button">{{template "icon-plug" "me-1"}}Connect modem</button>
{{if .ShowConfig}}
<button type="button" class="btn btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#config-panel" aria-expanded="false" aria-controls="config-panel" data-testid="apply-config">{{template "icon-settings" "me-1"}}Apply org configuration…</button>
{{end}}
</div>
<ul id="log" class="evlog console mt-3" role="log" aria-live="polite" aria-label="Console activity"></ul>
<form id="cmdform" class="input-group mt-3" autocomplete="off" hidden>
<input type="text" id="cmdinput" class="form-control font-monospace" placeholder="type a command, e.g. ver" aria-label="Command to send">
@@ -34,6 +39,42 @@
</form>
</div>
</div>
{{if .ShowConfig}}
<div class="collapse mt-3" id="config-panel"
data-config-url="/repeaters/{{.Repeater.PublicID}}/config.json"
data-location-url="/repeaters/{{.Repeater.PublicID}}/location">
<div class="card">
<div class="card-header"><h3 class="card-title">Apply organization configuration</h3></div>
<div class="card-body">
<p class="text-secondary">
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.
</p>
<div class="row g-2 mb-3">
<div class="col-sm-6">
<label class="form-label" for="cfg-org">Organization</label>
<select id="cfg-org" class="form-select" data-cfg="org"></select>
</div>
<div class="col-sm-6">
<label class="form-label" for="cfg-profile">Profile</label>
<select id="cfg-profile" class="form-select" data-cfg="profile"></select>
</div>
</div>
<div data-cfg="location"></div>
<div id="config-map" data-cfg="map" style="height:220px;display:none" class="mb-3 rounded" role="region" aria-label="Pick the repeater's location"></div>
<div data-cfg="commands" data-testid="config-commands"></div>
</div>
<div class="card-footer d-flex align-items-center">
<span data-cfg="hint" class="text-secondary small" data-testid="config-hint"></span>
<div class="btn-list ms-auto">
<button type="button" class="btn btn-primary" data-cfg="connect" data-testid="config-connect">{{template "icon-plug" "me-1"}}<span data-cfg="connect-label">Connect modem</span></button>
<button type="button" class="btn btn-primary" data-cfg="run-all" data-testid="config-run-all">Run all</button>
</div>
</div>
</div>
</div>
{{end}}
</div>
<div class="col-12 col-lg-4">
<div class="card" data-testid="allowed-commands">
@@ -55,6 +96,13 @@
<a class="back-link mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
{{if .ShowConfig}}
<link rel="stylesheet" href="/static/leaflet.css">
<script src="/static/leaflet.js"></script>
<script src="/static/regionmap.js"></script>
<script src="/static/console-config.js"></script>
{{end}}
<script nonce="{{.Nonce}}">
window.MESHTENDER_WS = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/repeaters/{{.Repeater.PublicID}}/console/ws";
</script>
+2
View File
@@ -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)
+123
View File
@@ -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)
}
+80
View File
@@ -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)
}
}
}
+43
View File
@@ -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
})
}
+441
View File
@@ -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 =
'<p class="text-secondary mb-0">This repeater isn\'t in an organization with a saved configuration.</p>';
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 =
'<p class="text-secondary mb-0">No recommended commands for this selection.</p>';
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);
});
})();
+53 -13
View File
@@ -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
})();
+3
View File
@@ -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;
};
})();