Primary region and details page cleanup

This commit is contained in:
Jonathon Leight
2026-06-26 20:27:35 -04:00
parent 37ecf13933
commit aa69eb6ec9
9 changed files with 145 additions and 143 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ func TestOrgConfigProfilesFlow(t *testing.T) {
}
body := readBody(t, do(t, ts, h.app, "/orgs/"+org.Slug+"/config", sess))
for _, want := range []string{"ESP32", "nRF52", "Mountains", "<select"} {
// Profiles list with a selector; the geofenced region surfaces as the
// click-to-preview location map (regions aren't listed individually).
for _, want := range []string{"ESP32", "nRF52", "<select", "Preview a location", "region-map"} {
if !strings.Contains(body, want) {
t.Fatalf("config view missing %q", want)
}
+12 -3
View File
@@ -32,6 +32,7 @@ type configRegionView struct {
DisplayName string
Token string
Layer int
Primary bool
GeofenceJSON string
}
@@ -203,6 +204,7 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
*errs = append(*errs, fmt.Sprintf("Too many regions (max %d).", maxConfigRegions))
}
seen := map[string]bool{}
primaryTaken := false
var ins []store.RegionInput
var views []configRegionView
for i := range tokens {
@@ -210,10 +212,11 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
display := strings.TrimSpace(formAt(r, "region_display", i))
geojson := strings.TrimSpace(formAt(r, "region_geojson", i))
layer, _ := strconv.Atoi(formAt(r, "region_layer", i))
primary := formAt(r, "region_primary", i) == "1"
if token == "" && display == "" && geojson == "" {
continue // empty block
}
zv := configRegionView{DisplayName: display, Token: token, Layer: layer, GeofenceJSON: geojson}
zv := configRegionView{DisplayName: display, Token: token, Layer: layer, Primary: primary, GeofenceJSON: geojson}
if token == "" {
*errs = append(*errs, "A region is missing its short name.")
views = append(views, zv)
@@ -239,8 +242,14 @@ func (s *Handlers) parseRegions(r *http.Request, errs *[]string) ([]store.Region
views = append(views, zv)
continue
}
// Only one region may be primary; keep the first and clear any later ones.
if primary && primaryTaken {
primary, zv.Primary = false, false
} else if primary {
primaryTaken = true
}
views = append(views, zv)
ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, GeofenceJSON: geofence})
ins = append(ins, store.RegionInput{Token: token, DisplayName: display, Layer: layer, Primary: primary, GeofenceJSON: geofence})
}
return ins, views
}
@@ -354,7 +363,7 @@ func regionViews(regions []store.Region) []configRegionView {
out := make([]configRegionView, 0, len(regions))
for _, z := range regions {
out = append(out, configRegionView{
DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer,
DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Primary: z.Primary,
GeofenceJSON: string(z.GeofenceJSON),
})
}
+15 -1
View File
@@ -73,6 +73,18 @@ function addBlock(kind) {
document.getElementById(kind + 's').appendChild(proto.content.cloneNode(true));
}
function removeBlock(btn, cls) { btn.closest('.' + cls).remove(); }
// Only one region may be primary: toggle the clicked one and clear the rest.
function setPrimaryRegion(btn) {
const block = btn.closest('.region-block');
const makePrimary = block.querySelector('input[name="region_primary"]').value !== '1';
document.querySelectorAll('#regions .region-block').forEach(function (b) {
const on = b === block && makePrimary;
b.querySelector('input[name="region_primary"]').value = on ? '1' : '';
const pb = b.querySelector('.region-primary-btn');
pb.classList.toggle('btn-primary', on);
pb.textContent = on ? '★ Primary' : 'Make primary';
});
}
</script>
<script src="/static/leaflet.js"></script>
<script src="/static/leaflet-geoman.js"></script>
@@ -106,7 +118,9 @@ function removeBlock(btn, cls) { btn.closest('.' + cls).remove(); }
<div class="col-md-auto"><label class="form-label">Layer</label>
<input class="form-control" type="number" name="region_layer" value="{{.Layer}}" style="width:5rem"></div>
<div class="col-md-auto">
<span class="badge bg-blue-lt region-shape-status">Applies everywhere</span>
<input type="hidden" name="region_primary" value="{{if .Primary}}1{{end}}">
<button type="button" class="btn btn-sm region-primary-btn{{if .Primary}} btn-primary{{end}}" onclick="setPrimaryRegion(this)">{{if .Primary}}★ Primary{{else}}Make primary{{end}}</button>
<span class="badge bg-blue-lt region-shape-status ms-1">Applies everywhere</span>
<button type="button" class="btn btn-sm btn-ghost-secondary region-clear ms-1">Clear area</button>
</div>
</div>
+6 -18
View File
@@ -51,6 +51,7 @@ type Region struct {
Token string
DisplayName string
Layer int
Primary bool // the org's primary region (frames the config preview map)
Geofence *geo.Shape
GeofenceJSON []byte
}
@@ -65,6 +66,7 @@ type RegionInput struct {
Token string
DisplayName string
Layer int
Primary bool
GeofenceJSON []byte
}
@@ -126,14 +128,14 @@ func (s *Store) ListProfiles(ctx context.Context, orgID int64) ([]Profile, error
// the order their tokens appear in a `region def` chain.
func (s *Store) ListRegions(ctx context.Context, orgID int64) ([]Region, error) {
rrows, err := s.pool.Query(ctx,
`SELECT id, token, display_name, layer, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID)
`SELECT id, token, display_name, layer, is_primary, geofence FROM config_regions WHERE org_id = $1 ORDER BY layer, token`, orgID)
if err != nil {
return nil, fmt.Errorf("list regions: %w", err)
}
regions, err := collectRows(rrows, func(r pgx.Row) (Region, error) {
var z Region
var raw []byte
if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &raw); err != nil {
if err := r.Scan(&z.ID, &z.Token, &z.DisplayName, &z.Layer, &z.Primary, &raw); err != nil {
return Region{}, err
}
if z.Geofence, err = geo.Parse(raw); err != nil {
@@ -177,8 +179,8 @@ func (s *Store) ReplaceOrgConfig(ctx context.Context, orgID int64, profiles []Pr
geofence = z.GeofenceJSON
}
if _, err := tx.Exec(ctx,
`INSERT INTO config_regions (org_id, token, display_name, layer, geofence) VALUES ($1, $2, $3, $4, $5)`,
orgID, z.Token, z.DisplayName, z.Layer, geofence); err != nil {
`INSERT INTO config_regions (org_id, token, display_name, layer, is_primary, geofence) VALUES ($1, $2, $3, $4, $5, $6)`,
orgID, z.Token, z.DisplayName, z.Layer, z.Primary, geofence); err != nil {
return fmt.Errorf("insert region %q: %w", z.Token, err)
}
}
@@ -236,20 +238,6 @@ func betterParent(a, b Region) bool {
return a.Token < b.Token
}
// RegionParentTokens returns each region's parent token ("" for a root), aligned
// with regions, using the same overlap-based parentage as the region def chain —
// for showing the derived hierarchy in the editor/read-only views.
func RegionParentTokens(regions []Region) []string {
parents := regionParents(regions)
out := make([]string, len(regions))
for i, p := range parents {
if p != -1 {
out[i] = regions[p].Token
}
}
return out
}
// RegionDefCommands renders the regions that apply at (lat, lon) into the MeshCore
// commands to run on a repeater: a single `region def …` line describing the
// region tree for the location, followed by `region save`. The tree is the subset
-7
View File
@@ -1,7 +1,6 @@
package store
import (
"reflect"
"testing"
"github.com/jleight/meshtender/internal/geo"
@@ -121,10 +120,4 @@ func TestRegionDefCommands(t *testing.T) {
if got := RegionDefCommands(regions, ptr(-5.0), ptr(-5.0)); got != nil {
t.Fatalf("outside: got %v, want nil", got)
}
// Parentage is exposed for display, aligned with regions.
wantParents := []string{"", "us", "us", "ny"}
if got := RegionParentTokens(regions); !reflect.DeepEqual(got, wantParents) {
t.Fatalf("parent tokens = %v, want %v", got, wantParents)
}
}
@@ -0,0 +1,7 @@
-- +goose Up
-- A region can be marked primary; the config page frames its location-preview
-- map on the primary region (falling back to the org's repeaters when none is set).
ALTER TABLE config_regions ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE config_regions DROP COLUMN is_primary;
+51 -38
View File
@@ -19,38 +19,36 @@ type ProfileView struct {
Steps []store.ConfigStep
}
// RegionView is an org region rendered for display: its display name, MeshCore
// token, and layer; the geofence reduced to its bounding box for the text summary
// (empty when it matches everywhere); the raw GeoJSON for the map; plus whether it
// applies at the previewed location.
type RegionView struct {
DisplayName string
Token string
Layer int
Parent string // derived parent token ("" = root)
MatchAll bool
MinLat string
MinLon string
MaxLat string
MaxLon string
GeofenceJSON string
Matches bool
}
// ConfigView is an org's configuration for display: its named profiles (with one
// selected for its base settings) and its regions (location steps), the two kept
// independent. PreviewActive is true when a location was supplied.
// selected for its base settings) and its regions, kept independent. The regions
// themselves aren't listed here — they surface only through the location picker —
// so this carries just what that map needs. PreviewActive is true when a location
// was supplied.
type ConfigView struct {
HasConfig bool
Profiles []ProfileView
Selected string
SelectedSteps []store.ConfigStep
Regions []RegionView
HasRegionShapes bool // any region has a drawn geofence (worth showing a map)
RegionDef []string // region def/save commands for the previewed location
HasRegions bool // org defines at least one region
HasRegionShapes bool // some region has a geofence (so the picker map is useful)
MapBounds []float64 // {minLat, minLon, maxLat, maxLon} framing all geofences, or nil
RegionDef []string // region def/save commands for the previewed location
PreviewActive bool
}
// bbox accumulates a lat/lon bounding box. A nil *bbox is empty, so extend can be
// chained from nil to fold in the first point/box.
type bbox struct{ minLat, minLon, maxLat, maxLon float64 }
func (b *bbox) extend(minLat, minLon, maxLat, maxLon float64) *bbox {
if b == nil {
return &bbox{minLat, minLon, maxLat, maxLon}
}
b.minLat, b.minLon = min(b.minLat, minLat), min(b.minLon, minLon)
b.maxLat, b.maxLon = max(b.maxLat, maxLat), max(b.maxLon, maxLon)
return b
}
// BuildConfigView loads an org's profiles and regions for read-only display.
// selected names the profile whose base settings to show (falls back to the
// first). lat/lon, when non-nil, mark which regions apply at that location. An
@@ -66,6 +64,7 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
}
cv := ConfigView{
HasConfig: len(profiles) > 0 || len(regions) > 0,
HasRegions: len(regions) > 0,
PreviewActive: lat != nil && lon != nil,
}
for _, p := range profiles {
@@ -82,21 +81,37 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
cv.Selected = profiles[idx].Name
cv.SelectedSteps = profiles[idx].Steps
}
parentToks := store.RegionParentTokens(regions)
for i, z := range regions {
rv := RegionView{DisplayName: z.DisplayName, Token: z.Token, Layer: z.Layer, Parent: parentToks[i], GeofenceJSON: string(z.GeofenceJSON), Matches: store.RegionMatches(z, lat, lon)}
if minLat, minLon, maxLat, maxLon, ok := z.Geofence.Bounds(); ok {
rv.MinLat = formatCoord(minLat)
rv.MinLon = formatCoord(minLon)
rv.MaxLat = formatCoord(maxLat)
rv.MaxLon = formatCoord(maxLon)
} else {
rv.MatchAll = true
}
if rv.GeofenceJSON != "" {
// Frame the location-preview map. Prefer the primary region's geofence; if
// none is set (or it has no shape), fall back to the org's public repeaters;
// failing that, the union of all geofences. (The picker only renders when
// HasRegionShapes, so a usable box almost always exists.)
var union *bbox
for _, z := range regions {
if len(z.GeofenceJSON) > 0 {
cv.HasRegionShapes = true
}
cv.Regions = append(cv.Regions, rv)
if a, b, c, d, ok := z.Geofence.Bounds(); ok {
union = union.extend(a, b, c, d)
if z.Primary {
cv.MapBounds = []float64{a, b, c, d}
}
}
}
if cv.MapBounds == nil {
if reps, err := st.ListPublicRepeaters(ctx, orgID); err == nil {
var rb *bbox
for _, rp := range reps {
if rp.HasLocation {
rb = rb.extend(rp.Lat, rp.Lon, rp.Lat, rp.Lon)
}
}
if rb != nil {
cv.MapBounds = []float64{rb.minLat, rb.minLon, rb.maxLat, rb.maxLon}
}
}
}
if cv.MapBounds == nil && union != nil {
cv.MapBounds = []float64{union.minLat, union.minLon, union.maxLat, union.maxLon}
}
if cv.PreviewActive {
cv.RegionDef = store.RegionDefCommands(regions, lat, lon)
@@ -104,8 +119,6 @@ func BuildConfigView(ctx context.Context, st *store.Store, orgID int64, selected
return cv, nil
}
func formatCoord(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
// OrgNav is the data the shared org-tabs sub-nav partial expects: the org slug,
// which tab is active ("home" | "repeaters" | "members" | "config"), whether the
// viewer is a member (the Members tab, which exposes personal info, only shows for
+27 -14
View File
@@ -221,24 +221,37 @@
// ---- Read-only viewer ---------------------------------------------------
// regions: [{name, geojson}]; preview: {lat, lon} | null.
window.regionMapView = function (mapId, regions, preview) {
// regionMapView renders a location-picker map (no region outlines — those are
// just noise here). opts:
// pickURL — clicking the map navigates here with lat/lon appended so the
// server resolves the region def for that point (must end "?"/"&");
// preview — {lat, lon} of an already-picked point, shown as a marker;
// bounds — [[minLat,minLon],[maxLat,maxLon]] to frame the org's regions.
window.regionMapView = function (mapId, opts) {
opts = opts || {};
var map = L.map(mapId, { scrollWheelZoom: false });
darkTiles(map);
var layers = [];
(regions || []).forEach(function (z, i) {
var layer = layerFromGeoJSON(z.geojson);
if (!layer) return;
if (layer.setStyle) layer.setStyle(styleFor(i, false));
layer.bindPopup(z.name).addTo(map);
layers.push(layer);
});
if (preview) {
var m = L.circleMarker([preview.lat, preview.lon], {
if (opts.pickURL) {
map.on("click", function (e) {
window.location = opts.pickURL + "lat=" + e.latlng.lat.toFixed(6) + "&lon=" + e.latlng.lng.toFixed(6);
});
}
var fit = [];
if (opts.bounds) fit.push(opts.bounds[0], opts.bounds[1]);
if (opts.preview) {
L.circleMarker([opts.preview.lat, opts.preview.lon], {
radius: 7, color: "#fff", weight: 2, fillColor: "#fff", fillOpacity: 0.9,
}).addTo(map);
layers.push(m);
fit.push([opts.preview.lat, opts.preview.lon]);
}
if (fit.length) {
var b = L.latLngBounds(fit);
// A zero-size box (single point — one repeater, or a preview with no
// region box) can't be fit; center on it at a neighborhood zoom instead.
if (b.getNorthEast().equals(b.getSouthWest())) map.setView(b.getCenter(), 11, { animate: false });
else map.fitBounds(b.pad(0.2), { animate: false });
} else {
map.setView([20, 0], 2, { animate: false });
}
fitToLayers(map, layers, preview ? [preview.lat, preview.lon] : null);
};
})();
+24 -61
View File
@@ -30,63 +30,20 @@
</div>
{{end}}
{{if .Config.Regions}}
{{if .Config.PreviewActive}}
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
<div class="card-header"><h3 class="card-title">Regions</h3>
<span class="card-subtitle ms-auto">region hierarchy</span>
</div>
<div class="card-body text-secondary">
A repeater is assigned to every region whose area contains it. Sorted by layer, their names form a <code>region def</code> command.
</div>
</div>
{{if .Config.HasRegionShapes}}
<div class="card mt-3">
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="region-map" style="height:320px" role="region" aria-label="Map of this organization's regions"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/regionmap.js"></script>
<script>
regionMapView('region-map', [
{{range .Config.Regions}}{{if .GeofenceJSON}}{name: {{.DisplayName}}, geojson: {{.GeofenceJSON}}},
{{end}}{{end}}
], {{if .Config.PreviewActive}}{lat: {{.PreviewLat}}, lon: {{.PreviewLon}}}{{else}}null{{end}});
</script>
</div>
</div>
{{end}}
{{if .Config.RegionDef}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">region def · at {{.PreviewLat}}, {{.PreviewLon}}</h3></div>
<div class="card-header"><h3 class="card-title">Regions</h3></div>
{{if .Config.RegionDef}}
<div class="card-body"><pre class="mb-0"><code>{{range .Config.RegionDef}}{{.}}
{{end}}</code></pre></div>
</div>
{{else if .Config.PreviewActive}}
<div class="card mt-3"><div class="card-body text-secondary">No regions cover {{.PreviewLat}}, {{.PreviewLon}}.</div></div>
{{end}}
<div class="card mt-3">
<div class="table-responsive">
<table class="table card-table">
<thead><tr><th>Display name</th><th>Name</th><th>Layer</th><th>Parent</th><th>Area</th>{{if .Config.PreviewActive}}<th></th>{{end}}</tr></thead>
<tbody>
{{range .Config.Regions}}
<tr>
<td>{{.DisplayName}}</td>
<td><code>{{.Token}}</code></td>
<td>{{.Layer}}</td>
<td>{{if .Parent}}<code>{{.Parent}}</code>{{else}}<span class="text-secondary">root</span>{{end}}</td>
<td class="text-secondary">{{if .MatchAll}}everywhere{{else}}lat {{.MinLat}}…{{.MaxLat}}, lon {{.MinLon}}…{{.MaxLon}}{{end}}</td>
{{if $.Config.PreviewActive}}<td>{{if .Matches}}<span class="badge bg-success-lt">applies</span>{{end}}</td>{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body text-secondary">No regions cover this location.</div>
{{end}}
</div>
{{end}}
</div>
<!-- Sidebar: profile selector (if profiles) + location preview (if regions) -->
<!-- Sidebar: profile selector (if profiles) + a click-to-preview location map -->
<div class="col-lg-4">
{{if .Config.Profiles}}
<div class="card">
@@ -100,24 +57,30 @@
{{if .PreviewLon}}<input type="hidden" name="lon" value="{{.PreviewLon}}">{{end}}
</form>
<p class="text-secondary small mt-2 mb-0">
Pick the profile matching your hardware to see its base settings.{{if .Config.Regions}} A repeater's regions are assigned separately, by location.{{end}}
Pick the profile matching your hardware to see its base settings.{{if .Config.HasRegions}} A repeater's regions are assigned separately, by location.{{end}}
</p>
</div>
</div>
{{end}}
{{if .Config.Regions}}
{{if .Config.HasRegionShapes}}
<div class="card{{if .Config.Profiles}} mt-3{{end}}">
<div class="card-header"><h3 class="card-title">Preview a location</h3></div>
<div class="card-body">
<form method="get" action="/orgs/{{.Org.Slug}}/config" class="row g-2 align-items-end">
{{if .Config.Profiles}}<input type="hidden" name="profile" value="{{.Config.Selected}}">{{end}}
<div class="col-12"><label class="form-label" for="lat">Latitude</label>
<input class="form-control" type="text" id="lat" name="lat" value="{{if .PreviewLat}}{{.PreviewLat}}{{end}}"></div>
<div class="col-12"><label class="form-label" for="lon">Longitude</label>
<input class="form-control" type="text" id="lon" name="lon" value="{{if .PreviewLon}}{{.PreviewLon}}{{end}}"></div>
<div class="col-12"><button class="btn w-100" type="submit">Resolve regions</button></div>
</form>
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="region-map" style="height:240px" role="region" aria-label="Click the map to preview a repeater's region config"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/regionmap.js"></script>
<script>
regionMapView('region-map', {
pickURL: '/orgs/{{.Org.Slug}}/config?{{if .Config.Profiles}}profile={{.Config.Selected | urlquery}}&{{end}}',
{{if .Config.PreviewActive}}preview: {lat: {{.PreviewLat}}, lon: {{.PreviewLon}}},{{end}}
{{if .Config.MapBounds}}bounds: [[{{index .Config.MapBounds 0}}, {{index .Config.MapBounds 1}}], [{{index .Config.MapBounds 2}}, {{index .Config.MapBounds 3}}]]{{end}}
});
</script>
{{if .Config.PreviewActive}}
<div class="text-secondary small mt-2 text-center">{{template "icon-map-pin" "me-1"}}<span class="font-monospace">{{.PreviewLat}}, {{.PreviewLon}}</span></div>
{{end}}
</div>
</div>
{{end}}