diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..7aab734 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/hamview-server" + cmd = "go build -o ./tmp/hamview-server ./cmd/hamview-server" + delay = 1000 + entrypoint = ["./tmp/hamview-server", "-T", "--config", "./etc/hamview-server.yaml"] + exclude_dir = ["assets", "tmp", "vendor", "testdata", "ui"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore index c343446..4ec4e1b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # Test binary, built with `go test -c` *.test +test.db # Output of the go coverage tool, specifically when used with LiteIDE *.out @@ -23,6 +24,7 @@ go.work.sum # Build artifacts build/ +tmp/ # Local configuration files etc/*.key diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/.vscode/settings.json b/.vscode/settings.json index 2ff3820..96706d6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,45 @@ { - "gopls": { - "formatting.local": "git.maze.io" - } + "gopls": { + "formatting.local": "git.maze.io", + "ui.semanticTokens": true + }, + + // Global defaults for all other languages (4 spaces) + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.detectIndentation": false, + + // Go: Use tabs, with a tab size of 4 + "[go]": { + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.detectIndentation": false + }, + + // CSS, JavaScript, TypeScript, JSON: Use 2 spaces + "[css]": { + "editor.tabSize": 2, + }, + "[javascript]": { + "editor.tabSize": 2, + }, + "[typescript]": { + "editor.tabSize": 2, + }, + "[typescriptreact]": { + "editor.tabSize": 2, + }, + "[json]": { + "editor.tabSize": 2, + }, + "[yaml]": { + "editor.tabSize": 2, + }, + + // For JSON with comments, often used in VSCode config files + "[jsonc]": { + "editor.insertSpaces": true, + "editor.tabSize": 2, + "editor.detectIndentation": false + } } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d965fe8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# AGENTS + +This document provides context for AI agents working on this codebase. + +## Project Overview + +HAMView is an online Amateur Radio digital protocol live viewer. It features: +- Displaying online radio receivers in near real-time +- Streaming of popular Amateur Radio protocols such as APRS, MeshCore, etc. +- A live packet stream for each of the protocols +- Packet inspection + +## Tech Stack + +Used technologies: +- **Framework**: Go with echo 4 +- **Logging**: logrus +- **Code Editor**: Visual Studio Code +- **Backend**: Xorm on PostgreSQL with PostGIS extensions +- **Broker**: Mosquitto broker +- **Testing**: use `go test -v` + +## Testing Requirements + +**Always run tests before completing a task.** + +Run `go test -v` and `golangci-lint run`. + +## Coding Guidelines + +### General +- Use builtins from Go, echo and the libraries from `go.mod` where possible +- Follow existing code patterns in the code base +- Don't add new imports unless necessary + +### Styling +- Use Go builtins where appropriate +- Order imports with `goimports` + +## Protected files + +Never add secrets to code, unless a secret is used as a test vector. In that case, ask +for confirmation before adding or changing. diff --git a/asset/image/device/unknown.png b/asset/image/device/unknown.png new file mode 100644 index 0000000..4c89a31 Binary files /dev/null and b/asset/image/device/unknown.png differ diff --git a/asset/image/protocol/aprs.org.png b/asset/image/protocol/aprs.org.png new file mode 100644 index 0000000..132ed1c Binary files /dev/null and b/asset/image/protocol/aprs.org.png differ diff --git a/asset/image/protocol/aprs.png b/asset/image/protocol/aprs.png new file mode 100644 index 0000000..6abc940 Binary files /dev/null and b/asset/image/protocol/aprs.png differ diff --git a/asset/image/protocol/unknown.org.png b/asset/image/protocol/unknown.org.png new file mode 100644 index 0000000..2644c45 Binary files /dev/null and b/asset/image/protocol/unknown.org.png differ diff --git a/asset/image/protocol/unknown.png b/asset/image/protocol/unknown.png new file mode 100644 index 0000000..562c2fc Binary files /dev/null and b/asset/image/protocol/unknown.png differ diff --git a/broker.go b/broker.go index ee18fc2..c3eacd9 100644 --- a/broker.go +++ b/broker.go @@ -33,13 +33,18 @@ type Broker interface { SubscribeRadios() (<-chan *Radio, error) PublishPacket(topic string, packet *protocol.Packet) error - SubscribePackets(topic string) (<-chan *protocol.Packet, error) + SubscribePackets(topic string) (<-chan *Packet, error) } type Receiver interface { Disconnected() } +type Packet struct { + RadioID string + *protocol.Packet +} + type BrokerConfig struct { Type string `yaml:"type"` Config yaml.Node `yaml:"conf"` @@ -197,7 +202,7 @@ func (broker *mqttBroker) SubscribeRadios() (<-chan *Radio, error) { } radios := make(chan *Radio, 8) - token := broker.client.Subscribe("radio/#", 0, func(_ mqtt.Client, message mqtt.Message) { + token := broker.client.Subscribe("radio/+", 0, func(_ mqtt.Client, message mqtt.Message) { var radio Radio if err := json.Unmarshal(message.Payload(), &radio); err == nil { select { @@ -232,17 +237,24 @@ func (broker *mqttBroker) PublishPacket(topic string, packet *protocol.Packet) e return nil } -func (broker *mqttBroker) SubscribePackets(topic string) (<-chan *protocol.Packet, error) { +func (broker *mqttBroker) SubscribePackets(topic string) (<-chan *Packet, error) { if broker.client == nil { return nil, ErrBrokerNotStarted } - packets := make(chan *protocol.Packet, 16) + packets := make(chan *Packet, 16) token := broker.client.Subscribe(topic, 0, func(_ mqtt.Client, message mqtt.Message) { - var packet protocol.Packet + var ( + part = strings.Split(message.Topic(), "/") + id = part[len(part)-1] + packet protocol.Packet + ) if err := json.Unmarshal(message.Payload(), &packet); err == nil { select { - case packets <- &packet: + case packets <- &Packet{ + RadioID: id, + Packet: &packet, + }: default: } } diff --git a/cmd/hamview-collector/main.go b/cmd/hamview-collector/main.go index c036970..0f87835 100644 --- a/cmd/hamview-collector/main.go +++ b/cmd/hamview-collector/main.go @@ -64,7 +64,6 @@ func run(ctx context.Context, command *cli.Command) error { if err != nil { return err } - defer collector.Close() broker, err := hamview.NewBroker(&config.Broker) if err != nil { @@ -79,7 +78,11 @@ func run(ctx context.Context, command *cli.Command) error { protocol.APRS, protocol.MeshCore, } { - go collector.Collect(broker, proto+"/packet") + go func() { + if err := collector.Collect(broker, proto+"/packet/+"); err != nil { + logger.Fatalf("Error collecting %s packets: %v", proto, err) + } + }() } return cmd.WaitForInterrupt(logger, "collector") diff --git a/cmd/hamview-receiver/run_aprsis.go b/cmd/hamview-receiver/run_aprsis.go index db172c8..2f60bcf 100644 --- a/cmd/hamview-receiver/run_aprsis.go +++ b/cmd/hamview-receiver/run_aprsis.go @@ -2,19 +2,22 @@ package main import ( "context" + "encoding/base64" + "time" "github.com/urfave/cli/v3" "git.maze.io/go/ham/protocol/aprs/aprsis" - + "git.maze.io/go/ham/radio" "git.maze.io/ham/hamview" "git.maze.io/ham/hamview/cmd" ) type aprsisConfig struct { - Broker hamview.BrokerConfig `yaml:"broker"` - Receiver hamview.APRSISConfig `yaml:"receiver"` - Include []string `yaml:"include"` + Broker hamview.BrokerConfig `yaml:"broker"` + Receiver hamview.APRSISConfig `yaml:"receiver"` + Radio map[string]*radio.Info `yaml:"radio"` + Include []string `yaml:"include"` } func (config *aprsisConfig) Includes() []string { @@ -43,15 +46,19 @@ func runAPRSIS(ctx context.Context, command *cli.Command) error { } proxy.OnClient = func(callsign string, client *aprsis.ProxyClient) { - go receiveAPRSIS(&config.Broker, callsign, client) + go receiveAPRSIS(&config.Broker, callsign, client, config.Radio[callsign]) } return waitForInterrupt() } -func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis.ProxyClient) { +func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis.ProxyClient, extra *radio.Info) { defer func() { _ = client.Close() }() + if extra == nil { + logger.Warnf("receiver: no radio info configured for %s!", callsign) + } + broker, err := hamview.NewBroker(config) if err != nil { logger.Errorf("receiver: can't setup to broker: %v", err) @@ -59,19 +66,81 @@ func receiveAPRSIS(config *hamview.BrokerConfig, callsign string, client *aprsis } defer func() { _ = broker.Close() }() - info := client.Info() // TODO: enrich info from config? + info := client.Info() + if extra != nil { + info.Manufacturer = pick(info.Manufacturer, extra.Manufacturer) + info.Device = pick(info.Device, extra.Device) + info.FirmwareDate = pickTime(info.FirmwareDate, extra.FirmwareDate) + info.FirmwareVersion = pick(info.FirmwareVersion, extra.FirmwareVersion) + info.Antenna = pick(info.Antenna, extra.Antenna) + info.Modulation = pick(info.Modulation, extra.Modulation) + info.Position = pickPosition(info.Position, extra.Position) + info.Frequency = pickFloat64(info.Frequency, extra.Frequency) + info.Bandwidth = pickFloat64(info.Bandwidth, extra.Bandwidth) + info.Power = pickFloat64(info.Power, extra.Power) + info.Gain = pickFloat64(info.Gain, extra.Gain) + info.LoRaSF = pickUint8(info.LoRaSF, extra.LoRaSF) + info.LoRaCR = pickUint8(info.LoRaCR, extra.LoRaCR) + } if err = broker.StartRadio("aprs", info); err != nil { logger.Fatalf("receiver: can't start broker: %v", err) return } + id := base64.RawURLEncoding.EncodeToString([]byte(callsign)) + logger.Infof("receiver: start receiving packets from station: %s", callsign) for packet := range client.RawPackets() { logger.Debugf("aprs packet: %#+v", packet) - if err := broker.PublishPacket("aprs/packet", packet); err != nil { + if err := broker.PublishPacket("aprs/packet/"+id, packet); err != nil { logger.Error(err) } } logger.Infof("receiver: stopped receiving packets from station: %s", callsign) } + +func pick(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} + +func pickFloat64(vv ...float64) float64 { + for _, v := range vv { + if v != 0 { + return v + } + } + return 0 +} + +func pickPosition(vv ...*radio.Position) *radio.Position { + for _, v := range vv { + if v != nil { + return v + } + } + return nil +} + +func pickTime(tt ...time.Time) time.Time { + for _, t := range tt { + if !t.Equal(time.Time{}) { + return t + } + } + return time.Time{} +} + +func pickUint8(vv ...uint8) uint8 { + for _, v := range vv { + if v != 0 { + return v + } + } + return 0 +} diff --git a/cmd/hamview-receiver/run_meshcore.go b/cmd/hamview-receiver/run_meshcore.go index f64f5ca..f8b5533 100644 --- a/cmd/hamview-receiver/run_meshcore.go +++ b/cmd/hamview-receiver/run_meshcore.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/base64" "github.com/urfave/cli/v3" @@ -48,6 +49,9 @@ func runMeshCore(ctx context.Context, command *cli.Command) error { return err } + // Node id + id := base64.RawURLEncoding.EncodeToString([]byte(info.Name)) + // Trace scheduler //go receiver.RunTraces() @@ -68,7 +72,7 @@ func runMeshCore(ctx context.Context, command *cli.Command) error { payloadType, len(packet.Raw)) } - if err = broker.PublishPacket("meshcore/packet", packet); err != nil { + if err = broker.PublishPacket("meshcore/packet/"+id, packet); err != nil { logger.Errorf("receiver: failed to publish packet: %v", err) } } diff --git a/collector.go b/collector.go index f497654..6177637 100644 --- a/collector.go +++ b/collector.go @@ -1,15 +1,22 @@ package hamview import ( + "context" "database/sql" + "encoding/hex" "fmt" + "strings" + "time" _ "github.com/cridenour/go-postgis" // PostGIS support - "github.com/lib/pq" // PostgreSQL support + _ "github.com/lib/pq" // PostgreSQL support + "xorm.io/builder" + "xorm.io/xorm" "git.maze.io/go/ham/protocol" "git.maze.io/go/ham/protocol/aprs" "git.maze.io/go/ham/protocol/meshcore" + "git.maze.io/ham/hamview/schema" ) type CollectorConfig struct { @@ -22,58 +29,20 @@ type DatabaseConfig struct { } type Collector struct { - *sql.DB - + radioByID map[string]*schema.Radio meshCoreGroup map[byte][]*meshcore.Group } func NewCollector(config *CollectorConfig) (*Collector, error) { - d, err := sql.Open(config.Database.Type, config.Database.Conf) - if err != nil { + Logger.Debugf("collector: opening %q database", config.Database.Type) + schema.Logger = Logger + if err := schema.Open(config.Database.Type, config.Database.Conf); err != nil { return nil, err } - for _, query := range []string{ - // radio.* - sqlCreateRadio, - sqlIndexRadioName, - sqlIndexRadioProtocol, - sqlGeometryRadioPosition, - - // meshcore_packet.* - sqlCreateMeshCorePacket, - sqlIndexMeshCorePacketHash, - sqlIndexMeshCorePacketPayloadType, - - // meshcore_node.* - sqlCreateMeshCoreNode, - sqlIndexMeshCoreNodeName, - sqlAlterMeshCoreNodePrefix, - sqlGeometryMeshCoreNodePosition, - - // meshcore_node_position.* - sqlCreateMeshCoreNodePosition, - sqlGeometryMeshCoreNodePositionPosition, - sqlIndexMeshCoreNodePositionPosition, - } { - if _, err := d.Exec(query); err != nil { - var ignore bool - if err, ok := err.(*pq.Error); ok { - switch err.Code { - case "42701": // column "x" of relation "y" already exists (42701) - ignore = true - } - } - Logger.Debugf("collector: sql error %T: %v", err, err) - if !ignore { - return nil, fmt.Errorf("error in query %s: %v", query, err) - } - } - } - return &Collector{ - DB: d, meshCoreGroup: make(map[byte][]*meshcore.Group), + radioByID: make(map[string]*schema.Radio), }, nil } @@ -92,6 +61,8 @@ func (c *Collector) Collect(broker Broker, topic string) error { return err } + ctx := context.Background() + loop: for { select { @@ -99,7 +70,7 @@ loop: if radio == nil { break loop } - c.processRadio(radio) + c.processRadio(ctx, radio) case packet := <-packets: if packet == nil { @@ -107,9 +78,9 @@ loop: } switch packet.Protocol { case protocol.APRS: - c.processAPRSPacket(packet) + c.processAPRSPacket(ctx, packet) case protocol.MeshCore: - c.processMeshCorePacket(packet) + c.processMeshCorePacket(ctx, packet) } } } @@ -119,150 +90,182 @@ loop: return nil } -func (c *Collector) processRadio(radio *Radio) { - Logger.Tracef("collector: process %s radio %q online %t", - radio.Protocol, - radio.Name, - radio.IsOnline) - - var latitude, longitude, altitude *float64 - if radio.Position != nil { - latitude = &radio.Position.Latitude - longitude = &radio.Position.Longitude - altitude = &radio.Position.Altitude +func (c *Collector) getRadioByID(ctx context.Context, id string) (*schema.Radio, error) { + id = strings.TrimRight(id, "=") + if radio, ok := c.radioByID[id]; ok { + return radio, nil } - var id int64 - if err := c.QueryRow(` - INSERT INTO radio ( - name, - is_online, - device, - manufacturer, - firmware_date, - firmware_version, - antenna, - modulation, - protocol, - latitude, - longitude, - altitude, - frequency, - rx_frequency, - tx_frequency, - bandwidth, - power, - gain, - lora_sf, - lora_cr, - extra - ) VALUES ( - $1, - $2, - NULLIF($3, ''), -- device - NULLIF($4, ''), -- manufacturer - $5, - NULLIF($6, ''), -- firmware_version - NULLIF($7, ''), -- antenna - NULLIF($8, ''), -- modulation - $9, -- protocol - NULLIF($10, 0.0), -- latitude - NULLIF($11, 0.0), -- longitude - $12, -- altitude - $13, -- frequency - NULLIF($14, 0.0), -- rx_frequency - NULLIF($15, 0.0), -- tx_frequency - $16, -- bandwidth - NULLIF($17, 0.0), -- power - NULLIF($18, 0.0), -- gain - NULLIF($19, 0), -- lora_sf - NULLIF($20, 0), -- lora_cr - $21 - ) - ON CONFLICT (name) - DO UPDATE - SET - is_online = $2, - device = NULLIF($3, ''), - manufacturer = NULLIF($4, ''), - firmware_date = $5, - firmware_version = NULLIF($6, ''), - antenna = NULLIF($7, ''), - modulation = NULLIF($8, ''), - protocol = $9, - latitude = NULLIF($10, 0.0), - longitude = NULLIF($11, 0.0), - altitude = $12, - frequency = $13, - rx_frequency = NULLIF($14, 0.0), - tx_frequency = NULLIF($15, 0.0), - bandwidth = $16, - power = NULLIF($17, 0), - gain = NULLIF($18, 0), - lora_sf = NULLIF($19, 0), - lora_cr = NULLIF($20, 0), - extra = $21 - RETURNING id - `, - radio.Name, - radio.IsOnline, - radio.Device, - radio.Manufacturer, - radio.FirmwareDate, - radio.FirmwareVersion, - radio.Antenna, - radio.Modulation, - radio.Protocol, - latitude, - longitude, - altitude, - radio.Frequency, - radio.RXFrequency, - radio.TXFrequency, - radio.Bandwidth, - radio.Power, - radio.Gain, - radio.LoRaSF, - radio.LoRaCR, - nil, - ).Scan(&id); err != nil { - Logger.Warnf("collector: error storing radio: %v", err) + radio, err := schema.GetRadioByEncodedID(ctx, id) + if err == nil { + c.radioByID[id] = radio + } + return radio, err +} + +func (c *Collector) processRadio(ctx context.Context, received *Radio) { + Logger.Tracef("collector: process %s radio %q online %t", + received.Protocol, + received.Name, + received.IsOnline) + + var ( + now = time.Now() + engine = schema.Query(ctx).(*xorm.Session) + ) + if err := engine.Begin(); err != nil { + Logger.Warnf("collector: can't start session: %v", err) return } + + radio := new(schema.Radio) + has, err := engine.Where(builder.Eq{ + "name": received.Name, + "protocol": received.Protocol, + }).Get(radio) + if err != nil { + Logger.Warnf("collector: can't query radio: %v", err) + return + } + if has { + radio.IsOnline = received.IsOnline + radio.UpdatedAt = now + if _, err = engine.Cols("is_online", "updated_at").Update(radio); err != nil { + _ = engine.Rollback() + Logger.Warnf("collector: can't update radio: %v", err) + return + } + } else { + radio = &schema.Radio{ + Name: received.Name, + IsOnline: received.IsOnline, + Manufacturer: received.Manufacturer, + Device: schema.NULLString(received.Device), + FirmwareVersion: schema.NULLString(received.FirmwareVersion), + FirmwareDate: schema.NULLTime(received.FirmwareDate), + Antenna: schema.NULLString(received.Antenna), + Modulation: received.Modulation, + Protocol: received.Protocol, + Frequency: received.Frequency, + Bandwidth: received.Bandwidth, + Power: schema.NULLFloat64(received.Power), + Gain: schema.NULLFloat64(received.Gain), + CreatedAt: now, + UpdatedAt: now, + } + if received.Position != nil { + radio.Latitude = &received.Position.Latitude + radio.Longitude = &received.Position.Longitude + radio.Altitude = &received.Position.Altitude + } + if received.LoRaCR != 0 && received.LoRaSF != 0 { + radio.LoRaSF = &received.LoRaSF + radio.LoRaCR = &received.LoRaCR + } + if _, err = engine.Insert(radio); err != nil { + Logger.Warnf("collector: can't insert radio %#+v: %v", radio, err) + return + } + } + + if err = engine.Commit(); err != nil { + Logger.Errorf("collector: can't commit radio session: %v", err) + } } -func (c *Collector) processAPRSPacket(packet *protocol.Packet) { - decoded, err := aprs.ParsePacket(string(packet.Raw)) +func (c *Collector) processAPRSPacket(ctx context.Context, received *Packet) { + radio, err := c.getRadioByID(ctx, received.RadioID) if err != nil { - Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err) + Logger.Warnf("collector: process %s packet: can't find radio %q: %v", received.Protocol, received.RadioID, err) + return + } + + decoded, err := aprs.Parse(string(received.Raw)) + if err != nil { + Logger.Warnf("collector: invalid %s packet: %v", received.Protocol, err) return } Logger.Tracef("collector: process %s packet (%d bytes)", - packet.Protocol, - len(packet.Raw)) + received.Protocol, + len(received.Raw)) - var id int64 - if err := c.QueryRow(` - INSERT INTO aprs_packet ( - src_address, - dst_address, - comment - ) VALUES ($1, $2, $3) - RETURNING id; - `, - decoded.Src.String(), - decoded.Dst.String(), - decoded.Comment, - ).Scan(&id); err != nil { - Logger.Warnf("collector: error storing packet: %v", err) + engine := schema.Query(ctx) + station := new(schema.APRSStation) + has, err := engine.Where(builder.Eq{"call": strings.ToUpper(decoded.Source.String())}).Get(station) + if err != nil { + Logger.Warnf("collector: can't query APRS station: %v", err) return + } else if has { + cols := []string{"last_heard_at"} + station.LastHeardAt = received.Time + if decoded.Latitude != 0 { + station.LastLatitude = sql.NullFloat64{Float64: decoded.Latitude, Valid: true} + station.LastLongitude = sql.NullFloat64{Float64: decoded.Longitude, Valid: true} + cols = append(cols, "last_latitude", "last_longitude") + } + if _, err = engine.ID(station.ID).Cols(cols...).Update(station); err != nil { + Logger.Warnf("collector: can't update APRS station: %v", err) + return + } + } else { + station = &schema.APRSStation{ + Call: strings.ToUpper(decoded.Source.String()), + Symbol: decoded.Symbol, + FirstHeardAt: received.Time, + LastHeardAt: received.Time, + } + if decoded.Latitude != 0 { + station.LastLatitude = sql.NullFloat64{ + Float64: decoded.Latitude, + Valid: decoded.Latitude != 0, + } + station.LastLongitude = sql.NullFloat64{ + Float64: decoded.Longitude, + Valid: decoded.Longitude != 0, + } + } + if station.ID, err = engine.Insert(station); err != nil { + Logger.Warnf("collector: can't insert APRS station: %v", err) + return + } + } + + packet := &schema.APRSPacket{ + RadioID: radio.ID, + StationID: station.ID, + Source: station.Call, + Destination: decoded.Destination.String(), + Path: decoded.Path.String(), + Comment: decoded.Comment, + Symbol: string(decoded.Symbol[:]), + Raw: string(received.Raw), + } + if decoded.Latitude != 0 { + packet.Latitude = sql.NullFloat64{ + Float64: decoded.Latitude, + Valid: decoded.Latitude != 0, + } + packet.Longitude = sql.NullFloat64{ + Float64: decoded.Longitude, + Valid: decoded.Longitude != 0, + } + } + + if _, err = engine.Insert(packet); err != nil { + Logger.Warnf("collector: can't insert APRS packet: %v", err) } } -func (c *Collector) processMeshCorePacket(packet *protocol.Packet) { +func (c *Collector) processMeshCorePacket(ctx context.Context, packet *Packet) { + radio, err := c.getRadioByID(ctx, packet.RadioID) + if err != nil { + Logger.Warnf("collector: process %s packet: can't find radio %q: %v", packet.Protocol, packet.RadioID, err) + return + } + var parsed meshcore.Packet - if err := parsed.UnmarshalBytes(packet.Raw); err != nil { + if err = parsed.UnmarshalBytes(packet.Raw); err != nil { Logger.Warnf("collector: invalid %s packet: %v", packet.Protocol, err) return } @@ -276,125 +279,137 @@ func (c *Collector) processMeshCorePacket(packet *protocol.Packet) { parsed.Path = nil // store NULL } - var id int64 - if err := c.QueryRow(` - INSERT INTO meshcore_packet ( - snr, - rssi, - hash, - route_type, - payload_type, - path, - payload, - raw, - received_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - RETURNING id;`, - packet.SNR, - packet.RSSI, - parsed.Hash(), - parsed.RouteType, - parsed.PayloadType, - parsed.Path, - parsed.Payload, - packet.Raw, - packet.Time, - ).Scan(&id); err != nil { + var channelHash string + switch parsed.PayloadType { + case meshcore.TypeGroupText, meshcore.TypeGroupData: + if len(parsed.Payload) > 0 { + channelHash = fmt.Sprintf("%02x", parsed.Payload[0]) + } + } + + var ( + save = &schema.MeshCorePacket{ + RadioID: radio.ID, + SNR: packet.SNR, + RSSI: packet.RSSI, + RouteType: uint8(parsed.RouteType), + PayloadType: uint8(parsed.PayloadType), + Version: parsed.Version, + Hash: hex.EncodeToString(parsed.Hash()), + Path: parsed.Path, + Payload: parsed.Payload, + ChannelHash: channelHash, + Raw: packet.Raw, + ReceivedAt: packet.Time, + } + ) + if _, err = schema.Query(ctx).Insert(save); err != nil { Logger.Warnf("collector: error storing packet: %v", err) return } switch parsed.PayloadType { case meshcore.TypeAdvert: - payload, err := parsed.Decode() - if err != nil { - Logger.Warnf("collector: error decoding packet: %v", err) - return - } - - var ( - advert = payload.(*meshcore.Advert) - nodeID int64 - latitude *float64 - longitude *float64 - ) - if advert.Position != nil { - latitude = &advert.Position.Latitude - longitude = &advert.Position.Longitude - } - if err = c.QueryRow(` - INSERT INTO meshcore_node ( - node_type, - public_key, - name, - local_time, - first_heard, - last_heard, - last_latitude, - last_longitude, - last_advert_id - ) VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - $8, - $9 - ) - ON CONFLICT (public_key) - DO UPDATE - SET - name = $3, - local_time = $4, - last_heard = $6, - last_latitude = $7, - last_longitude = $8, - last_advert_id = $9 - RETURNING id - `, - advert.Type, - advert.PublicKey.Bytes(), - advert.Name, - advert.Time, - packet.Time, - packet.Time, - latitude, - longitude, - id, - ).Scan(&nodeID); err != nil { - Logger.Warnf("collector: error storing node: %v", err) - return - } - - if advert.Position != nil { - if _, err = c.Exec(` - INSERT INTO meshcore_node_position ( - node_id, - heard_at, - latitude, - longitude, - position - ) VALUES ( - $1, - $2, - $3, - $4, - ST_SetSRID(ST_MakePoint($5, $6), 4326) - ); - `, - nodeID, - packet.Time, - advert.Position.Latitude, - advert.Position.Longitude, - advert.Position.Latitude, - advert.Position.Longitude, - ); err != nil { - Logger.Warnf("collector: error storing node position: %v", err) - return - } - } + c.processMeshCoreAdvert(ctx, save, &parsed) } } + +func (c *Collector) processMeshCoreAdvert(ctx context.Context, packet *schema.MeshCorePacket, parsed *meshcore.Packet) { + payload, err := parsed.Decode() + if err != nil { + Logger.Warnf("collector: error decoding packet: %v", err) + return + } + + advert, ok := payload.(*meshcore.Advert) + if !ok { + Logger.Warnf("collector: expected Advert, got %T!?", payload) + return + } + + node := &schema.MeshCoreNode{ + PacketHash: packet.Hash, + Name: advert.Name, + Type: uint8(advert.Type), + Prefix: fmt.Sprintf("%02x", advert.PublicKey.Bytes()[0]), + PublicKey: hex.EncodeToString(advert.PublicKey.Bytes()), + FirstHeardAt: packet.ReceivedAt, + LastHeardAt: packet.ReceivedAt, + } + if advert.Position != nil { + node.LastLatitude = &advert.Position.Latitude + node.LastLongitude = &advert.Position.Longitude + } + + var ( + engine = schema.Query(ctx) + existing = new(schema.MeshCoreNode) + ) + if err = engine.Begin(); err != nil { + Logger.Warnf("collector: can't start session: %v", err) + return + } + + var has bool + if has, err = engine.Where(builder.Eq{"`public_key`": node.PublicKey}).Get(existing); err != nil { + _ = engine.Rollback() + Logger.Warnf("collector: can't query session: %v", err) + return + } + + if has { + cols := []string{"last_heard_at"} + existing.LastHeardAt = packet.ReceivedAt + if advert.Position != nil { + existing.LastLatitude = node.LastLatitude + existing.LastLongitude = node.LastLongitude + cols = append(cols, "last_latitude", "last_longitude") + } + existing.Name = node.Name + _, err = engine.ID(existing.ID).Cols(cols...).Update(existing) + } else { + _, err = engine.Insert(node) + } + + if err != nil { + _ = engine.Rollback() + Logger.Warnf("collector: can't save (update: %t): %v", has, err) + return + } + + if err = engine.Commit(); err != nil { + Logger.Warnf("collector: can't commit session: %v", err) + return + } + + /* + if advert.Position != nil { + if _, err = c.DB.Exec(` + INSERT INTO meshcore_node_position ( + node_id, + heard_at, + latitude, + longitude, + position + ) VALUES ( + $1, + $2, + $3, + $4, + ST_SetSRID(ST_MakePoint($5, $6), 4326) + ); + `, + nodeID, + packet.Time, + advert.Position.Latitude, + advert.Position.Longitude, + advert.Position.Latitude, + advert.Position.Longitude, + ); err != nil { + Logger.Warnf("collector: error storing node position: %v", err) + return + } + } + } + */ +} diff --git a/go.mod b/go.mod index b3eac61..30ae8c3 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,11 @@ module git.maze.io/ham/hamview -go 1.25.6 +go 1.26 + +replace git.maze.io/go/ham => ../ham require ( - git.maze.io/go/ham v0.1.1-0.20260223201507-65f3fe39a98b + git.maze.io/go/ham v0.1.1-0.20260302213739-8f8a97300f5c github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34 github.com/cridenour/go-postgis v1.0.1 @@ -11,20 +13,26 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/labstack/echo/v4 v4.15.0 github.com/lib/pq v1.11.2 + github.com/mattn/go-sqlite3 v1.14.32 github.com/sirupsen/logrus v1.9.4 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 github.com/urfave/cli/v3 v3.6.2 go.yaml.in/yaml/v3 v3.0.4 + xorm.io/builder v0.3.13 + xorm.io/xorm v1.3.11 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/golang/snappy v0.0.4 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/kr/pretty v0.3.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/rogpeppe/go-internal v1.8.0 // indirect + github.com/syndtr/goleveldb v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/crypto v0.48.0 // indirect diff --git a/go.sum b/go.sum index 3123d60..2f5f71c 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -git.maze.io/go/ham v0.1.0 h1:ytqqkGux4E6h3QbCB3zJy/Ngc+fEqodyMpepbp9o/ts= -git.maze.io/go/ham v0.1.0/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU= -git.maze.io/go/ham v0.1.1-0.20260223201507-65f3fe39a98b h1:Wzt2uXbqW9h/159KeXY95CrDoLN0m3HCxPC6jPLO6ws= -git.maze.io/go/ham v0.1.1-0.20260223201507-65f3fe39a98b/go.mod h1:+WuiawzNBqlWgklVoodUAJc0cV+NDW6RR8Tn+AW8hsU= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= +gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e h1:Ck+0lNRr62RM/LNKkkD0R1aJ2DvgELqmmuNvyyHL75E= github.com/Vaniog/go-postgis v0.0.0-20240619200434-9c2eb8ed621e/go.mod h1:o3MIxN5drWoGBTtBGtLqFZlr7RjfdQKnfwYXoUU77vU= github.com/cemkiy/echo-logrus v0.0.0-20200218141616-06f9cd1dae34 h1:cGxEwqDl+PiqPtJpQNoiJIXcrVEkkSMuMQtb+PPAHL4= @@ -15,12 +13,29 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -49,9 +64,20 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= @@ -61,9 +87,12 @@ github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC4 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= @@ -81,13 +110,18 @@ golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -107,11 +141,43 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= +modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= +modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= +modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE= +modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= +xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= +xorm.io/xorm v1.3.11 h1:i4tlVUASogb0ZZFJHA7dZqoRU2pUpUsutnNdaOlFyMI= +xorm.io/xorm v1.3.11/go.mod h1:cs0ePc8O4a0jD78cNvD+0VFwhqotTvLQZv372QsDw7Q= diff --git a/meshcore.go b/meshcore.go index 9ca00b1..cd5b213 100644 --- a/meshcore.go +++ b/meshcore.go @@ -24,9 +24,10 @@ type MeshCoreConfig struct { } type MeshCoreCompanionConfig struct { - Port string `yaml:"port"` - Baud int `yaml:"baud"` - Addr string `yaml:"addr"` + Port string `yaml:"port"` + Baud int `yaml:"baud"` + Addr string `yaml:"addr"` + HasSNR bool `yaml:"has_snr"` // patch: adds SNR/RSSI data } type MeshCorePrefix byte @@ -54,6 +55,9 @@ func NewMeshCoreReceiver(config *MeshCoreConfig) (protocol.PacketReceiver, error case "companion", "": return newMeshCoreCompanionReceiver(config.Conf) + case "repeater": + return newMeshCoreRepeaterReceiver(config.Conf) + default: return nil, fmt.Errorf("hamview: unsupported MeshCore node type %q", config.Type) } @@ -104,6 +108,51 @@ func newMeshCoreCompanionReceiver(node yaml.Node) (protocol.PacketReceiver, erro return receiver, nil } +func newMeshCoreRepeaterReceiver(node yaml.Node) (protocol.PacketReceiver, error) { + var config MeshCoreCompanionConfig + if err := node.Decode(&config); err != nil { + return nil, err + } + + var ( + conn io.ReadWriteCloser + err error + ) + switch { + case config.Addr != "": + Logger.Infof("receiver: connecting to MeshCore repeater at tcp://%s", config.Addr) + conn, err = net.Dial("tcp", config.Addr) + + default: + if config.Port == "" { + // TODO: detect serial ports + config.Port = "/dev/ttyUSB0" + } + if config.Baud == 0 { + config.Baud = 115200 + } + Logger.Infof("receiver: connecting to MeshCore repeater on %s at %d baud", config.Port, config.Baud) + conn, err = serial.OpenPort(&serial.Config{ + Name: config.Port, + Baud: config.Baud, + }) + } + if err != nil { + return nil, err + } + + receiver, err := meshcore.NewRepeater(conn, config.HasSNR) + if err != nil { + _ = conn.Close() + Logger.Warnf("receiver: error connecting to repeater: %v", err) + return nil, err + } + + info := receiver.Info() + Logger.Infof("receiver: connected to MeshCore repeater %q model %q version %q", info.Name, info.Manufacturer, info.FirmwareVersion) + return receiver, nil +} + type meshCoreNode struct { Name string `json:"name"` PublicKey []byte `json:"public_key"` diff --git a/schema/aprs.go b/schema/aprs.go new file mode 100644 index 0000000..f43ff1e --- /dev/null +++ b/schema/aprs.go @@ -0,0 +1,87 @@ +package schema + +import ( + "context" + "database/sql" + "os" + "strings" + "time" + + "xorm.io/builder" +) + +func init() { + RegisterModel(new(APRSStation)) + RegisterModel(new(APRSPacket)) +} + +type APRSStation struct { + ID int64 `xorm:"pk autoincr" json:"id"` + Call string `xorm:"varchar(10) unique not null" json:"call"` + Symbol string `xorm:"varchar(2)" json:"symbol"` + FirstHeardAt time.Time `xorm:"timestamp not null"` + LastHeardAt time.Time `xorm:"timestamp not null"` + LastLatitude sql.NullFloat64 + LastLongitude sql.NullFloat64 +} + +func GetAPRSStation(ctx context.Context, call string) (*APRSStation, error) { + station := new(APRSStation) + has, err := Query(ctx). + Where(builder.Eq{`"call"`: strings.ToUpper(call)}). + Get(station) + if err != nil { + return nil, err + } else if !has { + return nil, os.ErrNotExist + } + return station, nil +} + +func (station APRSStation) GetPackets(ctx context.Context) ([]*APRSPacket, error) { + packets := make([]*APRSPacket, 0, 10) + return packets, Query(ctx). + Where(builder.Eq{"`station_id`": station.ID}). + Find(&packets) +} + +type APRSPacket struct { + ID int64 `xorm:"pk autoincr" json:"id"` + RadioID int64 `xorm:"index" json:"radio_id"` + Radio Radio `json:"radio"` + StationID int64 `json:"-"` + Station *APRSStation `xorm:"-" json:"station"` + Source string `xorm:"varchar(10) not null" json:"src"` + Destination string `xorm:"varchar(10) not null" json:"dst"` + Path string `xorm:"varchar(88) not null default ''" json:"path"` + Comment string `xorm:"varchar(250)" json:"comment"` + Latitude sql.NullFloat64 `json:"latitude,omitempty"` + Longitude sql.NullFloat64 `json:"longitude,omitempty"` + Symbol string `xorm:"varchar(2)" json:"symbol"` + Raw string `json:"raw"` + ReceivedAt time.Time `json:"received_at"` +} + +func GetAPRSPackets(ctx context.Context, limit int) ([]*APRSPacket, error) { + packets := make([]*APRSPacket, 0, limit) + return packets, Query(ctx). + OrderBy("`received_at` DESC"). + Limit(limit). + Find(&packets) +} + +func GetAPRSPacketsBySource(ctx context.Context, source string) ([]*APRSPacket, error) { + packets := make([]*APRSPacket, 0, 100) + return packets, Query(ctx). + Where(builder.Eq{"source": strings.ToUpper(source)}). + OrderBy("`received_at` DESC"). + Find(&packets) +} + +func GetAPRSPacketsByDestination(ctx context.Context, destination string) ([]*APRSPacket, error) { + packets := make([]*APRSPacket, 0, 100) + return packets, Query(ctx). + Where(builder.Eq{"destination": strings.ToUpper(destination)}). + OrderBy("`received_at` DESC"). + Find(&packets) +} diff --git a/schema/engine.go b/schema/engine.go new file mode 100644 index 0000000..7e3672a --- /dev/null +++ b/schema/engine.go @@ -0,0 +1,192 @@ +package schema + +import ( + "context" + "database/sql" + "time" + + "github.com/sirupsen/logrus" + "xorm.io/xorm" + "xorm.io/xorm/log" + "xorm.io/xorm/names" + + _ "github.com/lib/pq" // PostgreSQL support + _ "github.com/mattn/go-sqlite3" // SQLite support +) + +// Logger used by this package +var Logger = logrus.New() + +var xormEngine *xorm.Engine + +type engineContextKeyType struct{} + +var engineContextKey = engineContextKeyType{} + +// Engine represents a xorm engine or session. +type Engine interface { + Table(tableNameOrBean any) *xorm.Session + Count(...any) (int64, error) + Decr(column string, arg ...any) *xorm.Session + Delete(...any) (int64, error) + Truncate(...any) (int64, error) + Exec(...any) (sql.Result, error) + Find(any, ...any) error + Get(beans ...any) (bool, error) + ID(any) *xorm.Session + In(string, ...any) *xorm.Session + Incr(column string, arg ...any) *xorm.Session + Insert(...any) (int64, error) + Iterate(any, xorm.IterFunc) error + Join(joinOperator string, tablename, condition any, args ...any) *xorm.Session + SQL(any, ...any) *xorm.Session + Where(any, ...any) *xorm.Session + Asc(colNames ...string) *xorm.Session + Desc(colNames ...string) *xorm.Session + Limit(limit int, start ...int) *xorm.Session + NoAutoTime() *xorm.Session + SumInt(bean any, columnName string) (res int64, err error) + Sync(...any) error + Select(string) *xorm.Session + SetExpr(string, any) *xorm.Session + NotIn(string, ...any) *xorm.Session + OrderBy(any, ...any) *xorm.Session + Exist(...any) (bool, error) + Distinct(...string) *xorm.Session + Query(...any) ([]map[string][]byte, error) + Cols(...string) *xorm.Session + Context(ctx context.Context) *xorm.Session + Ping() error + IsTableExist(tableNameOrBean any) (bool, error) + Begin() error + Rollback() error + Commit() error +} + +// Query the engine from the context. +func Query(ctx context.Context) Engine { + if engine, ok := ctx.Value(engineContextKey).(Engine); ok { + return engine + } + return xormEngine.Context(ctx) +} + +// Open a database connection. +func Open(driver, config string) error { + var err error + if xormEngine, err = xorm.NewEngine(driver, config); err != nil { + return err + } + + gonicNames := []string{ + "ID", + "SSL", "UID", + "SNR", "RSSI", + "APRS", "MeshCore", + "LoRa", + } + for _, name := range gonicNames { + names.LintGonicMapper[name] = true + } + xormEngine.SetMapper(names.GonicMapper{}) + xormEngine.SetLogger(xormLogger{}) + + for _, model := range registeredModels { + Logger.Debugf("schema: sync schema %T", model) + if err = xormEngine.Sync(model); err != nil { + _ = xormEngine.Close() + xormEngine = nil + return err + } + } + + return nil +} + +var ( + registeredModels []any + registeredInitFuncs []func() error +) + +func RegisterModel(model any, initFuncs ...func() error) { + registeredModels = append(registeredModels, model) + if len(initFuncs) > 0 && initFuncs[0] != nil { + registeredInitFuncs = append(registeredInitFuncs, initFuncs...) + } +} + +func NULLFloat64(v float64) *float64 { + if v == 0 { + return nil + } + return &v +} + +func NULLString(s string) *string { + if s == "" { + return nil + } + return &s +} + +func NULLTime(t time.Time) *time.Time { + if t.Equal(time.Time{}) { + return nil + } + return &t +} + +type xormLogger struct{} + +func (l xormLogger) BeforeSQL(context log.LogContext) {} // only invoked when IsShowSQL is true +func (l xormLogger) AfterSQL(context log.LogContext) {} // only invoked when IsShowSQL is true +func (l xormLogger) Debug(args ...any) { Logger.Debug(append([]any{"engine: "}, args...)...) } +func (l xormLogger) Debugf(format string, args ...any) { Logger.Debugf("engine: "+format, args...) } +func (l xormLogger) Error(args ...any) { Logger.Error(append([]any{"engine: "}, args...)...) } +func (l xormLogger) Errorf(format string, args ...any) { Logger.Errorf("engine: "+format, args...) } +func (l xormLogger) Info(args ...any) { Logger.Info(append([]any{"engine: "}, args...)...) } +func (l xormLogger) Infof(format string, args ...any) { Logger.Infof("engine: "+format, args...) } +func (l xormLogger) Warn(args ...any) { Logger.Warn(append([]any{"engine: "}, args...)...) } +func (l xormLogger) Warnf(format string, args ...any) { Logger.Warnf("engine: "+format, args...) } + +func (l xormLogger) Level() log.LogLevel { + switch Logger.Level { + case logrus.TraceLevel: + return log.LOG_DEBUG + case logrus.DebugLevel: + return log.LOG_DEBUG + case logrus.InfoLevel: + return log.LOG_INFO + case logrus.ErrorLevel: + return log.LOG_ERR + case logrus.WarnLevel: + return log.LOG_WARNING + case logrus.FatalLevel: + return log.LOG_OFF + default: + return log.LOG_UNKNOWN + } +} + +func (l xormLogger) SetLevel(level log.LogLevel) { + switch level { + case log.LOG_DEBUG: + Logger.SetLevel(logrus.DebugLevel) + case log.LOG_INFO: + Logger.SetLevel(logrus.InfoLevel) + case log.LOG_ERR: + Logger.SetLevel(logrus.ErrorLevel) + case log.LOG_OFF: + Logger.SetLevel(logrus.FatalLevel) + } +} + +func (l xormLogger) ShowSQL(show ...bool) { + _ = show +} + +func (l xormLogger) IsShowSQL() bool { + return false +} + +var _ log.ContextLogger = (*xormLogger)(nil) diff --git a/schema/meshcore.go b/schema/meshcore.go new file mode 100644 index 0000000..f0a2761 --- /dev/null +++ b/schema/meshcore.go @@ -0,0 +1,273 @@ +package schema + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "os" + "time" + + "xorm.io/builder" + + "git.maze.io/go/ham/protocol" + "git.maze.io/go/ham/protocol/meshcore" +) + +func init() { + RegisterModel(new(MeshCorePacket)) + RegisterModel(new(MeshCoreNode)) + RegisterModel(new(MeshCoreNodePosition)) + RegisterModel(new(MeshCoreGroup)) +} + +type MeshCorePacket struct { + ID int64 `xorm:"pk autoincr" json:"id"` + RadioID int64 `xorm:"index" json:"radio_id"` + Radio *Radio `xorm:"-" json:"radio"` + SNR float64 `xorm:"not null default 0" json:"snr"` + RSSI int `xorm:"not null default 0" json:"rssi"` + Version int `xorm:"not null default 1" json:"version"` + RouteType uint8 `xorm:"index not null" json:"route_type"` + PayloadType uint8 `xorm:"index not null" json:"payload_type"` + Hash string `xorm:"varchar(16) index not null" json:"hash"` + Path []byte `xorm:"bytea" json:"path"` + Payload []byte `xorm:"bytea not null" json:"payload"` + Raw []byte `xorm:"bytea not null" json:"raw"` + Parsed *string `xorm:"jsonb" json:"parsed"` + ChannelHash string `xorm:"varchar(2) index" json:"channel_hash,omitempty"` + ReceivedAt time.Time `json:"received_at"` +} + +func (MeshCorePacket) TableName() string { + return "meshcore_packet" +} + +func GetMeshCorePackets(ctx context.Context, limit int) ([]*MeshCorePacket, error) { + packets := make([]*MeshCorePacket, 0, limit) + return packets, Query(ctx). + OrderBy("`received_at` DESC"). + Limit(limit). + Find(&packets) +} + +func GetMeshCorePacketsByHash(ctx context.Context, hash string) ([]*MeshCorePacket, error) { + if len(hash) != 16 { + return nil, errors.New("invalid hash") + } else if _, err := hex.DecodeString(hash); err != nil { + return nil, err + } + + packets := make([]*MeshCorePacket, 0, 10) + return packets, Query(ctx). + Where(builder.Eq{"hash": hash}). + OrderBy("`received_at` ASC"). + Find(&packets) +} + +func GetMeshCorePacketsByPayloadType(ctx context.Context, payloadType meshcore.PayloadType) ([]*MeshCorePacket, error) { + packets := make([]*MeshCorePacket, 0, 10) + return packets, Query(ctx). + Where(builder.Eq{"payload_type": int(payloadType)}). + OrderBy("`received_at` DESC"). + Find(&packets) +} + +func GetMeshCorePacketsByChannelHash(ctx context.Context, hash string) ([]*MeshCorePacket, error) { + packets := make([]*MeshCorePacket, 0, 10) + return packets, Query(ctx). + Where(builder.Eq{ + "`payload_type`": int(meshcore.TypeGroupText), + "`channel_hash`": hash, + }). + OrderBy("`received_at` DESC"). + Find(&packets) +} + +type MeshCoreGroup struct { + ID int64 `xorm:"pk autoincr" json:"id"` + Name string `xorm:"varchar(32) not null unique" json:"name"` + Secret string `xorm:"varchar(32) not null" json:"secret"` + IsPublic bool `xorm:"boolean not null default false" json:"-"` +} + +func (MeshCoreGroup) TableName() string { + return "meshcore_group" +} + +func GetMeshCoreGroups(ctx context.Context) ([]*MeshCoreGroup, error) { + groups := make([]*MeshCoreGroup, 0, 10) + return groups, Query(ctx). + Where(builder.Eq{"is_public": true}). + OrderBy("name asc"). + Find(&groups) +} + +type MeshCoreNode struct { + ID int64 `xorm:"pk autoincr" json:"id"` + PacketHash string `xorm:"varchar(16) index 'meshcore_packet_hash'" json:"-"` + Packets []*MeshCorePacket `xorm:"-" json:"packet"` + Name string `xorm:"varchar(100) not null" json:"name"` + Type uint8 `xorm:"index not null" json:"type"` + Prefix string `xorm:"varchar(2) not null" json:"prefix"` + PublicKey string `xorm:"varchar(64) not null unique" json:"public_key"` + FirstHeardAt time.Time `xorm:"timestamp not null" json:"first_heard_at"` + LastHeardAt time.Time `xorm:"timestamp not null" json:"last_heard_at"` + LastLatitude *float64 `json:"last_latitude"` + LastLongitude *float64 `json:"last_longitude"` + Distance float64 `xorm:"-" json:"distance,omitempty"` +} + +func (MeshCoreNode) TableName() string { + return "meshcore_node" +} + +func GetMeshCoreNodeByPublicKey(ctx context.Context, publicKey string) (*MeshCoreNode, error) { + node := new(MeshCoreNode) + if ok, err := Query(ctx). + Where(builder.Eq{"public_key": publicKey}). + Get(node); err != nil { + return nil, err + } else if !ok { + return nil, os.ErrNotExist + } + return node, nil +} + +func GetMeshCoreNodes(ctx context.Context) ([]*MeshCoreNode, error) { + nodes := make([]*MeshCoreNode, 0, 100) + return nodes, Query(ctx). + OrderBy("`last_heard_at` DESC"). + Find(&nodes) +} + +func GetMeshCoreNodesByType(ctx context.Context, nodeType meshcore.NodeType) ([]*MeshCoreNode, error) { + nodes := make([]*MeshCoreNode, 0, 100) + return nodes, Query(ctx). + Where(builder.Eq{"type": nodeType}). + OrderBy("`last_heard_at` DESC"). + Find(&nodes) +} + +type MeshCoreNodeWithDistance struct { + MeshCoreNode `xorm:"extends"` + Distance float64 `xorm:"distance"` +} + +func GetMeshCoreNodesCloseTo(ctx context.Context, publicKey string, radius float64) (*MeshCoreNode, []*MeshCoreNode, error) { + node, err := GetMeshCoreNodeByPublicKey(ctx, publicKey) + if err != nil { + return nil, nil, err + } else if node.LastLatitude == nil || node.LastLongitude == nil { + return nil, nil, errors.New("node has no location") + } + + nodesWithDistance := make([]*MeshCoreNodeWithDistance, 0, 100) + selectClause := fmt.Sprintf("*, "+ + "ST_Distance("+ + "ST_SetSRID(ST_MakePoint(%f, %f), 4326)::geography, "+ + "ST_SetSRID(ST_MakePoint(last_longitude, last_latitude), 4326)::geography"+ + ") as distance", + *node.LastLongitude, *node.LastLatitude) + if err = Query(ctx). + Select(selectClause). + Where("id != ?", node.ID). + Where("ST_DWithin("+ + "ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, "+ + "ST_SetSRID(ST_MakePoint(last_longitude, last_latitude), 4326)::geography, ?)", + *node.LastLongitude, *node.LastLatitude, radius). + OrderBy("`distance` ASC"). + Find(&nodesWithDistance); err != nil { + return nil, nil, err + } + + nodes := make([]*MeshCoreNode, len(nodesWithDistance)) + for i, node := range nodesWithDistance { + node.MeshCoreNode.Distance = node.Distance + nodes[i] = &node.MeshCoreNode + } + return node, nodes, nil +} + +type MeshCoreNodePosition struct { + ID int64 `xorm:"pk autoincr" json:"id"` + MeshCoreNodeId int64 `xorm:"not null 'meshcore_node_id'" json:"-"` + MeshCoreNode *MeshCoreNode `xorm:"-" json:"node"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + ReceivedAt time.Time `xorm:"timestamp not null" json:"received_at"` +} + +func (MeshCoreNodePosition) TableName() string { + return "meshcore_node_position" +} + +type MeshCoreStats struct { + Messages int64 `json:"messages"` + Nodes int64 `json:"nodes"` + Receivers int64 `json:"receivers"` + Packets struct { + Timestamps []int64 `json:"timestamps"` + Packets []int64 `json:"packets"` + } `json:"packets"` +} + +func GetMeshCoreStats(ctx context.Context) (*MeshCoreStats, error) { + var ( + engine = Query(ctx) + stats = new(MeshCoreStats) + err error + ) + if stats.Messages, err = engine. + In("`payload_type`", meshcore.TypeText, meshcore.TypeGroupText). + Count(&MeshCorePacket{}); err != nil { + return nil, err + } + if stats.Nodes, err = engine. + Count(&MeshCoreNode{}); err != nil { + return nil, err + } + if stats.Receivers, err = engine. + Where(builder.Eq{"`protocol`": protocol.MeshCore}). + Count(&Radio{}); err != nil { + return nil, err + } + return stats, nil +} + +/* + Column | Type | Collation | Nullable | Default +--------------+--------------------------+-----------+----------+--------------------------------------------- + id | bigint | | not null | nextval('meshcore_packet_id_seq'::regclass) + snr | real | | not null | 0 + rssi | smallint | | not null | 0 + hash | bytea | | not null | + route_type | smallint | | not null | + payload_type | smallint | | not null | + path | bytea | | | + payload | bytea | | | + raw | bytea | | | + parsed | jsonb | | | + received_at | timestamp with time zone | | not null | now() + created_at | timestamp with time zone | | not null | now() +*/ + +/* + id | bigint | | not null | nextval('meshcore_node_id_seq'::regclass) + last_advert_id | bigint | | | + node_type | smallint | | not null | 0 + public_key | bytea | | not null | + name | text | | | + local_time | timestamp with time zone | | not null | + first_heard | timestamp with time zone | | not null | now() + last_heard | timestamp with time zone | | not null | now() + last_latitude | numeric(10,8) | | | + last_longitude | numeric(11,8) | | | + prefix | bytea | | | generated always as ("substring"(public_key, 0, 2)) stored + last_position | geometry(Point,4326) | | | generated always as ( + + | | | | CASE + + | | | | WHEN last_latitude IS NOT NULL AND last_longitude IS NOT NULL THEN st_setsrid(st_makepoint(last_latitude::double precision, last_longitude::double precision), 4326)+ + | | | | ELSE NULL::geometry + + | | | | END) stored + position | geometry(Point,4326) | | | +*/ diff --git a/schema/radio.go b/schema/radio.go new file mode 100644 index 0000000..499a1c3 --- /dev/null +++ b/schema/radio.go @@ -0,0 +1,75 @@ +package schema + +import ( + "context" + "encoding/base64" + "os" + "strings" + "time" + + "xorm.io/builder" +) + +func init() { + RegisterModel(new(Radio)) +} + +type Radio struct { + ID int64 `xorm:"pk autoincr" json:"id"` + Name string `xorm:"not null unique" json:"name"` + IsOnline bool `xorm:"bool not null default false" json:"is_online"` + Manufacturer string `xorm:"varchar(64) not null" json:"manufacturer"` + Device *string `xorm:"varchar(64)" json:"device"` + FirmwareVersion *string `xorm:"varchar(32)" json:"firmware_version"` + FirmwareDate *time.Time `json:"firmware_date"` + Antenna *string `xorm:"varchar(100)" json:"antenna"` + Modulation string `xorm:"varchar(16) not null" json:"modulation"` + Protocol string `xorm:"varchar(16) not null index" json:"protocol"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + Altitude *float64 `json:"altitude,omitempty"` + Frequency float64 `xorm:"not null" json:"frequency"` + Bandwidth float64 `xorm:"not null" json:"bandwidth"` + Power *float64 `json:"power,omitempty"` + Gain *float64 `json:"gain,omitempty"` + LoRaSF *uint8 `xorm:"smallint 'lora_sf'" json:"lora_sf,omitempty"` + LoRaCR *uint8 `xorm:"smallint 'lora_cr'" json:"lora_cr,omitempty"` + Extra []byte `xorm:"jsonb" json:"extra,omitempty"` + CreatedAt time.Time `xorm:"timestamp not null default current_timestamp" json:"created_at"` + UpdatedAt time.Time `xorm:"timestamp not null default current_timestamp" json:"updated_at"` +} + +func GetRadioByEncodedID(ctx context.Context, id string) (*Radio, error) { + name, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(id, "=")) + if err != nil { + return nil, err + } + + radio := new(Radio) + has, err := Query(ctx).Where(builder.Eq{"`name`": name}).Get(radio) + if err != nil { + return nil, err + } else if !has { + return nil, os.ErrNotExist + } + return radio, nil +} + +func GetRadios(ctx context.Context) ([]*Radio, error) { + radios := make([]*Radio, 0, 5) + return radios, Query(ctx).Find(&radios) +} + +func GetRadiosByProtocol(ctx context.Context, protocol string) ([]*Radio, error) { + radios := make([]*Radio, 0, 5) + return radios, Query(ctx). + Where(builder.Eq{"`protocol`": protocol}). + Find(&radios) +} + +func GetRadiosRecentlyOnline(ctx context.Context) ([]*Radio, error) { + radios := make([]*Radio, 0, 5) + return radios, Query(ctx). + Where(builder.Eq{"`is_online`": true}). + Find(&radios) +} diff --git a/server.go b/server.go index 5d3bce4..bd45883 100644 --- a/server.go +++ b/server.go @@ -1,423 +1,30 @@ package hamview import ( - "database/sql" - "encoding/base64" - "encoding/hex" - "errors" - "fmt" - "net" - "net/http" - "os" - "slices" - "strconv" - "strings" - "time" + "github.com/sirupsen/logrus" - echologrus "github.com/cemkiy/echo-logrus" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - - "git.maze.io/go/ham/protocol/meshcore" - "git.maze.io/go/ham/protocol/meshcore/crypto" + "git.maze.io/ham/hamview/server" ) -const DefaultServerListen = ":8073" +// Deprecated: Use server.Config instead +type ServerConfig = server.Config -type ServerConfig struct { - Listen string `yaml:"listen"` -} - -type Server struct { - listen string - listenAddr *net.TCPAddr - db *sql.DB -} +// Deprecated: Use server.Server instead +type Server = server.Server +// NewServer creates a new server instance +// Deprecated: Use server.New instead func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) { - if serverConfig.Listen == "" { - serverConfig.Listen = DefaultServerListen + // Get logger from the global context or create a new one + logger := Logger + if logger == nil { + logger = logrus.New() } - listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen) - if err != nil { - return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err) + dbConfig := &server.DatabaseConfig{ + Type: databaseConfig.Type, + Conf: databaseConfig.Conf, } - db, err := sql.Open(databaseConfig.Type, databaseConfig.Conf) - if err != nil { - return nil, err - } - - return &Server{ - listen: serverConfig.Listen, - listenAddr: listenAddr, - db: db, - }, nil -} - -func (server *Server) Run() error { - echologrus.Logger = Logger - - e := echo.New() - e.Logger = echologrus.GetEchoLogger() - e.Use(echologrus.Hook()) - e.Use(middleware.RequestLogger()) - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, - AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept}, - })) - - e.GET("/api/v1/meshcore/nodes", server.apiGetMeshCoreNodes) - e.GET("/api/v1/meshcore/packets", server.apiGetMeshCorePackets) - e.GET("/api/v1/meshcore/path/:origin/:path", server.apiGetMeshCorePath) - e.GET("/api/v1/meshcore/sources", server.apiGetMeshCoreSources) - - if server.listenAddr.IP == nil || server.listenAddr.IP.Equal(net.ParseIP("0.0.0.0")) || server.listenAddr.IP.Equal(net.ParseIP("::")) { - Logger.Infof("server: listening on http://127.0.0.1:%d", server.listenAddr.Port) - } else { - Logger.Infof("server: listening on http://%s:%d", server.listenAddr.IP, server.listenAddr.Port) - } - - return e.Start(server.listen) -} - -func (server *Server) apiError(ctx echo.Context, err error, status ...int) error { - Logger.Warnf("server: error serving %s %s: %v", ctx.Request().Method, ctx.Request().URL.Path, err) - - if len(status) > 0 { - return ctx.JSON(status[0], map[string]any{ - "error": err.Error(), - }) - } - - switch { - case os.IsNotExist(err): - return ctx.JSON(http.StatusNotFound, nil) - case os.IsPermission(err): - return ctx.JSON(http.StatusUnauthorized, nil) - default: - return ctx.JSON(http.StatusInternalServerError, map[string]any{ - "error": err.Error(), - }) - } -} - -type meshCoreNodeResponse struct { - SNR float64 `json:"snr"` - RSSI int8 `json:"rssi"` - Name string `json:"name"` - PublicKey []byte `json:"public_key"` - Prefix byte `json:"prefix"` - NodeType meshcore.NodeType `json:"node_type"` - FirstHeard time.Time `json:"first_heard"` - LastHeard time.Time `json:"last_heard"` - Position *meshcore.Position `json:"position"` -} - -func (server *Server) apiGetMeshCoreNodes(ctx echo.Context) error { - /* - nodeTypes := getQueryInts(ctx, "type") - if len(nodeTypes) == 0 { - nodeTypes = []int{ - int(meshcore.Repeater), - } - } - */ - nodeType := meshcore.Repeater - if ctx.QueryParam("type") != "" { - nodeType = meshcore.NodeType(getQueryInt(ctx, "type")) - } - - rows, err := server.db.Query(sqlSelectMeshCoreNodesLastPosition, nodeType, 25) - if err != nil { - return server.apiError(ctx, err) - } - - var ( - response []meshCoreNodeResponse - prefix []byte - ) - for rows.Next() { - var ( - row meshCoreNodeResponse - lat, lng *float64 - ) - if err := rows.Scan( - &row.SNR, - &row.RSSI, - &row.Name, - &row.PublicKey, - &prefix, - &row.NodeType, - &row.FirstHeard, - &row.LastHeard, - &lat, - &lng, - ); err != nil { - return server.apiError(ctx, err) - } - if lat != nil && lng != nil { - row.Position = &meshcore.Position{ - Latitude: *lat, - Longitude: *lng, - } - } - if len(prefix) > 0 { - row.Prefix = prefix[0] - } - response = append(response, row) - } - - return ctx.JSON(http.StatusOK, response) -} - -type meshCorePacketResponse struct { - SNR float64 `json:"snr"` - RSSI int8 `json:"rssi"` - Hash []byte `json:"hash"` - RouteType byte `json:"route_type"` - PayloadType byte `json:"payload_type"` - Path []byte `json:"path"` - ReceivedAt time.Time `json:"received_at"` - Raw []byte `json:"raw"` - Parsed []byte `json:"parsed"` -} - -func (server *Server) apiGetMeshCorePackets(ctx echo.Context) error { - var ( - query string - limit = 25 - args []any - ) - if hashParam := ctx.QueryParam("hash"); hashParam != "" { - var ( - hash []byte - err error - ) - switch len(hashParam) { - case base64.URLEncoding.EncodedLen(8): - hash, err = base64.URLEncoding.DecodeString(hashParam) - case hex.EncodedLen(8): - hash, err = hex.DecodeString(hashParam) - default: - err = errors.New("invalid encoding") - } - if err != nil { - return server.apiError(ctx, err, http.StatusBadRequest) - } - query = sqlSelectMeshCorePacketsByHash - args = []any{hash} - } else { - query = sqlSelectMeshCorePackets - args = []any{limit} - } - - rows, err := server.db.Query(query, args...) - if err != nil { - return server.apiError(ctx, err) - } - - var response []meshCorePacketResponse - for rows.Next() { - var row meshCorePacketResponse - if err := rows.Scan( - &row.SNR, - &row.RSSI, - &row.Hash, - &row.RouteType, - &row.PayloadType, - &row.Path, - &row.ReceivedAt, - &row.Raw, - &row.Parsed, - ); err != nil { - return server.apiError(ctx, err) - } - response = append(response, row) - } - - return ctx.JSON(http.StatusOK, response) -} - -type meshCorePathResponse struct { - Origin *meshCoreNode `json:"origin"` - Path []*meshCoreNodeDistance `json:"path"` -} - -func (server *Server) apiGetMeshCorePath(ctx echo.Context) error { - origin, err := hex.DecodeString(ctx.Param("origin")) - if err != nil || len(origin) != crypto.PublicKeySize { - return ctx.JSON(http.StatusBadRequest, map[string]any{ - "error": "invalid origin", - }) - } - - path, err := hex.DecodeString(ctx.Param("path")) - if err != nil || len(path) == 0 { - return ctx.JSON(http.StatusBadRequest, map[string]any{ - "error": "invalid path", - }) - } - - var ( - node meshCoreNodeDistance - prefix []byte - latitude, longitude *float64 - ) - if err := server.db.QueryRow(` - SELECT - n.name, - n.public_key, - n.prefix, - n.first_heard, - n.last_heard, - n.last_latitude, - n.last_longitude - FROM - meshcore_node n - WHERE - n.node_type = 2 AND - n.public_key = $1 - `, - origin, - ).Scan( - &node.Name, - &node.PublicKey, - &prefix, - &node.FirstHeard, - &node.LastHeard, - &latitude, - &longitude, - ); err != nil { - return server.apiError(ctx, err) - } - node.Prefix = MeshCorePrefix(prefix[0]) - if latitude == nil || longitude == nil { - return ctx.JSON(http.StatusNotFound, map[string]any{ - "error": "origin has no known position", - }) - } - node.Position = &meshcore.Position{ - Latitude: *latitude, - Longitude: *longitude, - } - - var ( - current = &node - trace []*meshCoreNodeDistance - ) - slices.Reverse(path) - for _, prefix := range path { - if prefix != byte(current.Prefix) { - var hop *meshCoreNodeDistance - if hop, err = meshCoreRepeaterWithPrefixCloseTo(server.db, MeshCorePrefix(prefix), current.Position); err != nil { - if !os.IsNotExist(err) { - return server.apiError(ctx, err) - } - current = &meshCoreNodeDistance{ - meshCoreNode: meshCoreNode{ - Prefix: MeshCorePrefix(prefix), - Position: current.Position, - }, - } - } else { - current = hop - } - } - trace = append(trace, current) - } - - /* - if path[len(path)-1] == node.Prefix { - path = path[:len(path)-2] - } - */ - - var response = meshCorePathResponse{ - Origin: &node.meshCoreNode, - Path: trace, - } - return ctx.JSON(http.StatusOK, response) -} - -type meshCoreSourcesResponse struct { - Window time.Time `json:"time"` - Packets map[string]int `json:"packets"` -} - -func (server *Server) apiGetMeshCoreSources(ctx echo.Context) error { - var ( - now = time.Now().UTC() - windows = map[string]struct { - Interval int - Since time.Duration - }{ - "24h": {900, time.Hour * 24}, - "1w": {3600, time.Hour * 24 * 7}, - } - window = ctx.QueryParam("window") - ) - if window == "" { - window = "24h" - } - params, ok := windows[window] - if !ok { - return server.apiError(ctx, os.ErrNotExist) - } - - rows, err := server.db.Query(sqlSelectMeshCorePacketsByRepeaterWindowed, params.Interval, now.Add(-params.Since)) - if err != nil { - return server.apiError(ctx, err) - } - - var ( - response []*meshCoreSourcesResponse - buckets = make(map[int64]*meshCoreSourcesResponse) - ) - for rows.Next() { - var result struct { - Window time.Time - Repeater string - Packets int - } - if err := rows.Scan(&result.Window, &result.Repeater, &result.Packets); err != nil { - return server.apiError(ctx, err) - } - if result.Packets <= 10 { - continue // ignore - } - - if bucket, ok := buckets[result.Window.Unix()]; ok { - bucket.Packets[result.Repeater] = result.Packets - } else { - bucket = &meshCoreSourcesResponse{ - Window: result.Window, - Packets: map[string]int{ - result.Repeater: result.Packets, - }, - } - response = append(response, bucket) - buckets[result.Window.Unix()] = bucket - } - } - - return ctx.JSON(http.StatusOK, response) -} - -func getQueryInt(ctx echo.Context, param string) int { - v, _ := strconv.Atoi(ctx.QueryParam(param)) - return v -} - -func getQueryInts(ctx echo.Context, param string) []int { - var values []int - if keys := strings.Split(ctx.QueryParam(param), ","); len(keys) > 0 { - for _, value := range keys { - if v, err := strconv.Atoi(value); err == nil { - values = append(values, v) - } - } - } - return values + return server.New(serverConfig, dbConfig, logger) } diff --git a/server/API.md b/server/API.md new file mode 100644 index 0000000..a0c0ff7 --- /dev/null +++ b/server/API.md @@ -0,0 +1,713 @@ +# HAMView API Reference + +Version: 1.0 +Base URL: `/api/v1` +Content-Type: `application/json` + +## Table of Contents + +1. [Authentication](#authentication) +2. [Error Handling](#error-handling) +3. [Endpoints](#endpoints) + - [Radios](#radios) + - [MeshCore](#meshcore) + - [APRS](#aprs) +4. [Data Models](#data-models) +5. [Examples](#examples) + +--- + +## Authentication + +Currently, the API does not require authentication. All endpoints are publicly accessible. + +--- + +## Error Handling + +All error responses follow this format: + +```json +{ + "error": "Human-readable error message" +} +``` + +### HTTP Status Codes + +| Code | Description | +|------|-------------| +| 200 | Success | +| 400 | Bad Request - Invalid parameters | +| 404 | Not Found - Resource does not exist | +| 500 | Internal Server Error | + +--- + +## Endpoints + +### Radios + +#### List All Radios + +``` +GET /api/v1/radios +``` + +Returns a list of all radio receivers/stations. + +**Response:** `200 OK` + +```typescript +Radio[] +``` + +**Example:** + +```bash +curl http://localhost:8073/api/v1/radios +``` + +```json +[ + { + "id": 1, + "name": "Station-Alpha", + "is_online": true, + "manufacturer": "Heltec", + "protocol": "meshcore", + "frequency": 868.1 + } +] +``` + +--- + +#### List Radios by Protocol + +``` +GET /api/v1/radios/:protocol +``` + +Returns radios filtered by protocol. + +**Parameters:** + +| Name | Type | In | Description | +|----------|--------|------|-------------| +| protocol | string | path | Protocol name (e.g., "meshcore", "aprs") | + +**Response:** `200 OK` + +```typescript +Radio[] +``` + +**Example:** + +```bash +curl http://localhost:8073/api/v1/radios/meshcore +``` + +--- + +### MeshCore + +#### Get MeshCore Statistics + +``` +GET /api/v1/meshcore +``` + +Returns aggregated network statistics. + +**Response:** `200 OK` + +```typescript +{ + messages: number; + nodes: number; + receivers: number; + packets: { + timestamps: number[]; + packets: number[]; + }; +} +``` + +**Example:** + +```bash +curl http://localhost:8073/api/v1/meshcore +``` + +```json +{ + "messages": 150234, + "nodes": 127, + "receivers": 8, + "packets": { + "timestamps": [1709650800, 1709654400], + "packets": [142, 203] + } +} +``` + +--- + +#### List MeshCore Groups + +``` +GET /api/v1/meshcore/groups +``` + +Returns public MeshCore groups/channels. + +**Response:** `200 OK` + +```typescript +{ + id: number; + name: string; + secret: string; +}[] +``` + +**Example:** + +```bash +curl http://localhost:8073/api/v1/meshcore/groups +``` + +```json +[ + { + "id": 5, + "name": "General Chat", + "secret": "0123456789abcdef0123456789abcdef" + } +] +``` + +--- + +#### List MeshCore Nodes + +``` +GET /api/v1/meshcore/nodes +``` + +Returns MeshCore network nodes. + +**Query Parameters:** + +| Name | Type | Required | Description | +|------|--------|----------|-------------| +| type | string | No | Node type: "chat", "room", "sensor", "repeater" | + +**Response:** `200 OK` + +```typescript +{ + id: number; + packet: MeshCorePacket[]; + name: string; + type: number; + prefix: string; + public_key: string; + first_heard_at: string; + last_heard_at: string; + last_latitude: number | null; + last_longitude: number | null; + distance?: number; +}[] +``` + +**Example:** + +```bash +curl http://localhost:8073/api/v1/meshcore/nodes +curl http://localhost:8073/api/v1/meshcore/nodes?type=chat +``` + +```json +[ + { + "id": 42, + "packet": [], + "name": "NODE-CHARLIE", + "type": 0, + "prefix": "mc", + "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "first_heard_at": "2026-01-15T08:00:00Z", + "last_heard_at": "2026-03-05T14:25:00Z", + "last_latitude": 52.3667, + "last_longitude": 4.8945 + } +] +``` + +--- + +#### Find Nodes Near Location + +``` +GET /api/v1/meshcore/nodes/close-to/:publickey +``` + +Returns nodes within a specified radius of a reference node. + +**Path Parameters:** + +| Name | Type | Description | +|-----------|--------|-------------| +| publickey | string | 64-character hex-encoded Ed25519 public key | + +**Query Parameters:** + +| Name | Type | Required | Default | Description | +|--------|--------|----------|---------|-------------| +| radius | number | No | 25000 | Search radius in meters | + +**Response:** `200 OK` + +```typescript +{ + node: MeshCoreNode; + nodes: MeshCoreNode[]; // Sorted by distance, with distance field populated +} +``` + +**Example:** + +```bash +curl "http://localhost:8073/api/v1/meshcore/nodes/close-to/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef?radius=50000" +``` + +```json +{ + "node": { + "id": 42, + "name": "NODE-CHARLIE", + "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "last_latitude": 52.3667, + "last_longitude": 4.8945, + "distance": 0 + }, + "nodes": [ + { + "id": 43, + "name": "NODE-DELTA", + "last_latitude": 52.3700, + "last_longitude": 4.9000, + "distance": 450.5 + } + ] +} +``` + +--- + +#### List MeshCore Packets + +``` +GET /api/v1/meshcore/packets +``` + +Returns MeshCore packets based on filter criteria. + +**Query Parameters (mutually exclusive):** + +| Name | Type | Required | Description | +|--------------|--------|----------|-------------| +| hash | string | No | 16-character hex hash | +| type | number | No | Payload type (0-255) | +| channel_hash | string | No | 2-character channel hash (requires type) | + +**Response:** `200 OK` + +```typescript +{ + id: number; + radio_id: number; + radio: Radio | null; + snr: number; + rssi: number; + version: number; + route_type: number; + payload_type: number; + hash: string; + path: string; // Base64 + payload: string; // Base64 + raw: string; // Base64 + parsed: object | null; + channel_hash: string; + received_at: string; +}[] +``` + +**Payload Types:** + +| Value | Description | +|-------|-------------| +| 0 | Ping | +| 1 | Node announcement | +| 2 | Direct text message | +| 3 | Group text message | +| 4 | Position update | + +**Examples:** + +```bash +# Get 100 most recent packets +curl http://localhost:8073/api/v1/meshcore/packets + +# Get packets by hash +curl http://localhost:8073/api/v1/meshcore/packets?hash=a1b2c3d4e5f67890 + +# Get packets by type +curl http://localhost:8073/api/v1/meshcore/packets?type=3 + +# Get group messages for a channel +curl http://localhost:8073/api/v1/meshcore/packets?type=3&channel_hash=ab +``` + +```json +[ + { + "id": 12345, + "radio_id": 1, + "snr": 8.5, + "rssi": -95, + "version": 1, + "route_type": 0, + "payload_type": 3, + "hash": "a1b2c3d4e5f67890", + "path": "AQIDBA==", + "payload": "SGVsbG8gV29ybGQ=", + "raw": "AQIDBAUGBwg=", + "parsed": {"text": "Hello World"}, + "channel_hash": "ab", + "received_at": "2026-03-05T14:30:00Z" + } +] +``` + +--- + +### APRS + +#### List APRS Packets + +``` +GET /api/v1/aprs/packets +``` + +Returns APRS packets based on filter criteria. + +**Query Parameters (evaluated in order):** + +| Name | Type | Required | Default | Description | +|-------|--------|----------|---------|-------------| +| src | string | No | - | Source callsign (case-insensitive) | +| dst | string | No | - | Destination callsign (case-insensitive) | +| limit | number | No | 100 | Maximum number of packets when no src/dst filter is used | + +**Response:** `200 OK` + +```typescript +{ + id: number; + radio_id: number; + radio: Radio; + src: string; + dst: string; + path: string; + comment: string; + latitude: number | null; + longitude: number | null; + symbol: string; + raw: string; + received_at: string; +}[] +``` + +**Examples:** + +```bash +# Get 100 most recent packets +curl http://localhost:8073/api/v1/aprs/packets + +# Get packets by source callsign +curl http://localhost:8073/api/v1/aprs/packets?src=OE1ABC + +# Get packets by destination callsign +curl http://localhost:8073/api/v1/aprs/packets?dst=APRS + +# Get recent packets with explicit limit +curl http://localhost:8073/api/v1/aprs/packets?limit=200 +``` + +--- + +## Data Models + +### Radio + +```typescript +interface Radio { + id: number; // Unique identifier + name: string; // Station name + is_online: boolean; // Online status + manufacturer: string; // Hardware manufacturer + device: string | null; // Device model + firmware_version: string | null; // Firmware version + firmware_date: string | null; // ISO 8601 timestamp + antenna: string | null; // Antenna description + modulation: string; // e.g., "LoRa" + protocol: string; // e.g., "meshcore", "aprs" + latitude: number | null; // Decimal degrees + longitude: number | null; // Decimal degrees + altitude: number | null; // Meters + frequency: number; // Hz + bandwidth: number; // Hz + power: number | null; // dBm + gain: number | null; // dBi + lora_sf: number | null; // LoRa spreading factor (7-12) + lora_cr: number | null; // LoRa coding rate (5-8) + extra: object | null; // Additional metadata + created_at: string; // ISO 8601 timestamp + updated_at: string; // ISO 8601 timestamp +} +``` + +### MeshCorePacket + +```typescript +interface MeshCorePacket { + id: number; + radio_id: number; + radio: Radio | null; + snr: number; // Signal-to-noise ratio (dB) + rssi: number; // Received signal strength (dBm) + version: number; + route_type: number; + payload_type: number; + hash: string; // 16-char hex + path: string; // Base64-encoded + payload: string; // Base64-encoded + raw: string; // Base64-encoded + parsed: object | null; // Depends on payload_type + channel_hash: string; // 2-char hex + received_at: string; // ISO 8601 timestamp +} +``` + +### APRSPacket + +```typescript +interface APRSPacket { + id: number; + radio_id: number; + radio: Radio; + src: string; // Source callsign + dst: string; // Destination callsign + path: string; // Digipeater path + comment: string; + latitude: number | null; + longitude: number | null; + symbol: string; // APRS symbol table + code + raw: string; // Raw APRS packet + received_at: string; // ISO 8601 timestamp +} +``` + +### MeshCoreNode + +```typescript +interface MeshCoreNode { + id: number; + packet: MeshCorePacket[]; + name: string; + type: number; // 0=repeater, 1=chat, 2=room, 3=sensor + prefix: string; // 2-char network prefix + public_key: string; // 64-char hex Ed25519 public key + first_heard_at: string; // ISO 8601 timestamp + last_heard_at: string; // ISO 8601 timestamp + last_latitude: number | null; + last_longitude: number | null; + distance?: number; // Meters (proximity queries only) +} +``` + +### MeshCoreGroup + +```typescript +interface MeshCoreGroup { + id: number; + name: string; // Max 32 characters + secret: string; // 32-char hex +} +``` + +### MeshCoreStats + +```typescript +interface MeshCoreStats { + messages: number; + nodes: number; + receivers: number; + packets: { + timestamps: number[]; // Unix timestamps + packets: number[]; // Packet counts + }; +} +``` + +--- + +## Examples + +### JavaScript/TypeScript Client + +```typescript +// Using fetch API +const API_BASE = 'http://localhost:8073/api/v1'; + +// Get all radios +async function getRadios(): Promise { + const response = await fetch(`${API_BASE}/radios`); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +// Get MeshCore nodes by type +async function getMeshCoreNodes(type?: string): Promise { + const url = type + ? `${API_BASE}/meshcore/nodes?type=${type}` + : `${API_BASE}/meshcore/nodes`; + const response = await fetch(url); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} + +// Find nearby nodes +async function getNodesNearby(publicKey: string, radius: number = 25000) { + const response = await fetch( + `${API_BASE}/meshcore/nodes/close-to/${publicKey}?radius=${radius}` + ); + if (!response.ok) throw new Error(await response.text()); + return response.json(); +} +``` + +### Python Client + +```python +import requests +from typing import List, Optional, Dict, Any + +API_BASE = 'http://localhost:8073/api/v1' + +def get_radios(protocol: Optional[str] = None) -> List[Dict[str, Any]]: + """Get all radios or filter by protocol.""" + url = f"{API_BASE}/radios/{protocol}" if protocol else f"{API_BASE}/radios" + response = requests.get(url) + response.raise_for_status() + return response.json() + +def get_meshcore_packets( + hash: Optional[str] = None, + type: Optional[int] = None, + channel_hash: Optional[str] = None +) -> List[Dict[str, Any]]: + """Get MeshCore packets with optional filters.""" + params = {} + if hash: + params['hash'] = hash + if type is not None: + params['type'] = type + if channel_hash: + params['channel_hash'] = channel_hash + + response = requests.get(f"{API_BASE}/meshcore/packets", params=params) + response.raise_for_status() + return response.json() + +def get_meshcore_stats() -> Dict[str, Any]: + """Get MeshCore network statistics.""" + response = requests.get(f"{API_BASE}/meshcore") + response.raise_for_status() + return response.json() +``` + +### Go Client + +```go +package hamview + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +const APIBase = "http://localhost:8073/api/v1" + +type Client struct { + BaseURL string + HTTP *http.Client +} + +func NewClient() *Client { + return &Client{ + BaseURL: APIBase, + HTTP: &http.Client{}, + } +} + +func (c *Client) GetRadios() ([]Radio, error) { + var radios []Radio + err := c.get("/radios", &radios) + return radios, err +} + +func (c *Client) GetMeshCoreNodes(nodeType string) ([]MeshCoreNode, error) { + path := "/meshcore/nodes" + if nodeType != "" { + path += "?type=" + url.QueryEscape(nodeType) + } + var nodes []MeshCoreNode + err := c.get(path, &nodes) + return nodes, err +} + +func (c *Client) get(path string, result interface{}) error { + resp, err := c.HTTP.Get(c.BaseURL + path) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(result) +} +``` + +--- + +## Rate Limiting + +Currently no rate limiting is implemented. + +## Versioning + +API version is specified in the URL path (`/api/v1`). Breaking changes will increment the version number. + +## Support + +For issues or questions, please refer to the project documentation at https://git.maze.io/ham/hamview diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..f76fd5a --- /dev/null +++ b/server/README.md @@ -0,0 +1,164 @@ +# Server Package + +This package contains the restructured HTTP server implementation for HAMView. + +## Structure + +``` +server/ +├── server.go # Main server setup and configuration +├── router.go # Route configuration with nested routers +├── handlers_radios.go # Radio endpoint handlers +├── handlers_meshcore.go # MeshCore endpoint handlers +├── error.go # Error handling utilities +└── server_test.go # Test infrastructure and test cases +``` + +## Design Principles + +### Clean Separation of Concerns + +- **server.go**: Server initialization, configuration, and lifecycle management +- **router.go**: Centralized route definition using Echo's Group feature for nested routing +- **handlers_*.go**: Domain-specific handler functions grouped by feature +- **error.go**: Consistent error handling across all endpoints + +### Nested Routers + +The routing structure uses Echo's Group feature to create a clean hierarchy: + +``` +/api/v1 +├── /radios +│ ├── GET / -> handleGetRadios +│ └── GET /:protocol -> handleGetRadios +└── /meshcore + ├── GET / -> handleGetMeshCore + ├── GET /groups -> handleGetMeshCoreGroups + ├── GET /packets -> handleGetMeshCorePackets + └── /nodes + ├── GET / -> handleGetMeshCoreNodes + └── GET /close-to/:publickey -> handleGetMeshCoreNodesCloseTo +``` + +### Testing Infrastructure + +The test suite uses an in-memory SQLite3 database for fast, isolated unit tests: + +- **setupTestServer()**: Creates a test Echo instance with routes and in-memory DB +- **teardownTestServer()**: Cleans up test resources +- Test cases cover all endpoints with various query parameters +- Benchmarks for performance testing +- Example showing how to populate test data + +## Usage + +### Creating a Server + +```go +import ( + "github.com/sirupsen/logrus" + "git.maze.io/ham/hamview/server" +) + +logger := logrus.New() + +serverConfig := &server.Config{ + Listen: ":8073", +} + +dbConfig := &server.DatabaseConfig{ + Type: "postgres", + Conf: "host=localhost user=ham dbname=hamview", +} + +srv, err := server.New(serverConfig, dbConfig, logger) +if err != nil { + log.Fatal(err) +} + +if err := srv.Run(); err != nil { + log.Fatal(err) +} +``` + +### Running Tests + +```bash +# Run all tests +go test -v ./server/ + +# Run specific test +go test -v ./server/ -run TestRadiosEndpoints + +# Run with coverage +go test -v -cover ./server/ + +# Run benchmarks +go test -v -bench=. ./server/ +``` + +### Adding New Endpoints + +1. **Add handler function** in the appropriate `handlers_*.go` file: + ```go + func (s *Server) handleNewEndpoint(c echo.Context) error { + // Implementation + return c.JSON(http.StatusOK, result) + } + ``` + +2. **Register route** in `router.go`: + ```go + func setupSomethingRoutes(s *Server, api *echo.Group) { + group := api.Group("/something") + group.GET("/new", s.handleNewEndpoint) + } + ``` + +3. **Add test** in `server_test.go`: + ```go + func TestNewEndpoint(t *testing.T) { + e, _ := setupTestServer(t) + defer teardownTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/something/new", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + // Assertions + } + ``` + +## Backward Compatibility + +The root `server.go` file maintains backward compatibility by re-exporting types and providing a compatibility wrapper for `NewServer()`. Existing code continues to work without changes. + +## Best Practices + +1. **Handler naming**: Use `handle` prefix (e.g., `handleGetRadios`) +2. **Context parameter**: Use short name `c` for `echo.Context` +3. **Error handling**: Always use `s.apiError()` for consistent error responses +4. **Query parameters**: Use helper functions like `getQueryInt()` for parsing +5. **Testing**: Write tests for both success and error cases +6. **Documentation**: Add godoc comments for exported functions + +## Performance Considerations + +- Use in-memory SQLite for tests (fast and isolated) +- Benchmark critical endpoints +- Use Echo's built-in middleware for CORS, logging, etc. +- Consider adding route-specific middleware for caching or rate limiting + +## Future Enhancements + +Potential improvements to consider: + +- [ ] Add middleware for authentication/authorization +- [ ] Implement request validation using a schema library +- [ ] Add structured logging with trace IDs +- [ ] Implement health check and metrics endpoints +- [ ] Add integration tests with a real PostgreSQL instance +- [ ] Consider adding OpenAPI/Swagger documentation +- [ ] Add graceful shutdown handling diff --git a/server/error.go b/server/error.go new file mode 100644 index 0000000..5f50990 --- /dev/null +++ b/server/error.go @@ -0,0 +1,30 @@ +package server + +import ( + "net/http" + "os" + + "github.com/labstack/echo/v4" +) + +// apiError handles API errors consistently +func (s *Server) apiError(c echo.Context, err error, status ...int) error { + s.logger.Warnf("server: error serving %s %s: %v", c.Request().Method, c.Request().URL.Path, err) + + if len(status) > 0 { + return c.JSON(status[0], map[string]any{ + "error": err.Error(), + }) + } + + switch { + case os.IsNotExist(err): + return c.JSON(http.StatusNotFound, nil) + case os.IsPermission(err): + return c.JSON(http.StatusUnauthorized, nil) + default: + return c.JSON(http.StatusInternalServerError, map[string]any{ + "error": err.Error(), + }) + } +} diff --git a/server/handlers_aprs.go b/server/handlers_aprs.go new file mode 100644 index 0000000..cee5e5e --- /dev/null +++ b/server/handlers_aprs.go @@ -0,0 +1,65 @@ +package server + +import ( + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + + "git.maze.io/ham/hamview/schema" +) + +// handleGetAPRSPackets returns APRS packets based on filter criteria. +// +// Endpoint: GET /api/v1/aprs/packets +// +// Query Parameters (evaluated in order): +// - src: APRS source callsign (case-insensitive) +// - dst: APRS destination callsign (case-insensitive) +// - limit: Maximum number of recent packets (default: 100) +// - (no parameters): Returns the 100 most recent packets +// +// Response: 200 OK +// +// []APRSPacket - Array of APRS packet objects +// +// Response: 500 Internal Server Error +// +// ErrorResponse - Error retrieving packets +// +// Example Requests: +// +// GET /api/v1/aprs/packets +// GET /api/v1/aprs/packets?src=OE1ABC +// GET /api/v1/aprs/packets?dst=APRS +// GET /api/v1/aprs/packets?limit=200 +func (s *Server) handleGetAPRSPackets(c echo.Context) error { + var ( + ctx = c.Request().Context() + packets []*schema.APRSPacket + err error + ) + + if source := c.QueryParam("src"); source != "" { + packets, err = schema.GetAPRSPacketsBySource(ctx, source) + } else if destination := c.QueryParam("dst"); destination != "" { + packets, err = schema.GetAPRSPacketsByDestination(ctx, destination) + } else { + limit := 100 + if value := c.QueryParam("limit"); value != "" { + if limit, err = strconv.Atoi(value); err != nil { + return s.apiError(c, err) + } + } + if limit <= 0 { + limit = 100 + } + packets, err = schema.GetAPRSPackets(ctx, limit) + } + + if err != nil { + return s.apiError(c, err) + } + + return c.JSON(http.StatusOK, packets) +} diff --git a/server/handlers_meshcore.go b/server/handlers_meshcore.go new file mode 100644 index 0000000..bdb11ef --- /dev/null +++ b/server/handlers_meshcore.go @@ -0,0 +1,331 @@ +package server + +import ( + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + + "git.maze.io/go/ham/protocol/meshcore" + "git.maze.io/ham/hamview/schema" +) + +// handleGetMeshCore returns aggregated statistics for the MeshCore network. +// +// Endpoint: GET /api/v1/meshcore +// +// Response: 200 OK +// +// MeshCoreStats - Network statistics including message counts and packet timeline +// +// Response: 500 Internal Server Error +// +// ErrorResponse - Error retrieving statistics +// +// Example Request: +// +// GET /api/v1/meshcore +// +// Example Response: +// +// { +// "messages": 150234, +// "nodes": 127, +// "receivers": 8, +// "packets": { +// "timestamps": [1709650800, 1709654400, 1709658000], +// "packets": [142, 203, 178] +// } +// } +func (s *Server) handleGetMeshCore(c echo.Context) error { + stats, err := schema.GetMeshCoreStats(c.Request().Context()) + if err != nil { + return s.apiError(c, err) + } + return c.JSON(http.StatusOK, stats) +} + +// handleGetMeshCoreGroups returns a list of public MeshCore groups/channels. +// +// Endpoint: GET /api/v1/meshcore/groups +// +// Response: 200 OK +// +// []MeshCoreGroup - Array of public group objects +// +// Response: 500 Internal Server Error +// +// ErrorResponse - Error retrieving groups +// +// Example Request: +// +// GET /api/v1/meshcore/groups +// +// Example Response: +// +// [ +// { +// "id": 5, +// "name": "General Chat", +// "secret": "0123456789abcdef0123456789abcdef" +// }, +// { +// "id": 7, +// "name": "Emergency", +// "secret": "fedcba9876543210fedcba9876543210" +// } +// ] +func (s *Server) handleGetMeshCoreGroups(c echo.Context) error { + groups, err := schema.GetMeshCoreGroups(c.Request().Context()) + if err != nil { + return s.apiError(c, err) + } + return c.JSON(http.StatusOK, groups) +} + +// handleGetMeshCoreNodes returns a list of MeshCore network nodes. +// +// Endpoint: GET /api/v1/meshcore/nodes +// +// Query Parameters: +// - type (optional): Filter by node type +// - "chat" - Chat nodes +// - "room" - Room nodes +// - "sensor" - Sensor nodes +// - "repeater" - Repeater nodes (default) +// +// Response: 200 OK +// +// []MeshCoreNode - Array of node objects, sorted by last_heard_at (descending) +// +// Response: 500 Internal Server Error +// +// ErrorResponse - Error retrieving nodes +// +// Example Request: +// +// GET /api/v1/meshcore/nodes +// GET /api/v1/meshcore/nodes?type=chat +// +// Example Response: +// +// [ +// { +// "id": 42, +// "packet": [], +// "name": "NODE-CHARLIE", +// "type": 0, +// "prefix": "mc", +// "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", +// "first_heard_at": "2026-01-15T08:00:00Z", +// "last_heard_at": "2026-03-05T14:25:00Z", +// "last_latitude": 52.3667, +// "last_longitude": 4.8945 +// } +// ] +func (s *Server) handleGetMeshCoreNodes(c echo.Context) error { + var ( + nodes []*schema.MeshCoreNode + err error + ) + + if kind := c.QueryParam("type"); kind != "" { + switch kind { + case "chat": + nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Chat) + case "room": + nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Room) + case "sensor": + nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Sensor) + case "repeater": + fallthrough + default: + nodes, err = schema.GetMeshCoreNodesByType(c.Request().Context(), meshcore.Repeater) + } + } else { + nodes, err = schema.GetMeshCoreNodes(c.Request().Context()) + } + + if err != nil { + return s.apiError(c, err) + } + + return c.JSON(http.StatusOK, nodes) +} + +// handleGetMeshCoreNodesCloseTo returns nodes within a specified radius of a reference node. +// +// Endpoint: GET /api/v1/meshcore/nodes/close-to/:publickey +// +// Path Parameters: +// - publickey: Hex-encoded Ed25519 public key (64 characters) of the reference node +// +// Query Parameters: +// - radius (optional): Search radius in meters (default: 25000 = 25 km) +// +// Response: 200 OK +// +// NodesCloseToResponse - Object containing the reference node and nearby nodes +// { +// "node": MeshCoreNode, // The reference node +// "nodes": []MeshCoreNode // Nearby nodes, sorted by distance (ascending) +// // Each node has the "distance" field populated (in meters) +// } +// +// Response: 404 Not Found +// +// null - Node not found or has no location data +// +// Response: 400 Bad Request +// +// ErrorResponse - Invalid radius parameter +// +// Response: 500 Internal Server Error +// +// ErrorResponse - Error performing proximity query +// +// Example Request: +// +// GET /api/v1/meshcore/nodes/close-to/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef +// GET /api/v1/meshcore/nodes/close-to/1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef?radius=50000 +// +// Example Response: +// +// { +// "node": { +// "id": 42, +// "name": "NODE-CHARLIE", +// "type": 0, +// "prefix": "mc", +// "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", +// "first_heard_at": "2026-01-15T08:00:00Z", +// "last_heard_at": "2026-03-05T14:25:00Z", +// "last_latitude": 52.3667, +// "last_longitude": 4.8945, +// "distance": 0 +// }, +// "nodes": [ +// { +// "id": 43, +// "name": "NODE-DELTA", +// "type": 0, +// "prefix": "mc", +// "public_key": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", +// "first_heard_at": "2026-02-01T10:00:00Z", +// "last_heard_at": "2026-03-05T14:20:00Z", +// "last_latitude": 52.3700, +// "last_longitude": 4.9000, +// "distance": 450.5 +// } +// ] +// } +func (s *Server) handleGetMeshCoreNodesCloseTo(c echo.Context) error { + var radius float64 + if value := c.QueryParam("radius"); value != "" { + var err error + if radius, err = strconv.ParseFloat(value, 64); err != nil { + return s.apiError(c, err) + } + } + if radius <= 0 { + radius = 25000.0 // 25 km + } + + node, nodes, err := schema.GetMeshCoreNodesCloseTo(c.Request().Context(), c.Param("publickey"), radius) + if err != nil { + return s.apiError(c, err) + } + + return c.JSON(http.StatusOK, map[string]any{ + "node": node, + "nodes": nodes, + }) +} + +// handleGetMeshCorePackets returns MeshCore packets based on various filter criteria. +// +// Endpoint: GET /api/v1/meshcore/packets +// +// Query Parameters (mutually exclusive, evaluated in order): +// - hash: 16-character hex hash - Returns all packets with this hash +// - type: Integer payload type - Returns packets of this type +// - Can be combined with channel_hash for group messages +// - channel_hash: 2-character channel hash (requires type parameter) +// - (no parameters): Returns the 100 most recent packets +// +// Response: 200 OK +// +// []MeshCorePacket - Array of packet objects +// +// Response: 400 Bad Request +// +// ErrorResponse - Invalid hash format or payload type +// +// Response: 500 Internal Server Error +// +// ErrorResponse - Error retrieving packets +// +// Example Requests: +// +// GET /api/v1/meshcore/packets +// GET /api/v1/meshcore/packets?hash=a1b2c3d4e5f67890 +// GET /api/v1/meshcore/packets?type=3 +// GET /api/v1/meshcore/packets?type=3&channel_hash=ab +// +// Example Response: +// +// [ +// { +// "id": 12345, +// "radio_id": 1, +// "radio": null, +// "snr": 8.5, +// "rssi": -95, +// "version": 1, +// "route_type": 0, +// "payload_type": 3, +// "hash": "a1b2c3d4e5f67890", +// "path": "AQIDBA==", +// "payload": "SGVsbG8gV29ybGQ=", +// "raw": "AQIDBAUGBwg=", +// "parsed": {"text": "Hello World"}, +// "channel_hash": "ab", +// "received_at": "2026-03-05T14:30:00Z" +// } +// ] +// +// Payload Types: +// - 0: Ping +// - 1: Node announcement +// - 2: Direct text message +// - 3: Group text message +// - 4: Position update +// - (other types may be defined by the protocol) +func (s *Server) handleGetMeshCorePackets(c echo.Context) error { + var ( + ctx = c.Request().Context() + packets []*schema.MeshCorePacket + err error + ) + + if hash := c.QueryParam("hash"); hash != "" { + packets, err = schema.GetMeshCorePacketsByHash(ctx, hash) + } else if kind := c.QueryParam("type"); kind != "" { + var payloadType int + if payloadType, err = strconv.Atoi(kind); err == nil { + if hash := c.QueryParam("channel_hash"); hash != "" { + packets, err = schema.GetMeshCorePacketsByChannelHash(ctx, hash) + } else { + packets, err = schema.GetMeshCorePacketsByPayloadType(ctx, meshcore.PayloadType(payloadType)) + } + } + } else { + packets, err = schema.GetMeshCorePackets(ctx, 100) + } + + if err != nil { + return s.apiError(c, err) + } + + return c.JSON(http.StatusOK, packets) +} diff --git a/server/handlers_radios.go b/server/handlers_radios.go new file mode 100644 index 0000000..c540929 --- /dev/null +++ b/server/handlers_radios.go @@ -0,0 +1,68 @@ +package server + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "git.maze.io/ham/hamview/schema" +) + +// handleGetRadios returns a list of radio stations/receivers. +// +// Endpoint: GET /api/v1/radios +// Endpoint: GET /api/v1/radios/:protocol +// +// Path Parameters: +// - protocol (optional): Filter by protocol name (e.g., "meshcore", "aprs") +// +// Response: 200 OK +// +// []Radio - Array of radio objects +// +// Response: 500 Internal Server Error +// +// ErrorResponse - Error retrieving radios +// +// Example Request: +// +// GET /api/v1/radios +// GET /api/v1/radios/meshcore +// +// Example Response: +// +// [ +// { +// "id": 1, +// "name": "Station-Alpha", +// "is_online": true, +// "manufacturer": "Heltec", +// "device": "WiFi LoRa 32 V3", +// "modulation": "LoRa", +// "protocol": "meshcore", +// "latitude": 52.3667, +// "longitude": 4.8945, +// "frequency": 868.1, +// "bandwidth": 125.0, +// "created_at": "2026-01-01T12:00:00Z", +// "updated_at": "2026-03-05T10:30:00Z" +// } +// ] +func (s *Server) handleGetRadios(c echo.Context) error { + var ( + radios []*schema.Radio + err error + ) + + if protocol := c.Param("protocol"); protocol != "" { + radios, err = schema.GetRadiosByProtocol(c.Request().Context(), protocol) + } else { + radios, err = schema.GetRadios(c.Request().Context()) + } + + if err != nil { + return s.apiError(c, err) + } + + return c.JSON(http.StatusOK, radios) +} diff --git a/server/router.go b/server/router.go new file mode 100644 index 0000000..b3b7539 --- /dev/null +++ b/server/router.go @@ -0,0 +1,48 @@ +package server + +import ( + "github.com/labstack/echo/v4" +) + +// setupRoutes configures all API routes using nested routers +func setupRoutes(s *Server, e *echo.Echo) { + // API v1 group + api := e.Group("/api/v1") + + setupRadiosRoutes(s, api.Group("/radios")) + setupMeshCoreRoutes(s, api.Group("/meshcore")) + setupAPRSRoutes(s, api.Group("/aprs")) +} + +// setupRadiosRoutes configures routes for radio endpoints +func setupRadiosRoutes(s *Server, root *echo.Group) { + root.GET("", s.handleGetRadios) + root.GET("/:protocol", s.handleGetRadios) +} + +// setupMeshCoreRoutes configures routes for MeshCore endpoints +func setupMeshCoreRoutes(s *Server, root *echo.Group) { + // Stats endpoint + root.GET("", s.handleGetMeshCore) + + // Groups endpoints + root.GET("/groups", s.handleGetMeshCoreGroups) + + // Nodes endpoints + nodes := root.Group("/nodes") + nodes.GET("", s.handleGetMeshCoreNodes) + nodes.GET("/close-to/:publickey", s.handleGetMeshCoreNodesCloseTo) + + // Packets endpoint + root.GET("/packets", s.handleGetMeshCorePackets) + + // Commented out routes from original + // meshcore.GET("/path/:origin/:path", s.handleGetMeshCorePath) + // meshcore.GET("/sources", s.handleGetMeshCoreSources) +} + +// setupAPRSRoutes configures routes for APRS endpoints +func setupAPRSRoutes(s *Server, root *echo.Group) { + // Packets endpoint + root.GET("/packets", s.handleGetAPRSPackets) +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..f5652c1 --- /dev/null +++ b/server/server.go @@ -0,0 +1,99 @@ +package server + +import ( + "fmt" + "net" + + echologrus "github.com/cemkiy/echo-logrus" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/sirupsen/logrus" + + "git.maze.io/ham/hamview/schema" +) + +const DefaultServerListen = ":8073" + +// Config holds the server configuration +type Config struct { + Listen string `yaml:"listen"` +} + +// DatabaseConfig holds database configuration +type DatabaseConfig struct { + Type string `yaml:"type"` + Conf string `yaml:"conf"` +} + +// Server represents the HTTP server +type Server struct { + listen string + listenAddr *net.TCPAddr + logger *logrus.Logger +} + +// New creates a new Server instance +func New(serverConfig *Config, databaseConfig *DatabaseConfig, logger *logrus.Logger) (*Server, error) { + if serverConfig.Listen == "" { + serverConfig.Listen = DefaultServerListen + } + + listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen) + if err != nil { + return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err) + } + + if err = schema.Open(databaseConfig.Type, databaseConfig.Conf); err != nil { + return nil, err + } + + return &Server{ + listen: serverConfig.Listen, + listenAddr: listenAddr, + logger: logger, + }, nil +} + +// Run starts the HTTP server +func (s *Server) Run() error { + echologrus.Logger = s.logger + + e := echo.New() + e.HideBanner = true + e.Logger = echologrus.GetEchoLogger() + e.Use(echologrus.Hook()) + e.Use(middleware.RequestLogger()) + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept}, + })) + + // Static files + e.File("/", "./dashboard/dist/index.html") + e.Static("/asset/", "./asset") + e.Static("/assets/", "./dashboard/dist/assets") + + // Setup API routes + setupRoutes(s, e) + + if s.listenAddr.IP == nil || s.listenAddr.IP.Equal(net.ParseIP("0.0.0.0")) || s.listenAddr.IP.Equal(net.ParseIP("::")) { + s.logger.Infof("server: listening on http://127.0.0.1:%d", s.listenAddr.Port) + } else { + s.logger.Infof("server: listening on http://%s:%d", s.listenAddr.IP, s.listenAddr.Port) + } + + return e.Start(s.listen) +} + +// NewTestServer creates a server for testing with an in-memory SQLite database +func NewTestServer(logger *logrus.Logger) (*Server, error) { + serverConfig := &Config{ + Listen: "127.0.0.1:0", + } + databaseConfig := &DatabaseConfig{ + Type: "sqlite3", + Conf: ":memory:", + } + + return New(serverConfig, databaseConfig, logger) +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..48ae0d8 --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,370 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/sirupsen/logrus" + + "git.maze.io/ham/hamview/schema" +) + +// setupTestServer creates an Echo instance with routes configured and an in-memory database +func setupTestServer(t *testing.T) (*echo.Echo, *Server) { + t.Helper() + + logger := logrus.New() + logger.SetLevel(logrus.WarnLevel) + + // Initialize in-memory SQLite database + if err := schema.Open("sqlite3", ":memory:"); err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create server instance + server := &Server{ + listen: "127.0.0.1:0", + logger: logger, + } + + // Setup Echo with routes + e := echo.New() + e.HideBanner = true + setupRoutes(server, e) + + return e, server +} + +// teardownTestServer cleans up test resources +func teardownTestServer(t *testing.T) { + t.Helper() + // Close database connection if needed + // schema.Close() // Add this method to schema package if needed +} + +// TestRadiosEndpoints tests all radio-related endpoints +func TestRadiosEndpoints(t *testing.T) { + e, _ := setupTestServer(t) + defer teardownTestServer(t) + + t.Run("GET /api/v1/radios", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/radios", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var radios []*schema.Radio + if err := json.Unmarshal(rec.Body.Bytes(), &radios); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) + + t.Run("GET /api/v1/radios/:protocol", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/radios/aprs", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var radios []*schema.Radio + if err := json.Unmarshal(rec.Body.Bytes(), &radios); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) +} + +// TestAPRSEndpoints tests all APRS-related endpoints +func TestAPRSEndpoints(t *testing.T) { + e, _ := setupTestServer(t) + defer teardownTestServer(t) + + t.Run("GET /api/v1/aprs/packets", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/aprs/packets", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var packets []*schema.APRSPacket + if err := json.Unmarshal(rec.Body.Bytes(), &packets); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) + + t.Run("GET /api/v1/aprs/packets filters", func(t *testing.T) { + testCases := []string{ + "?src=OE1ABC", + "?dst=APRS", + "?limit=200", + } + + for _, query := range testCases { + req := httptest.NewRequest(http.MethodGet, "/api/v1/aprs/packets"+query, nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK && rec.Code != http.StatusInternalServerError { + t.Errorf("Expected status %d or %d, got %d", http.StatusOK, http.StatusInternalServerError, rec.Code) + } + } + }) +} + +// TestMeshCoreEndpoints tests all MeshCore-related endpoints +func TestMeshCoreEndpoints(t *testing.T) { + e, _ := setupTestServer(t) + defer teardownTestServer(t) + + t.Run("GET /api/v1/meshcore", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var stats map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &stats); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) + + t.Run("GET /api/v1/meshcore/groups", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/groups", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var groups []any + if err := json.Unmarshal(rec.Body.Bytes(), &groups); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) + + t.Run("GET /api/v1/meshcore/nodes", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var nodes []*schema.MeshCoreNode + if err := json.Unmarshal(rec.Body.Bytes(), &nodes); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) + + t.Run("GET /api/v1/meshcore/nodes?type=chat", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes?type=chat", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var nodes []*schema.MeshCoreNode + if err := json.Unmarshal(rec.Body.Bytes(), &nodes); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) + + t.Run("GET /api/v1/meshcore/packets", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/packets", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var packets []*schema.MeshCorePacket + if err := json.Unmarshal(rec.Body.Bytes(), &packets); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + }) +} + +// TestMeshCoreNodesCloseTo tests the close-to endpoint +func TestMeshCoreNodesCloseTo(t *testing.T) { + e, _ := setupTestServer(t) + defer teardownTestServer(t) + + // First, insert a test node if needed + // This is a placeholder - you'll need actual test data setup + + t.Run("GET /api/v1/meshcore/nodes/close-to/:publickey", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes/close-to/test_key", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + // May return 404 or error if no data exists, which is fine for skeleton test + if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError { + t.Errorf("Expected status %d, %d, or %d, got %d", http.StatusOK, http.StatusNotFound, http.StatusInternalServerError, rec.Code) + } + }) + + t.Run("GET /api/v1/meshcore/nodes/close-to/:publickey with radius", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/nodes/close-to/test_key?radius=50000", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + // May return 404 or error if no data exists, which is fine for skeleton test + if rec.Code != http.StatusOK && rec.Code != http.StatusNotFound && rec.Code != http.StatusInternalServerError { + t.Errorf("Expected status %d, %d, or %d, got %d", http.StatusOK, http.StatusNotFound, http.StatusInternalServerError, rec.Code) + } + }) +} + +// TestMeshCorePacketsWithFilters tests packet endpoint with various query parameters +func TestMeshCorePacketsWithFilters(t *testing.T) { + e, _ := setupTestServer(t) + defer teardownTestServer(t) + + testCases := []struct { + name string + queryParam string + }{ + {"With hash", "?hash=test_hash"}, + {"With type", "?type=1"}, + {"With type and channel_hash", "?type=1&channel_hash=test_channel"}, + {"No filters", ""}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/packets"+tc.queryParam, nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK && rec.Code != http.StatusInternalServerError { + t.Errorf("Expected status %d or %d, got %d", http.StatusOK, http.StatusInternalServerError, rec.Code) + } + }) + } +} + +// BenchmarkGetRadios benchmarks the radios endpoint +func BenchmarkGetRadios(b *testing.B) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + if err := schema.Open("sqlite3", ":memory:"); err != nil { + b.Fatalf("Failed to open test database: %v", err) + } + + server := &Server{ + listen: "127.0.0.1:0", + logger: logger, + } + + e := echo.New() + e.HideBanner = true + setupRoutes(server, e) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/radios", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + } +} + +// BenchmarkGetMeshCorePackets benchmarks the packets endpoint +func BenchmarkGetMeshCorePackets(b *testing.B) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + if err := schema.Open("sqlite3", ":memory:"); err != nil { + b.Fatalf("Failed to open test database: %v", err) + } + + server := &Server{ + listen: "127.0.0.1:0", + logger: logger, + } + + e := echo.New() + e.HideBanner = true + setupRoutes(server, e) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/meshcore/packets", nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + } +} + +// Example test showing how to populate test data +func TestWithTestData(t *testing.T) { + e, _ := setupTestServer(t) + defer teardownTestServer(t) + + // Example: Insert test data + ctx := context.Background() + + // Example radio insertion (adapt based on your schema package) + // radio := &schema.Radio{ + // ID: "test-radio-1", + // Protocol: "aprs", + // Name: "Test Radio", + // } + // if err := schema.InsertRadio(ctx, radio); err != nil { + // t.Fatalf("Failed to insert test radio: %v", err) + // } + + t.Run("GET /api/v1/radios with data", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/radios", nil) + rec := httptest.NewRecorder() + + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) + } + + var radios []*schema.Radio + if err := json.Unmarshal(rec.Body.Bytes(), &radios); err != nil { + t.Errorf("Failed to unmarshal response: %v", err) + } + + // Add assertions about the data + // if len(radios) != 1 { + // t.Errorf("Expected 1 radio, got %d", len(radios)) + // } + }) + + _ = ctx // Use ctx to avoid unused variable error +} diff --git a/server/types.go b/server/types.go new file mode 100644 index 0000000..d76daa3 --- /dev/null +++ b/server/types.go @@ -0,0 +1,273 @@ +// Package server provides HTTP API endpoints for HAMView +// +// # API Documentation +// +// This package exposes REST API endpoints for querying amateur radio data. +// All endpoints return JSON responses and use standard HTTP status codes. +// +// # Base URL +// +// All API endpoints are prefixed with `/api/v1` +// +// # Response Format +// +// Success responses return the requested data with HTTP 200 OK status. +// Error responses return JSON with the following structure: +// +// { +// "error": "error message description" +// } +// +// # HTTP Status Codes +// +// - 200 OK: Request successful +// - 400 Bad Request: Invalid query parameters +// - 404 Not Found: Resource not found +// - 500 Internal Server Error: Server error +// +// # Data Types +// +// ## Radio +// +// Represents an amateur radio receiver/station. +// +// JSON Schema: +// +// { +// "id": integer, // Unique identifier +// "name": string, // Station name (unique) +// "is_online": boolean, // Current online status +// "manufacturer": string, // Hardware manufacturer +// "device": string | null, // Device model name +// "firmware_version": string | null, // Firmware version string +// "firmware_date": string | null, // Firmware date (ISO 8601) +// "antenna": string | null, // Antenna description +// "modulation": string, // Modulation type (e.g., "LoRa") +// "protocol": string, // Protocol name (e.g., "meshcore", "aprs") +// "latitude": number | null, // Latitude in decimal degrees +// "longitude": number | null, // Longitude in decimal degrees +// "altitude": number | null, // Altitude in meters +// "frequency": number, // Frequency in Hz +// "bandwidth": number, // Bandwidth in Hz +// "power": number | null, // Transmit power in dBm +// "gain": number | null, // Antenna gain in dBi +// "lora_sf": integer | null, // LoRa spreading factor (7-12) +// "lora_cr": integer | null, // LoRa coding rate (5-8) +// "extra": object | null, // Additional metadata +// "created_at": string, // Creation timestamp (ISO 8601) +// "updated_at": string // Last update timestamp (ISO 8601) +// } +// +// Example: +// +// { +// "id": 1, +// "name": "Station-Alpha", +// "is_online": true, +// "manufacturer": "Heltec", +// "device": "WiFi LoRa 32 V3", +// "firmware_version": "1.0.0", +// "firmware_date": "2026-01-15T00:00:00Z", +// "antenna": "868MHz 3dBi", +// "modulation": "LoRa", +// "protocol": "meshcore", +// "latitude": 52.3667, +// "longitude": 4.8945, +// "altitude": 5.0, +// "frequency": 868.1, +// "bandwidth": 125.0, +// "power": 14.0, +// "gain": 3.0, +// "lora_sf": 7, +// "lora_cr": 5, +// "extra": null, +// "created_at": "2026-01-01T12:00:00Z", +// "updated_at": "2026-03-05T10:30:00Z" +// } +// +// ## MeshCorePacket +// +// Represents a received MeshCore protocol packet. +// +// JSON Schema: +// +// { +// "id": integer, // Unique packet identifier +// "radio_id": integer, // Radio that received this packet +// "radio": Radio | null, // Radio object (when populated) +// "snr": number, // Signal-to-noise ratio in dB +// "rssi": integer, // Received signal strength in dBm +// "version": integer, // Protocol version +// "route_type": integer, // Routing type (0=broadcast, 1=unicast, etc.) +// "payload_type": integer, // Payload type identifier +// "hash": string, // 16-character hex hash +// "path": string, // Base64-encoded routing path +// "payload": string, // Base64-encoded payload data +// "raw": string, // Base64-encoded raw packet +// "parsed": object | null, // Parsed payload (type depends on payload_type) +// "channel_hash": string, // 2-character channel hash (for group messages) +// "received_at": string // Reception timestamp (ISO 8601) +// } +// +// Example: +// +// { +// "id": 12345, +// "radio_id": 1, +// "radio": null, +// "snr": 8.5, +// "rssi": -95, +// "version": 1, +// "route_type": 0, +// "payload_type": 3, +// "hash": "a1b2c3d4e5f67890", +// "path": "AQIDBA==", +// "payload": "SGVsbG8gV29ybGQ=", +// "raw": "AQIDBAUGBwg=", +// "parsed": {"text": "Hello World"}, +// "channel_hash": "ab", +// "received_at": "2026-03-05T14:30:00Z" +// } +// +// ## MeshCoreNode +// +// Represents a MeshCore network node. +// +// JSON Schema: +// +// { +// "id": integer, // Unique node identifier +// "packet": MeshCorePacket[], // Associated packets +// "name": string, // Node name/callsign +// "type": integer, // Node type (0=repeater, 1=chat, 2=room, 3=sensor) +// "prefix": string, // 2-character network prefix +// "public_key": string, // Hex-encoded Ed25519 public key (64 chars) +// "first_heard_at": string, // First heard timestamp (ISO 8601) +// "last_heard_at": string, // Last heard timestamp (ISO 8601) +// "last_latitude": number | null, // Last known latitude +// "last_longitude": number | null, // Last known longitude +// "distance": number // Distance in meters (only in proximity queries) +// } +// +// Example: +// +// { +// "id": 42, +// "packet": [], +// "name": "NODE-CHARLIE", +// "type": 0, +// "prefix": "mc", +// "public_key": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", +// "first_heard_at": "2026-01-15T08:00:00Z", +// "last_heard_at": "2026-03-05T14:25:00Z", +// "last_latitude": 52.3667, +// "last_longitude": 4.8945, +// "distance": 0 +// } +// +// ## MeshCoreGroup +// +// Represents a MeshCore group/channel. +// +// JSON Schema: +// +// { +// "id": integer, // Unique group identifier +// "name": string, // Group name (max 32 chars) +// "secret": string // Group secret/key (32 chars) +// } +// +// Example: +// +// { +// "id": 5, +// "name": "General Chat", +// "secret": "0123456789abcdef0123456789abcdef" +// } +// +// ## MeshCoreStats +// +// Aggregated statistics for the MeshCore network. +// +// JSON Schema: +// +// { +// "messages": integer, // Total message count +// "nodes": integer, // Total node count +// "receivers": integer, // Total receiver count +// "packets": { +// "timestamps": integer[], // Unix timestamps +// "packets": integer[] // Packet counts per timestamp +// } +// } +// +// Example: +// +// { +// "messages": 150234, +// "nodes": 127, +// "receivers": 8, +// "packets": { +// "timestamps": [1709650800, 1709654400, 1709658000], +// "packets": [142, 203, 178] +// } +// } +// +// ## NodesCloseToResponse +// +// Response for nodes proximity query. +// +// JSON Schema: +// +// { +// "node": MeshCoreNode, // The reference node +// "nodes": MeshCoreNode[] // Nearby nodes (with distance field populated) +// } +// +// Example: +// +// { +// "node": { +// "id": 42, +// "name": "NODE-CHARLIE", +// "type": 0, +// "prefix": "mc", +// "public_key": "1234...abcdef", +// "first_heard_at": "2026-01-15T08:00:00Z", +// "last_heard_at": "2026-03-05T14:25:00Z", +// "last_latitude": 52.3667, +// "last_longitude": 4.8945, +// "distance": 0 +// }, +// "nodes": [ +// { +// "id": 43, +// "name": "NODE-DELTA", +// "type": 0, +// "prefix": "mc", +// "public_key": "abcd...5678", +// "first_heard_at": "2026-02-01T10:00:00Z", +// "last_heard_at": "2026-03-05T14:20:00Z", +// "last_latitude": 52.3700, +// "last_longitude": 4.9000, +// "distance": 450.5 +// } +// ] +// } +// +// ## ErrorResponse +// +// Standard error response format. +// +// JSON Schema: +// +// { +// "error": string // Human-readable error message +// } +// +// Example: +// +// { +// "error": "invalid hash" +// } +package server diff --git a/sql.go b/sql.go index 342fe38..dd60269 100644 --- a/sql.go +++ b/sql.go @@ -1,5 +1,31 @@ package hamview +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +type JSONB map[string]any + +func (v JSONB) Value() (driver.Value, error) { + return json.Marshal(v) +} + +func (v *JSONB) Scan(value any) error { + if value == nil { + *v = nil + return nil + } + b, ok := value.([]byte) + if !ok { + return fmt.Errorf("type assertion to []byte failed, got %T", value) + } + + return json.Unmarshal(b, &v) +} + +/* const ( sqlCreateRadio = ` CREATE TABLE IF NOT EXISTS radio ( @@ -16,20 +42,56 @@ const ( latitude NUMERIC(10, 8), -- GPS latitude in decimal degrees longitude NUMERIC(11, 8), -- GPS longitude in decimal degrees altitude REAL, -- Altitude in meters - frequency DOUBLE PRECISION, - bandwidth DOUBLE PRECISION, + frequency DOUBLE PRECISION NOT NULL, + bandwidth DOUBLE PRECISION NOT NULL, rx_frequency DOUBLE PRECISION, tx_frequency DOUBLE PRECISION, power REAL, gain REAL, lora_sf SMALLINT, lora_cr SMALLINT, - extra JSONB + extra JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); ` sqlIndexRadioName = `CREATE INDEX IF NOT EXISTS idx_radio_name ON radio(name);` sqlIndexRadioProtocol = `CREATE INDEX IF NOT EXISTS idx_radio_protocol ON radio(protocol);` sqlGeometryRadioPosition = `SELECT AddGeometryColumn('public', 'radio', 'position', 4326, 'POINT', 2);` + sqlSelectRadios = ` + SELECT + name, + is_online, + device, + manufacturer, + firmware_date, + firmware_version, + antenna, + modulation, + protocol, + latitude, + longitude, + altitude, + frequency, + bandwidth, + rx_frequency, + tx_frequency, + power, + gain, + lora_sf, + lora_cr, + extra + FROM radio + WHERE + protocol LIKE $1 + AND ( + is_online + OR + updated_at BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW() + ) + ORDER BY + protocol, name + ` ) const ( @@ -74,6 +136,38 @@ const ( ` sqlIndexMeshCorePacketHash = `CREATE INDEX IF NOT EXISTS idx_meshcore_packet_hash ON meshcore_packet(hash);` sqlIndexMeshCorePacketPayloadType = `CREATE INDEX IF NOT EXISTS idx_meshcore_packet_payload_type ON meshcore_packet(payload_type);` + sqlSelectMeshCoreTextStats = ` + SELECT + COUNT(id) + FROM + meshcore_packet + WHERE + received_at BETWEEN NOW() - INTERVAL '24 HOURS' AND NOW() + AND + payload_type IN (2, 5) + ` + sqlSelectMeshCorePacketStatsOverTime = ` + WITH time_buckets AS ( + SELECT + time_bucket, + COUNT(*) as packet_count + FROM ( + SELECT + date_trunc('minute', received_at) - INTERVAL '1 minute' * (EXTRACT(minute FROM received_at)::int % 5) AS time_bucket + FROM + meshcore_packet + WHERE + received_at >= NOW() - INTERVAL '24 hours' + ) AS bucketed + GROUP BY time_bucket + ORDER BY time_bucket DESC + ) + SELECT + time_bucket, + packet_count + FROM + time_buckets; + ` ) const ( @@ -105,6 +199,12 @@ const ( ELSE NULL END ) STORED;` + sqlSelectMeshCoreNodeStats = ` + SELECT + COUNT(id) + FROM + meshcore_node + ` ) const ( @@ -234,3 +334,4 @@ const ( ); ` ) +*/ diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/AGENTS.md b/ui/AGENTS.md new file mode 100644 index 0000000..39a7a49 --- /dev/null +++ b/ui/AGENTS.md @@ -0,0 +1,59 @@ +# AGENTS + +This document provides context for AI agents working on this codebase. + +## Project Overview + +HAMView is an online Amateur Radio digital protocol live viewer. It features: +- Displaying online radio receivers in near real-time +- Streaming of popular Amateur Radio protocols such as APRS, MeshCore, etc. +- A live packet stream for each of the protocols +- Packet inspection + +## Tech Stack + +Used technologies: +- **Framework**: React 19 with TypeScript +- **Build Tool**: Vite 7 +- **User Interface**: React-Bootstrap with Bootstrap version 5 +- **Code Editor**: Visual Studio Code +- **Backend**: Go with labstack echo router +- **Libraries used**: Axios for API requests, mqtt.js for streaming +- **Testing**: use `npm run build` + +Relevant documents: +- API documentation is in `../server` + +## Testing Requirements + +**Always run tests before completing a task.** + +Run `npm run build`. + +## Coding Guidelines + +### General +- Prefer ESM imports (`import`/`export`) +- Use builtins from React, React-Boostrap where possible +- Follow existing code patterns in the code base +- Never make changes outside of the `ui` directory, if you think this is necessary prompt me for approval. + +### Styling +- Use React-Bootstrap components where appropriate +- Follow existing CSS patterns +- Add reusable style elements to the `src/App.scss` +- Order imports: + - React import first; then any react plugin + - Third-party libraries; + - Services; + - Local types imports; + - Local imports; + - Stylesheets +- Long import statements (> 3 imports) should use multiline import +- Sort import imports alphabetically + +## Protected files + +**Never modify files inside the `data/` directory.** This directory contains game data that should remain unchanged. + +Never add secrets to code. diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..5d5f0b6 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + HAMView + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..99d2113 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,5960 @@ +{ + "name": "ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.0", + "dependencies": { + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.8", + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/ed25519": "^3.0.0", + "@noble/hashes": "^2.0.1", + "axios": "^1.13.6", + "bootstrap": "^5.3.8", + "date-fns": "^4.1.0", + "mqtt": "^5.15.0", + "react": "^19.2.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", + "react-qr-code": "^2.0.18", + "react-router": "^7.13.1", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/crypto-js": "^4.2.2", + "@types/node": "^24.11.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.0.18", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "happy-dom": "^20.8.3", + "sass": "^1.93.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.3", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.8.tgz", + "integrity": "sha512-s9UHZo7QJVly7gNArEZkbbsimHqJZhElgBpXIJdehZ4OWXt+CCr0SBDgUCDJnQrqpd1dWK2dLq5rmO4mCBmI3w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.8.tgz", + "integrity": "sha512-88sWg/UJc1X82OMO+ISR4E3P58I3BjFVg0qkmDu7OWlN8VijneZD3ylFA+ImxuPjMHW3SHosfSJYy1fztoz0fw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.8", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.8.tgz", + "integrity": "sha512-QKd1RhDXE1hf2sQDNayA9ic9jGkEgvZOf0tTkJxlBPG8ns8aS4rS8WwYURw2x5y3739p0HauUXX9WbH7UufFLw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/core-downloads-tracker": "^7.3.8", + "@mui/system": "^7.3.8", + "@mui/types": "^7.4.11", + "@mui/utils": "^7.3.8", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.3", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.8", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, + "node_modules/@mui/private-theming": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.8.tgz", + "integrity": "sha512-du5dlPZ9XL3xW2apHoGDXBI+QLtyVJGrXNCfcNYfP/ojkz1RQ0rRV6VG9Rkm1DqEFRG8mjjTL7zmE1Bvn1eR4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.8", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.8.tgz", + "integrity": "sha512-JHAeXQzS0tJ+Fq3C6J4TVDsW+yKhO4uuxuiLaopNStJeQYBIUCXpKYyUCcgXym4AmhbznQnv9RlHywSH6b0FOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.8.tgz", + "integrity": "sha512-hoFRj4Zw2Km8DPWZp/nKG+ao5Jw5LSk2m/e4EGc6M3RRwXKEkMSG4TgtfVJg7dS2homRwtdXSMW+iRO0ZJ4+IA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.8", + "@mui/styled-engine": "^7.3.8", + "@mui/types": "^7.4.11", + "@mui/utils": "^7.3.8", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.11", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.11.tgz", + "integrity": "sha512-fZ2xO9D08IKOxO2oUBi1nnVKH6oJUD+64cnv4YAaFoC0E5+i1+S5AHbNqqvZlYYsbPEQ6qEVwuBqY3jl5W4G+Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.8.tgz", + "integrity": "sha512-kZRcE2620CBGr+XI8YMmwPj6WIPwSF7uMJjvSfqd8zXVvlz0MCJbzRRUGNf8NgflCLthdji2DdS643TeyJ3+nA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.11", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT" + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/ed25519": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz", + "integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", + "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", + "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz", + "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.0.18", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/broker-factory": { + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz", + "integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-unique-numbers": "^9.0.26", + "tslib": "^2.8.1", + "worker-factory": "^7.0.48" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-unique-numbers": { + "version": "9.0.26", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz", + "integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", + "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/happy-dom": { + "version": "20.8.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.3.tgz", + "integrity": "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.0.tgz", + "integrity": "sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "node_modules/mqtt/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-qr-code": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", + "integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz", + "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/worker-factory": { + "version": "7.0.48", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz", + "integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-unique-numbers": "^9.0.26", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.30", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.30.tgz", + "integrity": "sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.15", + "worker-timers-worker": "^9.0.13" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.15", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz", + "integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "broker-factory": "^3.1.13", + "fast-unique-numbers": "^9.0.26", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.13" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz", + "integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "tslib": "^2.8.1", + "worker-factory": "^7.0.48" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..6f14dbb --- /dev/null +++ b/ui/package.json @@ -0,0 +1,53 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest", + "test:ui": "vitest --ui", + "test:run": "vitest run" + }, + "dependencies": { + "@emotion/styled": "^11.14.1", + "@mui/icons-material": "^7.3.8", + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/ed25519": "^3.0.0", + "@noble/hashes": "^2.0.1", + "axios": "^1.13.6", + "bootstrap": "^5.3.8", + "date-fns": "^4.1.0", + "mqtt": "^5.15.0", + "react": "^19.2.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", + "react-qr-code": "^2.0.18", + "react-router": "^7.13.1", + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/crypto-js": "^4.2.2", + "@types/node": "^24.11.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/ui": "^4.0.18", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "happy-dom": "^20.8.3", + "sass": "^1.93.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vitest": "^4.0.18" + } +} diff --git a/ui/public/image b/ui/public/image new file mode 120000 index 0000000..bc0829a --- /dev/null +++ b/ui/public/image @@ -0,0 +1 @@ +../../asset/image \ No newline at end of file diff --git a/ui/public/vite.svg b/ui/public/vite.svg new file mode 100644 index 0000000..ee9fada --- /dev/null +++ b/ui/public/vite.svg @@ -0,0 +1 @@ + diff --git a/ui/src/App.scss b/ui/src/App.scss new file mode 100644 index 0000000..e2fb01c --- /dev/null +++ b/ui/src/App.scss @@ -0,0 +1,147 @@ +/* Import theme configuration */ +@import './styles/variables'; +@import './styles/theme'; + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + display: block; + background-color: var(--app-bg); + color: var(--app-text); +} + +#root { + max-width: none; + padding: 0; + text-align: initial; + background-color: var(--app-bg); + color: var(--app-text); +} + +a { + color: var(--app-accent); +} + +a:hover { + color: #bfd2ff; +} + +.full-view { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + +.split-root { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + display: flex; +} + +.split-pane { + min-width: 0; + min-height: 0; + overflow: auto; + color: var(--app-text); +} + +.horizontal-split { + .split-pane { + width: 100%; + } + + .split-pane-primary, + .split-pane-secondary { + flex: 1 1 0; + } + + .split-gutter { + height: var(--layout-gutter); + flex: 0 0 var(--layout-gutter); + width: 100%; + } +} + +.vertical-split { + flex-direction: row; + + .split-gutter { + width: var(--layout-gutter); + flex: 0 0 var(--layout-gutter); + } +} + +.vertical-split-50-50 { + .split-pane-primary, + .split-pane-secondary { + flex: 1 1 0; + } +} + +.vertical-split-75-25 { + .split-pane-primary { + flex: 3 1 0; + } + + .split-pane-secondary { + flex: 1 1 0; + } +} + +.vertical-split-25-70 { + .split-pane-primary { + flex: 25 1 0; + } + + .split-pane-secondary { + flex: 70 1 0; + } +} + +/* List Group Styles */ +.list-item { + padding: 0.75rem; + border-bottom: 1px solid rgba(173, 205, 255, 0.15); + cursor: pointer; + transition: background 0.15s ease, border-left 0.15s ease, padding-left 0.15s ease; + background: rgba(11, 39, 82, 0.6); + color: var(--app-text); + + &:hover { + background: rgba(20, 60, 120, 0.85); + border-bottom-color: rgba(173, 205, 255, 0.25); + } + + &.is-selected { + background: rgba(30, 90, 160, 0.75); + border-left: 4px solid rgba(142, 180, 255, 0.95); + padding-left: calc(0.75rem - 4px); + } + + &:last-child { + border-bottom: none; + } +} + +.list-item-title { + font-weight: 600; + color: var(--app-text); + margin-bottom: 0.25rem; +} + +.list-item-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: var(--app-text-muted); +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..d9237cb --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,51 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router' + +import { RadiosProvider } from './contexts/RadiosContext' +import Overview from './pages/Overview' +import APRS from './pages/APRS' +import APRSPacketsView from './pages/aprs/APRSPacketsView' +import MeshCore from './pages/MeshCore' +import MeshCoreGroupChatView from './pages/meshcore/MeshCoreGroupChatView' +import MeshCoreMapView from './pages/meshcore/MeshCoreMapView' +import MeshCorePacketsView from './pages/meshcore/MeshCorePacketsView' +import StyleGuide from './pages/StyleGuide' +import NotFound from './pages/NotFound' +import './App.scss' + +const navLinks = [ + { label: 'Radios', to: '/' }, + { label: 'APRS', to: '/aprs' }, + { label: 'ADSB', to: '/adsb' }, + { label: 'MeshCore', to: '/meshcore' }, +]; + +const withRadiosProvider = (Component: React.ComponentType<{ navLinks: typeof navLinks }>) => + () => ( + + + + ); + +function App() { + return ( + + + + + } /> + } /> + + + } /> + } /> + } /> + } /> + + } /> + } /> + + + ) +} + +export default App diff --git a/ui/src/assets/react.svg b/ui/src/assets/react.svg new file mode 100644 index 0000000..8e0e0f1 --- /dev/null +++ b/ui/src/assets/react.svg @@ -0,0 +1 @@ + diff --git a/ui/src/components/Full.tsx b/ui/src/components/Full.tsx new file mode 100644 index 0000000..e571698 --- /dev/null +++ b/ui/src/components/Full.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Container } from 'react-bootstrap'; +import type { FullProps } from '../types/layout.types'; + +const Full: React.FC = ({ children, className = '' }) => { + return ( + + {children} + + ); +}; + +export default Full; diff --git a/ui/src/components/HorizontalSplit.tsx b/ui/src/components/HorizontalSplit.tsx new file mode 100644 index 0000000..725f7f5 --- /dev/null +++ b/ui/src/components/HorizontalSplit.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; +import type { HorizontalSplitProps } from '../types/layout.types'; + +const HorizontalSplit: React.FC = ({ top, bottom, className = '' }) => { + return ( + + + {top} + + {bottom} + + + ); +}; + +export default HorizontalSplit; diff --git a/ui/src/components/Layout.scss b/ui/src/components/Layout.scss new file mode 100644 index 0000000..1f43bfe --- /dev/null +++ b/ui/src/components/Layout.scss @@ -0,0 +1,90 @@ +.layout-container { + --layout-gutter: 16px; + width: 100%; + height: 100vh; + min-height: 100vh; + display: flex; + flex-direction: column; + background-color: var(--app-bg); + color: var(--app-text); +} + +.layout-navbar { + flex: 0 0 auto; + background: linear-gradient( + 135deg, + rgba(23, 58, 110, 0.46) 0%, + rgba(10, 32, 72, 0.62) 50%, + rgba(30, 74, 140, 0.42) 100% + ); + border-bottom: 1px solid rgba(167, 203, 255, 0.35); + box-shadow: + 0 8px 24px rgba(2, 10, 26, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.2); + backdrop-filter: blur(14px) saturate(140%); + -webkit-backdrop-filter: blur(14px) saturate(140%); + + .navbar-brand, + .nav-link, + .navbar-toggler { + color: var(--app-text); + } + + .navbar-brand:hover, + .nav-link:hover, + .nav-link:focus { + color: #ffffff; + } + + .navbar-toggler { + border-color: rgba(255, 255, 255, 0.35); + } + + .navbar-toggler-icon { + filter: brightness(0) invert(1); + } + + .nav-link-custom { + color: var(--app-text-muted); + transition: color 160ms ease, text-shadow 160ms ease; + } + + .nav-link-custom:hover, + .nav-link-custom:focus { + color: #f3f7ff; + text-shadow: 0 0 12px rgba(168, 203, 255, 0.45); + } + + .nav-link-custom.active { + color: #ffffff; + font-weight: 600; + text-shadow: 0 0 14px rgba(153, 193, 255, 0.5); + } +} + +.main-content { + flex: 1 1 auto; + min-height: 0; + min-width: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + background-color: var(--app-bg); + color: var(--app-text); + padding-left: var(--layout-gutter); + padding-right: var(--layout-gutter); + padding-bottom: var(--layout-gutter); +} +.layout-footer { + flex: 0 0 auto; + padding: 12px var(--layout-gutter); + background-color: var(--app-bg); + border-top: 1px solid rgba(167, 203, 255, 0.35); + text-align: center; + font-size: 12px; + color: var(--app-text); + + p { + margin: 0; + } +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 0000000..b5efba8 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Container, Navbar, Nav } from 'react-bootstrap'; +import { Link, NavLink, Outlet, useLocation } from 'react-router'; +import './Layout.scss'; +import type { LayoutProps, NavLinkItem } from '../types/layout.types'; + +const Layout: React.FC = ({ + children, + brandText = 'PD0MZ HAM View', + brandTo = '/', + buttonGroup, + navLinks = [], + gutterSize = 16 +}) => { + const location = useLocation(); + + const isActive = (link: NavLinkItem): boolean => { + return location.pathname.startsWith(link.to); + }; + + const resolvedGutter = typeof gutterSize === 'number' ? `${gutterSize}px` : gutterSize; + + return ( +
+ + +
+ + {brandText} + + {buttonGroup &&
{buttonGroup}
} +
+ + + + + +
+
+ + + {children || } + + +
+

