Adding upstream version 1.34.4.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
e393c3af3f
commit
4978089aab
4963 changed files with 677545 additions and 0 deletions
195
plugins/inputs/nsd/README.md
Normal file
195
plugins/inputs/nsd/README.md
Normal file
|
@ -0,0 +1,195 @@
|
|||
# NLnet Labs Name Server Daemon Input Plugin
|
||||
|
||||
This plugin gathers statistics from a [NLnet Labs Name Server Daemon][nsd], an
|
||||
authoritative DNS name server.
|
||||
|
||||
⭐ Telegraf v1.0.0
|
||||
🏷️ server
|
||||
💻 all
|
||||
|
||||
[nsd]: https://www.nlnetlabs.nl/projects/nsd/about
|
||||
|
||||
## Global configuration options <!-- @/docs/includes/plugin_config.md -->
|
||||
|
||||
In addition to the plugin-specific configuration settings, plugins support
|
||||
additional global and plugin configuration settings. These settings are used to
|
||||
modify metrics, tags, and field or create aliases and configure ordering, etc.
|
||||
See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
|
||||
|
||||
[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins
|
||||
|
||||
## Configuration
|
||||
|
||||
```toml @sample.conf
|
||||
# A plugin to collect stats from the NSD DNS resolver
|
||||
[[inputs.nsd]]
|
||||
## Address of server to connect to, optionally ':port'. Defaults to the
|
||||
## address in the nsd config file.
|
||||
server = "127.0.0.1:8953"
|
||||
|
||||
## If running as a restricted user you can prepend sudo for additional access:
|
||||
# use_sudo = false
|
||||
|
||||
## The default location of the nsd-control binary can be overridden with:
|
||||
# binary = "/usr/sbin/nsd-control"
|
||||
|
||||
## The default location of the nsd config file can be overridden with:
|
||||
# config_file = "/etc/nsd/nsd.conf"
|
||||
|
||||
## The default timeout of 1s can be overridden with:
|
||||
# timeout = "1s"
|
||||
```
|
||||
|
||||
### Permissions
|
||||
|
||||
It's important to note that this plugin references nsd-control, which may
|
||||
require additional permissions to execute successfully. Depending on the
|
||||
user/group permissions of the telegraf user executing this plugin, you may
|
||||
need to alter the group membership, set facls, or use sudo.
|
||||
|
||||
**Group membership (Recommended)**:
|
||||
|
||||
```bash
|
||||
$ groups telegraf
|
||||
telegraf : telegraf
|
||||
|
||||
$ usermod -a -G nsd telegraf
|
||||
|
||||
$ groups telegraf
|
||||
telegraf : telegraf nsd
|
||||
```
|
||||
|
||||
**Sudo privileges**:
|
||||
If you use this method, you will need the following in your telegraf config:
|
||||
|
||||
```toml
|
||||
[[inputs.nsd]]
|
||||
use_sudo = true
|
||||
```
|
||||
|
||||
You will also need to update your sudoers file:
|
||||
|
||||
```bash
|
||||
$ visudo
|
||||
# Add the following line:
|
||||
Cmnd_Alias NSDCONTROLCTL = /usr/sbin/nsd-control
|
||||
telegraf ALL=(ALL) NOPASSWD: NSDCONTROLCTL
|
||||
Defaults!NSDCONTROLCTL !logfile, !syslog, !pam_session
|
||||
```
|
||||
|
||||
Please use the solution you see as most appropriate.
|
||||
|
||||
## Metrics
|
||||
|
||||
This is the full list of stats provided by nsd-control. In the output, the
|
||||
dots in the nsd-control stat name are replaced by underscores (see
|
||||
<https://www.nlnetlabs.nl/documentation/nsd/nsd-control/> for details).
|
||||
|
||||
- nsd
|
||||
- fields:
|
||||
- num_queries
|
||||
- time_boot
|
||||
- time_elapsed
|
||||
- size_db_disk
|
||||
- size_db_mem
|
||||
- size_xfrd_mem
|
||||
- size_config_disk
|
||||
- size_config_mem
|
||||
- num_type_TYPE0
|
||||
- num_type_A
|
||||
- num_type_NS
|
||||
- num_type_MD
|
||||
- num_type_MF
|
||||
- num_type_CNAME
|
||||
- num_type_SOA
|
||||
- num_type_MB
|
||||
- num_type_MG
|
||||
- num_type_MR
|
||||
- num_type_NULL
|
||||
- num_type_WKS
|
||||
- num_type_PTR
|
||||
- num_type_HINFO
|
||||
- num_type_MINFO
|
||||
- num_type_MX
|
||||
- num_type_TXT
|
||||
- num_type_RP
|
||||
- num_type_AFSDB
|
||||
- num_type_X25
|
||||
- num_type_ISDN
|
||||
- num_type_RT
|
||||
- num_type_NSAP
|
||||
- num_type_SIG
|
||||
- num_type_KEY
|
||||
- num_type_PX
|
||||
- num_type_AAAA
|
||||
- num_type_LOC
|
||||
- num_type_NXT
|
||||
- num_type_SRV
|
||||
- num_type_NAPTR
|
||||
- num_type_KX
|
||||
- num_type_CERT
|
||||
- num_type_DNAME
|
||||
- num_type_OPT
|
||||
- num_type_APL
|
||||
- num_type_DS
|
||||
- num_type_SSHFP
|
||||
- num_type_IPSECKEY
|
||||
- num_type_RRSIG
|
||||
- num_type_NSEC
|
||||
- num_type_DNSKEY
|
||||
- num_type_DHCID
|
||||
- num_type_NSEC3
|
||||
- num_type_NSEC3PARAM
|
||||
- num_type_TLSA
|
||||
- num_type_SMIMEA
|
||||
- num_type_CDS
|
||||
- num_type_CDNSKEY
|
||||
- num_type_OPENPGPKEY
|
||||
- num_type_CSYNC
|
||||
- num_type_SPF
|
||||
- num_type_NID
|
||||
- num_type_L32
|
||||
- num_type_L64
|
||||
- num_type_LP
|
||||
- num_type_EUI48
|
||||
- num_type_EUI64
|
||||
- num_type_TYPE252
|
||||
- num_type_TYPE253
|
||||
- num_type_TYPE255
|
||||
- num_opcode_QUERY
|
||||
- num_opcode_NOTIFY
|
||||
- num_class_CLASS0
|
||||
- num_class_IN
|
||||
- num_class_CH
|
||||
- num_rcode_NOERROR
|
||||
- num_rcode_FORMERR
|
||||
- num_rcode_SERVFAIL
|
||||
- num_rcode_NXDOMAIN
|
||||
- num_rcode_NOTIMP
|
||||
- num_rcode_REFUSED
|
||||
- num_rcode_YXDOMAIN
|
||||
- num_rcode_NOTAUTH
|
||||
- num_edns
|
||||
- num_ednserr
|
||||
- num_udp
|
||||
- num_udp6
|
||||
- num_tcp
|
||||
- num_tcp6
|
||||
- num_tls
|
||||
- num_tls6
|
||||
- num_answer_wo_aa
|
||||
- num_rxerr
|
||||
- num_txerr
|
||||
- num_raxfr
|
||||
- num_truncated
|
||||
- num_dropped
|
||||
- zone_master
|
||||
- zone_slave
|
||||
|
||||
- nsd_servers
|
||||
- tags:
|
||||
- server
|
||||
- fields:
|
||||
- queries
|
||||
|
||||
## Example Output
|
147
plugins/inputs/nsd/nsd.go
Normal file
147
plugins/inputs/nsd/nsd.go
Normal file
|
@ -0,0 +1,147 @@
|
|||
//go:generate ../../../tools/readme_config_includer/generator
|
||||
package nsd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/telegraf"
|
||||
"github.com/influxdata/telegraf/config"
|
||||
"github.com/influxdata/telegraf/internal"
|
||||
"github.com/influxdata/telegraf/plugins/inputs"
|
||||
)
|
||||
|
||||
//go:embed sample.conf
|
||||
var sampleConfig string
|
||||
|
||||
var (
|
||||
defaultBinary = "/usr/sbin/nsd-control"
|
||||
defaultTimeout = config.Duration(time.Second)
|
||||
)
|
||||
|
||||
type NSD struct {
|
||||
Binary string `toml:"binary"`
|
||||
Timeout config.Duration `toml:"timeout"`
|
||||
UseSudo bool `toml:"use_sudo"`
|
||||
Server string `toml:"server"`
|
||||
ConfigFile string `toml:"config_file"`
|
||||
|
||||
run runner
|
||||
}
|
||||
|
||||
type runner func(cmdName string, timeout config.Duration, useSudo bool, Server string, ConfigFile string) (*bytes.Buffer, error)
|
||||
|
||||
func (*NSD) SampleConfig() string {
|
||||
return sampleConfig
|
||||
}
|
||||
|
||||
func (s *NSD) Gather(acc telegraf.Accumulator) error {
|
||||
out, err := s.run(s.Binary, s.Timeout, s.UseSudo, s.Server, s.ConfigFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error gathering metrics: %w", err)
|
||||
}
|
||||
|
||||
// Process values
|
||||
fields := make(map[string]interface{})
|
||||
fieldsServers := make(map[string]map[string]interface{})
|
||||
|
||||
scanner := bufio.NewScanner(out)
|
||||
for scanner.Scan() {
|
||||
cols := strings.Split(scanner.Text(), "=")
|
||||
|
||||
// Check split correctness
|
||||
if len(cols) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
stat := cols[0]
|
||||
value := cols[1]
|
||||
|
||||
fieldValue, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
acc.AddError(fmt.Errorf("expected a numerical value for %s = %v",
|
||||
stat, value))
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(stat, "server") {
|
||||
statTokens := strings.Split(stat, ".")
|
||||
if len(statTokens) > 1 {
|
||||
serverID := strings.TrimPrefix(statTokens[0], "server")
|
||||
if _, err := strconv.Atoi(serverID); err == nil {
|
||||
serverTokens := statTokens[1:]
|
||||
field := strings.Join(serverTokens[:], "_")
|
||||
if fieldsServers[serverID] == nil {
|
||||
fieldsServers[serverID] = make(map[string]interface{})
|
||||
}
|
||||
fieldsServers[serverID][field] = fieldValue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
field := strings.ReplaceAll(stat, ".", "_")
|
||||
fields[field] = fieldValue
|
||||
}
|
||||
}
|
||||
|
||||
acc.AddFields("nsd", fields, nil)
|
||||
for thisServerID, thisServerFields := range fieldsServers {
|
||||
thisServerTag := map[string]string{"server": thisServerID}
|
||||
acc.AddFields("nsd_servers", thisServerFields, thisServerTag)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shell out to nsd_stat and return the output
|
||||
func nsdRunner(cmdName string, timeout config.Duration, useSudo bool, server, configFile string) (*bytes.Buffer, error) {
|
||||
cmdArgs := []string{"stats_noreset"}
|
||||
|
||||
if server != "" {
|
||||
host, port, err := net.SplitHostPort(server)
|
||||
if err == nil {
|
||||
server = host + "@" + port
|
||||
}
|
||||
|
||||
cmdArgs = append([]string{"-s", server}, cmdArgs...)
|
||||
}
|
||||
|
||||
if configFile != "" {
|
||||
cmdArgs = append([]string{"-c", configFile}, cmdArgs...)
|
||||
}
|
||||
|
||||
cmd := exec.Command(cmdName, cmdArgs...)
|
||||
|
||||
if useSudo {
|
||||
cmdArgs = append([]string{cmdName}, cmdArgs...)
|
||||
cmd = exec.Command("sudo", cmdArgs...)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
err := internal.RunTimeout(cmd, time.Duration(timeout))
|
||||
if err != nil {
|
||||
return &out, fmt.Errorf("error running nsd-control: %w (%s %v)", err, cmdName, cmdArgs)
|
||||
}
|
||||
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
inputs.Add("nsd", func() telegraf.Input {
|
||||
return &NSD{
|
||||
run: nsdRunner,
|
||||
Binary: defaultBinary,
|
||||
Timeout: defaultTimeout,
|
||||
UseSudo: false,
|
||||
Server: "",
|
||||
ConfigFile: "",
|
||||
}
|
||||
})
|
||||
}
|
241
plugins/inputs/nsd/nsd_test.go
Normal file
241
plugins/inputs/nsd/nsd_test.go
Normal file
|
@ -0,0 +1,241 @@
|
|||
package nsd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/influxdata/telegraf/config"
|
||||
"github.com/influxdata/telegraf/testutil"
|
||||
)
|
||||
|
||||
func nsdControl(output string) func(string, config.Duration, bool, string, string) (*bytes.Buffer, error) {
|
||||
return func(string, config.Duration, bool, string, string) (*bytes.Buffer, error) {
|
||||
return bytes.NewBufferString(output), nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFullOutput(t *testing.T) {
|
||||
acc := &testutil.Accumulator{}
|
||||
v := &NSD{
|
||||
run: nsdControl(fullOutput),
|
||||
}
|
||||
err := v.Gather(acc)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, acc.HasMeasurement("nsd"))
|
||||
require.True(t, acc.HasMeasurement("nsd_servers"))
|
||||
|
||||
require.Len(t, acc.Metrics, 2)
|
||||
require.Equal(t, 99, acc.NFields())
|
||||
|
||||
acc.AssertContainsFields(t, "nsd", parsedFullOutput)
|
||||
acc.AssertContainsFields(t, "nsd_servers", parsedFullOutputServerAsTag)
|
||||
}
|
||||
|
||||
var parsedFullOutputServerAsTag = map[string]interface{}{
|
||||
"queries": float64(75576),
|
||||
}
|
||||
|
||||
var parsedFullOutput = map[string]interface{}{
|
||||
"num_queries": float64(75557),
|
||||
"time_boot": float64(2944405.500253),
|
||||
"time_elapsed": float64(2944405.500253),
|
||||
"size_db_disk": float64(98304),
|
||||
"size_db_mem": float64(22784),
|
||||
"size_xfrd_mem": float64(83956312),
|
||||
"size_config_disk": float64(0),
|
||||
"size_config_mem": float64(6088),
|
||||
"num_type_TYPE0": float64(6),
|
||||
"num_type_A": float64(46311),
|
||||
"num_type_NS": float64(478),
|
||||
"num_type_MD": float64(0),
|
||||
"num_type_MF": float64(0),
|
||||
"num_type_CNAME": float64(272),
|
||||
"num_type_SOA": float64(596),
|
||||
"num_type_MB": float64(0),
|
||||
"num_type_MG": float64(0),
|
||||
"num_type_MR": float64(0),
|
||||
"num_type_NULL": float64(0),
|
||||
"num_type_WKS": float64(0),
|
||||
"num_type_PTR": float64(83),
|
||||
"num_type_HINFO": float64(1),
|
||||
"num_type_MINFO": float64(0),
|
||||
"num_type_MX": float64(296),
|
||||
"num_type_TXT": float64(794),
|
||||
"num_type_RP": float64(0),
|
||||
"num_type_AFSDB": float64(0),
|
||||
"num_type_X25": float64(0),
|
||||
"num_type_ISDN": float64(0),
|
||||
"num_type_RT": float64(0),
|
||||
"num_type_NSAP": float64(0),
|
||||
"num_type_SIG": float64(0),
|
||||
"num_type_KEY": float64(1),
|
||||
"num_type_PX": float64(0),
|
||||
"num_type_AAAA": float64(22736),
|
||||
"num_type_LOC": float64(2),
|
||||
"num_type_NXT": float64(0),
|
||||
"num_type_SRV": float64(93),
|
||||
"num_type_NAPTR": float64(5),
|
||||
"num_type_KX": float64(0),
|
||||
"num_type_CERT": float64(0),
|
||||
"num_type_DNAME": float64(0),
|
||||
"num_type_OPT": float64(0),
|
||||
"num_type_APL": float64(0),
|
||||
"num_type_DS": float64(0),
|
||||
"num_type_SSHFP": float64(0),
|
||||
"num_type_IPSECKEY": float64(0),
|
||||
"num_type_RRSIG": float64(21),
|
||||
"num_type_NSEC": float64(0),
|
||||
"num_type_DNSKEY": float64(325),
|
||||
"num_type_DHCID": float64(0),
|
||||
"num_type_NSEC3": float64(0),
|
||||
"num_type_NSEC3PARAM": float64(0),
|
||||
"num_type_TLSA": float64(35),
|
||||
"num_type_SMIMEA": float64(0),
|
||||
"num_type_CDS": float64(0),
|
||||
"num_type_CDNSKEY": float64(0),
|
||||
"num_type_OPENPGPKEY": float64(0),
|
||||
"num_type_CSYNC": float64(0),
|
||||
"num_type_SPF": float64(16),
|
||||
"num_type_NID": float64(0),
|
||||
"num_type_L32": float64(0),
|
||||
"num_type_L64": float64(0),
|
||||
"num_type_LP": float64(0),
|
||||
"num_type_EUI48": float64(0),
|
||||
"num_type_EUI64": float64(0),
|
||||
"num_type_TYPE252": float64(962),
|
||||
"num_type_TYPE253": float64(2),
|
||||
"num_type_TYPE255": float64(1840),
|
||||
"num_opcode_QUERY": float64(75527),
|
||||
"num_opcode_NOTIFY": float64(6),
|
||||
"num_class_CLASS0": float64(6),
|
||||
"num_class_IN": float64(75395),
|
||||
"num_class_CH": float64(132),
|
||||
"num_rcode_NOERROR": float64(65541),
|
||||
"num_rcode_FORMERR": float64(8),
|
||||
"num_rcode_SERVFAIL": float64(0),
|
||||
"num_rcode_NXDOMAIN": float64(6642),
|
||||
"num_rcode_NOTIMP": float64(18),
|
||||
"num_rcode_REFUSED": float64(3341),
|
||||
"num_rcode_YXDOMAIN": float64(0),
|
||||
"num_rcode_NOTAUTH": float64(2),
|
||||
"num_edns": float64(71398),
|
||||
"num_ednserr": float64(0),
|
||||
"num_udp": float64(34111),
|
||||
"num_udp6": float64(40429),
|
||||
"num_tcp": float64(1015),
|
||||
"num_tcp6": float64(2),
|
||||
"num_tls": float64(0),
|
||||
"num_tls6": float64(0),
|
||||
"num_answer_wo_aa": float64(13),
|
||||
"num_rxerr": float64(0),
|
||||
"num_txerr": float64(0),
|
||||
"num_raxfr": float64(954),
|
||||
"num_truncated": float64(1),
|
||||
"num_dropped": float64(5),
|
||||
"zone_master": float64(2),
|
||||
"zone_slave": float64(1),
|
||||
}
|
||||
|
||||
var fullOutput = `server0.queries=75576
|
||||
num.queries=75557
|
||||
time.boot=2944405.500253
|
||||
time.elapsed=2944405.500253
|
||||
size.db.disk=98304
|
||||
size.db.mem=22784
|
||||
size.xfrd.mem=83956312
|
||||
size.config.disk=0
|
||||
size.config.mem=6088
|
||||
num.type.TYPE0=6
|
||||
num.type.A=46311
|
||||
num.type.NS=478
|
||||
num.type.MD=0
|
||||
num.type.MF=0
|
||||
num.type.CNAME=272
|
||||
num.type.SOA=596
|
||||
num.type.MB=0
|
||||
num.type.MG=0
|
||||
num.type.MR=0
|
||||
num.type.NULL=0
|
||||
num.type.WKS=0
|
||||
num.type.PTR=83
|
||||
num.type.HINFO=1
|
||||
num.type.MINFO=0
|
||||
num.type.MX=296
|
||||
num.type.TXT=794
|
||||
num.type.RP=0
|
||||
num.type.AFSDB=0
|
||||
num.type.X25=0
|
||||
num.type.ISDN=0
|
||||
num.type.RT=0
|
||||
num.type.NSAP=0
|
||||
num.type.SIG=0
|
||||
num.type.KEY=1
|
||||
num.type.PX=0
|
||||
num.type.AAAA=22736
|
||||
num.type.LOC=2
|
||||
num.type.NXT=0
|
||||
num.type.SRV=93
|
||||
num.type.NAPTR=5
|
||||
num.type.KX=0
|
||||
num.type.CERT=0
|
||||
num.type.DNAME=0
|
||||
num.type.OPT=0
|
||||
num.type.APL=0
|
||||
num.type.DS=0
|
||||
num.type.SSHFP=0
|
||||
num.type.IPSECKEY=0
|
||||
num.type.RRSIG=21
|
||||
num.type.NSEC=0
|
||||
num.type.DNSKEY=325
|
||||
num.type.DHCID=0
|
||||
num.type.NSEC3=0
|
||||
num.type.NSEC3PARAM=0
|
||||
num.type.TLSA=35
|
||||
num.type.SMIMEA=0
|
||||
num.type.CDS=0
|
||||
num.type.CDNSKEY=0
|
||||
num.type.OPENPGPKEY=0
|
||||
num.type.CSYNC=0
|
||||
num.type.SPF=16
|
||||
num.type.NID=0
|
||||
num.type.L32=0
|
||||
num.type.L64=0
|
||||
num.type.LP=0
|
||||
num.type.EUI48=0
|
||||
num.type.EUI64=0
|
||||
num.type.TYPE252=962
|
||||
num.type.TYPE253=2
|
||||
num.type.TYPE255=1840
|
||||
num.opcode.QUERY=75527
|
||||
num.opcode.NOTIFY=6
|
||||
num.class.CLASS0=6
|
||||
num.class.IN=75395
|
||||
num.class.CH=132
|
||||
num.rcode.NOERROR=65541
|
||||
num.rcode.FORMERR=8
|
||||
num.rcode.SERVFAIL=0
|
||||
num.rcode.NXDOMAIN=6642
|
||||
num.rcode.NOTIMP=18
|
||||
num.rcode.REFUSED=3341
|
||||
num.rcode.YXDOMAIN=0
|
||||
num.rcode.NOTAUTH=2
|
||||
num.edns=71398
|
||||
num.ednserr=0
|
||||
num.udp=34111
|
||||
num.udp6=40429
|
||||
num.tcp=1015
|
||||
num.tcp6=2
|
||||
num.tls=0
|
||||
num.tls6=0
|
||||
num.answer_wo_aa=13
|
||||
num.rxerr=0
|
||||
num.txerr=0
|
||||
num.raxfr=954
|
||||
num.truncated=1
|
||||
num.dropped=5
|
||||
zone.master=2
|
||||
zone.slave=1`
|
17
plugins/inputs/nsd/sample.conf
Normal file
17
plugins/inputs/nsd/sample.conf
Normal file
|
@ -0,0 +1,17 @@
|
|||
# A plugin to collect stats from the NSD DNS resolver
|
||||
[[inputs.nsd]]
|
||||
## Address of server to connect to, optionally ':port'. Defaults to the
|
||||
## address in the nsd config file.
|
||||
server = "127.0.0.1:8953"
|
||||
|
||||
## If running as a restricted user you can prepend sudo for additional access:
|
||||
# use_sudo = false
|
||||
|
||||
## The default location of the nsd-control binary can be overridden with:
|
||||
# binary = "/usr/sbin/nsd-control"
|
||||
|
||||
## The default location of the nsd config file can be overridden with:
|
||||
# config_file = "/etc/nsd/nsd.conf"
|
||||
|
||||
## The default timeout of 1s can be overridden with:
|
||||
# timeout = "1s"
|
Loading…
Add table
Add a link
Reference in a new issue