mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-17 08:54:22 +00:00
Add region config to console page
This commit is contained in:
+46
-39
@@ -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".
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
@@ -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
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user