Complete InfluxDB v3 rollout: tests, docs, and docker example.

Add v3 integration and version tests, migration notes, InfluxDB 3 docker-compose stack, and README updates to finish the remaining plan phases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Cody Lee
2026-08-27 16:24:09 -05:00
co-authored by Cursor
parent 5e31d2f252
commit 9bc7f2c5bf
7 changed files with 311 additions and 0 deletions
+9
View File
@@ -24,3 +24,12 @@ docker exec <container_name> /usr/bin/unpoller --health
The health check is automatically used by Docker and container orchestration The health check is automatically used by Docker and container orchestration
platforms (Kubernetes, Docker Swarm, etc.) to determine container health status. platforms (Kubernetes, Docker Swarm, etc.) to determine container health status.
## InfluxDB 3 Core
Use `docker-compose-influxdb3.yml` with `docker-compose-influxdb3.env.example` to run UniFi Poller against InfluxDB 3 Core on port `8181`. Copy the env example, set `INFLUXDB_ADMIN_TOKEN` to a token created in InfluxDB 3, then start the stack:
```bash
cp docker-compose-influxdb3.env.example docker-compose-influxdb3.env
docker compose -f docker-compose-influxdb3.yml --env-file docker-compose-influxdb3.env up -d
```
@@ -0,0 +1,16 @@
# InfluxDB 3 Core
# Create an admin token in InfluxDB 3 and set it here.
INFLUXDB_ADMIN_TOKEN=CHANGEME
INFLUXDB_DATABASE=unpoller
# Grafana
GRAFANA_USERNAME=admin
GRAFANA_PASSWORD=grafanaadmin
# UniFi Poller
POLLER_TAG=latest
POLLER_DEBUG=false
POLLER_SAVE_DPI=false
UNIFI_USER=unpoller
UNIFI_PASS=set_this_on_your_controller
UNIFI_URL=https://127.0.0.1:8443
+52
View File
@@ -0,0 +1,52 @@
# UniFi Poller with InfluxDB 3 Core.
# See README.md in this directory and pkg/influxunifi/README.md.
version: '3'
services:
influxdb3:
restart: always
image: influxdb:3-core
ports:
- '8181:8181'
command:
- influxdb3
- serve
- --node-id=node0
- --object-store=file
- --data-dir=/var/lib/influxdb3/data
- --plugin-dir=/var/lib/influxdb3/plugins
volumes:
- influxdb3-data:/var/lib/influxdb3/data
- influxdb3-plugins:/var/lib/influxdb3/plugins
grafana:
image: grafana/grafana:latest
restart: always
ports:
- '3000:3000'
volumes:
- grafana-storage:/var/lib/grafana
depends_on:
- influxdb3
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_USERNAME}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
- GF_INSTALL_PLUGINS=grafana-clock-panel,natel-discrete-panel,grafana-piechart-panel
unifi-poller:
restart: always
image: ghcr.io/unpoller/unpoller:${POLLER_TAG}
depends_on:
- grafana
- influxdb3
environment:
- UP_INFLUXDB_VERSION=3
- UP_INFLUXDB_URL=http://influxdb3:8181
- UP_INFLUXDB_AUTH_TOKEN=${INFLUXDB_ADMIN_TOKEN}
- UP_INFLUXDB_DATABASE=${INFLUXDB_DATABASE}
- UP_UNIFI_DEFAULT_USER=${UNIFI_USER}
- UP_UNIFI_DEFAULT_PASS=${UNIFI_PASS}
- UP_UNIFI_DEFAULT_URL=${UNIFI_URL}
- UP_POLLER_DEBUG=${POLLER_DEBUG}
- UP_UNIFI_DEFAULT_SAVE_DPI=${POLLER_SAVE_DPI}
volumes:
influxdb3-data:
influxdb3-plugins:
grafana-storage:
+42
View File
@@ -0,0 +1,42 @@
# InfluxDB Output Migration Notes
## InfluxDB 3 support
UnPoller now supports InfluxDB 1.x, 2.x, and 3.x from the same `influxdb` output plugin.
Enable v3 explicitly:
```yaml
influxdb:
version: 3
url: http://influxdb3:8181
auth_token: your-token
database: unifi
```
For InfluxDB Cloud Serverless or Clustered, set `use_v2_api: true` so writes use the v2-compatible endpoint.
See [README.md](README.md) and `init/docker/docker-compose-influxdb3.yml` for examples.
## Schema changes (v1/v2/v3)
InfluxDB 3 rejects line protocol where the same key is used as both a tag and a field on one point. The following keys were adjusted for compatibility:
| Measurement | Change |
|-------------|--------|
| `subsystems` | Removed duplicate field `wan_ip` (remains a tag) |
| `clients` | Renamed tag `channel` to `channel_name` (numeric field `channel` unchanged) |
| `uap_radios` | Renamed field `channel` to `channel_num`; removed duplicate field `radio` (remains a tag) |
| `usg`, `ubb`, `uci`, `udm`, `uxg` | Removed duplicate fields `source` and/or `version` (remain tags) |
### Grafana dashboards
Import updated dashboards from the [unpoller/dashboards](https://github.com/unpoller/dashboards) repository (`v2.0.0` InfluxDB JSON files) if panels reference the old tag/field names.
### Existing InfluxDB 3 databases
If writes previously failed with errors such as `invalid column type for column 'wan_ip'`, drop and recreate the affected database or bucket, then allow UnPoller to recreate the schema with the corrected layout.
## Issue reference
Fixes [unpoller#1061](https://github.com/unpoller/unpoller/issues/1061).
+2
View File
@@ -23,6 +23,8 @@ influxdb:
verify_ssl: false verify_ssl: false
``` ```
See [MIGRATION.md](MIGRATION.md) for schema changes affecting Grafana dashboards.
### InfluxDB 1.8+, 2.x ### InfluxDB 1.8+, 2.x
Note the use of `auth_token` to enable v2 mode when `version` is omitted. Note the use of `auth_token` to enable v2 mode when `version` is omitted.
+106
View File
@@ -0,0 +1,106 @@
package influxunifi_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
influxdb3 "github.com/InfluxCommunity/influxdb3-go/v2/influxdb3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/unpoller/unpoller/pkg/influxunifi"
"github.com/unpoller/unpoller/pkg/unittest"
"golift.io/cnfg"
)
type v3WriteCapture struct {
mu sync.Mutex
batch []string
}
func (c *v3WriteCapture) handler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
if !strings.Contains(r.URL.Path, "write") {
http.NotFound(w, r)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
c.mu.Lock()
c.batch = append(c.batch, string(body))
c.mu.Unlock()
w.WriteHeader(http.StatusNoContent)
}
func (c *v3WriteCapture) lines() []string {
c.mu.Lock()
defer c.mu.Unlock()
raw := strings.Join(c.batch, "\n")
return strings.Split(strings.TrimSpace(raw), "\n")
}
func TestInfluxV3Integration(t *testing.T) {
capture := &v3WriteCapture{}
server := httptest.NewServer(http.HandlerFunc(capture.handler))
t.Cleanup(server.Close)
client, err := influxdb3.New(influxdb3.ClientConfig{
Host: server.URL,
Token: "test-token",
Database: "unpoller",
WriteOptions: &influxdb3.WriteOptions{
UseV2Api: false,
},
})
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
testRig := unittest.NewTestSetup(t)
t.Cleanup(testRig.Close)
u := influxunifi.InfluxUnifi{
Collector: testRig.Collector,
Version: influxunifi.InfluxV3,
InfluxV3Client: client,
InfluxDB: &influxunifi.InfluxDB{
Config: &influxunifi.Config{
Version: 3,
Database: "unpoller",
AuthToken: "test-token",
URL: server.URL,
Interval: cnfg.Duration{Duration: time.Hour},
},
},
}
testRig.Initialize()
u.Poll(time.Minute)
lines := capture.lines()
require.NotEmpty(t, lines)
body := strings.Join(lines, "\n")
assert.Contains(t, body, "subsystems,")
assert.Contains(t, body, "clients,")
assert.Contains(t, body, "channel_name=")
assert.NotRegexp(t, `(?m)^clients,[^ ]* channel=`, body)
}
+84
View File
@@ -0,0 +1,84 @@
package influxunifi_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/unpoller/unpoller/pkg/influxunifi"
"golift.io/cnfg"
)
func TestInfluxVersionDefaults(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config influxunifi.Config
version influxunifi.InfluxVersion
}{
{
name: "v1 default without token",
config: influxunifi.Config{DB: "unifi"},
version: influxunifi.InfluxV1,
},
{
name: "v2 default with token",
config: influxunifi.Config{AuthToken: "secret"},
version: influxunifi.InfluxV2,
},
{
name: "explicit v3",
config: influxunifi.Config{Version: 3, AuthToken: "secret", Database: "unifi"},
version: influxunifi.InfluxV3,
},
{
name: "explicit v1 with token present",
config: influxunifi.Config{Version: 1, AuthToken: "ignored", DB: "unifi"},
version: influxunifi.InfluxV1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
u := &influxunifi.InfluxUnifi{
InfluxDB: &influxunifi.InfluxDB{Config: &tc.config},
}
u.Config.URL = server.URL
ok, err := u.DebugOutput()
require.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, tc.version, u.Version)
})
}
}
func TestInfluxV3RequiresAuthToken(t *testing.T) {
t.Parallel()
u := &influxunifi.InfluxUnifi{
InfluxDB: &influxunifi.InfluxDB{
Config: &influxunifi.Config{
Version: 3,
URL: "http://127.0.0.1:8181",
Database: "unifi",
Interval: cnfg.Duration{},
},
},
}
ok, err := u.DebugOutput()
require.Error(t, err)
assert.False(t, ok)
assert.Contains(t, err.Error(), "auth_token")
}