Add final aggregator (#5820)
This commit is contained in:
parent
d645e0303f
commit
a724bf487f
|
@ -519,6 +519,7 @@
|
||||||
name = "github.com/google/go-cmp"
|
name = "github.com/google/go-cmp"
|
||||||
packages = [
|
packages = [
|
||||||
"cmp",
|
"cmp",
|
||||||
|
"cmp/cmpopts",
|
||||||
"cmp/internal/diff",
|
"cmp/internal/diff",
|
||||||
"cmp/internal/function",
|
"cmp/internal/function",
|
||||||
"cmp/internal/value",
|
"cmp/internal/value",
|
||||||
|
@ -1585,6 +1586,7 @@
|
||||||
"github.com/golang/protobuf/ptypes/empty",
|
"github.com/golang/protobuf/ptypes/empty",
|
||||||
"github.com/golang/protobuf/ptypes/timestamp",
|
"github.com/golang/protobuf/ptypes/timestamp",
|
||||||
"github.com/google/go-cmp/cmp",
|
"github.com/google/go-cmp/cmp",
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts",
|
||||||
"github.com/google/go-github/github",
|
"github.com/google/go-github/github",
|
||||||
"github.com/gorilla/mux",
|
"github.com/gorilla/mux",
|
||||||
"github.com/harlow/kinesis-consumer",
|
"github.com/harlow/kinesis-consumer",
|
||||||
|
|
|
@ -2,6 +2,7 @@ package all
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "github.com/influxdata/telegraf/plugins/aggregators/basicstats"
|
_ "github.com/influxdata/telegraf/plugins/aggregators/basicstats"
|
||||||
|
_ "github.com/influxdata/telegraf/plugins/aggregators/final"
|
||||||
_ "github.com/influxdata/telegraf/plugins/aggregators/histogram"
|
_ "github.com/influxdata/telegraf/plugins/aggregators/histogram"
|
||||||
_ "github.com/influxdata/telegraf/plugins/aggregators/minmax"
|
_ "github.com/influxdata/telegraf/plugins/aggregators/minmax"
|
||||||
_ "github.com/influxdata/telegraf/plugins/aggregators/valuecounter"
|
_ "github.com/influxdata/telegraf/plugins/aggregators/valuecounter"
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Final Aggregator Plugin
|
||||||
|
|
||||||
|
The final aggregator emits the last metric of a contiguous series. A
|
||||||
|
contiguous series is defined as a series which receives updates within the
|
||||||
|
time period in `series_timeout`. The contiguous series may be longer than the
|
||||||
|
time interval defined by `period`.
|
||||||
|
|
||||||
|
This is useful for getting the final value for data sources that produce
|
||||||
|
discrete time series such as procstat, cgroup, kubernetes etc.
|
||||||
|
|
||||||
|
When a series has not been updated within the time defined in
|
||||||
|
`series_timeout`, the last metric is emitted with the `_final` appended.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[aggregators.final]]
|
||||||
|
## The period on which to flush & clear the aggregator.
|
||||||
|
period = "30s"
|
||||||
|
## If true, the original metric will be dropped by the
|
||||||
|
## aggregator and will not get sent to the output plugins.
|
||||||
|
drop_original = false
|
||||||
|
|
||||||
|
## The time that a series is not updated until considering it final.
|
||||||
|
series_timeout = "5m"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
Measurement and tags are unchanged, fields are emitted with the suffix
|
||||||
|
`_final`.
|
||||||
|
|
||||||
|
### Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
counter,host=bar i_final=3,j_final=6 1554281635115090133
|
||||||
|
counter,host=foo i_final=3,j_final=6 1554281635112992012
|
||||||
|
```
|
||||||
|
|
||||||
|
Original input:
|
||||||
|
```
|
||||||
|
counter,host=bar i=1,j=4 1554281633101153300
|
||||||
|
counter,host=foo i=1,j=4 1554281633099323601
|
||||||
|
counter,host=bar i=2,j=5 1554281634107980073
|
||||||
|
counter,host=foo i=2,j=5 1554281634105931116
|
||||||
|
counter,host=bar i=3,j=6 1554281635115090133
|
||||||
|
counter,host=foo i=3,j=6 1554281635112992012
|
||||||
|
```
|
|
@ -0,0 +1,72 @@
|
||||||
|
package final
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/influxdata/telegraf"
|
||||||
|
"github.com/influxdata/telegraf/internal"
|
||||||
|
"github.com/influxdata/telegraf/plugins/aggregators"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sampleConfig = `
|
||||||
|
## The period on which to flush & clear the aggregator.
|
||||||
|
period = "30s"
|
||||||
|
## If true, the original metric will be dropped by the
|
||||||
|
## aggregator and will not get sent to the output plugins.
|
||||||
|
drop_original = false
|
||||||
|
|
||||||
|
## The time that a series is not updated until considering it final.
|
||||||
|
series_timeout = "5m"
|
||||||
|
`
|
||||||
|
|
||||||
|
type Final struct {
|
||||||
|
SeriesTimeout internal.Duration `toml:"series_timeout"`
|
||||||
|
|
||||||
|
// The last metric for all series which are active
|
||||||
|
metricCache map[uint64]telegraf.Metric
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFinal() *Final {
|
||||||
|
return &Final{
|
||||||
|
SeriesTimeout: internal.Duration{Duration: 5 * time.Minute},
|
||||||
|
metricCache: make(map[uint64]telegraf.Metric),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Final) SampleConfig() string {
|
||||||
|
return sampleConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Final) Description() string {
|
||||||
|
return "Report the final metric of a series"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Final) Add(in telegraf.Metric) {
|
||||||
|
id := in.HashID()
|
||||||
|
m.metricCache[id] = in
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Final) Push(acc telegraf.Accumulator) {
|
||||||
|
// Preserve timestamp of original metric
|
||||||
|
acc.SetPrecision(time.Nanosecond)
|
||||||
|
|
||||||
|
for id, metric := range m.metricCache {
|
||||||
|
if time.Since(metric.Time()) > m.SeriesTimeout.Duration {
|
||||||
|
fields := map[string]interface{}{}
|
||||||
|
for _, field := range metric.FieldList() {
|
||||||
|
fields[field.Key+"_final"] = field.Value
|
||||||
|
}
|
||||||
|
acc.AddFields(metric.Name(), fields, metric.Tags(), metric.Time())
|
||||||
|
delete(m.metricCache, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Final) Reset() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
aggregators.Add("final", func() telegraf.Aggregator {
|
||||||
|
return NewFinal()
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,144 @@
|
||||||
|
package final
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/influxdata/telegraf"
|
||||||
|
"github.com/influxdata/telegraf/internal"
|
||||||
|
"github.com/influxdata/telegraf/metric"
|
||||||
|
"github.com/influxdata/telegraf/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSimple(t *testing.T) {
|
||||||
|
acc := testutil.Accumulator{}
|
||||||
|
final := NewFinal()
|
||||||
|
|
||||||
|
tags := map[string]string{"foo": "bar"}
|
||||||
|
m1, _ := metric.New("m1",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{"a": int64(1)},
|
||||||
|
time.Unix(1530939936, 0))
|
||||||
|
m2, _ := metric.New("m1",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{"a": int64(2)},
|
||||||
|
time.Unix(1530939937, 0))
|
||||||
|
m3, _ := metric.New("m1",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{"a": int64(3)},
|
||||||
|
time.Unix(1530939938, 0))
|
||||||
|
final.Add(m1)
|
||||||
|
final.Add(m2)
|
||||||
|
final.Add(m3)
|
||||||
|
final.Push(&acc)
|
||||||
|
|
||||||
|
expected := []telegraf.Metric{
|
||||||
|
testutil.MustMetric(
|
||||||
|
"m1",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{
|
||||||
|
"a_final": 3,
|
||||||
|
},
|
||||||
|
time.Unix(1530939938, 0),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoTags(t *testing.T) {
|
||||||
|
acc := testutil.Accumulator{}
|
||||||
|
final := NewFinal()
|
||||||
|
|
||||||
|
tags1 := map[string]string{"foo": "bar"}
|
||||||
|
tags2 := map[string]string{"foo": "baz"}
|
||||||
|
|
||||||
|
m1, _ := metric.New("m1",
|
||||||
|
tags1,
|
||||||
|
map[string]interface{}{"a": int64(1)},
|
||||||
|
time.Unix(1530939936, 0))
|
||||||
|
m2, _ := metric.New("m1",
|
||||||
|
tags2,
|
||||||
|
map[string]interface{}{"a": int64(2)},
|
||||||
|
time.Unix(1530939937, 0))
|
||||||
|
m3, _ := metric.New("m1",
|
||||||
|
tags1,
|
||||||
|
map[string]interface{}{"a": int64(3)},
|
||||||
|
time.Unix(1530939938, 0))
|
||||||
|
final.Add(m1)
|
||||||
|
final.Add(m2)
|
||||||
|
final.Add(m3)
|
||||||
|
final.Push(&acc)
|
||||||
|
|
||||||
|
expected := []telegraf.Metric{
|
||||||
|
testutil.MustMetric(
|
||||||
|
"m1",
|
||||||
|
tags2,
|
||||||
|
map[string]interface{}{
|
||||||
|
"a_final": 2,
|
||||||
|
},
|
||||||
|
time.Unix(1530939937, 0),
|
||||||
|
),
|
||||||
|
testutil.MustMetric(
|
||||||
|
"m1",
|
||||||
|
tags1,
|
||||||
|
map[string]interface{}{
|
||||||
|
"a_final": 3,
|
||||||
|
},
|
||||||
|
time.Unix(1530939938, 0),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.SortMetrics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongDifference(t *testing.T) {
|
||||||
|
acc := testutil.Accumulator{}
|
||||||
|
final := NewFinal()
|
||||||
|
final.SeriesTimeout = internal.Duration{Duration: 30 * time.Second}
|
||||||
|
tags := map[string]string{"foo": "bar"}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
m1, _ := metric.New("m",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{"a": int64(1)},
|
||||||
|
now.Add(time.Second*-290))
|
||||||
|
m2, _ := metric.New("m",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{"a": int64(2)},
|
||||||
|
now.Add(time.Second*-275))
|
||||||
|
m3, _ := metric.New("m",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{"a": int64(3)},
|
||||||
|
now.Add(time.Second*-100))
|
||||||
|
m4, _ := metric.New("m",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{"a": int64(4)},
|
||||||
|
now.Add(time.Second*-20))
|
||||||
|
final.Add(m1)
|
||||||
|
final.Add(m2)
|
||||||
|
final.Push(&acc)
|
||||||
|
final.Add(m3)
|
||||||
|
final.Push(&acc)
|
||||||
|
final.Add(m4)
|
||||||
|
final.Push(&acc)
|
||||||
|
|
||||||
|
expected := []telegraf.Metric{
|
||||||
|
testutil.MustMetric(
|
||||||
|
"m",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{
|
||||||
|
"a_final": 2,
|
||||||
|
},
|
||||||
|
now.Add(time.Second*-275),
|
||||||
|
),
|
||||||
|
testutil.MustMetric(
|
||||||
|
"m",
|
||||||
|
tags,
|
||||||
|
map[string]interface{}{
|
||||||
|
"a_final": 3,
|
||||||
|
},
|
||||||
|
now.Add(time.Second*-100),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.SortMetrics())
|
||||||
|
}
|
|
@ -1,11 +1,13 @@
|
||||||
package testutil
|
package testutil
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/influxdata/telegraf"
|
"github.com/influxdata/telegraf"
|
||||||
"github.com/influxdata/telegraf/metric"
|
"github.com/influxdata/telegraf/metric"
|
||||||
)
|
)
|
||||||
|
@ -18,6 +20,77 @@ type metricDiff struct {
|
||||||
Time time.Time
|
Time time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lessFunc(lhs, rhs *metricDiff) bool {
|
||||||
|
if lhs.Measurement != rhs.Measurement {
|
||||||
|
return lhs.Measurement < rhs.Measurement
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
if i >= len(lhs.Tags) && i >= len(rhs.Tags) {
|
||||||
|
break
|
||||||
|
} else if i >= len(lhs.Tags) {
|
||||||
|
return true
|
||||||
|
} else if i >= len(rhs.Tags) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhs.Tags[i].Key != rhs.Tags[i].Key {
|
||||||
|
return lhs.Tags[i].Key < rhs.Tags[i].Key
|
||||||
|
}
|
||||||
|
if lhs.Tags[i].Value != rhs.Tags[i].Value {
|
||||||
|
return lhs.Tags[i].Value < rhs.Tags[i].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
if i >= len(lhs.Fields) && i >= len(rhs.Fields) {
|
||||||
|
break
|
||||||
|
} else if i >= len(lhs.Fields) {
|
||||||
|
return true
|
||||||
|
} else if i >= len(rhs.Fields) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhs.Fields[i].Key != rhs.Fields[i].Key {
|
||||||
|
return lhs.Fields[i].Key < rhs.Fields[i].Key
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhs.Fields[i].Value != rhs.Fields[i].Value {
|
||||||
|
ltype := reflect.TypeOf(lhs.Fields[i].Value)
|
||||||
|
rtype := reflect.TypeOf(lhs.Fields[i].Value)
|
||||||
|
|
||||||
|
if ltype.Kind() != rtype.Kind() {
|
||||||
|
return ltype.Kind() < rtype.Kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := lhs.Fields[i].Value.(type) {
|
||||||
|
case int64:
|
||||||
|
return v < lhs.Fields[i].Value.(int64)
|
||||||
|
case uint64:
|
||||||
|
return v < lhs.Fields[i].Value.(uint64)
|
||||||
|
case float64:
|
||||||
|
return v < lhs.Fields[i].Value.(float64)
|
||||||
|
case string:
|
||||||
|
return v < lhs.Fields[i].Value.(string)
|
||||||
|
case bool:
|
||||||
|
return !v
|
||||||
|
default:
|
||||||
|
panic("unknown type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhs.Type != rhs.Type {
|
||||||
|
return lhs.Type < rhs.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
if lhs.Time.UnixNano() != rhs.Time.UnixNano() {
|
||||||
|
return lhs.Time.UnixNano() < rhs.Time.UnixNano()
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func newMetricDiff(metric telegraf.Metric) *metricDiff {
|
func newMetricDiff(metric telegraf.Metric) *metricDiff {
|
||||||
if metric == nil {
|
if metric == nil {
|
||||||
return nil
|
return nil
|
||||||
|
@ -45,6 +118,12 @@ func newMetricDiff(metric telegraf.Metric) *metricDiff {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SortMetrics enables sorting metrics before comparison.
|
||||||
|
func SortMetrics() cmp.Option {
|
||||||
|
return cmpopts.SortSlices(lessFunc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricEqual returns true if the metrics are equal.
|
||||||
func MetricEqual(expected, actual telegraf.Metric) bool {
|
func MetricEqual(expected, actual telegraf.Metric) bool {
|
||||||
var lhs, rhs *metricDiff
|
var lhs, rhs *metricDiff
|
||||||
if expected != nil {
|
if expected != nil {
|
||||||
|
@ -57,6 +136,8 @@ func MetricEqual(expected, actual telegraf.Metric) bool {
|
||||||
return cmp.Equal(lhs, rhs)
|
return cmp.Equal(lhs, rhs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireMetricEqual halts the test with an error if the metrics are not
|
||||||
|
// equal.
|
||||||
func RequireMetricEqual(t *testing.T, expected, actual telegraf.Metric) {
|
func RequireMetricEqual(t *testing.T, expected, actual telegraf.Metric) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
@ -73,7 +154,9 @@ func RequireMetricEqual(t *testing.T, expected, actual telegraf.Metric) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequireMetricsEqual(t *testing.T, expected, actual []telegraf.Metric) {
|
// RequireMetricsEqual halts the test with an error if the array of metrics
|
||||||
|
// are not equal.
|
||||||
|
func RequireMetricsEqual(t *testing.T, expected, actual []telegraf.Metric, opts ...cmp.Option) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
lhs := make([]*metricDiff, 0, len(expected))
|
lhs := make([]*metricDiff, 0, len(expected))
|
||||||
|
@ -84,7 +167,7 @@ func RequireMetricsEqual(t *testing.T, expected, actual []telegraf.Metric) {
|
||||||
for _, m := range actual {
|
for _, m := range actual {
|
||||||
rhs = append(rhs, newMetricDiff(m))
|
rhs = append(rhs, newMetricDiff(m))
|
||||||
}
|
}
|
||||||
if diff := cmp.Diff(lhs, rhs); diff != "" {
|
if diff := cmp.Diff(lhs, rhs, opts...); diff != "" {
|
||||||
t.Fatalf("[]telegraf.Metric\n--- expected\n+++ actual\n%s", diff)
|
t.Fatalf("[]telegraf.Metric\n--- expected\n+++ actual\n%s", diff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,18 +4,19 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/influxdata/telegraf"
|
"github.com/influxdata/telegraf"
|
||||||
"github.com/influxdata/telegraf/metric"
|
"github.com/influxdata/telegraf/metric"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRequireMetricsEqual(t *testing.T) {
|
func TestRequireMetricEqual(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
got telegraf.Metric
|
got telegraf.Metric
|
||||||
want telegraf.Metric
|
want telegraf.Metric
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "telegraf and testutil metrics should be equal",
|
name: "equal metrics should be equal",
|
||||||
got: func() telegraf.Metric {
|
got: func() telegraf.Metric {
|
||||||
m, _ := metric.New(
|
m, _ := metric.New(
|
||||||
"test",
|
"test",
|
||||||
|
@ -56,3 +57,50 @@ func TestRequireMetricsEqual(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequireMetricsEqual(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got []telegraf.Metric
|
||||||
|
want []telegraf.Metric
|
||||||
|
opts []cmp.Option
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sort metrics option sorts by name",
|
||||||
|
got: []telegraf.Metric{
|
||||||
|
MustMetric(
|
||||||
|
"cpu",
|
||||||
|
map[string]string{},
|
||||||
|
map[string]interface{}{},
|
||||||
|
time.Unix(0, 0),
|
||||||
|
),
|
||||||
|
MustMetric(
|
||||||
|
"net",
|
||||||
|
map[string]string{},
|
||||||
|
map[string]interface{}{},
|
||||||
|
time.Unix(0, 0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
want: []telegraf.Metric{
|
||||||
|
MustMetric(
|
||||||
|
"net",
|
||||||
|
map[string]string{},
|
||||||
|
map[string]interface{}{},
|
||||||
|
time.Unix(0, 0),
|
||||||
|
),
|
||||||
|
MustMetric(
|
||||||
|
"cpu",
|
||||||
|
map[string]string{},
|
||||||
|
map[string]interface{}{},
|
||||||
|
time.Unix(0, 0),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
opts: []cmp.Option{SortMetrics()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
RequireMetricsEqual(t, tt.want, tt.got, tt.opts...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue