From 4fd4c682190da48ae9601c6edaa118f6db1388be Mon Sep 17 00:00:00 2001 From: yo Date: Sun, 12 Jan 2025 14:59:32 +0100 Subject: [PATCH] v.0.2.1 --- .gitignore | 4 + config.yaml.sample | 103 +++++++++ edges.go | 85 ++++++++ go.mod | 57 +++++ go.sum | 132 ++++++++++++ main.go | 505 +++++++++++++++++++++++++++++++++++++++++++++ nodes.go | 96 +++++++++ 7 files changed, 982 insertions(+) create mode 100644 .gitignore create mode 100644 config.yaml.sample create mode 100644 edges.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 nodes.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b042b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +nodegopher +config.yaml* +!config.yaml.sample +main.go.* diff --git a/config.yaml.sample b/config.yaml.sample new file mode 100644 index 0000000..ae0a803 --- /dev/null +++ b/config.yaml.sample @@ -0,0 +1,103 @@ +# datasource describe a way to get prometheus metrics. +# Properties : +# - name: name of the query. To be used in edges or nodes mainstatquery or secondarystatquery. Result will be output in mainstat, or secondarystat. +# - address: the address of prometheus. +# - query: prometheus query. Same as typed in prometheus graph page. +# - type: type of query. "query" will get instant value, "query_range" will get all samples for the grafana period. Result will be averaged. +# - timeout: query timeout in seconds. +datasources: + - name: prom_samples_per_sec + type: query + address: 'http://prometheus.local.lan:9090' + query: 'rate(prometheus_tsdb_head_samples_appended_total{type="float"}[10m])' + timeout: 10 + - name: node_cpu_metric + # Simple query, return an instant metric + type: query + address: 'http://prometheus.local.lan:9090' + query: 'sum(rate(node_cpu_seconds_total{instance="router01.local.lan:9100",job="node",mode!~"idle"}[30s]))*100' + timeout: 10 + - name: router01_net_down_rate + # Range query. Return all metrics from a time range. Result will be averaged from these metrics. Time range will be provided by Grafana. + type: query_range + address: 'http://prometheus.local.lan:9090' + query: 'rate(node_network_receive_bytes_total{device="igb0", instance="router01.local.lan:9100", job="node"}[30s])' + timeout: 10 + - name: router01_net_up_rate + type: query_range + address: 'http://prometheus.local.lan:9090' + query: 'rate(node_network_transmit_bytes_total{device="igb0", instance="router01.local.lan:9100", job="node"}[30s])' + timeout: 10 + - name: router01_lan_down_rate + type: query_range + address: 'http://prometheus.local.lan:9090' + query: 'rate(node_network_receive_bytes_total{device="ix3", instance="router01.local.lan:9100", job="node"}[30s])' + timeout: 10 + - name: router01_lan_up_rate + type: query_range + address: 'http://prometheus.local.lan:9090' + query: 'rate(node_network_transmit_bytes_total{device="ix3", instance="router01.local.lan:9100", job="node"}[30s])' + timeout: 10 + +# graphs identifies context for a nodegraph. You can have many contexts, and your grafana query will mention this context name. +# For this example named "internet", grafana URL will be : +# Edges: http://nodegopher.local.lan:8080/internet/edges?from=$__from&to=$__to&interval=$__interval +# Nodes: http://nodegopher.local.lan:8080/internet/nodes?from=$__from&to=$__to&interval=$__interval +# Properties : +# - name: name of the context +# - nodes: list of nodegraph nodes +# - edges: list of nodegraph edges +graphs: + - name: internet + nodes: + - name: internet + id: internet + title: "internet" + subtitle: "The internets" + color: "grey" + # icons come from https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview + icon: "globe" + - name: router01 + id: router01 + title: router01 + subtitle: "A router" + color: "blue" + mainstatquery: '{{ node_cpu_metric }}' + # Use %% if you want to display '%' in you metric label + mainstatformat: '%0.2f%% cpu' + - name: host01 + id: host01 + title: host01 + subtitle: "A workstation" + color: "green" + icon: "user" + edges: + - id: edge0 + source: internet + target: router01 + mainstatquery: '{{ router01_net_up_rate }}' + mainstatformat: 'up %0.0f bps' + secondarystatquery: '{{ router01_net_down_rate }}' + secondarystatformat: 'down %0.0f bps' + - id: edge1 + source: router01 + target: internet + mainstatquery: '{{ router01_net_up_rate }}' + mainstatformat: 'up %0.0f bps' + secondarystatquery: '{{ router01_net_down_rate }}' + secondarystatformat: 'down %0.0f bps' + - id: edge2 + source: router01 + target: host01 + mainstatquery: '{{ router01_lan_down_rate }}' + mainstatformat: 'up %0.0f bps' + secondarystatquery: '{{ router01_lan_up_rate }}' + secondarystatformat: 'down %0.0f bps' + - id: edge3 + source: host01 + target: router01 + mainstatquery: '{{ router01_lan_down_rate }}' + mainstatformat: 'up %0.0f bps' + secondarystatquery: '{{ router01_lan_up_rate }}' + secondarystatformat: 'down %0.0f bps' + diff --git a/edges.go b/edges.go new file mode 100644 index 0000000..64e961e --- /dev/null +++ b/edges.go @@ -0,0 +1,85 @@ +// Edge implement Item interface + +package main + +import ( + "fmt" + "strconv" +) + +// TODO: handle arc__ and details__ fields +type Edge struct { + Id string `yaml:"id" json:"id"` + Source string `yaml:"source" json:"source"` + Target string `yaml:"target" json:"target"` + MainStat string `yaml:"mainstat,omitempty" json:"mainstat,omitempty"` + MainStatQuery string `yaml:"mainstatquery,omitempty" json:"mainstatquery,omitempty"` + MainStatFormat string `yaml:"mainstatformat,omitempty" json:"mainstatformat,omitempty"` + SecondaryStat string `yaml:"secondarystat,omitempty" json:"secondarystat,omitempty"` + SecondaryStatQuery string `yaml:"secondarystatquery,omitempty" json:"secondarystatquery,omitempty"` + SecondaryStatFormat string `yaml:"secondarystatformat,omitempty" json:"secondarystatformat,omitempty"` + Color string `yaml:"color,omitempty" json:"color,omitempty"` + Thickness int `yaml:"thickness,omitempty" json:"thickness,omitempty"` + HighLighted bool `yaml:"highlighted,omitempty" json:"hightlighted,omitempty"` + StrokeDashArray float32 `yaml:"strokeDasharray,omitempty" json:"strokeDasharray,omitempty"` +} + +func (e Edge) GetId() string { + return e.Id +} + +func (e Edge) GetMainStat() string { + return e.MainStat +} + +func (e Edge) GetSecondaryStat() string { + return e.SecondaryStat +} + +func (e Edge) GetMainStatQuery() string { + return e.MainStatQuery +} + +func (e Edge) GetSecondaryStatQuery() string { + return e.SecondaryStatQuery +} + +func (e Edge) GetMainStatFormat() string { + return e.MainStatFormat +} + +func (e Edge) GetSecondaryStatFormat() string { + return e.SecondaryStatFormat +} + +func (e *Edge) SetMainStat(stat string) { + e.MainStat = fmt.Sprintf("%s", stat) +} + +func (e *Edge) SetSecondaryStat(stat string) { + e.SecondaryStat = fmt.Sprintf("%s", stat) +} + +// Custom marshaler to not send (main|secondary)statquery +func (e Edge) MarshalJSON() ([]byte, error) { + jsonRes := `{"id":"` + e.Id + `","source":"` + e.Source + `","target":"` + e.Target + `"` + if len(e.MainStat) > 0 { + jsonRes += `,"mainstat":"` + e.MainStat + `"` + } + if len(e.SecondaryStat) > 0 { + jsonRes += `,"secondarystat":"` + e.SecondaryStat + `"` + } + if len(e.Color) > 0 { + jsonRes += `,"color":"` + e.Color + `"` + } + if e.Thickness > 0 { + jsonRes += `,"thickness":` + strconv.Itoa(e.Thickness) + } + if e.HighLighted { + jsonRes += `,"highlighted":true` + } + // TODO : e.StrokeDashArray + jsonRes += `}` + + return []byte(jsonRes), nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4177880 --- /dev/null +++ b/go.mod @@ -0,0 +1,57 @@ +module nodegopher + +go 1.23.2 + +require ( + github.com/fsnotify/fsnotify v1.7.0 + github.com/gin-gonic/gin v1.10.0 + github.com/prometheus/client_golang v1.20.5 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/viper v1.19.0 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7642552 --- /dev/null +++ b/go.sum @@ -0,0 +1,132 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..6318fad --- /dev/null +++ b/main.go @@ -0,0 +1,505 @@ +// nodegopher is a Grafana/Prometheus nodeGraph helper +// It builds nodegraph structure by merging static data and metrics pulled from prometheus instance +// Copyright (c) 2025 yo000 +// + +package main + +import ( + "os" + "fmt" + "flag" + "sync" + "time" + "regexp" + "context" + "strconv" + "strings" + "syscall" + "net/http" + "os/signal" + + "gopkg.in/yaml.v2" + "github.com/spf13/viper" + "github.com/gin-gonic/gin" + "github.com/fsnotify/fsnotify" + log "github.com/sirupsen/logrus" + + "github.com/prometheus/common/model" + "github.com/prometheus/client_golang/api" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" +) + +const ( + gVersion = "0.2.1" +) + +type PromDataSourceConfig struct { + Name string `yaml:"name"` + QType string `yaml:"type"` + Address string `yaml:"address"` + Query string `yaml:"query"` + Timeout int `yaml:"timeout"` +} + +type Graph struct { + Name string `yaml:"name"` + Nodes []Item + Edges []Item +} + +type Item interface { + GetId() string + GetMainStat() string + GetMainStatQuery() string + GetMainStatFormat() string + SetMainStat(string) + GetSecondaryStat() string + GetSecondaryStatQuery() string + GetSecondaryStatFormat() string + SetSecondaryStat(string) +} + +// Query arguments. Based on grafana internal variables. +type QueryArgs struct { + From int64 `form:"from" binding:"required"` + To int64 `form:"to" binding:"required"` + Interval string `form:"interval" binding:"required"` +} + +// Query arguments converted to prometheus_client format +type MyRange struct { + Start time.Time + End time.Time + Step time.Duration +} + +var ( + gDebug bool + gGraphs []Graph + gDataSources []PromDataSourceConfig + + gDSVarCompRegex *regexp.Regexp + gGrafanaIntervalUnitRegex *regexp.Regexp + + // manipulating In-Ram configuration Mutex. reloadConfig will lock WriteMutex, consuming queries will lock ReadMutex. + gCfgMutex sync.RWMutex +) + + +func (d *PromDataSourceConfig) GetData(timeRange *MyRange) (float64, error) { + client, err := api.NewClient(api.Config{ + Address: d.Address, + }) + if err != nil { + log.Errorf("DataSourceConfig.GetData: Error creating client: %v\n", err) + return 0.0, err + } + + v1api := v1.NewAPI(client) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(d.Timeout)*time.Second) + defer cancel() + var result model.Value + var warnings v1.Warnings + if d.QType == "query" { + result, warnings, err = v1api.Query(ctx, d.Query, time.Now(), v1.WithTimeout(time.Duration(d.Timeout)*time.Second)) + } else if d.QType == "query_range" { + rng := v1.Range{ + Start: timeRange.Start, + End: timeRange.End, + Step: timeRange.Step, + } + result, warnings, err = v1api.QueryRange(ctx, d.Query, rng, v1.WithTimeout(time.Duration(d.Timeout)*time.Second)) + } + if err != nil { + log.Errorf("DataSourceConfig.GetData: Error querying Prometheus: %v\n", err) + return 0.0, err + } + if len(warnings) > 0 { + log.Warningf("DataSourceConfig.GetData: Warnings: %v\n", warnings) + } + log.Debugf("DataSourceConfig.GetData: Result: %v\n", result) + + switch { + case result.Type() == model.ValScalar: + log.Errorf("This is a model.ValScalar type, not implemented!\n") + //scalarVal := result.(*model.Scalar) + // handle scalar stuff + case result.Type() == model.ValVector: + vectorVal := result.(model.Vector) + // FIXME: Is averaging the right thing to do? + var total float64 + for _, elem := range vectorVal { + log.Debugf("DataSourceConfig.GetData: Value: %v\n", elem.Value) + total += float64(elem.Value) + } + log.Debugf("DataSourceConfig.GetData: Total average: %f\n", total/float64(len(vectorVal))) + return total/float64(len(vectorVal)), nil + // QueryRange return this type + case result.Type() == model.ValMatrix: + matrixVal := result.(model.Matrix) + // FIXME: Is averaging the right thing to do? + var total float64 + var length int64 + for _, val := range matrixVal { + for _, v := range val.Values { + total += float64(v.Value) + } + length += int64(len(val.Values)) + } + log.Debugf("DataSourceConfig.GetData: Total average: %f\n", total/float64(length)) + return total/float64(length), nil + default: + log.Errorf("Prometheus result is an unknown type: %T", result) + return 0.0, nil + } + + return 0.0, nil +} + +func (g Graph) BuildMetrics(items *[]Item, timeRange *MyRange) (*[]Item, error) { + var err error + + // Acquire Read Lock. This won't prevent other queries being answered, but reloadConfig WLock will have to wait until we finish + gCfgMutex.RLock() + defer gCfgMutex.RUnlock() + + for _, item := range *items { + if len(item.GetMainStatQuery()) > 0 { + log.Debugf("Item %s have mainstatquery: %s\n", item.GetId(), item.GetMainStatQuery()) + r := gDSVarCompRegex.FindStringSubmatch(item.GetMainStatQuery()) + if len(r) > 1 { + var value float64 + dsname := strings.TrimSpace(r[1]) + log.Debugf("buildMetrics: datasource from mainstatquery : %s\n", dsname) + for _, d := range gDataSources { + if strings.EqualFold(d.Name, dsname) { + value, err = d.GetData(timeRange) + if err != nil { + return nil, err + } + break + } + } + format := item.GetMainStatFormat() + if len(format) == 0 { + format = "%f" + } + log.Debugf("buildMetrics: Replace %s mainstat with %s\n", item.GetId(), fmt.Sprintf(format, value)) + item.SetMainStat(fmt.Sprintf(format, value)) + } else { + log.Errorf("buildMetrics: Item %s mainstatquery unparseable: %s\n", item.GetId(), item.GetMainStatQuery()) + } + } + if len(item.GetSecondaryStatQuery()) > 0 { + log.Debugf("Item %s have mainstatquery: %s\n", item.GetId(), item.GetSecondaryStatQuery()) + r := gDSVarCompRegex.FindStringSubmatch(item.GetSecondaryStatQuery()) + if len(r) > 1 { + var value float64 + dsname := strings.TrimSpace(r[1]) + log.Debugf("buildMetrics: datasource from secondarystatquery : %s\n", dsname) + for _, d := range gDataSources { + if strings.EqualFold(d.Name, dsname) { + value, err = d.GetData(timeRange) + if err != nil { + return nil, err + } + break + } + } + format := item.GetSecondaryStatFormat() + if len(format) == 0 { + format = "%f" + } + log.Debugf("buildMetrics: Replace %s secondarystat with %s\n", item.GetId(), fmt.Sprintf(format, value)) + item.SetSecondaryStat(fmt.Sprintf(format, value)) + } else { + log.Errorf("buildMetrics: Item %s secondarystatquery unparseable: %s\n", item.GetId(), item.GetSecondaryStatQuery()) + } + } + } + return items, nil +} + +func queryArgsToTimeRange(args QueryArgs) (MyRange, error) { + // interval sent by Grafana could end with : ms, s, m, h, d + var step time.Duration + matches := gGrafanaIntervalUnitRegex.FindStringSubmatch(args.Interval) + if len(matches) != 3 { + return MyRange{}, fmt.Errorf("Invalid format: interval") + } + value, err := strconv.ParseInt(matches[gGrafanaIntervalUnitRegex.SubexpIndex("value")], 10, 64) + if err != nil { + return MyRange{}, fmt.Errorf("Invalid format (can not parse value): interval") + } + switch matches[gGrafanaIntervalUnitRegex.SubexpIndex("unit")] { + case "ms": + step = time.Duration(value)*time.Millisecond + case "s": + step = time.Duration(value)*time.Second + case "m": + step = time.Duration(value)*time.Minute + case "h": + step = time.Duration(value)*time.Hour + case "d": + step = time.Duration(value)*time.Hour*24 + default: + return MyRange{}, fmt.Errorf("Invalid format (can not parse unit): interval") + } + + return MyRange{Start: time.Unix(args.From/1000, 0), End: time.Unix(args.To/1000, 0), Step: step}, nil +} + +func getGraph(name string) (Graph, error) { + // Acquire Read Lock. This won't prevent other queries being answered, but reloadConfig WLock will have to wait until we finish + gCfgMutex.RLock() + defer gCfgMutex.RUnlock() + + for _, g := range gGraphs { + if strings.EqualFold(g.Name, name) { + return g, nil + } + } + return Graph{}, fmt.Errorf("Graph not found: %s", name) +} + +func initRoutes(r *gin.Engine) { + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "pong", + }) + }) + + // An endpoint to force read of configuration file + r.POST("/reload", func(c *gin.Context) { + reloadConfigFile() + c.JSON(http.StatusOK, gin.H{ + "message": "configuration successfully reloaded", + }) + }) + + // An endpoint to toggle debug mode + r.POST("/debug", func(c *gin.Context) { + toggleDebug() + if gDebug { + c.JSON(http.StatusOK, gin.H{ + "message": "debug mode enabled", + }) + } else { + c.JSON(http.StatusOK, gin.H{ + "message": "debug mode disabled", + }) + } + }) + + r.GET("/:graph/nodes", func(c *gin.Context) { + // Validate presence and type + var args QueryArgs + if err := c.ShouldBindQuery(&args); err != nil { + log.Errorf("Invalid query arguments\n") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + timeRange, err := queryArgsToTimeRange(args) + if err != nil { + log.Errorf("Unable to parse query arguments\n") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + gName := c.Param("graph") + graph, err := getGraph(gName) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + nodes, err := graph.BuildMetrics(&graph.Nodes, &timeRange) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + c.JSON(http.StatusOK, nodes) + }) + + r.GET("/:graph/edges", func(c *gin.Context) { + // Validate presence and type + var args QueryArgs + if err := c.ShouldBindQuery(&args); err != nil { + log.Errorf("Invalid query arguments\n") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + timeRange, err := queryArgsToTimeRange(args) + if err != nil { + log.Errorf("Unable to parse query arguments\n") + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + gName := c.Param("graph") + graph, err := getGraph(gName) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + edges, err := graph.BuildMetrics(&graph.Edges, &timeRange) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } + c.JSON(http.StatusOK, edges) + }) +} + +func toggleDebug() { + if gDebug { + gin.SetMode(gin.ReleaseMode) + log.SetLevel(log.InfoLevel) + gDebug = false + } else { + gin.SetMode(gin.DebugMode) + log.SetLevel(log.DebugLevel) + gDebug = true + } +} + +func reloadConfigFile() { + // First reread config file + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + log.Fatalf("config file not found") + os.Exit(1) + } else { + log.Fatalf("unknown error looking for config file: %v", err) + os.Exit(1) + } + } + // then clear current config, after acquiring WriteLock + gCfgMutex.Lock() + defer gCfgMutex.Unlock() + + for _, g := range gGraphs { + g.Nodes = nil + g.Edges = nil + } + gGraphs = nil + gDataSources = nil + + // Finally unmarshal graphs, nodes, edges and datasources + gps := viper.Get("graphs").([]interface{}) + for _, g := range gps { + yd, _ := yaml.Marshal(g) + // Unmarshal on anonymous structs with []Edge and []Node well defined so unmarshaler know how to handle + // then we convert to []Items + tmp := struct { + Name string `yaml:"name"` + Nodes []Node `yaml:"nodes"` + Edges []Edge `yaml:"edges"` + }{} + yaml.Unmarshal(yd, &tmp) + var graphNodes []Item + var graphEdges []Item + for _, n := range tmp.Nodes { + graphNodes = append(graphNodes, &n) + } + for _, e := range tmp.Edges { + graphEdges = append(graphEdges, &e) + } + + graph := Graph{ + Name: tmp.Name, + Nodes: graphNodes, + Edges: graphEdges, + } + gGraphs = append(gGraphs, graph) + } + + if viper.Get("datasources") == nil { + log.Printf("no datasources found, data will be static") + return + } + dss := viper.Get("datasources").([]interface{}) + for _, d := range dss { + yd, _ := yaml.Marshal(d) + var ds PromDataSourceConfig + yaml.Unmarshal(yd, &ds) + gDataSources = append(gDataSources, ds) + } +} + +func main() { + var listen string + var confFile string + + flag.StringVar(&confFile, "config", "", "Path to the config file") + flag.StringVar(&listen, "listen-addr", "0.0.0.0:8080", "listen address for server") + flag.BoolVar(&gDebug, "debug", false, "Set log level to debug") + flag.Parse() + + if len(confFile) == 0 { + log.Fatalf("config is mandatory") + os.Exit(1) + } + + viper.SetConfigFile(confFile) + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + log.Fatalf("config file not found") + os.Exit(1) + } else { + log.Fatalf("unknown error looking for config file: %v", err) + os.Exit(1) + } + } + + if false == gDebug { + b := viper.GetBool("debug") + if b { + gDebug = b + } + } + + if gDebug { + gin.SetMode(gin.DebugMode) + log.SetLevel(log.DebugLevel) + } else { + log.SetLevel(log.InfoLevel) + gin.SetMode(gin.ReleaseMode) + } + + if strings.EqualFold(listen, "0.0.0.0:8080") && len(confFile) > 0 { + l := viper.GetString("listen") + if len(l) > 0 { + listen = l + } + } + + // FIXME: Watch config changes. Does not work on FreeBSD. TODO: Test with linux + viper.OnConfigChange(func(e fsnotify.Event) { + log.Printf("Config file changed, reloading data\n") + reloadConfigFile() + }) + + // Lets reload config on SIGHUP + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGHUP) + go func() { + for { + _ = <- sigs + log.Infof("SIGHUP received, reloading configuration\n") + reloadConfigFile() + } + }() + + reloadConfigFile() + + // Capture variable name. There should be only one variable. Space is tolerated before and after name. + gDSVarCompRegex = regexp.MustCompile(`^\{\{(?:\ )?([a-zA-Z0-9\-_]+)(?:\ )?\}\}$`) + + // Grafana interval parser + gGrafanaIntervalUnitRegex = regexp.MustCompile(`^(?P[0-9]+)(?P[mshd]+)$`) + + log.Printf("Starting NodeGopher v.%s\n", gVersion) + + r := gin.Default() + initRoutes(r) + r.Run(listen) +} diff --git a/nodes.go b/nodes.go new file mode 100644 index 0000000..236fb9d --- /dev/null +++ b/nodes.go @@ -0,0 +1,96 @@ +// Node implement Item interface + +package main + +import ( + "fmt" + "strconv" +) + +// TODO: handle arc__ and details__ fields +type Node struct { + Name string `yaml:"name" json:"name"` + Id string `yaml:"id" json:"id"` + Title string `yaml:"title" json:"title",omitempty` + Subtitle string `yaml:"subtitle,omitempty" json:"subtitle,omitempty"` + MainStat string `yaml:"mainstat,omitempty" json:"mainstat,omitempty"` + MainStatQuery string `yaml:"mainstatquery,omitempty"` + MainStatFormat string `yaml:"mainstatformat,omitempty" json:"mainstatformat,omitempty"` + SecondaryStat string `yaml:"secondarystat,omitempty" json:"secondarystat,omitempty"` + SecondaryStatQuery string `yaml:"secondarystatquery,omitempty" json:"secondarystatquery,omitempty"` + SecondaryStatFormat string `yaml:"secondarystatformat,omitempty" json:"secondarystatformat,omitempty"` + Color string `yaml:"color,omitempty" json:"color,omitempty"` + Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` + NodeRadius int `yaml:"noderadius,omitempty" json:"noderadius,omitempty"` + HighLighted bool `yaml:"highlighted,omitempty" json:"hightlighted,omitempty"` +} + +func (n Node) GetId() string { + return n.Id +} + +func (n Node) GetMainStat() string { + return n.MainStat +} + +func (n Node) GetSecondaryStat() string { + return n.SecondaryStat +} + +func (n Node) GetMainStatQuery() string { + return n.MainStatQuery +} + +func (n Node) GetSecondaryStatQuery() string { + return n.SecondaryStatQuery +} + +func (n Node) GetMainStatFormat() string { + return n.MainStatFormat +} + +func (n Node) GetSecondaryStatFormat() string { + return n.SecondaryStatFormat +} + +func (n *Node) SetMainStat(stat string) { + n.MainStat = fmt.Sprintf("%s", stat) +} + +func (n *Node) SetSecondaryStat(stat string) { + n.SecondaryStat = fmt.Sprintf("%s", stat) +} + +// Custom marshaler to not send (main|secondary)statquery +func (n Node) MarshalJSON() ([]byte, error) { + jsonRes := `{"name":"` + n.Name + `","id":"` + n.Id + `"` + if len(n.Title) > 0 { + jsonRes += `,"title":"` + n.Title + `"` + } + if len(n.Subtitle) > 0 { + jsonRes += `,"subtitle":"` + n.Subtitle + `"` + } + if len(n.MainStat) > 0 { + jsonRes += `,"mainstat":"` + n.MainStat + `"` + } + if len(n.SecondaryStat) > 0 { + jsonRes += `,"secondarystat":"` + n.SecondaryStat + `"` + } + if len(n.Color) > 0 { + jsonRes += `,"color":"` + n.Color + `"` + } + if len(n.Icon) > 0 { + jsonRes += `,"icon":"` + n.Icon + `"` + } + if n.NodeRadius > 0 { + jsonRes += `,"noderadius":` + strconv.Itoa(n.NodeRadius) + } + if n.HighLighted { + jsonRes += `,"highlighted":true` + } + // TODO : n.StrokeDashArray + jsonRes += `}` + + return []byte(jsonRes), nil +} +