© {new Date().getFullYear()} PD0MZ. All rights reserved.

+
+
+ ); +}; + +export default Layout; diff --git a/ui/src/components/RadioCard.scss b/ui/src/components/RadioCard.scss new file mode 100644 index 0000000..2174b05 --- /dev/null +++ b/ui/src/components/RadioCard.scss @@ -0,0 +1,138 @@ +.radio-card { + background: linear-gradient( + 135deg, + rgba(20, 60, 120, 0.85) 0%, + rgba(25, 75, 140, 0.75) 100% + ); + border: 1px solid rgba(142, 180, 255, 0.35); + border-radius: 8px; + color: var(--app-text); + box-shadow: + 0 4px 16px rgba(2, 10, 26, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); + transition: all 200ms ease; + min-width: 260px; + height: 100%; + + &:hover { + border-color: rgba(142, 180, 255, 0.4); + box-shadow: + 0 8px 24px rgba(2, 10, 26, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.15); + transform: translateY(-4px); + } +} + +.radio-card-clickable { + cursor: pointer; + + &:hover { + border-color: rgba(142, 180, 255, 0.6); + box-shadow: + 0 12px 32px rgba(142, 180, 255, 0.2), + 0 8px 24px rgba(2, 10, 26, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.15); + transform: translateY(-6px); + } +} + +.radio-card-header { + background: linear-gradient( + 135deg, + rgba(30, 85, 160, 0.9) 0%, + rgba(35, 100, 180, 0.85) 100% + ); + border-bottom: 1px solid rgba(142, 180, 255, 0.3); + padding: 12px 8px; + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; +} + +.radio-card-image-container { + width: calc(100% - 16px); + height: 140px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 8px; + overflow: hidden; + border-radius: 4px; +} + +.radio-card-protocol-image, +.radio-card-device-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + filter: brightness(1.1) drop-shadow(0 0 8px rgba(142, 180, 255, 0.3)); +} + +.radio-card-body { + padding: 16px; +} + +.radio-card-title { + font-size: 14px; + font-weight: 600; + color: var(--app-text); + margin-bottom: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.radio-card-details { + display: flex; + flex-direction: column; + gap: 8px; +} + +.radio-card-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + gap: 8px; +} + +.radio-card-label { + color: var(--app-text-muted); + font-weight: 500; + flex: 0 0 auto; +} + +.radio-card-value { + color: var(--app-accent); + font-weight: 600; + text-align: right; + flex: 1 1 auto; +} + +.radio-card-status { + display: flex; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(142, 180, 255, 0.15); +} + +.radio-card-status-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + width: 100%; + text-align: center; + + &.online { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + } + + &.offline { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; + } +} diff --git a/ui/src/components/RadioCard.tsx b/ui/src/components/RadioCard.tsx new file mode 100644 index 0000000..738b38f --- /dev/null +++ b/ui/src/components/RadioCard.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Card } from 'react-bootstrap'; +import type { Radio } from '../types/radio.types'; +import { getDeviceImageURL } from '../libs/deviceImageMapper'; +import './RadioCard.scss'; + +interface RadioCardProps { + radio: Radio; +} + +export const RadioCard: React.FC = ({ radio }) => { + const deviceImageURL = getDeviceImageURL(radio.protocol, radio.manufacturer, radio.device); + + return ( + + +
+ {`${radio.manufacturer { + (e.target as HTMLImageElement).src = '/image/device/unknown.png'; + }} + /> +
+
+ + {radio.name} +
+
+ Frequency: + {radio.frequency.toFixed(2)} MHz +
+
+ Bandwidth: + {radio.bandwidth.toFixed(2)} kHz +
+
+ Modulation: + {radio.modulation} +
+
+ Protocol: + {radio.protocol} +
+ {(radio.lora_sf !== undefined || radio.lora_cr !== undefined) && ( + <> + {radio.lora_sf !== undefined && ( +
+ LoRa SF: + {radio.lora_sf} +
+ )} + {radio.lora_cr !== undefined && ( +
+ LoRa CR: + {radio.lora_cr} +
+ )} + + )} +
+ + {radio.is_online ? '🟢 Online' : '🔴 Offline'} + +
+
+
+
+ ); +}; + +export default RadioCard; diff --git a/ui/src/components/StreamStatus.scss b/ui/src/components/StreamStatus.scss new file mode 100644 index 0000000..6571601 --- /dev/null +++ b/ui/src/components/StreamStatus.scss @@ -0,0 +1,29 @@ +@keyframes stream-status-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +.stream-status { + &__text { + font-size: 0.875rem; + } + + &__dot { + font-size: 0.875rem; + + &.is-ready { + color: #22c55e; + animation: none; + } + + &.is-connecting { + color: #ef4444; + animation: stream-status-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + } + } +} diff --git a/ui/src/components/StreamStatus.tsx b/ui/src/components/StreamStatus.tsx new file mode 100644 index 0000000..e7f12b8 --- /dev/null +++ b/ui/src/components/StreamStatus.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; +import './StreamStatus.scss'; + +interface StreamStatusProps { + ready: boolean; +} + +const StreamStatus: React.FC = ({ ready }) => { + return ( +
+ + {ready ? 'Connected' : 'Connecting'} + + +
+ ); +}; + +export default StreamStatus; diff --git a/ui/src/components/VerticalSplit.tsx b/ui/src/components/VerticalSplit.tsx new file mode 100644 index 0000000..98b5b02 --- /dev/null +++ b/ui/src/components/VerticalSplit.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; +import type { VerticalSplitProps } from '../types/layout.types'; + +const VerticalSplit: React.FC = ({ + left, + right, + ratio = '50/50', + className = '' +}) => { + const ratioClass = + ratio === '75/25' ? 'vertical-split-75-25' : + ratio === '25/70' ? 'vertical-split-25-70' : + 'vertical-split-50-50'; + + return ( + + + {left} + + {right} + + + ); +}; + +export default VerticalSplit; diff --git a/ui/src/contexts/RadiosContext.tsx b/ui/src/contexts/RadiosContext.tsx new file mode 100644 index 0000000..0f355a8 --- /dev/null +++ b/ui/src/contexts/RadiosContext.tsx @@ -0,0 +1,60 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; + +import API from '../services/API'; +import type { Radio } from '../types/radio.types'; + +interface RadiosContextValue { + radios: Radio[]; + loading: boolean; + error: string | null; +} + +const RadiosContext = createContext(null); + +export const RadiosProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [radios, setRadios] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRadios = async () => { + try { + setLoading(true); + setError(null); + const data = await API.fetchAllRadios(); + setRadios(data || []); + } catch (err) { + console.error('Failed to fetch radios:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch radios'); + } finally { + setLoading(false); + } + }; + + fetchRadios(); + }, []); + + const value = useMemo( + () => ({ + radios, + loading, + error, + }), + [radios, loading, error] + ); + + return {children}; +}; + +export const useRadios = (): RadiosContextValue => { + const ctx = useContext(RadiosContext); + if (!ctx) { + throw new Error('useRadios must be used within RadiosProvider'); + } + return ctx; +}; + +export const useRadiosByProtocol = (protocol: string): Radio[] => { + const { radios } = useRadios(); + return useMemo(() => radios.filter((radio) => radio.protocol === protocol), [radios, protocol]); +}; diff --git a/ui/src/contexts/StreamContext.tsx b/ui/src/contexts/StreamContext.tsx new file mode 100644 index 0000000..5574837 --- /dev/null +++ b/ui/src/contexts/StreamContext.tsx @@ -0,0 +1,215 @@ +import React, { createContext, useContext, useEffect, useRef, useState, useCallback, type JSX } from 'react'; +import type { BaseStream } from '../services/Stream'; +import type { StreamState } from '../types/stream.types'; + +interface StreamContextValue { + stream: BaseStream | null; + state: StreamState; + subscribe: ( + topic: string, + callback: (data: T, topic: string) => void, + qos?: 0 | 1 | 2 + ) => () => void; + subscribeMany: ( + subscriptions: Array<{ topic: string; qos?: 0 | 1 | 2 }>, + callback: (data: any, topic: string) => void + ) => () => void; + reconnect: () => void; + getLastMessage: (topic: string) => T | undefined; + isSubscribed: (topic: string) => boolean; +} + +const StreamContext = createContext(undefined); + +interface StreamProviderProps { + stream: BaseStream; + children: React.ReactNode; +} + +export function StreamProvider({ stream, children }: StreamProviderProps): JSX.Element { + const [state, setState] = useState(stream.getState()); + const streamRef = useRef(stream); + + useEffect(() => { + const currentStream = streamRef.current; + + const unsubscribeState = currentStream.subscribeToState((newState) => { + setState(newState); + }); + + return () => { + unsubscribeState(); + }; + }, []); + + const subscribe = useCallback(( + topic: string, + callback: (data: T, topic: string) => void, + qos: 0 | 1 | 2 = 0 + ) => { + return streamRef.current.subscribe(topic, callback, qos); + }, []); + + const subscribeMany = useCallback(( + subscriptions: Array<{ topic: string; qos?: 0 | 1 | 2 }>, + callback: (data: any, topic: string) => void + ) => { + return streamRef.current.subscribeMany(subscriptions, callback); + }, []); + + const reconnect = useCallback(() => { + if (streamRef.current) { + streamRef.current.disconnect(); + streamRef.current.connect(); + } + }, []); + + const getLastMessage = useCallback((topic: string) => { + return streamRef.current.getLastMessage(topic); + }, []); + + const isSubscribed = useCallback((topic: string) => { + return streamRef.current.isSubscribed(topic); + }, []); + + const value: StreamContextValue = { + stream: streamRef.current, + state, + subscribe, + subscribeMany, + reconnect, + getLastMessage, + isSubscribed, + }; + + return ( + + {children} + + ); +} + +// Main hook +export function useStream(): StreamContextValue { + const context = useContext(StreamContext); + + if (context === undefined) { + throw new Error('useStream must be used within a StreamProvider'); + } + + return context; +} + +// Topic-specific hook +export function useTopic( + topic: string, + qos: 0 | 1 | 2 = 0 +): { + lastMessage: T | undefined; + isSubscribed: boolean; + subscribe: () => void; + unsubscribe: () => void; +} { + const { subscribe: subscribeToTopic, getLastMessage, isSubscribed: checkSubscription } = useStream(); + const [lastMessage, setLastMessage] = useState(() => getLastMessage(topic)); + const unsubscribeRef = useRef<(() => void) | null>(null); + + useEffect(() => { + // Cleanup on unmount + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + } + }; + }, []); + + const subscribe = useCallback(() => { + if (!unsubscribeRef.current) { + unsubscribeRef.current = subscribeToTopic(topic, (data) => { + setLastMessage(data); + }, qos); + } + }, [topic, qos, subscribeToTopic]); + + const unsubscribe = useCallback(() => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + setLastMessage(undefined); + } + }, []); + + return { + lastMessage, + isSubscribed: checkSubscription(topic), + subscribe, + unsubscribe, + }; +} + +// Multiple topics hook +export function useTopics>( + topics: Array<{ topic: keyof T & string; qos?: 0 | 1 | 2 }> +): { + lastMessages: Partial; + isSubscribed: Record; + subscribe: () => void; + unsubscribe: () => void; +} { + const { subscribe: subscribeToTopic, getLastMessage, isSubscribed: checkSubscription } = useStream(); + const [lastMessages, setLastMessages] = useState>(() => { + const initial: Partial = {}; + topics.forEach(({ topic }) => { + initial[topic as keyof T] = getLastMessage(topic as string); + }); + return initial; + }); + + const unsubscribersRef = useRef void>>(new Map()); + + useEffect(() => { + return () => { + unsubscribersRef.current.forEach(unsub => unsub()); + }; + }, []); + + const subscribe = useCallback(() => { + topics.forEach(({ topic, qos = 0 }) => { + if (!unsubscribersRef.current.has(topic)) { + const unsub = subscribeToTopic(topic as string, (data) => { + setLastMessages(prev => ({ ...prev, [topic]: data })); + }, qos); + unsubscribersRef.current.set(topic, unsub); + } + }); + }, [topics, subscribeToTopic]); + + const unsubscribe = useCallback(() => { + unsubscribersRef.current.forEach(unsub => unsub()); + unsubscribersRef.current.clear(); + setLastMessages({}); + }, []); + + const isSubscribedRecord = topics.reduce((acc, { topic }) => { + acc[topic as keyof T] = checkSubscription(topic as string); + return acc; + }, {} as Record); + + return { + lastMessages, + isSubscribed: isSubscribedRecord, + subscribe, + unsubscribe, + }; +} + +// Connection state hook +export function useStreamConnection() { + const { state } = useStream(); + return { + isConnected: state.isConnected, + isConnecting: state.isConnecting, + error: state.error, + subscriptions: Array.from(state.subscriptions.keys()), + }; +} diff --git a/ui/src/libs/base91.test.ts b/ui/src/libs/base91.test.ts new file mode 100644 index 0000000..70aba8d --- /dev/null +++ b/ui/src/libs/base91.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { base91ToNumber, numberToBase91 } from './base91'; + +describe('Base91 encoding/decoding', () => { + describe('base91ToNumber', () => { + it('should decode single Base91 character', () => { + expect(base91ToNumber('!')).toBe(0); + expect(base91ToNumber('"')).toBe(1); + expect(base91ToNumber('#')).toBe(2); + }); + + it('should decode multiple Base91 characters', () => { + // "!!" = 0 * 91 + 0 = 0 + expect(base91ToNumber('!!')).toBe(0); + + // "!#" = 0 * 91 + 2 = 2 + expect(base91ToNumber('!#')).toBe(2); + + // "#!" = 2 * 91 + 0 = 182 + expect(base91ToNumber('#!')).toBe(182); + + // "##" = 2 * 91 + 2 = 184 + expect(base91ToNumber('##')).toBe(184); + }); + + it('should decode 4-character Base91 strings (used in APRS)', () => { + // Test with printable ASCII Base91 characters (33-123) + const testValue = base91ToNumber('!#%\''); + expect(testValue).toBeGreaterThan(0); + expect(testValue).toBeLessThan(91 * 91 * 91 * 91); + }); + + it('should decode maximum valid Base91 value', () => { + // Maximum is '{' (ASCII 123, digit 90) repeated + const maxValue = base91ToNumber('{{{{'); + const expected = 90 * 91 * 91 * 91 + 90 * 91 * 91 + 90 * 91 + 90; + expect(maxValue).toBe(expected); + }); + + it('should throw error for invalid Base91 characters', () => { + expect(() => base91ToNumber(' ')).toThrow('Invalid Base91 character'); + expect(() => base91ToNumber('}')).toThrow('Invalid Base91 character'); + expect(() => base91ToNumber('~')).toThrow('Invalid Base91 character'); + }); + + it('should handle APRS compressed position example', () => { + // Using actual characters from APRS test vector + const latStr = '/:*E'; + const lonStr = 'qZ=O'; + + const latValue = base91ToNumber(latStr); + const lonValue = base91ToNumber(lonStr); + + // Just verify they decode without error and produce valid numbers + expect(typeof latValue).toBe('number'); + expect(typeof lonValue).toBe('number'); + expect(latValue).toBeGreaterThanOrEqual(0); + expect(lonValue).toBeGreaterThanOrEqual(0); + }); + }); + + describe('numberToBase91', () => { + it('should encode zero', () => { + expect(numberToBase91(0)).toBe('!'); + expect(numberToBase91(0, 1)).toBe('!'); + expect(numberToBase91(0, 4)).toBe('!!!!'); + }); + + it('should encode small numbers', () => { + expect(numberToBase91(5, 1)).toBe('&'); + expect(numberToBase91(1)).toBe('"'); + expect(numberToBase91(90)).toBe('{'); + }); + + it('should encode multi-digit Base91 values', () => { + // 91 = "!" (1 char to next position) but we get "#!" (2 chars) because it's 1*91 + const encoded = numberToBase91(91); + expect(encoded.length).toBeGreaterThan(1); + }); + + it('should pad with leading ! when length specified', () => { + const encoded1 = numberToBase91(5, 1); + expect(encoded1).toBe('&'); // ASCII 33 + 5 = 38 (&) + expect(encoded1.length).toBe(1); + + const encoded4 = numberToBase91(5, 4); + expect(encoded4.length).toBe(4); + expect(encoded4.endsWith('&')).toBe(true); + expect(encoded4.startsWith('!')).toBe(true); + }); + + it('should encode and decode consistently', () => { + const testValues = [0, 1, 42, 91, 182, 1000, 10000, 100000]; + + for (const value of testValues) { + const encoded = numberToBase91(value, 4); + const decoded = base91ToNumber(encoded); + expect(decoded).toBe(value); + } + }); + + it('should handle maximum Base91 4-byte value', () => { + const maxValue = 91 * 91 * 91 * 91 - 1; + const encoded = numberToBase91(maxValue, 4); + expect(encoded.length).toBe(4); + + const decoded = base91ToNumber(encoded); + expect(decoded).toBe(maxValue); + }); + }); + + describe('Round-trip encoding/decoding', () => { + it('should preserve values through encode-decode cycle', () => { + const testCases = [ + { value: 12345, length: 4 }, + { value: 0, length: 4 }, + { value: 65535, length: 4 }, + { value: 1000000, length: 4 }, + ]; + + for (const { value, length } of testCases) { + const encoded = numberToBase91(value, length); + const decoded = base91ToNumber(encoded); + expect(decoded).toBe(value); + } + }); + + it('should work with APRS latitude/longitude encoding pattern', () => { + // APRS uses 4-character Base91 strings for latitude and longitude + // Latitude range: 0 to 380926 (representing 90 to -90 degrees) + const latValues = [0, 190463, 380926]; // ~90N, ~0, ~90S + + for (const value of latValues) { + const encoded = numberToBase91(value, 4); + expect(encoded.length).toBe(4); + + const decoded = base91ToNumber(encoded); + expect(decoded).toBe(value); + } + }); + }); +}); diff --git a/ui/src/libs/base91.ts b/ui/src/libs/base91.ts new file mode 100644 index 0000000..e40d70a --- /dev/null +++ b/ui/src/libs/base91.ts @@ -0,0 +1,55 @@ +/** + * Decode a Base91 encoded string to a number. + * Base91 uses ASCII characters 33-123 (! to {) for encoding. + * Each character represents a digit in base 91. + * + * @param str Base91 encoded string (typically 4 characters for 32-bit values in APRS) + * @returns Decoded number value + */ +export const base91ToNumber = (str: string): number => { + let value = 0; + const base = 91; + + for (let i = 0; i < str.length; i++) { + const charCode = str.charCodeAt(i); + const digit = charCode - 33; // Base91 uses chars 33-123 (! to {) + + if (digit < 0 || digit >= base) { + throw new Error(`Invalid Base91 character: '${str[i]}' (code ${charCode})`); + } + + value = value * base + digit; + } + + return value; +} + +/** + * Encode a number to Base91 representation. + * Base91 uses ASCII characters 33-123 (! to {) for encoding. + * + * @param value Number to encode + * @param length Number of Base91 characters to generate (default: minimum needed) + * @returns Base91 encoded string + */ +export const numberToBase91 = (value: number, length?: number): string => { + const base = 91; + const chars: string[] = []; + let num = Math.abs(Math.floor(value)); + + if (num === 0) { + return length ? '!'.repeat(length) : '!'; + } + + while (num > 0) { + chars.unshift(String.fromCharCode((num % base) + 33)); + num = Math.floor(num / base); + } + + // Pad with leading '!' characters if needed + if (length && chars.length < length) { + chars.unshift(...Array(length - chars.length).fill('!')); + } + + return chars.join(''); +} diff --git a/ui/src/libs/deviceImageMapper.ts b/ui/src/libs/deviceImageMapper.ts new file mode 100644 index 0000000..fea3bad --- /dev/null +++ b/ui/src/libs/deviceImageMapper.ts @@ -0,0 +1,235 @@ +/** + * Protocol-aware device image mapper + * Maps manufacturer and device names to device image filenames based on protocol + */ + +type DeviceImageMap = Record>; + +const meshcoreDeviceMap: DeviceImageMap = { + heltec: { + mesh_solar: 'heltec_mesh_solar.svg', + meshpocket: 'heltec_meshpocket.svg', + paper: 'heltec_paper.svg', + t114: 'heltec_t114.svg', + v2: 'heltec_v2.svg', + v3: 'heltec_v3.svg', + v4: 'heltec_v4.svg', + wp: 'heltec_wp.svg', + wsl3: 'heltec_wsl3.svg', + wt3: 'heltec_wt3.svg', + }, + lilygo: { + t_beam: 'lilygo_tbeam.svg', + tbeam: 'lilygo_tbeam.svg', + t_beam_supreme: 'lilygo_tbeam_supreme.svg', + tbeam_supreme: 'lilygo_tbeam_supreme.svg', + t_deck: 'lilygo_tdeck.svg', + tdeck: 'lilygo_tdeck.svg', + t_deck_pro: 'lilygo_tdeck_pro.svg', + tdeck_pro: 'lilygo_tdeck_pro.svg', + t_display: 'lilygo_tdisplay.svg', + tdisplay: 'lilygo_tdisplay.svg', + t_echo: 'lilygo_techo.svg', + techo: 'lilygo_techo.svg', + t_echo_lite: 'lilygo_techo_lite.svg', + techo_lite: 'lilygo_techo_lite.svg', + t_pager: 'lilygo_pager.svg', + pager: 'lilygo_pager.svg', + }, + rak: { + '4631': 'rak_4631.svg', + '11300': 'rak_11300.svg', + wismesh_tag: 'rak_wismesh_tag.svg', + }, + seeed: { + wio_tracker_l1: 'wio_tracker_l1.svg', + wio_tracker_l1_eink: 'wio_tracker_l1_eink.svg', + sensecap_solar: 'sensecap_solar.svg', + sensecap_t1000e: 'sensecap_t1000e.svg', + }, + xiao: { + esp32c3: 'xiao_esp32c3.svg', + esp32c6: 'xiao_esp32c6.svg', + esp32s3: 'xiao_esp32s3.svg', + nrf52: 'xiao_nrf52.svg', + }, + ikoka: { + nano: 'ikoka_nano.svg', + stick: 'ikoka_stick.svg', + }, + keepteen: { + lt1: 'keepteen_lt1.svg', + }, + thinknode: { + m1: 'thinknode_m1.svg', + m2: 'thinknode_m2.svg', + m3: 'thinknode_m3.svg', + m5: 'thinknode_m5.svg', + m6: 'thinknode_m6.svg', + }, + nano: { + g2: 'nano_g2.svg', + }, + station: { + g2: 'station_g2.svg', + }, + generic: { + meshcore: 'meshcore.svg', + esp32: 'esp32.svg', + esp_now: 'esp_now.svg', + lora: 'lora.svg', + nrf52: 'nrf52.svg', + rpi: 'rpi.svg', + rpi_pico: 'rpi_picow.svg', + picow: 'rpi_picow.svg', + }, +}; + +const aprsDeviceMap: DeviceImageMap = { + lilygo: { + lora32: 'lilygo_tlora_1.6.svg', + 'lora32 v2': 'lilygo_tlora_1.6.svg', + 'lora32 v2.1': 'lilygo_tlora_1.6.svg', + 't3s3': 'lilygo_t3s3.svg', + 't5_pro': 'lilygo_t5_pro.svg', + 't_beam': 'lilygo_tbeam.svg', + 'tbeam_v1.1': 'lilygo_tbeam.svg', + 't_deck': 'lilygo_tdeck.svg', + 't_lora_c6': 'lilygo_tlora_c6.svg', + 't_echo': 'lilygo_techo.svg', + }, + heltec: { + 'lora32': 'heltec_v2.svg', + 'lora32 v2': 'heltec_v3.svg', + 'lora32 v3': 'heltec_v3.svg', + 'lora32 v4': 'heltec_v4.svg', + 'wireless_paper': 'heltec_paper.svg', + 'wireless_paper_pro': 'heltec_paper.svg', + }, + rak: { + '4631': 'rak_4631.svg', + '11300': 'rak_11300.svg', + }, + generic: { + lora: 'lora.svg', + esp32: 'esp32.svg', + }, +}; + +export function getDeviceImageURL( + protocol: string, + manufacturer?: string | null, + device?: string | null +): string { + if (!manufacturer || !device) { + return '/image/device/unknown.png'; + } + + const protocolLower = protocol.toLowerCase(); + const manufacturerLower = manufacturer.toLowerCase().replace(/\s+/g, '_'); + const deviceLower = device.toLowerCase().replace(/\s+/g, '_'); + + let deviceMap: DeviceImageMap | undefined; + + if (protocolLower === 'meshcore' || protocolLower.includes('mesh')) { + deviceMap = meshcoreDeviceMap; + } else if (protocolLower === 'aprs') { + deviceMap = aprsDeviceMap; + } + + if (!deviceMap) { + // Unknown protocol, try generic lookup + return `/image/device/unknown.png`; + } + + // Try exact match first + const mfgMap = deviceMap[manufacturerLower]; + if (mfgMap && mfgMap[deviceLower]) { + return `/image/device/${mfgMap[deviceLower]}`; + } + + // Try partial match (search for device containing the key) + if (mfgMap) { + for (const [key, filename] of Object.entries(mfgMap)) { + if (deviceLower.includes(key) || key.includes(deviceLower)) { + return `/image/device/${filename}`; + } + } + } + + // Fall back to looking for a generic manufacturer-based image + const genericMfgKey = manufacturerLower.split('_')[0]; + const genericMfgImage = `/image/device/${genericMfgKey}.svg`; + + // Check if a manufacturer-specific generic image exists + if (hasDeviceImage(genericMfgImage)) { + return genericMfgImage; + } + + return `/image/device/unknown.png`; +} + +/** + * Helper to check if a device image likely exists + * (In a real app, this could be from an asset manifest) + */ +function hasDeviceImage(filename: string): boolean { + const knownImages = [ + 'esp32.svg', + 'esp_now.svg', + 'faketec.svg', + 'heltec_mesh_solar.svg', + 'heltec_meshpocket.svg', + 'heltec_paper.svg', + 'heltec_t114.svg', + 'heltec_v2.svg', + 'heltec_v3.svg', + 'heltec_v4.svg', + 'heltec_wp.svg', + 'heltec_wsl3.svg', + 'heltec_wt3.svg', + 'ikoka_nano.svg', + 'ikoka_stick.svg', + 'keepteen_lt1.svg', + 'lilygo_pager.svg', + 'lilygo_t3s3.svg', + 'lilygo_t5_pro.svg', + 'lilygo_tbeam.svg', + 'lilygo_tbeam_supreme.svg', + 'lilygo_tdeck.svg', + 'lilygo_tdeck_pro.svg', + 'lilygo_tdisplay.svg', + 'lilygo_techo.svg', + 'lilygo_techo_lite.svg', + 'lilygo_tlora_1.6.svg', + 'lilygo_tlora_c6.svg', + 'lora.svg', + 'meshcore.svg', + 'nano_g2.svg', + 'nrf52.svg', + 'rak_11300.svg', + 'rak_4631.svg', + 'rak_wismesh_tag.svg', + 'rpi.svg', + 'rpi_picow.svg', + 'sensecap_solar.svg', + 'sensecap_t1000e.svg', + 'station_g2.svg', + 'thinknode_m1.svg', + 'thinknode_m2.svg', + 'thinknode_m3.svg', + 'thinknode_m5.svg', + 'thinknode_m6.svg', + 'wio_tracker_l1.svg', + 'wio_tracker_l1_eink.svg', + 'xiao_esp32c3.svg', + 'xiao_esp32c6.svg', + 'xiao_esp32s3.svg', + 'xiao_nrf52.svg', + 'yeasu_ft817.jpg', + 'unknown.png', + ]; + return knownImages.some( + (img) => filename.includes(img) || img.includes(filename.split('/').pop() || '') + ); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..6f6f799 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import 'bootstrap/dist/css/bootstrap.min.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/ui/src/pages/APRS.scss b/ui/src/pages/APRS.scss new file mode 100644 index 0000000..d23b06b --- /dev/null +++ b/ui/src/pages/APRS.scss @@ -0,0 +1,107 @@ +.aprs-view-switch { + .btn { + border-color: rgba(173, 205, 255, 0.45); + color: var(--app-text); + } + + .btn.btn-primary { + background: rgba(90, 146, 255, 0.5); + border-color: rgba(173, 205, 255, 0.75); + } + + .btn:hover, + .btn:focus { + color: #ffffff; + border-color: rgba(225, 237, 255, 0.8); + } +} + +.aprs-table-card, +.aprs-detail-card { + background: rgba(8, 24, 56, 0.5); + border: 1px solid rgba(173, 205, 255, 0.2); + color: var(--app-text); +} + +.aprs-table-header { + background: rgba(27, 56, 108, 0.45); + border-bottom: 1px solid rgba(173, 205, 255, 0.2); + color: var(--app-text); + font-weight: 600; +} + +.aprs-table-body { + height: 100%; + min-height: 0; +} + +.aprs-table-scroll { + height: 100%; + max-height: 100%; + overflow-y: auto; +} + +.aprs-table { + color: var(--app-text); + + thead th { + position: sticky; + top: 0; + z-index: 2; + background: rgba(13, 36, 82, 0.95); + border-color: rgba(173, 205, 255, 0.18); + color: var(--app-text); + } + + td { + border-color: rgba(173, 205, 255, 0.12); + vertical-align: middle; + cursor: pointer; + } + + tr.is-selected td { + background: rgba(102, 157, 255, 0.16); + } + + tr:hover td { + background: rgba(102, 157, 255, 0.08); + } +} + +.aprs-detail-stack { + overflow-y: auto; + padding-right: 0.25rem; +} + +.aprs-fact-row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 8px; + padding: 4px 0; +} + +.aprs-fact-label { + color: var(--app-text-muted); +} + +.aprs-fact-value { + color: var(--app-text); + word-break: break-all; +} + +.aprs-raw-code { + display: block; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.3); + border-radius: 0.25rem; + white-space: pre-wrap; + word-break: break-all; + color: #b8d1ff; + font-size: 0.85rem; +} + +.aprs-map { + .leaflet-container { + background: rgba(13, 36, 82, 0.95); + } +} diff --git a/ui/src/pages/APRS.tsx b/ui/src/pages/APRS.tsx new file mode 100644 index 0000000..9c5738a --- /dev/null +++ b/ui/src/pages/APRS.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Button, ButtonGroup } from 'react-bootstrap'; +import Layout from '../components/Layout'; +import { NavLink, Outlet, useLocation } from 'react-router'; +import { APRSDataProvider } from './aprs/APRSData'; +import type { NavLinkItem } from '../types/layout.types'; +import './APRS.scss'; + +interface Props { + navLinks?: NavLinkItem[]; +} + +const APRS: React.FC = ({ navLinks = [] }) => { + const location = useLocation(); + + const viewButtons = ( + + + + ); + + return ( + + + + + + ); +}; + +export default APRS; diff --git a/ui/src/pages/MeshCore.scss b/ui/src/pages/MeshCore.scss new file mode 100644 index 0000000..93b905d --- /dev/null +++ b/ui/src/pages/MeshCore.scss @@ -0,0 +1,416 @@ +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.meshcore-stream-status-text { + color: var(--app-text); + font-weight: 500; +} + +.meshcore-view-switch { + .btn { + border-color: rgba(173, 205, 255, 0.45); + color: var(--app-text); + } + + .btn.btn-primary { + background: rgba(90, 146, 255, 0.5); + border-color: rgba(173, 205, 255, 0.75); + } + + .btn:hover, + .btn:focus { + color: #ffffff; + border-color: rgba(225, 237, 255, 0.8); + } +} + +.meshcore-btn-icon { + display: inline-flex; + align-items: center; + gap: 0.25rem; + + .meshcore-icon { + font-size: 1rem; + width: 1rem; + height: 1rem; + } +} + +.meshcore-node-type-cell { + display: flex; + align-items: center; + justify-content: center; + padding: 0.25rem !important; + border: none !important; + + .meshcore-node-type-icon { + display: inline-flex; + align-items: center; + justify-content: center; + + svg { + font-size: 1.125rem; + width: 1.125rem; + height: 1.125rem; + color: black; + } + } +} + + +.meshcore-table-card, +.meshcore-detail-card { + background: rgba(8, 24, 56, 0.5); + border: 1px solid rgba(173, 205, 255, 0.2); + color: var(--app-text); +} + +.meshcore-table-header { + background: rgba(27, 56, 108, 0.45); + border-bottom: 1px solid rgba(173, 205, 255, 0.2); + color: var(--app-text); + font-weight: 600; +} + +.meshcore-table-body { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.meshcore-filters { + background: rgba(13, 36, 82, 0.6); + padding: 0.75rem !important; +} + +.meshcore-filter-dropdown { + .meshcore-dropdown-toggle { + border-color: rgba(173, 205, 255, 0.35) !important; + color: var(--app-text) !important; + font-size: 0.875rem; + padding: 0.375rem 0.75rem !important; + display: inline-flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; + + &:hover, + &:focus { + background: rgba(90, 146, 255, 0.2) !important; + border-color: rgba(173, 205, 255, 0.6) !important; + color: #ffffff !important; + } + + svg { + width: 1rem; + height: 1rem; + transition: transform 0.2s ease; + } + + &[aria-expanded='true'] svg { + transform: rotate(180deg); + } + } + + .meshcore-dropdown-menu { + background: rgba(8, 24, 56, 0.95); + border-color: rgba(173, 205, 255, 0.25); + min-width: 200px; + + .meshcore-dropdown-item { + padding: 0.5rem 1rem; + color: var(--app-text); + + &:hover { + background: rgba(90, 146, 255, 0.2); + } + + .form-check { + margin-bottom: 0; + } + + .form-check-input { + background: rgba(8, 24, 56, 0.8); + border-color: rgba(173, 205, 255, 0.3); + cursor: pointer; + + &:checked { + background: rgba(90, 146, 255, 0.6); + border-color: rgba(173, 205, 255, 0.75); + } + + &:focus { + border-color: rgba(173, 205, 255, 0.5); + box-shadow: 0 0 0 0.25rem rgba(90, 146, 255, 0.25); + } + } + + .form-check-label { + color: var(--app-text); + cursor: pointer; + margin-bottom: 0; + } + } + + .dropdown-divider { + border-color: rgba(173, 205, 255, 0.2); + } + } +} + +.meshcore-checkbox-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-height: 200px; + overflow-y: auto; + padding-right: 0.25rem; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: rgba(173, 205, 255, 0.05); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(173, 205, 255, 0.2); + border-radius: 3px; + + &:hover { + background: rgba(173, 205, 255, 0.4); + } + } +} + +.meshcore-filter-check { + font-size: 0.875rem; + + .form-check-input { + background: rgba(8, 24, 56, 0.8); + border-color: rgba(173, 205, 255, 0.25); + cursor: pointer; + + &:checked { + background: rgba(90, 146, 255, 0.6); + border-color: rgba(173, 205, 255, 0.75); + } + + &:focus { + border-color: rgba(173, 205, 255, 0.5); + box-shadow: 0 0 0 0.25rem rgba(90, 146, 255, 0.25); + } + } + + .form-check-label { + color: var(--app-text); + cursor: pointer; + margin-bottom: 0; + } +} + +.meshcore-table-scroll { + height: 100%; + max-height: 100%; + overflow-y: auto; +} + +.meshcore-table { + color: var(--app-text); + + thead th { + position: sticky; + top: 0; + z-index: 2; + background: rgba(13, 36, 82, 0.95); + border-color: rgba(173, 205, 255, 0.18); + color: var(--app-text); + } + + td { + border-color: rgba(173, 205, 255, 0.12); + vertical-align: middle; + } + + tr.is-selected td { + background: rgba(102, 157, 255, 0.16); + } + + tr.meshcore-packet-green td { + border-left: 3px solid #22c55e; + + &:nth-child(4) svg { + color: #22c55e !important; + } + + &:nth-child(5) { + color: #22c55e; + font-weight: 500; + } + } + + tr.meshcore-packet-purple td { + border-left: 3px solid #a855f7; + + &:nth-child(4) svg { + color: #a855f7 !important; + } + + &:nth-child(5) { + color: #a855f7; + font-weight: 500; + } + } + + tr.meshcore-packet-amber td { + border-left: 3px solid #f59e0b; + + &:nth-child(4) svg { + color: #f59e0b !important; + } + + &:nth-child(5) { + color: #f59e0b; + font-weight: 500; + } + } +} + +.meshcore-hash-button { + appearance: none; + border: 0; + background: transparent; + color: #b8d1ff; + font-family: monospace; + padding: 0; + + &:hover { + color: #f2f7ff; + text-decoration: underline; + } +} + +.meshcore-expand-button { + appearance: none; + border: 0; + background: transparent; + color: #b8d1ff; + padding: 0; + margin: 0; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color 0.15s ease-in-out; + + &:hover { + color: #f2f7ff; + } + + svg { + transition: transform 0.2s ease-in-out; + } +} + +.meshcore-duplicate-badge { + display: inline-block; + margin-left: 0.375rem; + padding: 0.125rem 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + color: #b8d1ff; + background: rgba(102, 157, 255, 0.16); + border: 1px solid rgba(173, 205, 255, 0.25); + border-radius: 0.25rem; +} + +.meshcore-duplicate-row { + td { + border-color: rgba(173, 205, 255, 0.08); + background: rgba(13, 36, 82, 0.2); + font-size: 0.875rem; + color: var(--app-text-muted); + } +} + +.meshcore-detail-stack { + overflow-y: auto; + padding-right: 0.25rem; +} + +.meshcore-fact-row { + display: grid; + grid-template-columns: 120px 1fr; + gap: 8px; + padding: 4px 0; +} + +.meshcore-fact-label { + color: var(--app-text-muted); +} + +.meshcore-fact-value { + color: var(--app-text); + word-break: break-all; +} + +.meshcore-message-item { + padding: 0.75rem; + border: 1px solid rgba(173, 205, 255, 0.12); + border-radius: 0.375rem; + background: rgba(13, 36, 82, 0.3); + color: var(--app-text); +} + +.meshcore-message-sender { + color: #b8d1ff; +} + +.meshcore-message-text { + color: var(--app-text); + word-break: break-word; + line-height: 1.5; +} + +.meshcore-hash-code { + font-family: monospace; + color: #82aeff; + font-size: 0.8125rem; + word-break: break-all; +} + +.meshcore-map-view { + min-height: 0; +} + +.meshcore-map-canvas { + position: relative; + width: 100%; + min-height: 220px; + border: 1px solid rgba(173, 205, 255, 0.25); + border-radius: 0.5rem; + background: + radial-gradient(circle at 20% 20%, rgba(130, 174, 255, 0.18), transparent 45%), + linear-gradient(180deg, rgba(13, 35, 79, 0.9), rgba(8, 24, 56, 0.9)); + overflow: hidden; +} + +.meshcore-map-dot { + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + background: #a8c9ff; + box-shadow: 0 0 10px rgba(168, 201, 255, 0.9); + transform: translate(-50%, -50%); +} diff --git a/ui/src/pages/MeshCore.tsx b/ui/src/pages/MeshCore.tsx new file mode 100644 index 0000000..b438410 --- /dev/null +++ b/ui/src/pages/MeshCore.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { ButtonGroup } from 'react-bootstrap'; +import ChatIcon from '@mui/icons-material/Chat'; +import MapIcon from '@mui/icons-material/Map'; +import StorageIcon from '@mui/icons-material/Storage'; +import Layout from '../components/Layout'; +import { NavLink, Outlet, useLocation } from 'react-router'; +import { MeshCoreDataProvider } from './meshcore/MeshCoreData'; +import type { NavLinkItem } from '../types/layout.types'; +import './MeshCore.scss'; + +interface Props { + navLinks?: NavLinkItem[]; +} + +const MeshCore: React.FC = ({ navLinks = [] }) => { + const location = useLocation(); + + const viewButtons = ( + + + + Packets + + + + Chat + + + + Map + + + ); + + return ( + + + + + + ); +}; + +export default MeshCore; diff --git a/ui/src/pages/NotFound.scss b/ui/src/pages/NotFound.scss new file mode 100644 index 0000000..2ef21e0 --- /dev/null +++ b/ui/src/pages/NotFound.scss @@ -0,0 +1,71 @@ +.not-found-container { + justify-content: center; + align-items: center; + min-height: 100vh; + background: linear-gradient(135deg, var(--app-bg) 0%, var(--app-bg-elevated) 100%); + + .not-found-content { + text-align: center; + animation: fadeIn 0.6s ease-in-out; + } + + .not-found-code { + font-size: 7rem; + font-weight: 900; + background: linear-gradient(135deg, var(--app-accent-primary) 0%, var(--app-accent-blue) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 0.5rem; + letter-spacing: -0.05em; + } + + .not-found-title { + font-size: 3rem; + font-weight: 700; + color: var(--app-text); + margin-bottom: 1rem; + letter-spacing: -0.02em; + } + + .not-found-message { + font-size: 1.25rem; + color: var(--app-text-muted); + margin-bottom: 2.5rem; + max-width: 500px; + margin-left: auto; + margin-right: auto; + line-height: 1.6; + } + + .not-found-button { + background-color: var(--app-accent-primary); + border: none; + padding: 12px 48px; + font-size: 1rem; + font-weight: 600; + border-radius: 6px; + transition: all 0.3s ease; + + &:hover { + background-color: var(--app-accent-blue); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(94, 158, 255, 0.3); + } + + &:active { + transform: translateY(0); + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/ui/src/pages/NotFound.tsx b/ui/src/pages/NotFound.tsx new file mode 100644 index 0000000..b878c4f --- /dev/null +++ b/ui/src/pages/NotFound.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { useNavigate } from 'react-router'; +import { Button } from 'react-bootstrap'; +import Full from '../components/Full'; +import './NotFound.scss'; + +const NotFound: React.FC = () => { + const navigate = useNavigate(); + + return ( + +
+
404
+

Page Not Found

+

+ Sorry, the page you're looking for doesn't exist or may have been moved. +

+ +
+
+ ); +}; + +export default NotFound; diff --git a/ui/src/pages/Overview.scss b/ui/src/pages/Overview.scss new file mode 100644 index 0000000..a9e566d --- /dev/null +++ b/ui/src/pages/Overview.scss @@ -0,0 +1,50 @@ +.overview-container { + padding: 24px; + height: 100%; + overflow-y: auto; +} + +.overview-section { + margin-bottom: 32px; +} + +.overview-title { + font-size: 24px; + font-weight: 700; + color: var(--app-text); + margin-bottom: 20px; + text-shadow: 0 0 12px rgba(142, 180, 255, 0.2); +} + +.overview-loading, +.overview-empty, +.overview-error { + padding: 24px; + text-align: center; + border-radius: 8px; + font-size: 14px; +} + +.overview-loading { + background: rgba(142, 180, 255, 0.1); + color: var(--app-accent); +} + +.overview-empty { + background: rgba(142, 180, 255, 0.08); + color: var(--app-text-muted); +} + +.overview-error { + background: rgba(239, 68, 68, 0.1); + color: #fca5a5; +} + +.overview-radios-row { + gap: 16px; +} + +.overview-radio-col { + display: flex; + margin-bottom: 16px; +} diff --git a/ui/src/pages/Overview.tsx b/ui/src/pages/Overview.tsx new file mode 100644 index 0000000..ed09a32 --- /dev/null +++ b/ui/src/pages/Overview.tsx @@ -0,0 +1,17 @@ +import type React from "react"; +import Layout from "../components/Layout"; +import type { NavLinkItem } from "../types/layout.types"; + +interface Props { + navLinks?: NavLinkItem[] +} + +export const Overview: React.FC = ({ navLinks = [] }) => { + return ( + + Hi mom! + + ) +} + +export default Overview diff --git a/ui/src/pages/StyleGuide.scss b/ui/src/pages/StyleGuide.scss new file mode 100644 index 0000000..876eb64 --- /dev/null +++ b/ui/src/pages/StyleGuide.scss @@ -0,0 +1,382 @@ +.style-guide { + width: 100%; + background: var(--app-bg); + color: var(--app-text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif; + line-height: 1.6; +} + +/* Header */ +.sg-header { + background: linear-gradient(135deg, rgba(7, 27, 58, 0.8) 0%, rgba(11, 39, 82, 0.6) 100%); + border-bottom: 1px solid var(--app-border-color); + padding: 60px 0; + margin-bottom: 40px; + + .sg-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + } +} + +.sg-title { + font-size: 40px; + font-weight: 700; + margin: 0 0 12px 0; + color: var(--app-text); + letter-spacing: -0.5px; +} + +.sg-subtitle { + font-size: 16px; + color: var(--app-text-muted); + margin: 0; + font-weight: 400; +} + +/* Container */ +.sg-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px 60px 24px; +} + +/* Section Structure */ +.sg-section { + margin-bottom: 60px; + + &.sg-footer { + margin-bottom: 40px; + padding-top: 40px; + border-top: 1px solid var(--app-divider); + } +} + +.sg-section-title { + font-size: 28px; + font-weight: 700; + color: var(--app-text); + margin: 0 0 32px 0; + padding-bottom: 12px; + border-bottom: 2px solid var(--app-accent-primary); + display: inline-block; +} + +.sg-subsection { + margin-bottom: 40px; +} + +.sg-subsection-title { + font-size: 16px; + font-weight: 600; + color: var(--app-accent-primary); + margin: 0 0 20px 0; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Color Palette - Display Specific */ +.sg-color-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.sg-color-card { + background: var(--app-bg-elevated); + border: 1px solid var(--app-border-color); + border-radius: 8px; + overflow: hidden; + transition: all 0.2s ease; + + &:hover { + border-color: var(--app-border-color-active); + box-shadow: 0 4px 16px rgba(2, 10, 26, 0.4); + } +} + +.sg-color-swatch { + width: 100%; + height: 140px; + border-bottom: 1px solid var(--app-divider); +} + +.sg-color-info { + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sg-color-name { + font-size: 14px; + font-weight: 600; + color: var(--app-text); +} + +.sg-color-code, +.sg-color-var { + font-family: 'Courier New', monospace; + font-size: 12px; + color: var(--app-text-code); + background: rgba(255, 215, 0, 0.08); + padding: 4px 8px; + border-radius: 4px; + word-break: break-all; +} + +.sg-color-var { + color: var(--app-accent-primary); + background: rgba(142, 180, 255, 0.08); +} + +/* Typography Display Grid */ +.sg-typography-grid { + display: flex; + flex-direction: column; + gap: 32px; +} + +.sg-type-item { + background: var(--app-bg-elevated); + border-left: 3px solid var(--app-accent-primary); + padding: 24px; + border-radius: 4px; +} + +/* Demo Grid & Sections */ +.sg-demo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 24px; + margin-bottom: 32px; +} + +.sg-demo-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 20px; + background: var(--app-bg-elevated); + border: 1px solid var(--app-border-color); + border-radius: 8px; +} + +.sg-demo-label { + font-size: 12px; + color: var(--app-text-muted); + text-align: center; +} + +.sg-demo-inline { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 20px; + background: var(--app-bg-elevated); + border: 1px solid var(--app-border-color); + border-radius: 8px; +} + +.sg-demo-block { + padding: 20px; + background: var(--app-bg-elevated); + border-left: 3px solid var(--app-accent-primary); + border-radius: 4px; + + p { + margin: 12px 0; + font-size: 14px; + line-height: 1.6; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } +} + +/* Cards Display */ +.sg-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; +} + +.sg-card { + border-radius: 8px; + transition: all 0.2s ease; + overflow: hidden; + + &:hover { + box-shadow: 0 8px 24px rgba(2, 10, 26, 0.4); + } +} + +.sg-card-default { + background: rgba(11, 39, 82, 0.5); + border: 1px solid var(--app-border-color); +} + +.sg-card-elevated { + background: linear-gradient(135deg, rgba(11, 39, 82, 0.7) 0%, rgba(15, 48, 95, 0.5) 100%); + border: 1px solid var(--app-border-color-active); + box-shadow: 0 4px 16px rgba(2, 10, 26, 0.3); +} + +.sg-card-accent { + background: rgba(11, 39, 82, 0.5); + border: 2px solid var(--app-accent-primary); +} + +.sg-card-header { + padding: 16px; + border-bottom: 1px solid var(--app-divider); + + h4 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--app-text); + } +} + +.sg-card-body { + padding: 16px; + + p { + margin: 0; + font-size: 14px; + color: var(--app-text-muted); + line-height: 1.6; + } +} + +/* Form Display Grid */ +.sg-form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 24px; +} + +.sg-form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Spacing & Effects Display */ +.sg-spacing-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 24px; +} + +.sg-spacing-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + + span { + font-size: 12px; + color: var(--app-text-muted); + font-family: 'Courier New', monospace; + } +} + +.sg-spacing-box { + width: 100%; + background: linear-gradient(135deg, var(--app-accent-primary) 0%, var(--app-accent-blue) 100%); + border-radius: 4px; +} + +.sg-effects-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 20px; +} + +.sg-effect-box { + padding: 40px 20px; + background: var(--app-bg-elevated); + border: 1px solid var(--app-border-color); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 13px; + font-weight: 500; + color: var(--app-text-muted); +} + +.sg-shadow-box { + padding: 40px 20px; + background: var(--app-bg-elevated); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 13px; + font-weight: 500; + color: var(--app-text); +} + +.sg-shadow-sm { + box-shadow: 0 2px 8px rgba(2, 10, 26, 0.15); +} + +.sg-shadow-md { + box-shadow: 0 4px 16px rgba(2, 10, 26, 0.3); +} + +.sg-shadow-lg { + box-shadow: 0 8px 32px rgba(2, 10, 26, 0.5); +} + +/* Footer */ +.sg-footer-text { + font-size: 14px; + color: var(--app-text-muted); + margin: 0; + line-height: 1.6; +} + +/* Responsive */ +@media (max-width: 768px) { + .sg-container { + padding: 0 16px 40px 16px; + } + + .sg-title { + font-size: 28px; + } + + .sg-section-title { + font-size: 24px; + } + + .sg-color-grid, + .sg-demo-grid, + .sg-card-grid, + .sg-form-grid, + .sg-spacing-grid, + .sg-effects-grid { + grid-template-columns: 1fr; + } + + .sg-type-h1 { + font-size: 24px; + } + + .sg-type-h2 { + font-size: 20px; + } +} diff --git a/ui/src/pages/StyleGuide.tsx b/ui/src/pages/StyleGuide.tsx new file mode 100644 index 0000000..cfc736a --- /dev/null +++ b/ui/src/pages/StyleGuide.tsx @@ -0,0 +1,421 @@ +import React from 'react'; +import './StyleGuide.scss'; + +const StyleGuide: React.FC = () => { + return ( +
+ {/* Header */} +
+
+

Visual Style Guide

+

Professional technical design system for HamView

+
+
+ + {/* Main Content */} +
+ {/* Color Palette Section */} +
+

Color Palette

+ + {/* Base Colors */} +
+

Base Colors

+
+
+
+
+ Primary Background + #071b3a + --app-bg +
+
+
+
+
+ Elevated Background + #0b2752 + --app-bg-elevated +
+
+
+
+
+ Primary Text + #e8efff + --app-text +
+
+
+
+
+ Muted Text + #afc1e6 + --app-text-muted +
+
+
+
+ + {/* Accent Colors */} +
+

Accent Colors

+
+
+
+
+ Primary Blue + #8eb4ff + --app-accent-primary +
+
+
+
+
+ Secondary Blue + #5a9eff + --app-accent-blue +
+
+
+
+
+ Yellow Accent + #ffd700 + --app-accent-yellow +
+
+
+
+
+ Light Blue + #a8c5ff + --app-blue-light +
+
+
+
+ + {/* Status Colors */} +
+

Status Colors

+
+
+
+
+ Success + #4ade80 + --app-status-success +
+
+
+
+
+ Warning + #facc15 + --app-status-warning +
+
+
+
+
+ Error + #ef4444 + --app-status-error +
+
+
+
+
+ Info + #60a5fa + --app-status-info +
+
+
+
+
+ + {/* Typography Section */} +
+

Typography

+ +
+

Font Family: Inter, -apple-system, sans-serif

+
+
+

Heading 1 - Page Title

+ 32px • 700 • Line Height: 1.2 +
+
+

Heading 2 - Section Title

+ 24px • 700 • Line Height: 1.3 +
+
+

Heading 3 - Component Title

+ 18px • 600 • Line Height: 1.4 +
+
+

Body text - Regular paragraph content

+ 14px • 400 • Line Height: 1.6 +
+
+

Small text - Secondary information

+ 12px • 400 • Line Height: 1.5 +
+
+ const x = "monospace code"; + 13px • 400 • Courier New/Monospace +
+
+
+
+ + {/* Buttons Section */} +
+

Buttons

+ +
+

Button States

+
+
+ + Primary +
+
+ + Primary Disabled +
+
+ + Secondary +
+
+ + Tertiary +
+
+ + Danger +
+
+ + Success +
+
+
+
+ + {/* Badges Section */} +
+

Badges & Tags

+ +
+

Status Badges

+
+ Online + Pending + Offline + Active +
+
+ +
+

Technical Tags

+
+ TypeScript + Component + Core + Radio +
+
+
+ + {/* Cards Section */} +
+

Cards & Containers

+ +
+

Card Styles

+
+
+
+

Default Card

+
+
+

Standard content area with consistent spacing and typography.

+
+
+
+
+

Elevated Card

+
+
+

Higher elevation for primary focus areas and important content.

+
+
+
+
+

Accent Card

+
+
+

Emphasizes technical information with blue accent border.

+
+
+
+
+
+ + {/* Input Section */} +
+

Form Elements

+ +
+

Input Fields

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Code Block Section */} +
+

Code & Technical Elements

+ +
+

Code Block

+
+
{`/* CSS Variables - Easy Reuse */
+:root {
+  --app-accent-primary: #8eb4ff;
+  --app-accent-yellow: #ffd700;
+  --app-status-success: #4ade80;
+}
+
+/* Usage Example */
+.component {
+  color: var(--app-text);
+  background: var(--app-bg-elevated);
+  border: 1px solid var(--app-border-color);
+}`}
+
+
+ +
+

Inline Code & Emphasis

+
+

+ Use className for CSS classes and var(--app-accent-yellow) for CSS variables. +

+

+ Technical terms like HamView or APRS can be emphasized. +

+
+
+
+ + {/* Grid & Spacing Section */} +
+

Spacing & Layout

+ +
+

Spacing Scale

+
+
+
+ 4px (xs) +
+
+
+ 8px (sm) +
+
+
+ 12px (md) +
+
+
+ 16px (lg) +
+
+
+ 24px (xl) +
+
+
+ 32px (2xl) +
+
+
+
+ + {/* Borders & Shadows Section */} +
+

Borders & Effects

+ +
+

Border Radius

+
+
Square
+
4px
+
8px
+
12px
+
+
+ +
+

Shadow Elevations

+
+
Slight Elevation
+
Medium Elevation
+
High Elevation
+
+
+
+ + {/* Footer Section */} +
+

+ This style guide is a living document. All CSS variables are centralized in App.scss for easy customization and consistency across the application. +

+
+
+
+ ); +}; + +export default StyleGuide; diff --git a/ui/src/pages/aprs/APRSData.tsx b/ui/src/pages/aprs/APRSData.tsx new file mode 100644 index 0000000..f20300d --- /dev/null +++ b/ui/src/pages/aprs/APRSData.tsx @@ -0,0 +1,240 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import type { Frame } from '../../protocols/aprs'; +import { parseFrame } from '../../protocols/aprs'; +import API from '../../services/API'; +import APRSServiceImpl, { type FetchedAPRSPacket } from '../../services/APRSService'; +import APRSStream, { type APRSMessage } from '../../services/APRSStream'; + +export interface APRSPacketRecord { + timestamp: Date; + raw: string; + frame: Frame; + latitude?: number; + longitude?: number; + altitude?: number; + speed?: number; + course?: number; + comment?: string; + radioName?: string; +} + +interface APRSDataContextValue { + packets: APRSPacketRecord[]; +} + +const APRSDataContext = createContext(null); + +export const useAPRSData = (): APRSDataContextValue => { + const context = useContext(APRSDataContext); + if (!context) { + throw new Error('useAPRSData must be used within APRSDataProvider'); + } + return context; +}; + +const aprsService = new APRSServiceImpl(API); +const maxPacketCount = 500; + +const fromNullableNumber = (value: unknown): number | undefined => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (value && typeof value === 'object') { + const candidate = value as { Float64?: number; Valid?: boolean }; + if (candidate.Valid && typeof candidate.Float64 === 'number' && Number.isFinite(candidate.Float64)) { + return candidate.Float64; + } + } + + return undefined; +}; + +const extractAPRSDetails = (frame: Frame) => { + const decoded = frame.decode(); + + let latitude: number | undefined; + let longitude: number | undefined; + let altitude: number | undefined; + let speed: number | undefined; + let course: number | undefined; + let comment: string | undefined; + + if (decoded && typeof decoded === 'object') { + if ('position' in decoded && decoded.position) { + latitude = decoded.position.latitude; + longitude = decoded.position.longitude; + altitude = decoded.position.altitude; + comment = decoded.position.comment; + } + + if ('altitude' in decoded && typeof decoded.altitude === 'number') { + altitude = decoded.altitude; + } + + if ('speed' in decoded && typeof decoded.speed === 'number') { + speed = decoded.speed; + } + + if ('course' in decoded && typeof decoded.course === 'number') { + course = decoded.course; + } + + if ('text' in decoded && typeof decoded.text === 'string' && !comment) { + comment = decoded.text; + } + } + + return { + latitude, + longitude, + altitude, + speed, + course, + comment, + }; +}; + +const parseTimestamp = (input: unknown): Date => { + if (typeof input === 'string' && input.trim().length > 0) { + const parsed = new Date(input); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + + return new Date(); +}; + +const mergePackets = (incoming: APRSPacketRecord[], current: APRSPacketRecord[]): APRSPacketRecord[] => { + const merged = [...incoming, ...current]; + const byKey = new Map(); + + merged.forEach((packet) => { + const key = `${packet.timestamp.toISOString()}|${packet.raw}|${packet.radioName ?? ''}`; + if (!byKey.has(key)) { + byKey.set(key, packet); + } + }); + + return Array.from(byKey.values()) + .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) + .slice(0, maxPacketCount); +}; + +const buildRecord = ({ + raw, + timestamp, + radioName, + comment, + latitude, + longitude, +}: { + raw: string; + timestamp: Date; + radioName?: string; + comment?: string; + latitude?: number; + longitude?: number; +}): APRSPacketRecord | null => { + try { + const frame = parseFrame(raw); + const details = extractAPRSDetails(frame); + + return { + timestamp, + raw, + frame, + latitude: latitude ?? details.latitude, + longitude: longitude ?? details.longitude, + altitude: details.altitude, + speed: details.speed, + course: details.course, + comment: comment ?? details.comment, + radioName, + }; + } catch { + return null; + } +}; + +const toRecordFromAPI = (packet: FetchedAPRSPacket): APRSPacketRecord | null => { + const raw = packet.raw ?? packet.payload; + if (!raw) { + return null; + } + + return buildRecord({ + raw, + timestamp: parseTimestamp(packet.received_at ?? packet.timestamp ?? packet.time ?? packet.created_at), + radioName: packet.radio?.name, + comment: packet.comment, + latitude: fromNullableNumber(packet.latitude), + longitude: fromNullableNumber(packet.longitude), + }); +}; + +const toRecordFromStream = (message: APRSMessage): APRSPacketRecord | null => { + return buildRecord({ + raw: message.raw, + timestamp: message.receivedAt, + radioName: message.radioName, + }); +}; + +interface APRSDataProviderProps { + children: React.ReactNode; +} + +export const APRSDataProvider: React.FC = ({ children }) => { + const [packets, setPackets] = useState([]); + const stream = useMemo(() => new APRSStream(false), []); + + useEffect(() => { + let isMounted = true; + + const fetchPackets = async () => { + try { + const fetchedPackets = await aprsService.fetchPackets(); + if (!isMounted) { + return; + } + + const records = fetchedPackets + .map(toRecordFromAPI) + .filter((packet): packet is APRSPacketRecord => packet !== null); + + setPackets((prev) => mergePackets(records, prev)); + } catch (error) { + console.error('Failed to fetch APRS packets:', error); + } + }; + + fetchPackets(); + stream.connect(); + + const unsubscribePackets = stream.subscribe('aprs/packet/#', (message) => { + const record = toRecordFromStream(message); + if (!record) { + return; + } + + setPackets((prev) => mergePackets([record], prev)); + }); + + return () => { + isMounted = false; + unsubscribePackets(); + stream.disconnect(); + }; + }, [stream]); + + const value = useMemo( + () => ({ + packets, + }), + [packets] + ); + + return {children}; +}; diff --git a/ui/src/pages/aprs/APRSPacketsView.tsx b/ui/src/pages/aprs/APRSPacketsView.tsx new file mode 100644 index 0000000..870b000 --- /dev/null +++ b/ui/src/pages/aprs/APRSPacketsView.tsx @@ -0,0 +1,221 @@ +import React, { useMemo, useState } from 'react'; +import { Badge, Card, Stack, Table, Alert } from 'react-bootstrap'; +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import { useSearchParams } from 'react-router'; +import VerticalSplit from '../../components/VerticalSplit'; +import HorizontalSplit from '../../components/HorizontalSplit'; +import { useAPRSData } from './APRSData'; +import type { APRSPacketRecord } from './APRSData'; + +const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+ {label} + {value} +
+); + +const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => { + const hasPosition = packet && packet.latitude && packet.longitude; + + // Create bounds from center point (offset by ~2 degrees) + const createBoundsFromCenter = (lat: number, lng: number): [[number, number], [number, number]] => { + const offset = 2; + return [[lat - offset, lng - offset], [lat + offset, lng + offset]]; + }; + + const bounds = hasPosition + ? createBoundsFromCenter(packet.latitude as number, packet.longitude as number) + : createBoundsFromCenter(50.0, 5.0); + + return ( + + + {hasPosition && ( + + +
+ {packet.frame.source.call} + {packet.frame.source.ssid && -{packet.frame.source.ssid}} +
+ Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)} + {packet.altitude &&
Alt: {packet.altitude.toFixed(0)}m
} + {packet.speed &&
Speed: {packet.speed}kt
} +
+
+
+ )} +
+ ); +}; + +const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => { + if (!packet) { + return ( + +
Select a packet
+
Click any packet in the list to view details and map.
+
+ ); + } + + return ( + + + +
Packet Details
+ {packet.frame.source.call} +
+ + + {packet.frame.source.call} + {packet.frame.source.ssid && -{packet.frame.source.ssid}} + + } + /> + {packet.radioName && } + + {packet.frame.destination.call} + {packet.frame.destination.ssid && -{packet.frame.destination.ssid}} + + } + /> + `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} /> +
+ + {(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && ( + +
Position Data
+ {packet.latitude && } + {packet.longitude && } + {packet.altitude && } + {packet.speed !== undefined && } + {packet.course !== undefined && } +
+ )} + + {packet.comment && ( + +
Comment
+
{packet.comment}
+
+ )} + + +
Raw Data
+ {packet.raw} +
+
+ ); +}; + +const PacketTable: React.FC<{ + packets: APRSPacketRecord[]; + selectedIndex: number | null; + onSelect: (index: number) => void; +}> = ({ packets, selectedIndex, onSelect }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const radioFilter = searchParams.get('radio') || undefined; + + // Filter packets by radio name if specified + const filteredPackets = useMemo(() => { + if (!radioFilter) return packets; + return packets.filter(packet => packet.radioName === radioFilter); + }, [packets, radioFilter]); + + return ( + + APRS Packets + + {radioFilter && ( + + Filtering by radio: {radioFilter} +