Add wlan station and interface metrics collection. (#14)
* Fix newlines. * Added wlan metrics. * Fix WlanSTA collector - return on errors.
This commit is contained in:
parent
f6b4764691
commit
25911b4e60
|
@ -85,6 +85,20 @@ func WithOptics() Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithWlanSTA enables wlan STA metrics
|
||||||
|
func WithWlanSTA() Option {
|
||||||
|
return func(c *collector) {
|
||||||
|
c.collectors = append(c.collectors, newWlanSTACollector())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWlanIF enables wireless interface metrics
|
||||||
|
func WithWlanIF() Option {
|
||||||
|
return func(c *collector) {
|
||||||
|
c.collectors = append(c.collectors, newWlanIFCollector())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithTimeout sets timeout for connecting to router
|
// WithTimeout sets timeout for connecting to router
|
||||||
func WithTimeout(d time.Duration) Option {
|
func WithTimeout(d time.Duration) Option {
|
||||||
return func(c *collector) {
|
return func(c *collector) {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package collector
|
package collector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
@ -27,3 +29,17 @@ func description(prefix, name, helpText string, labelNames []string) *prometheus
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func splitStringToFloats(metric string) (float64, float64, error) {
|
||||||
|
strings := strings.Split(metric, ",")
|
||||||
|
|
||||||
|
m1, err := strconv.ParseFloat(strings[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return math.NaN(), math.NaN(), err
|
||||||
|
}
|
||||||
|
m2, err := strconv.ParseFloat(strings[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return math.NaN(), math.NaN(), err
|
||||||
|
}
|
||||||
|
return m1, m2, nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"gopkg.in/routeros.v2/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wlanIFCollector struct {
|
||||||
|
props []string
|
||||||
|
descriptions map[string]*prometheus.Desc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWlanIFCollector() routerOSCollector {
|
||||||
|
c := &wlanIFCollector{}
|
||||||
|
c.init()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanIFCollector) init() {
|
||||||
|
c.props = []string{"channel", "registered-clients", "noise-floor", "overall-tx-ccq"}
|
||||||
|
labelNames := []string{"name", "address", "interface", "channel"}
|
||||||
|
c.descriptions = make(map[string]*prometheus.Desc)
|
||||||
|
for _, p := range c.props {
|
||||||
|
c.descriptions[p] = descriptionForPropertyName("wlan_interface", p, labelNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanIFCollector) describe(ch chan<- *prometheus.Desc) {
|
||||||
|
for _, d := range c.descriptions {
|
||||||
|
ch <- d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanIFCollector) collect(ctx *collectorContext) error {
|
||||||
|
names, err := c.fetchInterfaceNames(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range names {
|
||||||
|
err := c.collectForInterface(n, ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanIFCollector) fetchInterfaceNames(ctx *collectorContext) ([]string, error) {
|
||||||
|
reply, err := ctx.client.Run("/interface/wireless/print", "?disabled=false", "=.proplist=name")
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"device": ctx.device.Name,
|
||||||
|
"error": err,
|
||||||
|
}).Error("error fetching wireless interface names")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
names := []string{}
|
||||||
|
for _, re := range reply.Re {
|
||||||
|
names = append(names, re.Map["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanIFCollector) collectForInterface(iface string, ctx *collectorContext) error {
|
||||||
|
reply, err := ctx.client.Run("/interface/wireless/monitor", fmt.Sprintf("=numbers=%s", iface), "=once=", "=.proplist="+strings.Join(c.props, ","))
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"interface": iface,
|
||||||
|
"device": ctx.device.Name,
|
||||||
|
"error": err,
|
||||||
|
}).Error("error fetching interface statistics")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range c.props[1:] {
|
||||||
|
// there's always going to be only one sentence in reply, as we
|
||||||
|
// have to explicitly specify the interface
|
||||||
|
c.collectMetricForProperty(p, iface, reply.Re[0], ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanIFCollector) collectMetricForProperty(property, iface string, re *proto.Sentence, ctx *collectorContext) {
|
||||||
|
desc := c.descriptions[property]
|
||||||
|
channel := re.Map["channel"]
|
||||||
|
|
||||||
|
v, err := strconv.ParseFloat(re.Map[property], 64)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"property": property,
|
||||||
|
"interface": iface,
|
||||||
|
"device": ctx.device.Name,
|
||||||
|
"error": err,
|
||||||
|
}).Error("error parsing interface metric value")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, v, ctx.device.Name, ctx.device.Address, iface, channel)
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"gopkg.in/routeros.v2/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type wlanSTACollector struct {
|
||||||
|
props []string
|
||||||
|
descriptions map[string]*prometheus.Desc
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWlanSTACollector() routerOSCollector {
|
||||||
|
c := &wlanSTACollector{}
|
||||||
|
c.init()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanSTACollector) init() {
|
||||||
|
c.props = []string{"interface", "mac-address", "signal-to-noise", "signal-strength-ch0", "packets", "bytes", "frames"}
|
||||||
|
labelNames := []string{"name", "address", "interface", "mac_address"}
|
||||||
|
c.descriptions = make(map[string]*prometheus.Desc)
|
||||||
|
for _, p := range c.props[:len(c.props)-3] {
|
||||||
|
c.descriptions[p] = descriptionForPropertyName("wlan_station", p, labelNames)
|
||||||
|
}
|
||||||
|
for _, p := range c.props[len(c.props)-3:] {
|
||||||
|
c.descriptions["tx_"+p] = descriptionForPropertyName("wlan_station", "tx_"+p, labelNames)
|
||||||
|
c.descriptions["rx_"+p] = descriptionForPropertyName("wlan_station", "rx_"+p, labelNames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanSTACollector) describe(ch chan<- *prometheus.Desc) {
|
||||||
|
for _, d := range c.descriptions {
|
||||||
|
ch <- d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanSTACollector) collect(ctx *collectorContext) error {
|
||||||
|
stats, err := c.fetch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, re := range stats {
|
||||||
|
c.collectForStat(re, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanSTACollector) fetch(ctx *collectorContext) ([]*proto.Sentence, error) {
|
||||||
|
reply, err := ctx.client.Run("/interface/wireless/registration-table/print", "=.proplist="+strings.Join(c.props, ","))
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"device": ctx.device.Name,
|
||||||
|
"error": err,
|
||||||
|
}).Error("error fetching wlan station metrics")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.Re, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanSTACollector) collectForStat(re *proto.Sentence, ctx *collectorContext) {
|
||||||
|
iface := re.Map["interface"]
|
||||||
|
mac := re.Map["mac-address"]
|
||||||
|
|
||||||
|
for _, p := range c.props[2 : len(c.props)-3] {
|
||||||
|
c.collectMetricForProperty(p, iface, mac, re, ctx)
|
||||||
|
}
|
||||||
|
for _, p := range c.props[len(c.props)-3:] {
|
||||||
|
c.collectMetricForTXRXCounters(p, iface, mac, re, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanSTACollector) collectMetricForProperty(property, iface, mac string, re *proto.Sentence, ctx *collectorContext) {
|
||||||
|
v, err := strconv.ParseFloat(re.Map[property], 64)
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"device": ctx.device.Name,
|
||||||
|
"property": property,
|
||||||
|
"value": re.Map[property],
|
||||||
|
"error": err,
|
||||||
|
}).Error("error parsing wlan station metric value")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := c.descriptions[property]
|
||||||
|
ctx.ch <- prometheus.MustNewConstMetric(desc, prometheus.GaugeValue, v, ctx.device.Name, ctx.device.Address, iface, mac)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *wlanSTACollector) collectMetricForTXRXCounters(property, iface, mac string, re *proto.Sentence, ctx *collectorContext) {
|
||||||
|
tx, rx, err := splitStringToFloats(re.Map[property])
|
||||||
|
if err != nil {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"device": ctx.device.Name,
|
||||||
|
"property": property,
|
||||||
|
"value": re.Map[property],
|
||||||
|
"error": err,
|
||||||
|
}).Error("error parsing wlan station metric value")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desc_tx := c.descriptions["tx_"+property]
|
||||||
|
desc_rx := c.descriptions["rx_"+property]
|
||||||
|
ctx.ch <- prometheus.MustNewConstMetric(desc_tx, prometheus.CounterValue, tx, ctx.device.Name, ctx.device.Address, iface, mac)
|
||||||
|
ctx.ch <- prometheus.MustNewConstMetric(desc_rx, prometheus.CounterValue, rx, ctx.device.Name, ctx.device.Address, iface, mac)
|
||||||
|
}
|
|
@ -1,45 +1,47 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
|
||||||
yaml "gopkg.in/yaml.v2"
|
yaml "gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the configuration for the exporter
|
// Config represents the configuration for the exporter
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Devices []Device `yaml:"devices"`
|
Devices []Device `yaml:"devices"`
|
||||||
Features struct {
|
Features struct {
|
||||||
BGP bool `yaml:"bgp,omitempty"`
|
BGP bool `yaml:"bgp,omitempty"`
|
||||||
DHCP bool `yaml:"dhcp,omitempty"`
|
DHCP bool `yaml:"dhcp,omitempty"`
|
||||||
DHCPv6 bool `yaml:"dhcpv6,omitempty"`
|
DHCPv6 bool `yaml:"dhcpv6,omitempty"`
|
||||||
Routes bool `yaml:"routes,omitempty"`
|
Routes bool `yaml:"routes,omitempty"`
|
||||||
Pools bool `yaml:"pools,omitempty"`
|
Pools bool `yaml:"pools,omitempty"`
|
||||||
Optics bool `yaml:"optics,omitempty"`
|
Optics bool `yaml:"optics,omitempty"`
|
||||||
} `yaml:"features,omitempty"`
|
WlanSTA bool `yaml:"wlansta,omitempty"`
|
||||||
}
|
WlanIF bool `yaml:"wlanif,omitempty"`
|
||||||
|
} `yaml:"features,omitempty"`
|
||||||
// Device represents a target device
|
}
|
||||||
type Device struct {
|
|
||||||
Name string `yaml:"name"`
|
// Device represents a target device
|
||||||
Address string `yaml:"address"`
|
type Device struct {
|
||||||
User string `yaml:"user"`
|
Name string `yaml:"name"`
|
||||||
Password string `yaml:"password"`
|
Address string `yaml:"address"`
|
||||||
}
|
User string `yaml:"user"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
// Load reads YAML from reader and unmashals in Config
|
}
|
||||||
func Load(r io.Reader) (*Config, error) {
|
|
||||||
b, err := ioutil.ReadAll(r)
|
// Load reads YAML from reader and unmashals in Config
|
||||||
if err != nil {
|
func Load(r io.Reader) (*Config, error) {
|
||||||
return nil, err
|
b, err := ioutil.ReadAll(r)
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
c := &Config{}
|
}
|
||||||
err = yaml.Unmarshal(b, c)
|
|
||||||
if err != nil {
|
c := &Config{}
|
||||||
return nil, err
|
err = yaml.Unmarshal(b, c)
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
return c, nil
|
}
|
||||||
}
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,61 +1,63 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestShouldParse(t *testing.T) {
|
func TestShouldParse(t *testing.T) {
|
||||||
b := loadTestFile(t)
|
b := loadTestFile(t)
|
||||||
c, err := Load(bytes.NewReader(b))
|
c, err := Load(bytes.NewReader(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not parse: %v", err)
|
t.Fatalf("could not parse: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(c.Devices) != 2 {
|
if len(c.Devices) != 2 {
|
||||||
t.Fatalf("expected 2 devices, got %v", len(c.Devices))
|
t.Fatalf("expected 2 devices, got %v", len(c.Devices))
|
||||||
}
|
}
|
||||||
|
|
||||||
assertDevice("test1", "192.168.1.1", "foo", "bar", c.Devices[0], t)
|
assertDevice("test1", "192.168.1.1", "foo", "bar", c.Devices[0], t)
|
||||||
assertDevice("test2", "192.168.2.1", "test", "123", c.Devices[1], t)
|
assertDevice("test2", "192.168.2.1", "test", "123", c.Devices[1], t)
|
||||||
assertFeature("BGP", c.Features.BGP, t)
|
assertFeature("BGP", c.Features.BGP, t)
|
||||||
assertFeature("DHCP", c.Features.DHCP, t)
|
assertFeature("DHCP", c.Features.DHCP, t)
|
||||||
assertFeature("DHCPv6", c.Features.DHCPv6, t)
|
assertFeature("DHCPv6", c.Features.DHCPv6, t)
|
||||||
assertFeature("Pools", c.Features.Pools, t)
|
assertFeature("Pools", c.Features.Pools, t)
|
||||||
assertFeature("Routes", c.Features.Routes, t)
|
assertFeature("Routes", c.Features.Routes, t)
|
||||||
assertFeature("Optics", c.Features.Optics, t)
|
assertFeature("Optics", c.Features.Optics, t)
|
||||||
}
|
assertFeature("WlanSTA", c.Features.WlanSTA, t)
|
||||||
|
assertFeature("WlanIF", c.Features.WlanIF, t)
|
||||||
func loadTestFile(t *testing.T) []byte {
|
}
|
||||||
b, err := ioutil.ReadFile("test/config.test.yml")
|
|
||||||
if err != nil {
|
func loadTestFile(t *testing.T) []byte {
|
||||||
t.Fatalf("could not load config: %v", err)
|
b, err := ioutil.ReadFile("test/config.test.yml")
|
||||||
}
|
if err != nil {
|
||||||
|
t.Fatalf("could not load config: %v", err)
|
||||||
return b
|
}
|
||||||
}
|
|
||||||
|
return b
|
||||||
func assertDevice(name, address, user, password string, c Device, t *testing.T) {
|
}
|
||||||
if c.Name != name {
|
|
||||||
t.Fatalf("expected name %s, got %s", name, c.Name)
|
func assertDevice(name, address, user, password string, c Device, t *testing.T) {
|
||||||
}
|
if c.Name != name {
|
||||||
|
t.Fatalf("expected name %s, got %s", name, c.Name)
|
||||||
if c.Address != address {
|
}
|
||||||
t.Fatalf("expected address %s, got %s", address, c.Address)
|
|
||||||
}
|
if c.Address != address {
|
||||||
|
t.Fatalf("expected address %s, got %s", address, c.Address)
|
||||||
if c.User != user {
|
}
|
||||||
t.Fatalf("expected user %s, got %s", user, c.User)
|
|
||||||
}
|
if c.User != user {
|
||||||
|
t.Fatalf("expected user %s, got %s", user, c.User)
|
||||||
if c.Password != password {
|
}
|
||||||
t.Fatalf("expected password %s, got %s", password, c.Password)
|
|
||||||
}
|
if c.Password != password {
|
||||||
}
|
t.Fatalf("expected password %s, got %s", password, c.Password)
|
||||||
|
}
|
||||||
func assertFeature(name string, v bool, t *testing.T) {
|
}
|
||||||
if !v {
|
|
||||||
t.Fatalf("exprected feature %s to be enabled", name)
|
func assertFeature(name string, v bool, t *testing.T) {
|
||||||
}
|
if !v {
|
||||||
}
|
t.Fatalf("exprected feature %s to be enabled", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,19 +1,21 @@
|
||||||
---
|
---
|
||||||
|
|
||||||
devices:
|
devices:
|
||||||
- name: test1
|
- name: test1
|
||||||
address: 192.168.1.1
|
address: 192.168.1.1
|
||||||
user: foo
|
user: foo
|
||||||
password: bar
|
password: bar
|
||||||
- name: test2
|
- name: test2
|
||||||
address: 192.168.2.1
|
address: 192.168.2.1
|
||||||
user: test
|
user: test
|
||||||
password: 123
|
password: 123
|
||||||
|
|
||||||
features:
|
features:
|
||||||
bgp: true
|
bgp: true
|
||||||
dhcp: true
|
dhcp: true
|
||||||
dhcpv6: true
|
dhcpv6: true
|
||||||
routes: true
|
routes: true
|
||||||
pools: true
|
pools: true
|
||||||
optics: true
|
optics: true
|
||||||
|
wlansta: true
|
||||||
|
wlanif: true
|
||||||
|
|
10
main.go
10
main.go
|
@ -35,6 +35,8 @@ var (
|
||||||
withDHCPv6 = flag.Bool("with-dhcpv6", false, "retrieves DHCPv6 server metrics")
|
withDHCPv6 = flag.Bool("with-dhcpv6", false, "retrieves DHCPv6 server metrics")
|
||||||
withPools = flag.Bool("with-pools", false, "retrieves IP(v6) pool metrics")
|
withPools = flag.Bool("with-pools", false, "retrieves IP(v6) pool metrics")
|
||||||
withOptics = flag.Bool("with-optics", false, "retrieves optical diagnostic metrics")
|
withOptics = flag.Bool("with-optics", false, "retrieves optical diagnostic metrics")
|
||||||
|
withWlanSTA = flag.Bool("with-wlansta", false, "retrieves connected wlan station metrics")
|
||||||
|
withWlanIF = flag.Bool("with-wlanif", false, "retrieves wlan interface metrics")
|
||||||
timeout = flag.Duration("timeout", collector.DefaultTimeout*time.Second, "timeout when connecting to routers")
|
timeout = flag.Duration("timeout", collector.DefaultTimeout*time.Second, "timeout when connecting to routers")
|
||||||
tls = flag.Bool("tls", false, "use tls to connect to routers")
|
tls = flag.Bool("tls", false, "use tls to connect to routers")
|
||||||
insecure = flag.Bool("insecure", false, "skips verification of server certificate when using TLS (not recommended)")
|
insecure = flag.Bool("insecure", false, "skips verification of server certificate when using TLS (not recommended)")
|
||||||
|
@ -175,6 +177,14 @@ func collectorOptions() []collector.Option {
|
||||||
opts = append(opts, collector.WithOptics())
|
opts = append(opts, collector.WithOptics())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *withWlanSTA || cfg.Features.WlanSTA {
|
||||||
|
opts = append(opts, collector.WithWlanSTA())
|
||||||
|
}
|
||||||
|
|
||||||
|
if *withWlanIF || cfg.Features.WlanIF {
|
||||||
|
opts = append(opts, collector.WithWlanIF())
|
||||||
|
}
|
||||||
|
|
||||||
if *timeout != collector.DefaultTimeout {
|
if *timeout != collector.DefaultTimeout {
|
||||||
opts = append(opts, collector.WithTimeout(*timeout))
|
opts = append(opts, collector.WithTimeout(*timeout))
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue