├── .github └── workflows │ ├── release-helm.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yml ├── .release_info.md ├── Dockerfile ├── LICENSE ├── README.md ├── charts ├── nut-exporter │ ├── Chart.yaml │ ├── templates │ │ ├── _helper.tpl │ │ ├── dashboard-config.yaml │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── prometheus-rules.yaml │ │ ├── secret-env.yaml │ │ ├── service-monitor.yaml │ │ └── service.yaml │ └── values.yaml └── release-config.yaml ├── collectors └── nut_collector.go ├── dashboard ├── capture.png └── dashboard.json ├── go.mod ├── go.sum ├── nut_exporter.go ├── nut_exporter_test.go └── scripts ├── do_release.sh └── test.sh /.github/workflows/release-helm.yml: -------------------------------------------------------------------------------- 1 | name: Release Helm Chart 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Configure Git 20 | run: | 21 | git config user.name "$GITHUB_ACTOR" 22 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 23 | - name: Install Helm 24 | uses: azure/setup-helm@v3 25 | 26 | - name: Run chart-releaser 27 | uses: helm/chart-releaser-action@v1.6.0 28 | env: 29 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 30 | with: 31 | skip_existing: true 32 | config: charts/release-config.yaml 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | - push 4 | - pull_request 5 | 6 | env: 7 | DOCKERHUB_USERNAME: druggeri 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | strategy: 13 | matrix: 14 | go-version: 15 | - '1.23.x' 16 | os: 17 | - ubuntu-latest 18 | - macos-latest 19 | - windows-latest 20 | runs-on: ${{ matrix.os }} 21 | 22 | steps: 23 | - name: Install Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: '1.23' 27 | 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Test 32 | run: go test ./... 33 | 34 | release: 35 | needs: test 36 | name: Build and release 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | 44 | - name: Set up Go 45 | uses: actions/setup-go@v5 46 | with: 47 | go-version: '1.23' 48 | 49 | - name: Run GoReleaser 50 | uses: goreleaser/goreleaser-action@v4 51 | with: 52 | distribution: goreleaser 53 | version: latest 54 | args: build --clean --parallelism=2 --timeout=1h --skip=validate 55 | 56 | - name: Publish release 57 | uses: goreleaser/goreleaser-action@v4 58 | if: startsWith(github.ref, 'refs/tags/v') 59 | with: 60 | distribution: goreleaser 61 | version: latest 62 | args: release --clean --parallelism=2 --timeout=1h --release-notes=.release_info.md 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | containers: 67 | if: startsWith(github.ref, 'refs/tags/v') 68 | needs: release 69 | name: Push containers 70 | runs-on: ubuntu-latest 71 | steps: 72 | - name: Prep variables 73 | run: | 74 | set -u 75 | export PROJECT_NAME=${GITHUB_REPOSITORY##*/} 76 | export TAG=${GITHUB_REF##*/} 77 | 78 | echo "PROJECT_NAME=$PROJECT_NAME" >> $GITHUB_ENV 79 | echo "TAG=$TAG" >> $GITHUB_ENV 80 | echo "URL_LINUX_AMD64=https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG/${PROJECT_NAME}-${TAG}-linux-amd64" >> $GITHUB_ENV 81 | echo "URL_LINUX_ARM64=https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG/${PROJECT_NAME}-${TAG}-linux-arm64" >> $GITHUB_ENV 82 | echo "URL_LINUX_ARM=https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG/${PROJECT_NAME}-${TAG}-linux-arm" >> $GITHUB_ENV 83 | 84 | - name: Setup Docker Buildx 85 | uses: docker/setup-buildx-action@v2 86 | 87 | - name: Set up QEMU 88 | uses: docker/setup-qemu-action@v2 89 | 90 | - name: Log in to the Container registry 91 | uses: docker/login-action@v2 92 | with: 93 | registry: ghcr.io 94 | username: ${{ github.actor }} 95 | password: ${{ secrets.GITHUB_TOKEN }} 96 | 97 | - name: Login to DockerHub 98 | uses: docker/login-action@v2 99 | with: 100 | username: ${{ env.DOCKERHUB_USERNAME }} 101 | password: ${{ secrets.DOCKERHUB_TOKEN }} 102 | 103 | - name: Checkout code 104 | uses: actions/checkout@v2 105 | 106 | - name: Extract metadata (tags, labels) for Docker 107 | id: meta 108 | uses: docker/metadata-action@v4 109 | with: 110 | images: | 111 | ${{ env.DOCKERHUB_USERNAME }}/${{ env.PROJECT_NAME }} 112 | ghcr.io/${{ github.repository }} 113 | tags: | 114 | type=ref,event=branch 115 | type=semver,pattern={{version}} 116 | type=semver,pattern={{major}}.{{minor}} 117 | type=sha 118 | 119 | - name: Create scratch docker image 120 | run: | 121 | echo " 122 | FROM alpine AS builder 123 | RUN apk --no-cache add wget ca-certificates \ 124 | && if uname -m | grep 'x86_64' >/dev/null 2>&1; then wget -O /downloaded_file $URL_LINUX_AMD64;fi \ 125 | && if uname -m | grep 'aarch64' >/dev/null 2>&1; then wget -O /downloaded_file $URL_LINUX_ARM64;fi \ 126 | && if uname -m | grep 'arm' >/dev/null 2>&1; then wget -O /downloaded_file $URL_LINUX_ARM;fi \ 127 | && if [ ! -f /downloaded_file ];then echo "===Failed to download for:";uname -m;echo "===";exit 1;fi \ 128 | && chmod 755 /downloaded_file 129 | 130 | FROM scratch 131 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 132 | COPY --from=builder /downloaded_file /$PROJECT_NAME 133 | ENTRYPOINT [\"/$PROJECT_NAME\"] 134 | " > Dockerfile 135 | 136 | echo "Rendered Dockerfile in $PWD:" 137 | cat Dockerfile 138 | 139 | - name: Build and push Docker images 140 | uses: docker/build-push-action@v4 141 | with: 142 | context: . 143 | platforms: linux/amd64,linux/arm64,linux/arm 144 | push: true 145 | tags: ${{ steps.meta.outputs.tags }} 146 | labels: ${{ steps.meta.outputs.labels }} 147 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *_exporter 2 | scripts/github_api_token 3 | scripts/nut_exporter* 4 | tmp/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | builds: 7 | - env: 8 | - CGO_ENABLED=0 9 | mod_timestamp: '{{ .CommitTimestamp }}' 10 | flags: 11 | - -trimpath 12 | ldflags: 13 | - '-X main.Version={{ .Version }} -X main.Commit={{ .Commit }}' 14 | goos: 15 | - freebsd 16 | - windows 17 | - linux 18 | - darwin 19 | goarch: 20 | - amd64 21 | - '386' 22 | - arm 23 | - arm64 24 | #- riskv64 25 | ignore: 26 | - goos: darwin 27 | goarch: '386' 28 | 29 | archives: 30 | - format: binary 31 | name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}" 32 | release: 33 | draft: false 34 | -------------------------------------------------------------------------------- /.release_info.md: -------------------------------------------------------------------------------- 1 | ## New 2 | - Allow setting log level and formatting of logs as JSON - see README.md for new parameters 3 | 4 | ## Potential Breaking Changes 5 | - The changes to the logging in this release may break monitoring systems watching the output of the logs. Everything is now formatted with the go slog package which uses keys and values with the new option of JSON formatting 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### STAGE 1: Build ### 2 | 3 | FROM golang:1-alpine as builder 4 | 5 | WORKDIR /app 6 | COPY . /app 7 | RUN go install 8 | 9 | ### STAGE 2: Setup ### 10 | 11 | FROM alpine 12 | RUN apk add --no-cache \ 13 | libc6-compat 14 | COPY --from=builder /go/bin/nut_exporter /nut_exporter 15 | RUN chmod +x /nut_exporter 16 | ENTRYPOINT ["/nut_exporter"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Daniel Ruggeri 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Network UPS Tools (NUT) Prometheus Exporter 2 | 3 | A [Prometheus](https://prometheus.io) exporter for the Network UPS Tools server. This exporter utilizes the [go.nut](https://github.com/robbiet480/go.nut) project as a network client of the NUT platform. The exporter is written in a way to permit an administrator to scrape one or many UPS devices visible to a NUT client as well as one or all NUT variables. A single instance of this exporter can scrape one or many NUT servers as well. 4 | 5 | A sample [dashboard](dashboard/dashboard.json) for Grafana is also available 6 | ![dashboard](dashboard/capture.png) 7 | 8 | ## Variables and information 9 | The variables exposed to a NUT client by the NUT system are the lifeblood of a deployment. These variables are consumed by this exporter and coaxed to Prometheus types. 10 | 11 | * See the [NUT documentation](https://networkupstools.org/docs/user-manual.chunked/_variables.html) for a list of all possible variables 12 | * Variables are set as prometheus metrics with the `ups` name added as a lable. Example: `ups.load` is set as `network_ups_tools_ups_load 100` 13 | * The exporter SHOULD be called with the ups to scrape set in the query string. Example: `https://127.0.0.1:9199/ups_metrics?ups=foo` 14 | * If the exporter scrapes NUT and detects more than one UPS, it is an error condition that will fail the scrape. In this case, use a variant of the scrape config example below for your environment 15 | * Default configs usually permit reading variables without authentication. If you have disabled this, see the Usage below to set credentials 16 | * This exporter will always export the device.* metrics as labels, except for uptime, with a constant value of 1 17 | * Setting the `nut.vars_enable` parameter to an empty string will cause all numeric variables to be exported 18 | * NUT may return strings as values for some variables. Prometheus supports only float values, so the `on_regex` and `off_regex` parameters can be used to convert these to 0 or 1 in some cases 19 | * Not all driver and UPS implementations provide all variables. Run this exporter with log.level at debug or use the `LIST VAR` upsc command to see available variables for your UPS 20 | * All number-like values are coaxed to the appropriate go type by the library and are set as the value of the exported metric 21 | * Boolean values are coaxed to 0 (false) or 1 (true) 22 | 23 | ### ups.status handling 24 | The special `ups.status` variable is returned by NUT as a string containing a list of status flags. 25 | There may be one or more flags set depending on the driver in use and the current state of the UPS. 26 | For example, `OL TRIM CHRG` indicates the UPS is online, stepping down incoming voltage and charging the battery. 27 | 28 | The metric `network_ups_tools_ups_status` will be set with a label for each flag returned with a constant value of `1` 29 | 30 | **Example** 31 | The above example will coax `OL TRIM CHRG` to... 32 | ``` 33 | network_ups_tools_ups_status{flag="OL"} 1 34 | network_ups_tools_ups_status{flag="TRIM"} 1 35 | network_ups_tools_ups_status{flag="CHRG"} 1 36 | ``` 37 | 38 | The exporter supports the `--nut.statuses` flag to allow you to force certain statuses to be exported at all times, regardless of whether NUT reports the status. 39 | This defaults to the below known list of statuses. 40 | 41 | **Example** 42 | Without changing the defaults, the status of `OL TRIM CHRG` will cause the following labels and values to be exported: 43 | ``` 44 | network_ups_tools_ups_status{flag="OL"} 1 45 | network_ups_tools_ups_status{flag="TRIM"} 1 46 | network_ups_tools_ups_status{flag="CHRG"} 1 47 | network_ups_tools_ups_status{flag="OB"} 0 48 | network_ups_tools_ups_status{flag="LB"} 0 49 | network_ups_tools_ups_status{flag="HB"} 0 50 | network_ups_tools_ups_status{flag="RB"} 0 51 | network_ups_tools_ups_status{flag="DISCHRG"} 0 52 | network_ups_tools_ups_status{flag="BYPASS"} 0 53 | network_ups_tools_ups_status{flag="CAL"} 0 54 | network_ups_tools_ups_status{flag="OFF"} 0 55 | network_ups_tools_ups_status{flag="OVER"} 0 56 | network_ups_tools_ups_status{flag="BOOST"} 0 57 | network_ups_tools_ups_status{flag="FSD"} 0 58 | network_ups_tools_ups_status{flag="SD"} 0 59 | ``` 60 | Because each UPS differs, it is advisable to observe your UPS under various conditions to know which of these statuses will never apply. 61 | 62 | 63 | #### Alerting on ups.status 64 | **IMPORTANT NOTE:** Not all UPSs utilize all values! What is reported by NUT depends greatly on the driver and the intelligence of the UPS. 65 | It is strongly suggested to observe your UPS under both "normal" and "abnormal" conditions to know what to expect NUT will report. 66 | 67 | As noted above, the UPS status is a special case and is handled with flags set as labels on the `network_ups_tools_ups_status` metric. Therefore, alerting can be configured for specific statuses. Examples: 68 | * **Alert if the UPS has exited 'online' mode**: `network_ups_tools_ups_status{flag="OL"} == 0` 69 | * **Alert if the UPS has gone on battery**: `network_ups_tools_ups_status{flag="OB"} == 1` 70 | * **Alert if any status changed in the past 5 minutes** `changes(network_ups_tools_ups_status[5m]) > 0` 71 | 72 | Unfortunately, the NUT documentation does not call out the full list of statuses each driver implements nor what a user can expect for a status. 73 | The following values were detected in the [NUT driver documentation](https://github.com/networkupstools/nut/blob/master/docs/new-drivers.txt): 74 | * OL - On line (mains is present) 75 | * OB - On battery (mains is not present) 76 | * LB - Low battery 77 | * HB - High battery 78 | * RB - The battery needs to be replaced 79 | * CHRG - The battery is charging 80 | * DISCHRG - The battery is discharging (inverter is providing load power) 81 | * BYPASS - UPS bypass circuit is active -- no battery protection is available 82 | * CAL - UPS is currently performing runtime calibration (on battery) 83 | * OFF - UPS is offline and is not supplying power to the load 84 | * OVER - UPS is overloaded 85 | * TRIM - UPS is trimming incoming voltage (called "buck" in some hardware) 86 | * BOOST - UPS is boosting incoming voltage 87 | * FSD and SD - Forced Shutdown 88 | Therefore, these are all enabled in the default value for `--nut.statuses`. 89 | 90 | ### Query String Parameters 91 | The exporter allows for per-scrape overrides of command line parameters by passing query string parameters. This enables a single nut_exporter to scrape multiple NUT servers 92 | 93 | The following query string parameters can be passed to the `/ups_metrics` path: 94 | * `ups` - Required if more than one UPS is present in NUT 95 | * `server` - Overrides the command line parameter `--nut.server` 96 | * `username` - Overrides the command line parameter `--nut.username` 97 | * `password` - Overrides the environment variable NUT_EXPORTER_PASSWORD. It is **strongly** recommended to avoid passing credentials over http unless the exporter is configured with TLS 98 | * `variables` - Overrides the command line parameter `--nut.vars_enable` 99 | * `statuses` - Overrides the command line parameter `--nut.statuses` 100 | See the example scrape configurations below for how to utilize this capability 101 | 102 | ### Example Prometheus Scrape Configurations 103 | Note that this exporter will scrape only one UPS per scrape invocation. If there are multiple UPS devices visible to NUT, you MUST ensure that you set up different scrape configs for each UPS device. Here is an example configuration for such a use case: 104 | 105 | ``` 106 | - job_name: nut-primary 107 | metrics_path: /ups_metrics 108 | static_configs: 109 | - targets: ['myserver:9199'] 110 | labels: 111 | ups: "primary" 112 | params: 113 | ups: [ "primary" ] 114 | - job_name: nut-secondary 115 | metrics_path: /ups_metrics 116 | static_configs: 117 | - targets: ['myserver:9199'] 118 | labels: 119 | ups: "secondary" 120 | params: 121 | ups: [ "secondary" ] 122 | ``` 123 | 124 | You can also configure a single exporter to scrape several NUT servers like so: 125 | ``` 126 | - job_name: nut-primary 127 | metrics_path: /ups_metrics 128 | static_configs: 129 | - targets: ['exporterserver:9199'] 130 | labels: 131 | ups: "primary" 132 | params: 133 | ups: [ "primary" ] 134 | server: [ "nutserver1" ] 135 | - job_name: nut-secondary 136 | metrics_path: /ups_metrics 137 | static_configs: 138 | - targets: ['exporterserver:9199'] 139 | labels: 140 | ups: "secondary" 141 | params: 142 | ups: [ "secondary" ] 143 | server: [ "nutserver2" ] 144 | ``` 145 | 146 | Or use a more robust relabel config similar to the [snmp_exporter](https://github.com/prometheus/snmp_exporter) (thanks to @sshaikh for the example): 147 | ``` 148 | - job_name: ups 149 | static_configs: 150 | - targets: ['server1','server2'] # nut exporter 151 | metrics_path: /ups_metrics 152 | relabel_configs: 153 | - source_labels: [__address__] 154 | target_label: __param_server 155 | - source_labels: [__param_server] 156 | target_label: instance 157 | - target_label: __address__ 158 | replacement: nut-exporter.local:9199 159 | ``` 160 | 161 |   162 | 163 | ## Installation 164 | 165 | ### Binaries 166 | 167 | Download the already existing [binaries](https://github.com/DRuggeri/nut_exporter/releases) for your platform: 168 | 169 | ```bash 170 | $ ./nut_exporter 171 | ``` 172 | 173 | ### From source 174 | 175 | Using the standard `go install` (you must have [Go](https://golang.org/) already installed in your local machine): 176 | 177 | ```bash 178 | $ go install github.com/DRuggeri/nut_exporter 179 | $ nut_exporter 180 | ``` 181 | 182 | ### With Docker 183 | An official scratch-based Docker image is built with every tag and pushed to DockerHub and ghcr. Additionally, PRs will be tested by GitHubs actions. 184 | 185 | The following images are available for use: 186 | - [druggeri/nut_exporter](https://hub.docker.com/r/druggeri/nut_exporter) 187 | - [ghcr.io/DRuggeri/nut_exporter](https://ghcr.io/DRuggeri/nut_exporter) 188 | 189 |   190 | 191 | ## Usage 192 | 193 | ### Flags 194 | 195 | ``` 196 | usage: nut_exporter [] 197 | 198 | 199 | Flags: 200 | -h, --[no-]help Show context-sensitive help (also try --help-long and --help-man). 201 | --nut.server="127.0.0.1" Hostname or IP address of the server to connect to. ($NUT_EXPORTER_SERVER) ($NUT_EXPORTER_SERVER) 202 | --nut.serverport=3493 Port on the NUT server to connect to. ($NUT_EXPORTER_SERVERPORT) ($NUT_EXPORTER_SERVERPORT) 203 | --nut.username=NUT.USERNAME 204 | If set, will authenticate with this username to the server. Password must be set in NUT_EXPORTER_PASSWORD environment variable. ($NUT_EXPORTER_USERNAME) 205 | ($NUT_EXPORTER_USERNAME) 206 | --[no-]nut.disable_device_info 207 | A flag to disable the generation of the device_info meta metric. ($NUT_EXPORTER_DISABLE_DEVICE_INFO) ($NUT_EXPORTER_DISABLE_DEVICE_INFO) 208 | --nut.vars_enable="battery.charge,battery.voltage,battery.voltage.nominal,input.voltage,input.voltage.nominal,ups.load,ups.status" 209 | A comma-separated list of variable names to monitor. See the variable notes in README. ($NUT_EXPORTER_VARIABLES) ($NUT_EXPORTER_VARIABLES) 210 | --nut.on_regex="^(enable|enabled|on|true|active|activated)$" 211 | This regular expression will be used to determine if the var's value should be coaxed to 1 if it is a string. Match is case-insensitive. ($NUT_EXPORTER_ON_REGEX) 212 | ($NUT_EXPORTER_ON_REGEX) 213 | --nut.off_regex="^(disable|disabled|off|false|inactive|deactivated)$" 214 | This regular expression will be used to determine if the var's value should be coaxed to 0 if it is a string. Match is case-insensitive. ($NUT_EXPORTER_OFF_REGEX) 215 | ($NUT_EXPORTER_OFF_REGEX) 216 | --nut.statuses="OL,OB,LB,HB,RB,CHRG,DISCHRG,BYPASS,CAL,OFF,OVER,TRIM,BOOST,FSD,SD" 217 | A comma-separated list of statuses labels that will always be set by the exporter. If NUT does not set these flags, the exporter will force the 218 | network_ups_tools_ups_status{flag="NAME"} to 0. See the ups.status notes in README.' ($NUT_EXPORTER_STATUSES) ($NUT_EXPORTER_STATUSES) 219 | --metrics.namespace="network_ups_tools" 220 | Metrics Namespace ($NUT_EXPORTER_METRICS_NAMESPACE) ($NUT_EXPORTER_METRICS_NAMESPACE) 221 | --[no-]web.systemd-socket Use systemd socket activation listeners instead of port listeners (Linux only). 222 | --web.listen-address=:9199 ... 223 | Addresses on which to expose metrics and web interface. Repeatable for multiple addresses. Examples: `:9100` or `[::1]:9100` for http, `vsock://:9100` for vsock 224 | --web.config.file="" Path to configuration file that can enable TLS or authentication. See: https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md 225 | --web.telemetry-path="/ups_metrics" 226 | Path under which to expose the UPS Prometheus metrics ($NUT_EXPORTER_WEB_TELEMETRY_PATH) ($NUT_EXPORTER_WEB_TELEMETRY_PATH) 227 | --web.exporter-telemetry-path="/metrics" 228 | Path under which to expose process metrics about this exporter ($NUT_EXPORTER_WEB_EXPORTER_TELEMETRY_PATH) ($NUT_EXPORTER_WEB_EXPORTER_TELEMETRY_PATH) 229 | --[no-]printMetrics Print the metrics this exporter exposes and exits. Default: false ($NUT_EXPORTER_PRINT_METRICS) ($NUT_EXPORTER_PRINT_METRICS) 230 | --log.level="info" Minimum log level for messages. One of error, warn, info, or debug. Default: info ($NETGEAR_EXPORTER_LOG_LEVEL) ($NUT_EXPORTER__LOG_LEVEL) 231 | --[no-]log.json Format log lines as JSON. Default: false ($NETGEAR_EXPORTER_LOG_JSON) ($NUT_EXPORTER__LOG_JSON) 232 | --[no-]version Show application version. 233 | ``` 234 | 235 |   236 | 237 | ## TLS and basic authentication 238 | 239 | The NUT Exporter supports TLS and basic authentication. 240 | 241 | To use TLS and/or basic authentication, you need to pass a configuration file 242 | using the `--web.config.file` parameter. The format of the file is described 243 | [in the exporter-toolkit repository](https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md). 244 | 245 | ## Metrics 246 | 247 | ### NUT 248 | This collector is the workhorse of the exporter. Default metrics are exported for the device and scrape stats. The `network_ups_tools_ups_variable` metric is exported with labels of `ups` and `variable` with the value set as noted in the README 249 | 250 | ``` 251 | network_ups_tools_device_info - UPS device information 252 | network_ups_tools_VARIABLE_NAME - Variable from Network UPS Tools as noted in the variable notes above 253 | ``` 254 | 255 | ## Helm Chart 256 | To install the [Helm](https://helm.sh/docs/) chart into a Kubernetes cluster run: 257 | ```sh 258 | helm repo add nut-exporter https://github.com/DRuggeri/nut_exporter 259 | helm install nut-exporter/nut-exporter nut-exporter 260 | ``` 261 | -------------------------------------------------------------------------------- /charts/nut-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | description: Installs NUT exporter in kubernetes 3 | name: nut-exporter 4 | version: 1.0.0 5 | appVersion: "3.1" 6 | sources: 7 | - https://github.com/DRuggeri/nut_exporter -------------------------------------------------------------------------------- /charts/nut-exporter/templates/_helper.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "nut-exporter.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "nut-exporter.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | 27 | {{/* 28 | Create chart name and version as used by the chart label. 29 | */}} 30 | {{- define "nut-exporter.chart" -}} 31 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 32 | {{- end }} 33 | 34 | {{/* 35 | Common labels 36 | */}} 37 | {{- define "nut-exporter.labels" -}} 38 | helm.sh/chart: {{ include "nut-exporter.chart" . }} 39 | {{ include "nut-exporter.selectorLabels" . }} 40 | {{- if .Chart.AppVersion }} 41 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 42 | {{- end }} 43 | app.kubernetes.io/managed-by: {{ .Release.Service }} 44 | {{- end }} 45 | 46 | {{/* 47 | Selector labels 48 | */}} 49 | {{- define "nut-exporter.selectorLabels" -}} 50 | app.kubernetes.io/name: {{ include "nut-exporter.name" . }} 51 | app.kubernetes.io/instance: {{ .Release.Name }} 52 | {{- end }} 53 | -------------------------------------------------------------------------------- /charts/nut-exporter/templates/dashboard-config.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.dashboard.enabled }} 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ include "nut-exporter.fullname" . }}-dashboards 6 | labels: 7 | {{- include "nut-exporter.labels" . | nindent 4 }} 8 | {{- toYaml .Values.dashboard.labels | nindent 4 }} 9 | {{- with $.Values.dashboard.namespace }} 10 | namespace: {{ . }} 11 | {{- end }} 12 | data: 13 | nutdashboard.json: |- 14 | {{ $.Files.Get "dashboards/default.json" | nindent 4 }} 15 | --- 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /charts/nut-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "nut-exporter.fullname" . }} 5 | labels: 6 | {{- include "nut-exporter.labels" . | nindent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | {{- include "nut-exporter.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | {{- with .Values.podAnnotations }} 15 | annotations: 16 | {{- toYaml . | nindent 8 }} 17 | {{- end }} 18 | labels: 19 | {{- include "nut-exporter.labels" . | nindent 8 }} 20 | spec: 21 | hostNetwork: {{ .Values.podHostNetwork }} 22 | securityContext: 23 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 24 | containers: 25 | - name: nut-exporter 26 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 27 | imagePullPolicy: {{ .Values.image.pullPolicy }} 28 | securityContext: 29 | {{- toYaml .Values.securityContext | nindent 12 }} 30 | {{- if .Values.envSecret }} 31 | envFrom: 32 | - secretRef: 33 | name: {{ include "nut-exporter.fullname" . }}-env 34 | {{- end }} 35 | {{- with .Values.env }} 36 | env: 37 | {{- toYaml . | nindent 12 }} 38 | {{- end }} 39 | {{- with .Values.extraArgs }} 40 | args: 41 | {{- toYaml . | nindent 12 }} 42 | {{- end }} 43 | ports: 44 | - containerPort: 9199 45 | name: http 46 | protocol: TCP 47 | livenessProbe: 48 | {{- toYaml .Values.livenessProbe | nindent 12 }} 49 | readinessProbe: 50 | {{- toYaml .Values.readinessProbe | nindent 12 }} 51 | resources: 52 | {{- toYaml .Values.resources | nindent 12 }} 53 | {{- with .Values.affinity }} 54 | affinity: 55 | {{- toYaml . | nindent 8 }} 56 | {{- end }} 57 | {{- with .Values.nodeSelector }} 58 | nodeSelector: 59 | {{- toYaml . | nindent 8 }} 60 | {{- end }} 61 | {{- with .Values.tolerations }} 62 | tolerations: 63 | {{- toYaml . | nindent 8 }} 64 | {{- end }} 65 | {{- with .Values.imagePullSecrets }} 66 | imagePullSecrets: 67 | {{- toYaml . | nindent 8 }} 68 | {{- end }} 69 | -------------------------------------------------------------------------------- /charts/nut-exporter/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: {{ include "nut-exporter.fullname" . }} 6 | labels: 7 | {{- include "nut-exporter.labels" . | nindent 4 }} 8 | {{- with .Values.ingress.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | spec: 13 | {{- with .Values.ingress.className }} 14 | ingressClassName: {{ . }} 15 | {{- end }} 16 | {{- if .Values.ingress.tls }} 17 | tls: 18 | {{- range .Values.ingress.tls }} 19 | - hosts: 20 | {{- range .hosts }} 21 | - {{ . | quote }} 22 | {{- end }} 23 | secretName: {{ .secretName }} 24 | {{- end }} 25 | {{- end }} 26 | rules: 27 | {{- range .Values.ingress.hosts }} 28 | - host: {{ .host | quote }} 29 | http: 30 | paths: 31 | {{- range .paths }} 32 | - path: {{ .path }} 33 | {{- with .pathType }} 34 | pathType: {{ . }} 35 | {{- end }} 36 | backend: 37 | service: 38 | name: {{ include "nut-exporter.fullname" $ }} 39 | port: 40 | number: {{ $.Values.service.port }} 41 | {{- end }} 42 | {{- end }} 43 | {{- end }} 44 | -------------------------------------------------------------------------------- /charts/nut-exporter/templates/prometheus-rules.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rules.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: PrometheusRule 4 | metadata: 5 | name: {{ include "nut-exporter.fullname" . }}-rules 6 | labels: 7 | {{- include "nut-exporter.labels" . | nindent 4 }} 8 | {{- with .Values.rules.labels }} 9 | {{- toYaml . | nindent 4 }} 10 | {{- end }} 11 | {{- with $.Values.rules.namespace }} 12 | namespace: {{ . }} 13 | {{- end }} 14 | spec: 15 | groups: 16 | - name: NutExporter 17 | rules: 18 | {{- toYaml .Values.rules.rules | nindent 6 }} 19 | {{- end }} 20 | -------------------------------------------------------------------------------- /charts/nut-exporter/templates/secret-env.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.envSecret }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ include "nut-exporter.fullname" . }}-env 6 | labels: 7 | {{- include "nut-exporter.labels" . | nindent 4 }} 8 | stringData: 9 | {{- range $key, $val := .Values.envSecret }} 10 | {{ $key }}: {{ $val | quote }} 11 | {{- end }} 12 | 13 | {{- end }} 14 | -------------------------------------------------------------------------------- /charts/nut-exporter/templates/service-monitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | labels: 6 | {{- include "nut-exporter.labels" . | nindent 4 }} 7 | {{- with .Values.serviceMonitor.labels }} 8 | {{- toYaml . | nindent 4 }} 9 | {{- end }} 10 | name: {{ include "nut-exporter.fullname" . }} 11 | {{- with $.Values.serviceMonitor.namespace }} 12 | namespace: {{ . }} 13 | {{- end }} 14 | spec: 15 | endpoints: 16 | - interval: 15s 17 | {{- with $.Values.serviceMonitor.metricRelabelings }} 18 | metricRelabelings: 19 | {{ toYaml . | nindent 6}} 20 | {{- end }} 21 | {{- with $.Values.serviceMonitor.relabelings }} 22 | relabelings: 23 | {{ toYaml . | nindent 6}} 24 | {{- end }} 25 | path: /ups_metrics 26 | port: http 27 | scheme: http 28 | jobLabel: nut-exporter 29 | namespaceSelector: 30 | matchNames: 31 | - {{ .Release.Namespace }} 32 | selector: 33 | matchLabels: 34 | {{- include "nut-exporter.selectorLabels" . | nindent 6 }} 35 | {{- end }} 36 | -------------------------------------------------------------------------------- /charts/nut-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | {{- include "nut-exporter.labels" . | nindent 4 }} 6 | name: {{ include "nut-exporter.fullname" . }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | selector: 15 | {{- include "nut-exporter.selectorLabels" . | nindent 4 }} 16 | -------------------------------------------------------------------------------- /charts/nut-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | repository: ghcr.io/druggeri/nut_exporter 3 | pullPolicy: IfNotPresent 4 | # Overrides the image tag whose default is the chart appVersion. 5 | tag: "" 6 | 7 | replicaCount: 1 8 | 9 | imagePullSecrets: [] 10 | nameOverride: "" 11 | fullnameOverride: "" 12 | 13 | dashboard: 14 | enabled: false 15 | namespace: "" 16 | labels: 17 | # Label that config maps with dashboards should have to be added for the Grafana helm chart 18 | # https://github.com/grafana/helm-charts/blob/main/charts/grafana/README.md 19 | grafana_dashboard: "1" 20 | 21 | serviceMonitor: 22 | enabled: false 23 | namespace: "" 24 | labels: {} 25 | # key: value 26 | relabelings: [] 27 | # - replacement: "My UPS" 28 | # targetLabel: ups 29 | 30 | extraArgs: [] 31 | # - --log.level=debug 32 | 33 | envSecret: 34 | NUT_EXPORTER_PASSWORD: "mypasswd" 35 | 36 | env: 37 | - name: NUT_EXPORTER_SERVER 38 | value: "127.0.0.1" 39 | - name: NUT_EXPORTER_USERNAME 40 | value: "admin" 41 | # - name: NUT_EXPORTER_USERNAME 42 | # valueFrom: 43 | # secretKeyRef: 44 | # name: nut-credentials 45 | # key: username 46 | # - name: NUT_EXPORTER_PASSWORD 47 | # valueFrom: 48 | # secretKeyRef: 49 | # name: nut-credentials 50 | # key: password 51 | 52 | 53 | nodeSelector: {} 54 | # has-ups-server: yes 55 | 56 | tolerations: [] 57 | # - key: node-role.kubernetes.io/master 58 | # operator: "Exists" 59 | # effect: NoSchedule 60 | 61 | podAnnotations: 62 | prometheus.io/scrape: "false" 63 | prometheus.io/path: "/ups_metrics" 64 | prometheus.io/port: "9199" 65 | 66 | podSecurityContext: {} 67 | # fsGroup: 2000 68 | 69 | securityContext: {} 70 | # privileged: true 71 | # capabilities: 72 | # drop: 73 | # - ALL 74 | # readOnlyRootFilesystem: true 75 | # runAsNonRoot: true 76 | # runAsUser: 1000 77 | 78 | podHostNetwork: false 79 | 80 | service: 81 | type: ClusterIP 82 | port: 9199 83 | 84 | # This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ 85 | ingress: 86 | enabled: false 87 | className: "" 88 | annotations: {} 89 | # kubernetes.io/ingress.class: nginx 90 | # kubernetes.io/tls-acme: "true" 91 | hosts: 92 | - host: chart-nut-exporter.local 93 | paths: 94 | - path: / 95 | pathType: ImplementationSpecific 96 | tls: [] 97 | # - secretName: chart-nut-exporter-tls 98 | # hosts: 99 | # - chart-nut-exporter.local 100 | 101 | livenessProbe: 102 | httpGet: 103 | path: /ups_metrics 104 | port: http 105 | initialDelaySeconds: 10 106 | failureThreshold: 5 107 | timeoutSeconds: 2 108 | 109 | readinessProbe: 110 | httpGet: 111 | path: /ups_metrics 112 | port: http 113 | initialDelaySeconds: 10 114 | failureThreshold: 5 115 | timeoutSeconds: 2 116 | 117 | resources: {} 118 | # We usually recommend not to specify default resources and to leave this as a conscious 119 | # choice for the user. This also increases chances charts run on environments with little 120 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 121 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 122 | # limits: 123 | # cpu: 100m 124 | # memory: 128Mi 125 | # requests: 126 | # cpu: 100m 127 | # memory: 128Mi 128 | 129 | rules: 130 | enabled: false 131 | namespace: "" 132 | labels: {} 133 | # key: value 134 | rules: 135 | - alert: UPSBatteryNeedsReplacement 136 | annotations: 137 | message: '{{ $labels.ups }} is indicating a need for a battery replacement.' 138 | expr: network_ups_tools_ups_status{flag="RB"} != 0 139 | for: 60s 140 | labels: 141 | severity: high 142 | - alert: UPSLowBattery 143 | annotations: 144 | message: '{{ $labels.ups }} has low battery and is running on backup. Expect shutdown soon' 145 | expr: network_ups_tools_ups_status{flag="LB"} == 0 and network_ups_tools_ups_status{flag="OL"} == 0 146 | for: 60s 147 | labels: 148 | severity: critical 149 | - alert: UPSRuntimeShort 150 | annotations: 151 | message: '{{ $labels.ups }} has only {{ $value | humanizeDuration}} of battery autonomy' 152 | expr: network_ups_tools_battery_runtime < 300 153 | for: 30s 154 | labels: 155 | severity: high 156 | - alert: UPSMainPowerOutage 157 | annotations: 158 | message: '{{ $labels.ups }} has no main power and is running on backup.' 159 | expr: network_ups_tools_ups_status{flag="OL"} == 0 160 | for: 60s 161 | labels: 162 | severity: critical 163 | - alert: UPSIndicatesWarningStatus 164 | annotations: 165 | message: '{{ $labels.ups }} is indicating a need for a battery replacement.' 166 | expr: network_ups_tools_ups_status{flag="HB"} != 0 167 | for: 60s 168 | labels: 169 | severity: warning 170 | -------------------------------------------------------------------------------- /charts/release-config.yaml: -------------------------------------------------------------------------------- 1 | release-name-template: "helm-{{ .Name }}-{{ .Version }}" 2 | -------------------------------------------------------------------------------- /collectors/nut_collector.go: -------------------------------------------------------------------------------- 1 | package collectors 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/prometheus/client_golang/prometheus" 10 | nut "github.com/robbiet480/go.nut" 11 | ) 12 | 13 | var deviceLabels = []string{"model", "mfr", "serial", "type", "description", "contact", "location", "part", "macaddr"} 14 | 15 | type NutCollector struct { 16 | deviceDesc *prometheus.Desc 17 | logger *slog.Logger 18 | opts *NutCollectorOpts 19 | onRegex *regexp.Regexp 20 | offRegex *regexp.Regexp 21 | } 22 | 23 | type NutCollectorOpts struct { 24 | Namespace string 25 | Server string 26 | ServerPort int 27 | Ups string 28 | Username string 29 | Password string 30 | Variables []string 31 | Statuses []string 32 | OnRegex string 33 | OffRegex string 34 | DisableDeviceInfo bool 35 | } 36 | 37 | func NewNutCollector(opts NutCollectorOpts, logger *slog.Logger) (*NutCollector, error) { 38 | deviceDesc := prometheus.NewDesc(prometheus.BuildFQName(opts.Namespace, "", "device_info"), 39 | "UPS Device information", 40 | deviceLabels, nil, 41 | ) 42 | if opts.DisableDeviceInfo { 43 | deviceDesc = nil 44 | } 45 | 46 | var onRegex, offRegex *regexp.Regexp 47 | var err error 48 | 49 | if opts.OnRegex != "" { 50 | onRegex, err = regexp.Compile(fmt.Sprintf("(?i)%s", opts.OnRegex)) 51 | if err != nil { 52 | return nil, err 53 | } 54 | } 55 | 56 | if opts.OffRegex != "" { 57 | offRegex, err = regexp.Compile(fmt.Sprintf("(?i)%s", opts.OffRegex)) 58 | if err != nil { 59 | return nil, err 60 | } 61 | } 62 | 63 | collector := &NutCollector{ 64 | deviceDesc: deviceDesc, 65 | logger: logger, 66 | opts: &opts, 67 | onRegex: onRegex, 68 | offRegex: offRegex, 69 | } 70 | 71 | if opts.Ups != "" { 72 | valid, err := collector.IsValidUPSName(opts.Ups) 73 | if err != nil { 74 | logger.Warn("Error detected while verifying UPS name - proceeding without validation", "error", err) 75 | } else if !valid { 76 | return nil, fmt.Errorf("%s UPS is not a valid name in the NUT server %s", opts.Ups, opts.Server) 77 | } 78 | } 79 | 80 | logger.Info("collector configured", "variables", strings.Join(collector.opts.Variables, ",")) 81 | return collector, nil 82 | } 83 | 84 | func (c *NutCollector) Collect(ch chan<- prometheus.Metric) { 85 | c.logger.Debug("Connecting to server", "server", c.opts.Server, "port", c.opts.ServerPort) 86 | client, err := nut.Connect(c.opts.Server, c.opts.ServerPort) 87 | if err != nil { 88 | c.logger.Error("failed connecting to server", "err", err) 89 | ch <- prometheus.NewInvalidMetric( 90 | prometheus.NewDesc(prometheus.BuildFQName(c.opts.Namespace, "", "error"), 91 | "Failure gathering UPS variables", nil, nil), 92 | err) 93 | return 94 | } 95 | 96 | defer client.Disconnect() 97 | c.logger.Debug("Connected to server", "server", c.opts.Server) 98 | 99 | if c.opts.Username != "" && c.opts.Password != "" { 100 | _, err = client.Authenticate(c.opts.Username, c.opts.Password) 101 | if err == nil { 102 | c.logger.Debug("Authenticated", "server", c.opts.Server, "user", c.opts.Username) 103 | } else { 104 | c.logger.Warn("Failed to authenticate to NUT server", "server", c.opts.Server, "user", c.opts.Username) 105 | //Don't bail after logging the warning. Most NUT configurations do not require authn to read variables 106 | } 107 | } 108 | 109 | upsList := []nut.UPS{} 110 | if c.opts.Ups != "" { 111 | ups, err := nut.NewUPS(c.opts.Ups, &client) 112 | if err == nil { 113 | c.logger.Debug("Instantiated UPS", "name", c.opts.Ups) 114 | upsList = append(upsList, ups) 115 | } else { 116 | c.logger.Error("Failure instantiating the UPS", "name", c.opts.Ups, "err", err) 117 | ch <- prometheus.NewInvalidMetric( 118 | prometheus.NewDesc(prometheus.BuildFQName(c.opts.Namespace, "", "error"), 119 | "Failure instantiating the UPS", nil, nil), 120 | err) 121 | return 122 | } 123 | } else { 124 | tmp, err := client.GetUPSList() 125 | if err == nil { 126 | c.logger.Debug("Obtained list of UPS devices") 127 | upsList = tmp 128 | for _, ups := range tmp { 129 | c.logger.Debug("UPS name detection", "name", ups.Name) 130 | } 131 | } else { 132 | c.logger.Error("Failure getting the list of UPS devices", "err", err) 133 | ch <- prometheus.NewInvalidMetric( 134 | prometheus.NewDesc(prometheus.BuildFQName(c.opts.Namespace, "", "error"), 135 | "Failure getting the list of UPS devices", nil, nil), 136 | err) 137 | return 138 | } 139 | } 140 | 141 | if len(upsList) > 1 { 142 | c.logger.Error("Multiple UPS devices were found by NUT for this scrape. For this configuration, you MUST scrape this exporter with a query string parameter indicating which UPS to scrape. Valid values of ups are:") 143 | for _, ups := range upsList { 144 | c.logger.Error(ups.Name) 145 | } 146 | ch <- prometheus.NewInvalidMetric( 147 | prometheus.NewDesc(prometheus.BuildFQName(c.opts.Namespace, "", "error"), 148 | "Multiple UPS devices were found from NUT. Please add a ups= query string", nil, nil), 149 | err) 150 | return 151 | } else { 152 | //Set the name so subsequent scrapes don't have to look it up 153 | c.opts.Ups = upsList[0].Name 154 | } 155 | 156 | for _, ups := range upsList { 157 | device := make(map[string]string) 158 | for _, label := range deviceLabels { 159 | device[label] = "" 160 | } 161 | 162 | c.logger.Debug( 163 | "UPS info", 164 | "name", ups.Name, 165 | "description", ups.Description, 166 | "master", ups.Master, 167 | "nmumber_of_logins", ups.NumberOfLogins, 168 | ) 169 | for i, clientName := range ups.Clients { 170 | c.logger.Debug(fmt.Sprintf("client %d", i), "name", clientName) 171 | } 172 | for _, command := range ups.Commands { 173 | c.logger.Debug("ups command", "command", command.Name, "description", command.Description) 174 | } 175 | for _, variable := range ups.Variables { 176 | c.logger.Debug( 177 | "variable_name", variable.Name, 178 | "value", variable.Value, 179 | "type", variable.Type, 180 | "description", variable.Description, 181 | "writeable", variable.Writeable, 182 | "maximum_length", variable.MaximumLength, 183 | "original_type", variable.OriginalType, 184 | ) 185 | path := strings.Split(variable.Name, ".") 186 | if path[0] == "device" { 187 | device[path[1]] = fmt.Sprintf("%v", variable.Value) 188 | } 189 | 190 | /* Done special processing - now get as general as possible and gather all requested or number-like metrics */ 191 | if len(c.opts.Variables) == 0 || sliceContains(c.opts.Variables, variable.Name) { 192 | c.logger.Debug("Export the variable? true") 193 | value := float64(0) 194 | 195 | /* Deal with ups.status specially because it is a collection of 'flags' */ 196 | if variable.Name == "ups.status" { 197 | setStatuses := make(map[string]bool) 198 | varDesc := prometheus.NewDesc(prometheus.BuildFQName(c.opts.Namespace, "", strings.Replace(variable.Name, ".", "_", -1)), 199 | fmt.Sprintf("%s (%s)", variable.Description, variable.Name), 200 | []string{"flag"}, nil, 201 | ) 202 | 203 | for _, statusFlag := range strings.Split(variable.Value.(string), " ") { 204 | setStatuses[statusFlag] = true 205 | ch <- prometheus.MustNewConstMetric(varDesc, prometheus.GaugeValue, float64(1), statusFlag) 206 | } 207 | 208 | /* If the user specifies the statues that must always be set, handle that here */ 209 | if len(c.opts.Statuses) > 0 { 210 | for _, status := range c.opts.Statuses { 211 | /* This status flag was set because we saw it in the output... skip it */ 212 | if _, ok := setStatuses[status]; ok { 213 | continue 214 | } 215 | ch <- prometheus.MustNewConstMetric(varDesc, prometheus.GaugeValue, float64(0), status) 216 | } 217 | } 218 | continue 219 | } 220 | 221 | /* This is overkill - the library only deals with bool, string, int64 and float64 */ 222 | switch v := variable.Value.(type) { 223 | case bool: 224 | if v { 225 | value = float64(1) 226 | } 227 | case int: 228 | value = float64(v) 229 | case int8: 230 | value = float64(v) 231 | case int16: 232 | value = float64(v) 233 | case int64: 234 | value = float64(v) 235 | case float32: 236 | value = float64(v) 237 | case float64: 238 | value = float64(v) 239 | case string: 240 | /* All numbers should be coaxed to native types by the library, so see if we can figure out 241 | if this string could possible represent a binary value 242 | */ 243 | if c.onRegex != nil && c.onRegex.MatchString(variable.Value.(string)) { 244 | c.logger.Debug("Converted string to 1 due to regex match", "value", variable.Value.(string)) 245 | value = float64(1) 246 | } else if c.offRegex != nil && c.offRegex.MatchString(variable.Value.(string)) { 247 | c.logger.Debug("Converted string to 0 due to regex match", "value", variable.Value.(string)) 248 | value = float64(0) 249 | } else { 250 | c.logger.Debug("Cannot convert string to binary 0/1", "value", variable.Value.(string)) 251 | continue 252 | } 253 | continue 254 | default: 255 | c.logger.Warn("Unknown variable type from nut client library", "name", variable.Name, "type", fmt.Sprintf("%T", v), "claimed_type", variable.Type, "value", v) 256 | continue 257 | } 258 | 259 | name := strings.Replace(variable.Name, ".", "_", -1) 260 | name = strings.Replace(name, "-", "_", -1) 261 | 262 | varDesc := prometheus.NewDesc(prometheus.BuildFQName(c.opts.Namespace, "", name), 263 | fmt.Sprintf("%s (%s)", variable.Description, variable.Name), 264 | nil, nil, 265 | ) 266 | 267 | ch <- prometheus.MustNewConstMetric(varDesc, prometheus.GaugeValue, value) 268 | } else { 269 | c.logger.Debug("Export the variable? false", "count", len(c.opts.Variables), "variables", strings.Join(c.opts.Variables, ",")) 270 | } 271 | } 272 | 273 | // Only provide device info if not disabled 274 | if !c.opts.DisableDeviceInfo { 275 | deviceValues := []string{} 276 | for _, label := range deviceLabels { 277 | deviceValues = append(deviceValues, device[label]) 278 | } 279 | ch <- prometheus.MustNewConstMetric(c.deviceDesc, prometheus.GaugeValue, float64(1), deviceValues...) 280 | } 281 | } 282 | } 283 | 284 | func (c *NutCollector) Describe(ch chan<- *prometheus.Desc) { 285 | if !c.opts.DisableDeviceInfo { 286 | ch <- c.deviceDesc 287 | } 288 | } 289 | 290 | func sliceContains(c []string, value string) bool { 291 | for _, sliceValue := range c { 292 | if sliceValue == value { 293 | return true 294 | } 295 | } 296 | return false 297 | } 298 | 299 | func (c *NutCollector) IsValidUPSName(upsName string) (bool, error) { 300 | result := false 301 | 302 | c.logger.Debug(fmt.Sprintf("Connecting to server and verifying `%s` is a valid UPS name", upsName), "server", c.opts.Server) 303 | client, err := nut.Connect(c.opts.Server) 304 | if err != nil { 305 | c.logger.Error("error while connecting to server", "err", err) 306 | return result, err 307 | } 308 | 309 | defer client.Disconnect() 310 | 311 | if c.opts.Username != "" && c.opts.Password != "" { 312 | _, err = client.Authenticate(c.opts.Username, c.opts.Password) 313 | if err != nil { 314 | c.logger.Warn("Failed to authenticate to NUT server", "server", c.opts.Server, "user", c.opts.Username) 315 | //Don't bail after logging the warning. Most NUT configurations do not require authn to get the UPS list 316 | } 317 | } 318 | 319 | tmp, err := client.GetUPSList() 320 | if err != nil { 321 | c.logger.Error("Failure getting the list of UPS devices", "err", err) 322 | return result, err 323 | } 324 | 325 | for _, ups := range tmp { 326 | c.logger.Debug("UPS name detection", "name", ups.Name) 327 | if ups.Name == upsName { 328 | result = true 329 | } 330 | } 331 | 332 | c.logger.Debug(fmt.Sprintf("Validity result for UPS named `%s`", upsName), "valid", result) 333 | return result, nil 334 | } 335 | -------------------------------------------------------------------------------- /dashboard/capture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DRuggeri/nut_exporter/e763ee7412ae36000d41d0c68fcaedf66ce5bda1/dashboard/capture.png -------------------------------------------------------------------------------- /dashboard/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "$$hashKey": "object:7", 6 | "builtIn": 1, 7 | "datasource": { 8 | "type": "datasource", 9 | "uid": "grafana" 10 | }, 11 | "enable": true, 12 | "hide": true, 13 | "iconColor": "rgba(0, 211, 255, 1)", 14 | "name": "Annotations & Alerts", 15 | "type": "dashboard" 16 | } 17 | ] 18 | }, 19 | "editable": true, 20 | "fiscalYearStartMonth": 0, 21 | "graphTooltip": 0, 22 | "id": 26, 23 | "links": [], 24 | "panels": [ 25 | { 26 | "collapsed": false, 27 | "gridPos": { 28 | "h": 1, 29 | "w": 24, 30 | "x": 0, 31 | "y": 0 32 | }, 33 | "id": 17, 34 | "panels": [], 35 | "repeat": "ups", 36 | "title": "$ups", 37 | "type": "row" 38 | }, 39 | { 40 | "datasource": { 41 | "type": "prometheus", 42 | "uid": "prometheus" 43 | }, 44 | "description": " * OL - On line (mains is present)\n * OB - On battery (mains is not present)\n * LB - Low battery\n * HB - High battery\n * RB - The battery needs to be replaced\n * CHRG - The battery is charging\n * DISCHRG - The battery is discharging (inverter is providing load power)\n * BYPASS - UPS bypass circuit is active -- no battery protection is available\n * CAL - UPS is currently performing runtime calibration (on battery)\n * OFF - UPS is offline and is not supplying power to the load\n * OVER - UPS is overloaded\n * TRIM - UPS is trimming incoming voltage (called \"buck\" in some hardware)\n * BOOST - UPS is boosting incoming voltage\n * FSD and SD - Forced Shutdown", 45 | "fieldConfig": { 46 | "defaults": { 47 | "color": { 48 | "mode": "fixed" 49 | }, 50 | "mappings": [], 51 | "thresholds": { 52 | "mode": "absolute", 53 | "steps": [ 54 | { 55 | "color": "green", 56 | "value": null 57 | }, 58 | { 59 | "color": "red", 60 | "value": 80 61 | } 62 | ] 63 | } 64 | }, 65 | "overrides": [] 66 | }, 67 | "gridPos": { 68 | "h": 3, 69 | "w": 3, 70 | "x": 0, 71 | "y": 1 72 | }, 73 | "id": 26, 74 | "options": { 75 | "colorMode": "value", 76 | "graphMode": "none", 77 | "justifyMode": "auto", 78 | "orientation": "auto", 79 | "percentChangeColorMode": "standard", 80 | "reduceOptions": { 81 | "calcs": [ 82 | "lastNotNull" 83 | ], 84 | "fields": "", 85 | "values": false 86 | }, 87 | "showPercentChange": false, 88 | "textMode": "name", 89 | "wideLayout": true 90 | }, 91 | "pluginVersion": "11.3.0", 92 | "targets": [ 93 | { 94 | "datasource": { 95 | "type": "prometheus", 96 | "uid": "prometheus" 97 | }, 98 | "editorMode": "code", 99 | "expr": "network_ups_tools_ups_status{ups=\"$ups\"} == 1", 100 | "interval": "", 101 | "legendFormat": "{{flag}}", 102 | "range": true, 103 | "refId": "A" 104 | } 105 | ], 106 | "title": "UPS Status", 107 | "transparent": true, 108 | "type": "stat" 109 | }, 110 | { 111 | "datasource": { 112 | "type": "prometheus", 113 | "uid": "prometheus" 114 | }, 115 | "description": "", 116 | "fieldConfig": { 117 | "defaults": { 118 | "color": { 119 | "mode": "fixed" 120 | }, 121 | "mappings": [], 122 | "thresholds": { 123 | "mode": "absolute", 124 | "steps": [ 125 | { 126 | "color": "green", 127 | "value": null 128 | }, 129 | { 130 | "color": "red", 131 | "value": 80 132 | } 133 | ] 134 | } 135 | }, 136 | "overrides": [] 137 | }, 138 | "gridPos": { 139 | "h": 3, 140 | "w": 10, 141 | "x": 3, 142 | "y": 1 143 | }, 144 | "id": 19, 145 | "options": { 146 | "colorMode": "value", 147 | "graphMode": "none", 148 | "justifyMode": "auto", 149 | "orientation": "auto", 150 | "percentChangeColorMode": "standard", 151 | "reduceOptions": { 152 | "calcs": [ 153 | "mean" 154 | ], 155 | "fields": "", 156 | "values": false 157 | }, 158 | "showPercentChange": false, 159 | "textMode": "name", 160 | "wideLayout": true 161 | }, 162 | "pluginVersion": "11.3.0", 163 | "targets": [ 164 | { 165 | "datasource": { 166 | "type": "prometheus", 167 | "uid": "prometheus" 168 | }, 169 | "expr": "network_ups_tools_device_info{ups=\"$ups\"}", 170 | "interval": "", 171 | "legendFormat": "{{mfr}}", 172 | "refId": "A" 173 | } 174 | ], 175 | "title": "Manufacturer", 176 | "transparent": true, 177 | "type": "stat" 178 | }, 179 | { 180 | "datasource": { 181 | "type": "prometheus", 182 | "uid": "prometheus" 183 | }, 184 | "description": "", 185 | "fieldConfig": { 186 | "defaults": { 187 | "color": { 188 | "mode": "fixed" 189 | }, 190 | "mappings": [], 191 | "thresholds": { 192 | "mode": "absolute", 193 | "steps": [ 194 | { 195 | "color": "green", 196 | "value": null 197 | }, 198 | { 199 | "color": "red", 200 | "value": 80 201 | } 202 | ] 203 | } 204 | }, 205 | "overrides": [] 206 | }, 207 | "gridPos": { 208 | "h": 3, 209 | "w": 11, 210 | "x": 13, 211 | "y": 1 212 | }, 213 | "id": 20, 214 | "options": { 215 | "colorMode": "value", 216 | "graphMode": "none", 217 | "justifyMode": "auto", 218 | "orientation": "auto", 219 | "percentChangeColorMode": "standard", 220 | "reduceOptions": { 221 | "calcs": [ 222 | "mean" 223 | ], 224 | "fields": "", 225 | "values": false 226 | }, 227 | "showPercentChange": false, 228 | "textMode": "name", 229 | "wideLayout": true 230 | }, 231 | "pluginVersion": "11.3.0", 232 | "targets": [ 233 | { 234 | "datasource": { 235 | "type": "prometheus", 236 | "uid": "prometheus" 237 | }, 238 | "expr": "network_ups_tools_device_info{ups=\"$ups\"}", 239 | "interval": "", 240 | "legendFormat": "{{model}}", 241 | "refId": "A" 242 | } 243 | ], 244 | "title": "Model", 245 | "transparent": true, 246 | "type": "stat" 247 | }, 248 | { 249 | "datasource": { 250 | "type": "prometheus", 251 | "uid": "prometheus" 252 | }, 253 | "fieldConfig": { 254 | "defaults": { 255 | "color": { 256 | "mode": "thresholds" 257 | }, 258 | "mappings": [], 259 | "max": 100, 260 | "min": 0, 261 | "thresholds": { 262 | "mode": "absolute", 263 | "steps": [ 264 | { 265 | "color": "dark-red", 266 | "value": null 267 | }, 268 | { 269 | "color": "dark-orange", 270 | "value": 30 271 | }, 272 | { 273 | "color": "dark-yellow", 274 | "value": 60 275 | }, 276 | { 277 | "color": "dark-green", 278 | "value": 80 279 | } 280 | ] 281 | }, 282 | "unit": "percent" 283 | }, 284 | "overrides": [] 285 | }, 286 | "gridPos": { 287 | "h": 4, 288 | "w": 3, 289 | "x": 0, 290 | "y": 4 291 | }, 292 | "id": 2, 293 | "options": { 294 | "minVizHeight": 75, 295 | "minVizWidth": 75, 296 | "orientation": "auto", 297 | "reduceOptions": { 298 | "calcs": [ 299 | "last" 300 | ], 301 | "fields": "", 302 | "values": false 303 | }, 304 | "showThresholdLabels": false, 305 | "showThresholdMarkers": true, 306 | "sizing": "auto" 307 | }, 308 | "pluginVersion": "11.3.0", 309 | "targets": [ 310 | { 311 | "datasource": { 312 | "type": "prometheus", 313 | "uid": "prometheus" 314 | }, 315 | "expr": "network_ups_tools_battery_charge{ups=\"$ups\"}", 316 | "instant": false, 317 | "interval": "", 318 | "legendFormat": "", 319 | "refId": "A" 320 | } 321 | ], 322 | "title": "Battery Charge", 323 | "transparent": true, 324 | "type": "gauge" 325 | }, 326 | { 327 | "datasource": { 328 | "type": "prometheus", 329 | "uid": "prometheus" 330 | }, 331 | "description": "", 332 | "fieldConfig": { 333 | "defaults": { 334 | "color": { 335 | "mode": "palette-classic" 336 | }, 337 | "custom": { 338 | "axisBorderShow": false, 339 | "axisCenteredZero": false, 340 | "axisColorMode": "text", 341 | "axisLabel": "", 342 | "axisPlacement": "auto", 343 | "barAlignment": 0, 344 | "barWidthFactor": 0.6, 345 | "drawStyle": "line", 346 | "fillOpacity": 50, 347 | "gradientMode": "opacity", 348 | "hideFrom": { 349 | "legend": false, 350 | "tooltip": false, 351 | "viz": false 352 | }, 353 | "insertNulls": false, 354 | "lineInterpolation": "linear", 355 | "lineWidth": 1, 356 | "pointSize": 5, 357 | "scaleDistribution": { 358 | "type": "linear" 359 | }, 360 | "showPoints": "never", 361 | "spanNulls": false, 362 | "stacking": { 363 | "group": "A", 364 | "mode": "none" 365 | }, 366 | "thresholdsStyle": { 367 | "mode": "area" 368 | } 369 | }, 370 | "mappings": [], 371 | "min": 0, 372 | "thresholds": { 373 | "mode": "absolute", 374 | "steps": [ 375 | { 376 | "color": "red", 377 | "value": null 378 | }, 379 | { 380 | "color": "orange", 381 | "value": 300 382 | }, 383 | { 384 | "color": "transparent", 385 | "value": 900 386 | } 387 | ] 388 | }, 389 | "unit": "s" 390 | }, 391 | "overrides": [ 392 | { 393 | "matcher": { 394 | "id": "byName", 395 | "options": "network_ups_tools_battery_runtime{instance=\"romulus.home.bitnebula.com:9199\", job=\"nut-ups\", ups=\"ups\"}" 396 | }, 397 | "properties": [ 398 | { 399 | "id": "unit" 400 | }, 401 | { 402 | "id": "links" 403 | } 404 | ] 405 | } 406 | ] 407 | }, 408 | "gridPos": { 409 | "h": 6, 410 | "w": 10, 411 | "x": 3, 412 | "y": 4 413 | }, 414 | "id": 11, 415 | "options": { 416 | "alertThreshold": true, 417 | "legend": { 418 | "calcs": [], 419 | "displayMode": "list", 420 | "placement": "bottom", 421 | "showLegend": false 422 | }, 423 | "tooltip": { 424 | "mode": "multi", 425 | "sort": "none" 426 | } 427 | }, 428 | "pluginVersion": "11.3.0", 429 | "targets": [ 430 | { 431 | "datasource": { 432 | "type": "prometheus", 433 | "uid": "prometheus" 434 | }, 435 | "expr": "network_ups_tools_battery_runtime{ups=\"$ups\"}", 436 | "interval": "", 437 | "legendFormat": "", 438 | "refId": "A" 439 | } 440 | ], 441 | "title": "Battery Run Time Left", 442 | "transparent": true, 443 | "type": "timeseries" 444 | }, 445 | { 446 | "datasource": { 447 | "type": "prometheus", 448 | "uid": "prometheus" 449 | }, 450 | "fieldConfig": { 451 | "defaults": { 452 | "color": { 453 | "mode": "palette-classic" 454 | }, 455 | "custom": { 456 | "axisBorderShow": false, 457 | "axisCenteredZero": false, 458 | "axisColorMode": "text", 459 | "axisLabel": "", 460 | "axisPlacement": "auto", 461 | "barAlignment": 0, 462 | "barWidthFactor": 0.6, 463 | "drawStyle": "line", 464 | "fillOpacity": 50, 465 | "gradientMode": "opacity", 466 | "hideFrom": { 467 | "legend": false, 468 | "tooltip": false, 469 | "viz": false 470 | }, 471 | "insertNulls": false, 472 | "lineInterpolation": "linear", 473 | "lineWidth": 1, 474 | "pointSize": 5, 475 | "scaleDistribution": { 476 | "type": "linear" 477 | }, 478 | "showPoints": "never", 479 | "spanNulls": false, 480 | "stacking": { 481 | "group": "A", 482 | "mode": "none" 483 | }, 484 | "thresholdsStyle": { 485 | "mode": "off" 486 | } 487 | }, 488 | "mappings": [], 489 | "max": 100, 490 | "min": 0, 491 | "thresholds": { 492 | "mode": "absolute", 493 | "steps": [ 494 | { 495 | "color": "green", 496 | "value": null 497 | }, 498 | { 499 | "color": "red", 500 | "value": 80 501 | } 502 | ] 503 | }, 504 | "unit": "percent" 505 | }, 506 | "overrides": [] 507 | }, 508 | "gridPos": { 509 | "h": 6, 510 | "w": 11, 511 | "x": 13, 512 | "y": 4 513 | }, 514 | "id": 15, 515 | "options": { 516 | "alertThreshold": true, 517 | "legend": { 518 | "calcs": [], 519 | "displayMode": "list", 520 | "placement": "bottom", 521 | "showLegend": false 522 | }, 523 | "tooltip": { 524 | "mode": "multi", 525 | "sort": "none" 526 | } 527 | }, 528 | "pluginVersion": "11.3.0", 529 | "targets": [ 530 | { 531 | "datasource": { 532 | "type": "prometheus", 533 | "uid": "prometheus" 534 | }, 535 | "expr": "network_ups_tools_ups_load{ups=\"$ups\"}", 536 | "interval": "", 537 | "legendFormat": "", 538 | "refId": "A" 539 | } 540 | ], 541 | "title": "Load", 542 | "transparent": true, 543 | "type": "timeseries" 544 | }, 545 | { 546 | "datasource": { 547 | "type": "prometheus", 548 | "uid": "prometheus" 549 | }, 550 | "fieldConfig": { 551 | "defaults": { 552 | "mappings": [], 553 | "thresholds": { 554 | "mode": "absolute", 555 | "steps": [ 556 | { 557 | "color": "dark-red", 558 | "value": null 559 | }, 560 | { 561 | "color": "dark-green", 562 | "value": 120 563 | } 564 | ] 565 | }, 566 | "unit": "s" 567 | }, 568 | "overrides": [] 569 | }, 570 | "gridPos": { 571 | "h": 2, 572 | "w": 3, 573 | "x": 0, 574 | "y": 8 575 | }, 576 | "id": 24, 577 | "options": { 578 | "colorMode": "value", 579 | "graphMode": "none", 580 | "justifyMode": "auto", 581 | "orientation": "auto", 582 | "percentChangeColorMode": "standard", 583 | "reduceOptions": { 584 | "calcs": [ 585 | "mean" 586 | ], 587 | "fields": "", 588 | "values": false 589 | }, 590 | "showPercentChange": false, 591 | "textMode": "value", 592 | "wideLayout": true 593 | }, 594 | "pluginVersion": "11.3.0", 595 | "targets": [ 596 | { 597 | "datasource": { 598 | "type": "prometheus", 599 | "uid": "prometheus" 600 | }, 601 | "expr": "network_ups_tools_battery_runtime{ups=\"$ups\"}", 602 | "interval": "", 603 | "legendFormat": "", 604 | "refId": "A" 605 | } 606 | ], 607 | "title": "Battery Runtime", 608 | "transparent": true, 609 | "type": "stat" 610 | }, 611 | { 612 | "datasource": { 613 | "type": "prometheus", 614 | "uid": "prometheus" 615 | }, 616 | "fieldConfig": { 617 | "defaults": { 618 | "mappings": [], 619 | "max": 140, 620 | "min": 90, 621 | "thresholds": { 622 | "mode": "absolute", 623 | "steps": [ 624 | { 625 | "color": "dark-red", 626 | "value": null 627 | }, 628 | { 629 | "color": "dark-green", 630 | "value": 95 631 | }, 632 | { 633 | "color": "dark-yellow", 634 | "value": 125 635 | }, 636 | { 637 | "color": "dark-red", 638 | "value": 135 639 | } 640 | ] 641 | } 642 | }, 643 | "overrides": [] 644 | }, 645 | "gridPos": { 646 | "h": 6, 647 | "w": 3, 648 | "x": 0, 649 | "y": 10 650 | }, 651 | "id": 5, 652 | "options": { 653 | "minVizHeight": 75, 654 | "minVizWidth": 75, 655 | "orientation": "auto", 656 | "reduceOptions": { 657 | "calcs": [ 658 | "last" 659 | ], 660 | "fields": "", 661 | "values": false 662 | }, 663 | "showThresholdLabels": false, 664 | "showThresholdMarkers": true, 665 | "sizing": "auto" 666 | }, 667 | "pluginVersion": "11.3.0", 668 | "targets": [ 669 | { 670 | "datasource": { 671 | "type": "prometheus", 672 | "uid": "prometheus" 673 | }, 674 | "expr": "network_ups_tools_input_voltage{ups=\"$ups\"}", 675 | "interval": "", 676 | "legendFormat": "", 677 | "refId": "A" 678 | } 679 | ], 680 | "title": "Line Volts", 681 | "transparent": true, 682 | "type": "gauge" 683 | }, 684 | { 685 | "datasource": { 686 | "type": "prometheus", 687 | "uid": "prometheus" 688 | }, 689 | "fieldConfig": { 690 | "defaults": { 691 | "color": { 692 | "mode": "palette-classic" 693 | }, 694 | "custom": { 695 | "axisBorderShow": false, 696 | "axisCenteredZero": false, 697 | "axisColorMode": "text", 698 | "axisLabel": "", 699 | "axisPlacement": "auto", 700 | "barAlignment": 0, 701 | "barWidthFactor": 0.6, 702 | "drawStyle": "line", 703 | "fillOpacity": 50, 704 | "gradientMode": "opacity", 705 | "hideFrom": { 706 | "legend": false, 707 | "tooltip": false, 708 | "viz": false 709 | }, 710 | "insertNulls": false, 711 | "lineInterpolation": "linear", 712 | "lineWidth": 1, 713 | "pointSize": 5, 714 | "scaleDistribution": { 715 | "type": "linear" 716 | }, 717 | "showPoints": "never", 718 | "spanNulls": false, 719 | "stacking": { 720 | "group": "A", 721 | "mode": "none" 722 | }, 723 | "thresholdsStyle": { 724 | "mode": "off" 725 | } 726 | }, 727 | "mappings": [], 728 | "max": 140, 729 | "min": 90, 730 | "thresholds": { 731 | "mode": "absolute", 732 | "steps": [ 733 | { 734 | "color": "green", 735 | "value": null 736 | }, 737 | { 738 | "color": "red", 739 | "value": 80 740 | } 741 | ] 742 | }, 743 | "unit": "short" 744 | }, 745 | "overrides": [] 746 | }, 747 | "gridPos": { 748 | "h": 6, 749 | "w": 21, 750 | "x": 3, 751 | "y": 10 752 | }, 753 | "id": 12, 754 | "options": { 755 | "alertThreshold": true, 756 | "legend": { 757 | "calcs": [], 758 | "displayMode": "list", 759 | "placement": "bottom", 760 | "showLegend": false 761 | }, 762 | "tooltip": { 763 | "mode": "multi", 764 | "sort": "none" 765 | } 766 | }, 767 | "pluginVersion": "11.3.0", 768 | "targets": [ 769 | { 770 | "datasource": { 771 | "type": "prometheus", 772 | "uid": "prometheus" 773 | }, 774 | "expr": "network_ups_tools_input_voltage{ups=\"$ups\"}", 775 | "interval": "", 776 | "legendFormat": "", 777 | "refId": "A" 778 | } 779 | ], 780 | "title": "", 781 | "transparent": true, 782 | "type": "timeseries" 783 | }, 784 | { 785 | "datasource": { 786 | "type": "prometheus", 787 | "uid": "prometheus" 788 | }, 789 | "fieldConfig": { 790 | "defaults": { 791 | "mappings": [ 792 | { 793 | "options": { 794 | "match": "null", 795 | "result": { 796 | "text": "N/A" 797 | } 798 | }, 799 | "type": "special" 800 | } 801 | ], 802 | "max": 30, 803 | "min": 0, 804 | "thresholds": { 805 | "mode": "absolute", 806 | "steps": [ 807 | { 808 | "color": "dark-red", 809 | "value": null 810 | }, 811 | { 812 | "color": "dark-green", 813 | "value": 22 814 | }, 815 | { 816 | "color": "dark-red", 817 | "value": 28 818 | } 819 | ] 820 | }, 821 | "unit": "short" 822 | }, 823 | "overrides": [] 824 | }, 825 | "gridPos": { 826 | "h": 6, 827 | "w": 3, 828 | "x": 0, 829 | "y": 16 830 | }, 831 | "id": 4, 832 | "options": { 833 | "minVizHeight": 75, 834 | "minVizWidth": 75, 835 | "orientation": "horizontal", 836 | "reduceOptions": { 837 | "calcs": [ 838 | "last" 839 | ], 840 | "fields": "", 841 | "values": false 842 | }, 843 | "showThresholdLabels": false, 844 | "showThresholdMarkers": true, 845 | "sizing": "auto" 846 | }, 847 | "pluginVersion": "11.3.0", 848 | "targets": [ 849 | { 850 | "datasource": { 851 | "type": "prometheus", 852 | "uid": "prometheus" 853 | }, 854 | "expr": "network_ups_tools_battery_voltage{ups=\"$ups\"}", 855 | "interval": "", 856 | "legendFormat": "", 857 | "refId": "A" 858 | } 859 | ], 860 | "title": "Battery Volts", 861 | "transparent": true, 862 | "type": "gauge" 863 | }, 864 | { 865 | "datasource": { 866 | "type": "prometheus", 867 | "uid": "prometheus" 868 | }, 869 | "fieldConfig": { 870 | "defaults": { 871 | "color": { 872 | "mode": "palette-classic" 873 | }, 874 | "custom": { 875 | "axisBorderShow": false, 876 | "axisCenteredZero": false, 877 | "axisColorMode": "text", 878 | "axisLabel": "", 879 | "axisPlacement": "auto", 880 | "barAlignment": 0, 881 | "barWidthFactor": 0.6, 882 | "drawStyle": "line", 883 | "fillOpacity": 50, 884 | "gradientMode": "opacity", 885 | "hideFrom": { 886 | "legend": false, 887 | "tooltip": false, 888 | "viz": false 889 | }, 890 | "insertNulls": false, 891 | "lineInterpolation": "linear", 892 | "lineWidth": 1, 893 | "pointSize": 5, 894 | "scaleDistribution": { 895 | "type": "linear" 896 | }, 897 | "showPoints": "never", 898 | "spanNulls": false, 899 | "stacking": { 900 | "group": "A", 901 | "mode": "none" 902 | }, 903 | "thresholdsStyle": { 904 | "mode": "off" 905 | } 906 | }, 907 | "mappings": [], 908 | "min": 0, 909 | "thresholds": { 910 | "mode": "absolute", 911 | "steps": [ 912 | { 913 | "color": "green", 914 | "value": null 915 | }, 916 | { 917 | "color": "red", 918 | "value": 80 919 | } 920 | ] 921 | }, 922 | "unit": "short" 923 | }, 924 | "overrides": [] 925 | }, 926 | "gridPos": { 927 | "h": 6, 928 | "w": 21, 929 | "x": 3, 930 | "y": 16 931 | }, 932 | "id": 13, 933 | "options": { 934 | "alertThreshold": true, 935 | "legend": { 936 | "calcs": [], 937 | "displayMode": "list", 938 | "placement": "bottom", 939 | "showLegend": false 940 | }, 941 | "tooltip": { 942 | "mode": "multi", 943 | "sort": "none" 944 | } 945 | }, 946 | "pluginVersion": "11.3.0", 947 | "targets": [ 948 | { 949 | "datasource": { 950 | "type": "prometheus", 951 | "uid": "prometheus" 952 | }, 953 | "expr": "network_ups_tools_battery_voltage{ups=\"$ups\"}", 954 | "interval": "", 955 | "legendFormat": "", 956 | "refId": "A" 957 | } 958 | ], 959 | "title": "", 960 | "transparent": true, 961 | "type": "timeseries" 962 | } 963 | ], 964 | "preload": false, 965 | "refresh": false, 966 | "schemaVersion": 40, 967 | "tags": [], 968 | "templating": { 969 | "list": [ 970 | { 971 | "current": { 972 | "text": "tripplite", 973 | "value": "tripplite" 974 | }, 975 | "datasource": "Prometheus", 976 | "definition": "label_values(network_ups_tools_device_info, ups)", 977 | "includeAll": false, 978 | "label": "UPS", 979 | "name": "ups", 980 | "options": [], 981 | "query": "label_values(network_ups_tools_device_info, ups)", 982 | "refresh": 1, 983 | "regex": "", 984 | "type": "query" 985 | } 986 | ] 987 | }, 988 | "time": { 989 | "from": "now-24h", 990 | "to": "now" 991 | }, 992 | "timepicker": {}, 993 | "timezone": "", 994 | "title": "UPS statistics", 995 | "uid": "j4a-DMWRk", 996 | "version": 1, 997 | "weekStart": "" 998 | } 999 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/DRuggeri/nut_exporter 2 | 3 | go 1.22 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/alecthomas/kingpin/v2 v2.4.0 9 | github.com/prometheus/client_golang v1.20.4 10 | github.com/prometheus/exporter-toolkit v0.14.0 11 | github.com/robbiet480/go.nut v0.0.0-20220219091450-bd8f121e1fa1 12 | ) 13 | 14 | require ( 15 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect 16 | github.com/beorn7/perks v1.0.1 // indirect 17 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 18 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 19 | github.com/jpillora/backoff v1.0.0 // indirect 20 | github.com/klauspost/compress v1.17.9 // indirect 21 | github.com/mdlayher/socket v0.4.1 // indirect 22 | github.com/mdlayher/vsock v1.2.1 // indirect 23 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 24 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect 25 | github.com/prometheus/client_model v0.6.1 // indirect 26 | github.com/prometheus/common v0.63.0 // indirect 27 | github.com/prometheus/procfs v0.15.1 // indirect 28 | github.com/xhit/go-str2duration/v2 v2.1.0 // indirect 29 | golang.org/x/crypto v0.33.0 // indirect 30 | golang.org/x/net v0.35.0 // indirect 31 | golang.org/x/oauth2 v0.25.0 // indirect 32 | golang.org/x/sync v0.11.0 // indirect 33 | golang.org/x/sys v0.30.0 // indirect 34 | golang.org/x/text v0.22.0 // indirect 35 | google.golang.org/protobuf v1.36.5 // indirect 36 | gopkg.in/yaml.v2 v2.4.0 // indirect 37 | ) 38 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= 2 | github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= 3 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= 4 | github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= 5 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 6 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 7 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 8 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 9 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 10 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 15 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 16 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 17 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 18 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 19 | github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 20 | github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 21 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 22 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 23 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 25 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 26 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 27 | github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= 28 | github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= 29 | github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= 30 | github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= 31 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 32 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 33 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= 34 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 35 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 36 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 37 | github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= 38 | github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 39 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 40 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 41 | github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 42 | github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 43 | github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= 44 | github.com/prometheus/exporter-toolkit v0.14.0/go.mod h1:Gu5LnVvt7Nr/oqTBUC23WILZepW0nffNo10XdhQcwWA= 45 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 46 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 47 | github.com/robbiet480/go.nut v0.0.0-20220219091450-bd8f121e1fa1 h1:YmFqprZILGlF/X3tvMA4Rwn3ySxyE3hGUajBHkkaZbM= 48 | github.com/robbiet480/go.nut v0.0.0-20220219091450-bd8f121e1fa1/go.mod h1:pL1huxuIlWub46MsMVJg4p7OXkzbPp/APxh9IH0eJjQ= 49 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 50 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 51 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 52 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 53 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 54 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 55 | github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= 56 | github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= 57 | golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= 58 | golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 59 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 60 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 61 | golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= 62 | golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 63 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 64 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 65 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 66 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 67 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 68 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 69 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 70 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 71 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 72 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 73 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 74 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 75 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 76 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 77 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 78 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 79 | -------------------------------------------------------------------------------- /nut_exporter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/alecthomas/kingpin/v2" 12 | "github.com/prometheus/client_golang/prometheus" 13 | "github.com/prometheus/client_golang/prometheus/promhttp" 14 | 15 | promcollectors "github.com/prometheus/client_golang/prometheus/collectors" 16 | "github.com/prometheus/exporter-toolkit/web" 17 | "github.com/prometheus/exporter-toolkit/web/kingpinflag" 18 | 19 | "github.com/DRuggeri/nut_exporter/collectors" 20 | ) 21 | 22 | var Version = "testing" 23 | 24 | var ( 25 | server = kingpin.Flag( 26 | "nut.server", "Hostname or IP address of the server to connect to. ($NUT_EXPORTER_SERVER)", 27 | ).Envar("NUT_EXPORTER_SERVER").Default("127.0.0.1").String() 28 | 29 | serverport = kingpin.Flag( 30 | "nut.serverport", "Port on the NUT server to connect to. ($NUT_EXPORTER_SERVERPORT)", 31 | ).Envar("NUT_EXPORTER_SERVERPORT").Default("3493").Int() 32 | 33 | nutUsername = kingpin.Flag( 34 | "nut.username", "If set, will authenticate with this username to the server. Password must be set in NUT_EXPORTER_PASSWORD environment variable. ($NUT_EXPORTER_USERNAME)", 35 | ).Envar("NUT_EXPORTER_USERNAME").String() 36 | nutPassword = "" 37 | 38 | disableDeviceInfo = kingpin.Flag( 39 | "nut.disable_device_info", "A flag to disable the generation of the device_info meta metric. ($NUT_EXPORTER_DISABLE_DEVICE_INFO)", 40 | ).Envar("NUT_EXPORTER_DISABLE_DEVICE_INFO").Default("false").Bool() 41 | 42 | enableFilter = kingpin.Flag( 43 | "nut.vars_enable", "A comma-separated list of variable names to monitor. See the variable notes in README. ($NUT_EXPORTER_VARIABLES)", 44 | ).Envar("NUT_EXPORTER_VARIABLES").Default("battery.charge,battery.voltage,battery.voltage.nominal,input.voltage,input.voltage.nominal,ups.load,ups.status").String() 45 | 46 | onRegex = kingpin.Flag( 47 | "nut.on_regex", "This regular expression will be used to determine if the var's value should be coaxed to 1 if it is a string. Match is case-insensitive. ($NUT_EXPORTER_ON_REGEX)", 48 | ).Envar("NUT_EXPORTER_ON_REGEX").Default("^(enable|enabled|on|true|active|activated)$").String() 49 | 50 | offRegex = kingpin.Flag( 51 | "nut.off_regex", "This regular expression will be used to determine if the var's value should be coaxed to 0 if it is a string. Match is case-insensitive. ($NUT_EXPORTER_OFF_REGEX)", 52 | ).Envar("NUT_EXPORTER_OFF_REGEX").Default("^(disable|disabled|off|false|inactive|deactivated)$").String() 53 | 54 | statusList = kingpin.Flag( 55 | "nut.statuses", "A comma-separated list of statuses labels that will always be set by the exporter. If NUT does not set these flags, the exporter will force the network_ups_tools_ups_status{flag=\"NAME\"} to 0. See the ups.status notes in README.' ($NUT_EXPORTER_STATUSES)", 56 | ).Envar("NUT_EXPORTER_STATUSES").Default("OL,OB,LB,HB,RB,CHRG,DISCHRG,BYPASS,CAL,OFF,OVER,TRIM,BOOST,FSD,SD").String() 57 | 58 | metricsNamespace = kingpin.Flag( 59 | "metrics.namespace", "Metrics Namespace ($NUT_EXPORTER_METRICS_NAMESPACE)", 60 | ).Envar("NUT_EXPORTER_METRICS_NAMESPACE").Default("network_ups_tools").String() 61 | 62 | tookitFlags = kingpinflag.AddFlags(kingpin.CommandLine, ":9199") 63 | 64 | metricsPath = kingpin.Flag( 65 | "web.telemetry-path", "Path under which to expose the UPS Prometheus metrics ($NUT_EXPORTER_WEB_TELEMETRY_PATH)", 66 | ).Envar("NUT_EXPORTER_WEB_TELEMETRY_PATH").Default("/ups_metrics").String() 67 | 68 | exporterMetricsPath = kingpin.Flag( 69 | "web.exporter-telemetry-path", "Path under which to expose process metrics about this exporter ($NUT_EXPORTER_WEB_EXPORTER_TELEMETRY_PATH)", 70 | ).Envar("NUT_EXPORTER_WEB_EXPORTER_TELEMETRY_PATH").Default("/metrics").String() 71 | 72 | printMetrics = kingpin.Flag( 73 | "printMetrics", "Print the metrics this exporter exposes and exits. Default: false ($NUT_EXPORTER_PRINT_METRICS)", 74 | ).Envar("NUT_EXPORTER_PRINT_METRICS").Default("false").Bool() 75 | 76 | logLevel = kingpin.Flag( 77 | "log.level", "Minimum log level for messages. One of error, warn, info, or debug. Default: info ($NETGEAR_EXPORTER_LOG_LEVEL)", 78 | ).Envar("NUT_EXPORTER__LOG_LEVEL").Default("info").String() 79 | 80 | logJson = kingpin.Flag( 81 | "log.json", "Format log lines as JSON. Default: false ($NETGEAR_EXPORTER_LOG_JSON)", 82 | ).Envar("NUT_EXPORTER__LOG_JSON").Bool() 83 | ) 84 | var collectorOpts collectors.NutCollectorOpts 85 | 86 | var logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) 87 | 88 | func init() { 89 | prometheus.MustRegister(promcollectors.NewBuildInfoCollector()) 90 | } 91 | 92 | type metricsHandler struct { 93 | handlers map[string]*http.Handler 94 | } 95 | 96 | func (h *metricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 97 | thisCollectorOpts := collectorOpts 98 | thisCollectorOpts.Ups = r.URL.Query().Get("ups") 99 | 100 | if r.URL.Query().Get("server") != "" { 101 | thisCollectorOpts.Server = r.URL.Query().Get("server") 102 | } 103 | 104 | if r.URL.Query().Get("serverport") != "" { 105 | if port, err := strconv.Atoi(r.URL.Query().Get("serverport")); err != nil { 106 | thisCollectorOpts.ServerPort = port 107 | } 108 | } 109 | 110 | if r.URL.Query().Get("username") != "" { 111 | thisCollectorOpts.Username = r.URL.Query().Get("username") 112 | } 113 | 114 | if r.URL.Query().Get("password") != "" { 115 | thisCollectorOpts.Password = r.URL.Query().Get("password") 116 | } 117 | 118 | if r.URL.Query().Get("variables") != "" { 119 | thisCollectorOpts.Variables = strings.Split(r.URL.Query().Get("variables"), ",") 120 | } 121 | 122 | if r.URL.Query().Get("statuses") != "" { 123 | thisCollectorOpts.Statuses = strings.Split(r.URL.Query().Get("statuses"), ",") 124 | } 125 | 126 | var promHandler http.Handler 127 | cacheName := fmt.Sprintf("%s:%d/%s", thisCollectorOpts.Server, thisCollectorOpts.ServerPort, thisCollectorOpts.Ups) 128 | if tmp, ok := h.handlers[cacheName]; ok { 129 | logger.Debug(fmt.Sprintf("Using existing handler for UPS `%s`", cacheName)) 130 | promHandler = *tmp 131 | } else { 132 | //Build a custom registry to include only the UPS metrics on the UPS metrics path 133 | logger.Info(fmt.Sprintf("Creating new registry, handler, and collector for UPS `%s`", cacheName)) 134 | registry := prometheus.NewRegistry() 135 | promHandler = promhttp.HandlerFor(registry, promhttp.HandlerOpts{Registry: registry}) 136 | promHandler = promhttp.InstrumentMetricHandler(registry, promHandler) 137 | 138 | nutCollector, err := collectors.NewNutCollector(thisCollectorOpts, logger) 139 | if err != nil { 140 | w.WriteHeader(http.StatusInternalServerError) 141 | w.Write([]byte("500 - InternalServer Error")) 142 | logger.Error("Internal server error", "err", err) 143 | return 144 | } 145 | registry.MustRegister(nutCollector) 146 | h.handlers[cacheName] = &promHandler 147 | } 148 | 149 | promHandler.ServeHTTP(w, r) 150 | } 151 | 152 | func main() { 153 | //flag.AddFlags(kingpin.CommandLine, promlogConfig) 154 | kingpin.Version(Version) 155 | kingpin.HelpFlag.Short('h') 156 | kingpin.Parse() 157 | 158 | /* Reconfigure logger after parsing arguments */ 159 | opts := &slog.HandlerOptions{} 160 | switch *logLevel { 161 | case "error": 162 | opts.Level = slog.LevelError 163 | case "warn": 164 | opts.Level = slog.LevelWarn 165 | case "info": 166 | opts.Level = slog.LevelInfo 167 | case "debug": 168 | opts.Level = slog.LevelDebug 169 | } 170 | if *logJson { 171 | logger = slog.New(slog.NewJSONHandler(os.Stderr, opts)) 172 | slog.SetDefault(logger) 173 | } else { 174 | logger = slog.New(slog.NewTextHandler(os.Stderr, opts)) 175 | slog.SetDefault(logger) 176 | } 177 | 178 | if *nutUsername != "" { 179 | logger.Debug("Authenticating to NUT server") 180 | nutPassword = os.Getenv("NUT_EXPORTER_PASSWORD") 181 | if nutPassword == "" { 182 | logger.Error("Username set, but NUT_EXPORTER_PASSWORD environment variable missing. Cannot authenticate!") 183 | os.Exit(2) 184 | } 185 | } 186 | 187 | variables := []string{} 188 | hasUpsStatusVariable := false 189 | for _, varName := range strings.Split(*enableFilter, ",") { 190 | // Be nice and clear spaces for those that like them 191 | variable := strings.Trim(varName, " ") 192 | if variable == "" { 193 | continue 194 | } 195 | variables = append(variables, variable) 196 | 197 | // Special handling because this is an important and commonly needed variable 198 | if variable == "ups.status" { 199 | hasUpsStatusVariable = true 200 | } 201 | } 202 | 203 | if !hasUpsStatusVariable { 204 | logger.Warn("Exporter has been started without `ups.status` variable to be exported with --nut.vars_enable. Online/offline/etc statuses will not be reported!") 205 | } 206 | 207 | statuses := []string{} 208 | for _, status := range strings.Split(*statusList, ",") { 209 | // Be nice and clear spaces for those that like them 210 | stat := strings.Trim(status, " ") 211 | if stat == "" { 212 | continue 213 | } 214 | statuses = append(statuses, strings.Trim(stat, " ")) 215 | } 216 | 217 | collectorOpts = collectors.NutCollectorOpts{ 218 | Namespace: *metricsNamespace, 219 | Server: *server, 220 | ServerPort: *serverport, 221 | Username: *nutUsername, 222 | Password: nutPassword, 223 | DisableDeviceInfo: *disableDeviceInfo, 224 | Variables: variables, 225 | Statuses: statuses, 226 | OnRegex: *onRegex, 227 | OffRegex: *offRegex, 228 | } 229 | 230 | if *printMetrics { 231 | /* Make a channel and function to send output along */ 232 | var out chan *prometheus.Desc 233 | eatOutput := func(in <-chan *prometheus.Desc) { 234 | for desc := range in { 235 | /* Weaksauce... no direct access to the variables */ 236 | //Desc{fqName: "the_name", help: "help text", constLabels: {}, variableLabels: []} 237 | tmp := desc.String() 238 | vals := strings.Split(tmp, `"`) 239 | fmt.Printf(" %s - %s\n", vals[1], vals[3]) 240 | } 241 | } 242 | 243 | /* Interesting juggle here... 244 | - Make a channel the describe function can send output to 245 | - Start the printing function that consumes the output in the background 246 | - Call the describe function to feed the channel (which blocks until the consume function eats a message) 247 | - When the describe function exits after returning the last item, close the channel to end the background consume function 248 | */ 249 | fmt.Println("NUT") 250 | nutCollector, _ := collectors.NewNutCollector(collectorOpts, logger) 251 | out = make(chan *prometheus.Desc) 252 | go eatOutput(out) 253 | nutCollector.Describe(out) 254 | close(out) 255 | 256 | os.Exit(0) 257 | } 258 | 259 | logger.Info("Starting nut_exporter", "version", Version) 260 | 261 | handler := &metricsHandler{ 262 | handlers: make(map[string]*http.Handler), 263 | } 264 | 265 | http.Handle(*metricsPath, handler) 266 | http.Handle(*exporterMetricsPath, promhttp.Handler()) 267 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 268 | w.Write([]byte(` 269 | NUT Exporter 270 | 271 |

NUT Exporter

272 |

UPS metrics

273 |

Exporter metrics

274 | 275 | `)) 276 | }) 277 | 278 | srv := &http.Server{} 279 | if err := web.ListenAndServe(srv, tookitFlags, logger); err != nil { 280 | logger.Error(err.Error()) 281 | os.Exit(1) 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /nut_exporter_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "os/exec" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | var ( 14 | binary = "nut_exporter" 15 | ) 16 | 17 | const ( 18 | address = "localhost:19100" 19 | ) 20 | 21 | func TestSuccessfulLaunch(t *testing.T) { 22 | if _, err := os.Stat(binary); err != nil { 23 | return 24 | } 25 | 26 | exporter := exec.Command(binary, "--web.listen-address", address) 27 | test := func(pid int) error { 28 | if err := queryExporter(address); err != nil { 29 | return err 30 | } 31 | return nil 32 | } 33 | 34 | if err := runCommandAndTests(exporter, address, test); err != nil { 35 | t.Error(err) 36 | } 37 | } 38 | 39 | func queryExporter(address string) error { 40 | resp, err := http.Get(fmt.Sprintf("http://%s/metrics", address)) 41 | if err != nil { 42 | return err 43 | } 44 | b, err := io.ReadAll(resp.Body) 45 | if err != nil { 46 | return err 47 | } 48 | if err := resp.Body.Close(); err != nil { 49 | return err 50 | } 51 | if want, have := http.StatusOK, resp.StatusCode; want != have { 52 | return fmt.Errorf("want /metrics status code %d, have %d. Body:\n%s", want, have, b) 53 | } 54 | return nil 55 | } 56 | 57 | func runCommandAndTests(cmd *exec.Cmd, address string, fn func(pid int) error) error { 58 | if err := cmd.Start(); err != nil { 59 | return fmt.Errorf("failed to start command: %s", err) 60 | } 61 | time.Sleep(50 * time.Millisecond) 62 | for i := 0; i < 10; i++ { 63 | if err := queryExporter(address); err == nil { 64 | break 65 | } 66 | time.Sleep(500 * time.Millisecond) 67 | if cmd.Process == nil || i == 9 { 68 | return fmt.Errorf("can't start command") 69 | } 70 | } 71 | 72 | errc := make(chan error) 73 | go func(pid int) { 74 | errc <- fn(pid) 75 | }(cmd.Process.Pid) 76 | 77 | err := <-errc 78 | if cmd.Process != nil { 79 | cmd.Process.Kill() 80 | } 81 | return err 82 | } 83 | -------------------------------------------------------------------------------- /scripts/do_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | 4 | usage (){ 5 | echo "$0 - Tag and prepare a release 6 | 7 | USAGE: $0 (major|minor|patch|vX.Y.Z) 8 | 9 | The argument may be one of: 10 | major - Increments the current major version and performs the release 11 | minor - Increments the current minor version and preforms the release 12 | patch - Increments the current patch version and preforms the release 13 | vX.Y.Z - Sets the tag to the value of vX.Y.Z where X=major, Y=minor, and Z=patch 14 | " 15 | exit 1 16 | } 17 | 18 | if [ -z "$1" -o -n "$2" ];then 19 | usage 20 | fi 21 | 22 | TAG=`git describe --tags --abbrev=0` 23 | VERSION="${TAG#[vV]}" 24 | MAJOR="${VERSION%%\.*}" 25 | MINOR="${VERSION#*.}" 26 | MINOR="${MINOR%.*}" 27 | PATCH="${VERSION##*.}" 28 | echo "Current tag: v$MAJOR.$MINOR.$PATCH" 29 | 30 | #Determine what the user wanted 31 | case $1 in 32 | major) 33 | MAJOR=$((MAJOR+1)) 34 | MINOR=0 35 | PATCH=0 36 | TAG="v$MAJOR.$MINOR.$PATCH" 37 | ;; 38 | minor) 39 | MINOR=$((MINOR+1)) 40 | PATCH=0 41 | TAG="v$MAJOR.$MINOR.$PATCH" 42 | ;; 43 | patch) 44 | PATCH=$((PATCH+1)) 45 | TAG="v$MAJOR.$MINOR.$PATCH" 46 | ;; 47 | v*.*.*) 48 | TAG="$1" 49 | ;; 50 | *.*.*) 51 | TAG="v$1" 52 | ;; 53 | *) 54 | usage 55 | ;; 56 | esac 57 | 58 | echo "New tag: $TAG" 59 | 60 | #Get into the right directory 61 | cd $(dirname $0)/.. 62 | 63 | vi .release_info.md 64 | 65 | git commit -m "Changes for $TAG" .release_info.md 66 | 67 | git tag $TAG 68 | git push origin 69 | git push origin $TAG 70 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | echo "Building testing binary and running tests..." 4 | #Get into the right directory 5 | cd $(dirname $0) 6 | 7 | export GOOS="" 8 | export GOARCH="" 9 | 10 | #Add this directory to PATH 11 | export PATH="$PATH:`pwd`" 12 | 13 | go build -ldflags "-X main.Version=testing:$(git rev-list -1 HEAD)" -o "nut_exporter" ../ 14 | 15 | echo "Running tests..." 16 | cd ../ 17 | 18 | go test 19 | --------------------------------------------------------------------------------