Files
beacon-server/internal/meshmapper/groups.go
T

202 lines
6.2 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"cmp"
"context"
"fmt"
"log/slog"
"slices"
"strings"
)
// RegionState is a stored region; imported ones are also what SaveImportedRegion writes.
type RegionState struct {
Slug, Name string
DisplayOrder int
Imported bool
IATAs []string
CenterLat, CenterLng *float64
}
// IATADetails is an IATA's display name and approximate location.
type IATADetails struct {
IATA string
Name string
Lat, Lng *float64
}
// SetConfiguredIATAs names the IATAs whose config entry sets a name or location; those win.
func (z *Zones) SetConfiguredIATAs(iatas []string) {
z.configured = map[string]bool{}
for _, iata := range iatas {
z.configured[iata] = true
}
}
// OnIATAsChange is told whenever imported IATA names or locations change.
func (z *Zones) OnIATAsChange(fn func(ctx context.Context)) { z.onIATAs = fn }
// OnRegionsChange is told whenever the imported regions change, for cache invalidation.
func (z *Zones) OnRegionsChange(fn func(ctx context.Context)) { z.onRegions = fn }
// pruneGroups drops every imported region when group import is off.
func (z *Zones) pruneGroups(ctx context.Context) error {
pruned, err := z.store.PruneImportedRegions(ctx, nil)
if err != nil {
return fmt.Errorf("prune MeshMapper regions: %w", err)
}
z.regionsRemoved(pruned)
if len(pruned) > 0 {
z.regionsChanged(ctx)
}
return nil
}
// syncDirectory applies the loaded zone lists to IATA details and, when enabled,
// imported regions. It only runs once every tracked country has a list, and again
// when a list or the IATA set changes.
func (z *Zones) syncDirectory(ctx context.Context, iatas []string) error {
var countries []string
for _, r := range z.regions {
countries = append(countries, r.country)
}
slices.Sort(countries)
groups, zones, version, complete := z.dir.snapshot(slices.Compact(countries))
if !complete || (z.groupsSynced && version == z.groupsVersion && len(iatas) == z.groupsKnown) {
return nil
}
if err := z.syncIATADetails(ctx, zones); err != nil {
return err
}
if z.importGroups {
if err := z.syncGroups(ctx, iatas, groups, zones); err != nil {
return err
}
}
z.groupsSynced, z.groupsVersion, z.groupsKnown = true, version, len(iatas)
return nil
}
// syncIATADetails names and locates each listed IATA unless its config entry does.
func (z *Zones) syncIATADetails(ctx context.Context, zones map[string]zoneEntry) error {
stored, err := z.store.ListIATADetails(ctx)
if err != nil {
return fmt.Errorf("list IATAs for MeshMapper details: %w", err)
}
changed := false
for _, cur := range stored {
zone, ok := zones[cur.IATA]
if !ok || z.configured[cur.IATA] || zone.name == "" && zone.lat == nil {
continue
}
name := cmp.Or(zone.name, cur.Name)
lat, lng := cur.Lat, cur.Lng
if zone.lat != nil {
lat, lng = zone.lat, zone.lon
}
if name == cur.Name && sameFloat(lat, cur.Lat) && sameFloat(lng, cur.Lng) {
continue
}
if err := z.store.UpsertIATADetails(ctx, cur.IATA, name, lat, lng); err != nil {
return fmt.Errorf("save MeshMapper details for %s: %w", cur.IATA, err)
}
changed = true
}
if changed && z.onIATAs != nil {
z.onIATAs(ctx)
}
return nil
}
func (z *Zones) syncGroups(ctx context.Context, iatas []string, groups map[string]zoneGroup, zones map[string]zoneEntry) error {
known := make(map[string]bool, len(iatas))
for _, iata := range iatas {
known[iata] = true
}
state, err := z.store.ListRegionState(ctx)
if err != nil {
return fmt.Errorf("list regions for MeshMapper groups: %w", err)
}
stored, handOrder := map[string]RegionState{}, 0
for _, r := range state {
stored[r.Slug] = r
if !r.Imported {
handOrder = max(handOrder, r.DisplayOrder)
}
}
var want []RegionState
for code, g := range groups {
if slices.ContainsFunc(g.members, func(m string) bool { return known[m] }) {
lat, lng := center(g.members, zones)
want = append(want, RegionState{Slug: strings.ToLower(code), Name: g.name, Imported: true, IATAs: g.members, CenterLat: lat, CenterLng: lng})
}
}
slices.SortFunc(want, func(a, b RegionState) int { return cmp.Or(cmp.Compare(a.Name, b.Name), cmp.Compare(a.Slug, b.Slug)) })
keep, changed := []string{}, false
for n, r := range want {
r.DisplayOrder = handOrder + 1 + n
if cur, ok := stored[r.Slug]; ok && !cur.Imported {
slog.Info("MeshMapper group skipped: a configured region uses its slug", "component", "meshmapper.zones", "slug", r.Slug)
continue
} else if ok && cur.Name == r.Name && cur.DisplayOrder == r.DisplayOrder && slices.Equal(cur.IATAs, r.IATAs) &&
sameFloat(cur.CenterLat, r.CenterLat) && sameFloat(cur.CenterLng, r.CenterLng) {
keep = append(keep, r.Slug)
continue
}
saved, err := z.store.SaveImportedRegion(ctx, r)
if err != nil {
return fmt.Errorf("save MeshMapper region %s: %w", r.Slug, err)
}
if !saved {
slog.Info("MeshMapper group skipped: a configured region uses its slug", "component", "meshmapper.zones", "slug", r.Slug)
continue
}
keep, changed = append(keep, r.Slug), true
slog.Info("MeshMapper region imported", "component", "meshmapper.zones", "slug", r.Slug, "name", r.Name, "iatas", r.IATAs)
}
pruned, err := z.store.PruneImportedRegions(ctx, keep)
if err != nil {
return fmt.Errorf("prune MeshMapper regions: %w", err)
}
z.regionsRemoved(pruned)
if changed || len(pruned) > 0 {
z.regionsChanged(ctx)
}
return nil
}
// center averages the members' zone locations; nil when none has one.
func center(members []string, zones map[string]zoneEntry) (*float64, *float64) {
var lat, lng float64
n := 0
for _, m := range members {
if z := zones[m]; z.lat != nil {
lat, lng, n = lat+*z.lat, lng+*z.lon, n+1
}
}
if n == 0 {
return nil, nil
}
lat, lng = lat/float64(n), lng/float64(n)
return &lat, &lng
}
func sameFloat(a, b *float64) bool {
return a == nil && b == nil || a != nil && b != nil && *a == *b
}
func (z *Zones) regionsRemoved(slugs []string) {
for _, slug := range slugs {
slog.Info("MeshMapper region removed", "component", "meshmapper.zones", "slug", slug)
}
}
func (z *Zones) regionsChanged(ctx context.Context) {
if z.onRegions != nil {
z.onRegions(ctx)
}
}