feat: initial release - TeamSpeak 6 Prometheus Exporter v1.0.0
This commit is contained in:
commit
eeeec364c0
13
.env.example
Normal file
13
.env.example
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# TeamSpeak 6 WebQuery connection
|
||||||
|
TS6_HOST=ts6-server
|
||||||
|
TS6_QUERY_PORT=10080
|
||||||
|
TS6_API_KEY=your-api-key-here
|
||||||
|
TS6_SERVER_ID=1
|
||||||
|
|
||||||
|
# Exporter settings
|
||||||
|
EXPORTER_PORT=9189
|
||||||
|
POLL_INTERVAL=15
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Set to "false" to include Python process/GC metrics
|
||||||
|
DISABLE_DEFAULT_COLLECTORS=true
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
38
CHANGELOG.md
Normal file
38
CHANGELOG.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-03-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of TeamSpeak 6 Prometheus Exporter
|
||||||
|
- **TS6 WebQuery HTTP API client** (`ts6_client.py`)
|
||||||
|
- HTTP communication with TeamSpeak 6 WebQuery on port 10080
|
||||||
|
- API Key authentication via `x-api-key` header
|
||||||
|
- Methods: `server_info()`, `client_list()`, `channel_list()`, `ban_list()`, `server_group_list()`, `version()`, `whoami()`
|
||||||
|
- Support for both JSON and legacy key=value response formats
|
||||||
|
- **Prometheus exporter** (`exporter.py`)
|
||||||
|
- Server metrics: `ts6_server_up`, `ts6_server_uptime_seconds`
|
||||||
|
- Client metrics: `ts6_clients_online`, `ts6_clients_max`, `ts6_query_clients_online`
|
||||||
|
- Channel metrics: `ts6_channels_total`
|
||||||
|
- Bandwidth metrics: `ts6_bytes_sent_total`, `ts6_bytes_received_total`, `ts6_packets_sent_total`, `ts6_packets_received_total`
|
||||||
|
- File transfer metrics: `ts6_file_transfer_bytes_sent_total`, `ts6_file_transfer_bytes_received_total`
|
||||||
|
- Quality metrics: `ts6_average_ping_seconds`, `ts6_average_packet_loss`
|
||||||
|
- Moderation metrics: `ts6_bans_total`, `ts6_server_groups_total`
|
||||||
|
- Per-client info with labels: `ts6_client_connected{nickname, platform, version, country, channel_id}`
|
||||||
|
- Exporter health: `ts6_scrape_duration_seconds`, `ts6_scrape_errors_total`
|
||||||
|
- Server version info: `ts6_server_version_info{version, build, platform}`
|
||||||
|
- **Docker support**
|
||||||
|
- Dockerfile based on Python 3.12-slim
|
||||||
|
- docker-compose.yml with external network support for existing TS6 stacks
|
||||||
|
- **Grafana dashboard** (`grafana/dashboard.json`)
|
||||||
|
- Server Overview row: Status, Uptime, Clients Online, Max Clients, Channels, Active Bans
|
||||||
|
- Clients row: Clients Online Over Time (time series), Connected Clients table
|
||||||
|
- Bandwidth & Network row: Bandwidth bytes/s, Packets/s
|
||||||
|
- Quality row: Average Ping gauge, Average Packet Loss gauge, File Transfer bandwidth
|
||||||
|
- Exporter Health row: Scrape Duration, Scrape Errors, Server Groups, Query Clients
|
||||||
|
- **Configuration** via environment variables
|
||||||
|
- MIT License
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
LABEL maintainer="Bxio"
|
||||||
|
LABEL description="Prometheus exporter for TeamSpeak 6 servers"
|
||||||
|
LABEL org.opencontainers.image.source="https://gitea.zol.oixb.run/oixb.run/ts6-grafana"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY exporter.py .
|
||||||
|
COPY ts6_client.py .
|
||||||
|
|
||||||
|
EXPOSE 9189
|
||||||
|
|
||||||
|
USER nobody
|
||||||
|
|
||||||
|
ENTRYPOINT ["python", "-u", "exporter.py"]
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Bxio
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
244
README.md
Normal file
244
README.md
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# TeamSpeak 6 Prometheus Exporter
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://gitea.zol.oixb.run/oixb.run/ts6-grafana)
|
||||||
|
[](#metrics)
|
||||||
|
|
||||||
|
> Prometheus exporter for TeamSpeak 6 servers. Collects metrics via the WebQuery HTTP API and exposes them for Prometheus scraping, with a ready-to-import Grafana dashboard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔍 **15+ metrics** — Server status, clients, bandwidth, channels, bans, quality, and more
|
||||||
|
- 📊 **Grafana dashboard included** — Ready to import with time series, gauges, and tables
|
||||||
|
- 🐳 **Docker ready** — Run alongside your existing TS6 stack
|
||||||
|
- ⚡ **Lightweight** — Python-based, minimal resource usage
|
||||||
|
- 🏷️ **Per-client labels** — Track individual clients with nickname, platform, country
|
||||||
|
- 🔧 **Fully configurable** — All settings via environment variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Generate a TS6 API Key
|
||||||
|
|
||||||
|
Connect to your TeamSpeak 6 server via ServerQuery (telnet/SSH) and generate an API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect via SSH Query
|
||||||
|
ssh serveradmin@your-ts6-server -p 10022
|
||||||
|
|
||||||
|
# Or via telnet
|
||||||
|
telnet your-ts6-server 10011
|
||||||
|
|
||||||
|
# Login and generate API key
|
||||||
|
login serveradmin YOUR_PASSWORD
|
||||||
|
apikeyadd scope=manage lifetime=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the returned `apikey` value.
|
||||||
|
|
||||||
|
### 2. Run with Docker Compose
|
||||||
|
|
||||||
|
Create a `.env` file:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TS6_API_KEY=your-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
If your TS6 server is in a Docker network (e.g., `ts6-panel_ts6-net`), update the network name in `docker-compose.yml` and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run Standalone (Python)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
export TS6_HOST=your-ts6-server
|
||||||
|
export TS6_QUERY_PORT=10080
|
||||||
|
export TS6_API_KEY=your-api-key-here
|
||||||
|
|
||||||
|
python exporter.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Metrics will be available at `http://localhost:9189/metrics`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `TS6_HOST` | `localhost` | TeamSpeak 6 server hostname |
|
||||||
|
| `TS6_QUERY_PORT` | `10080` | WebQuery HTTP port |
|
||||||
|
| `TS6_API_KEY` | **(required)** | API key for authentication |
|
||||||
|
| `TS6_SERVER_ID` | `1` | Virtual server ID |
|
||||||
|
| `EXPORTER_PORT` | `9189` | Port for the `/metrics` endpoint |
|
||||||
|
| `POLL_INTERVAL` | `15` | Polling interval in seconds |
|
||||||
|
| `LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) |
|
||||||
|
| `DISABLE_DEFAULT_COLLECTORS` | `true` | Remove default Python/GC metrics |
|
||||||
|
| `METRIC_PREFIX` | `ts6` | Prefix for all metric names |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts6_server_up` | Gauge | 1 if server is reachable, 0 otherwise |
|
||||||
|
| `ts6_server_uptime_seconds` | Gauge | Server uptime in seconds |
|
||||||
|
| `ts6_server_version_info` | Info | Version, build number, platform |
|
||||||
|
|
||||||
|
### Clients
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts6_clients_online` | Gauge | Online clients (excluding query clients) |
|
||||||
|
| `ts6_clients_max` | Gauge | Maximum allowed clients |
|
||||||
|
| `ts6_query_clients_online` | Gauge | Connected query clients |
|
||||||
|
| `ts6_client_connected` | Gauge | Per-client info (labels: nickname, platform, version, country, channel_id) |
|
||||||
|
|
||||||
|
### Channels & Groups
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts6_channels_total` | Gauge | Total channels |
|
||||||
|
| `ts6_server_groups_total` | Gauge | Total server groups |
|
||||||
|
|
||||||
|
### Bandwidth
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts6_bytes_sent_total` | Gauge | Total bytes sent |
|
||||||
|
| `ts6_bytes_received_total` | Gauge | Total bytes received |
|
||||||
|
| `ts6_packets_sent_total` | Gauge | Total packets sent |
|
||||||
|
| `ts6_packets_received_total` | Gauge | Total packets received |
|
||||||
|
| `ts6_file_transfer_bytes_sent_total` | Gauge | File transfer bytes sent |
|
||||||
|
| `ts6_file_transfer_bytes_received_total` | Gauge | File transfer bytes received |
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts6_average_ping_seconds` | Gauge | Average client ping |
|
||||||
|
| `ts6_average_packet_loss` | Gauge | Average packet loss ratio |
|
||||||
|
|
||||||
|
### Bans
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts6_bans_total` | Gauge | Active bans count |
|
||||||
|
|
||||||
|
### Exporter Health
|
||||||
|
|
||||||
|
| Metric | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ts6_scrape_duration_seconds` | Gauge | Last scrape duration |
|
||||||
|
| `ts6_scrape_errors_total` | Counter | Total scrape errors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Grafana Dashboard
|
||||||
|
|
||||||
|
Import the included dashboard from `grafana/dashboard.json`:
|
||||||
|
|
||||||
|
1. Open Grafana → **Dashboards** → **Import**
|
||||||
|
2. Upload the `grafana/dashboard.json` file or paste its contents
|
||||||
|
3. Select your Prometheus data source
|
||||||
|
4. Click **Import**
|
||||||
|
|
||||||
|
### Dashboard Sections
|
||||||
|
|
||||||
|
| Section | Panels |
|
||||||
|
|---|---|
|
||||||
|
| **Server Overview** | Status indicator, Uptime, Clients Online, Max Clients, Channels, Active Bans |
|
||||||
|
| **Clients** | Clients Online Over Time (graph), Connected Clients (table) |
|
||||||
|
| **Bandwidth & Network** | Bandwidth bytes/s, Packets/s |
|
||||||
|
| **Quality** | Average Ping (gauge), Packet Loss (gauge), File Transfer bandwidth |
|
||||||
|
| **Exporter Health** | Scrape Duration, Scrape Errors, Server Groups, Query Clients |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prometheus Configuration
|
||||||
|
|
||||||
|
Add the exporter as a scrape target in your `prometheus.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'teamspeak6'
|
||||||
|
scrape_interval: 15s
|
||||||
|
static_configs:
|
||||||
|
- targets: ['ts6-exporter:9189']
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Integration
|
||||||
|
|
||||||
|
### With existing TS6 Docker stack
|
||||||
|
|
||||||
|
If your TeamSpeak 6 runs in Docker, the exporter can join the same network. Update the `networks` section in `docker-compose.yml` to match your TS6 network name:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
networks:
|
||||||
|
ts6-net:
|
||||||
|
external: true
|
||||||
|
name: your-ts6-network-name # e.g., ts6_ts6-net
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ts6-prometheus-exporter .
|
||||||
|
docker run -d \
|
||||||
|
--name ts6-exporter \
|
||||||
|
--network your-ts6-network \
|
||||||
|
-p 9189:9189 \
|
||||||
|
-e TS6_HOST=ts6-server \
|
||||||
|
-e TS6_API_KEY=your-api-key \
|
||||||
|
ts6-prometheus-exporter
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ HTTP/API Key ┌─────────────┐ Scrape ┌────────────┐
|
||||||
|
│ TeamSpeak 6 │ ◄──────────────── │ TS6 │ ◄──────────── │ Prometheus │
|
||||||
|
│ Server │ Port 10080 │ Exporter │ Port 9189 │ │
|
||||||
|
│ (WebQuery) │ ──────────────► │ (Python) │ ────────────► │ │
|
||||||
|
└─────────────┘ JSON response └─────────────┘ /metrics └────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────┐
|
||||||
|
│ Grafana │
|
||||||
|
│ Dashboard │
|
||||||
|
└────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. The **exporter** polls the TS6 WebQuery HTTP API every N seconds
|
||||||
|
2. Responses are parsed and converted to **Prometheus metrics**
|
||||||
|
3. **Prometheus** scrapes the `/metrics` endpoint
|
||||||
|
4. **Grafana** queries Prometheus and renders the dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- TeamSpeak 6 server with **WebQuery HTTP enabled** (port 10080)
|
||||||
|
- A valid **API key** with `manage` scope
|
||||||
|
- Python 3.10+ (for standalone) or Docker
|
||||||
|
- Prometheus + Grafana for visualization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE)
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
ts6-exporter:
|
||||||
|
build: .
|
||||||
|
container_name: ts6-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9189:9189"
|
||||||
|
environment:
|
||||||
|
TS6_HOST: ts6-server
|
||||||
|
TS6_QUERY_PORT: 10080
|
||||||
|
TS6_API_KEY: ${TS6_API_KEY}
|
||||||
|
TS6_SERVER_ID: 1
|
||||||
|
EXPORTER_PORT: 9189
|
||||||
|
POLL_INTERVAL: 15
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
networks:
|
||||||
|
- ts6-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ts6-net:
|
||||||
|
external: true
|
||||||
|
name: ts6-panel_ts6-net
|
||||||
281
exporter.py
Normal file
281
exporter.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
TeamSpeak 6 Prometheus Exporter.
|
||||||
|
|
||||||
|
Polls the TS6 WebQuery HTTP API and exposes metrics
|
||||||
|
in Prometheus format on /metrics endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import signal
|
||||||
|
import logging
|
||||||
|
from prometheus_client import (
|
||||||
|
start_http_server,
|
||||||
|
Gauge,
|
||||||
|
Counter,
|
||||||
|
Info,
|
||||||
|
REGISTRY,
|
||||||
|
GC_COLLECTOR,
|
||||||
|
PLATFORM_COLLECTOR,
|
||||||
|
PROCESS_COLLECTOR,
|
||||||
|
)
|
||||||
|
from ts6_client import TS6Client
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("ts6_exporter")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
TS6_HOST = os.getenv("TS6_HOST", "localhost")
|
||||||
|
TS6_QUERY_PORT = int(os.getenv("TS6_QUERY_PORT", "10080"))
|
||||||
|
TS6_API_KEY = os.getenv("TS6_API_KEY", "")
|
||||||
|
TS6_SERVER_ID = int(os.getenv("TS6_SERVER_ID", "1"))
|
||||||
|
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9189"))
|
||||||
|
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "15"))
|
||||||
|
METRIC_PREFIX = os.getenv("METRIC_PREFIX", "ts6")
|
||||||
|
|
||||||
|
if not TS6_API_KEY:
|
||||||
|
logger.error("TS6_API_KEY environment variable is required!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Optionally remove default Python collectors for cleaner output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DISABLE_DEFAULT_COLLECTORS = os.getenv("DISABLE_DEFAULT_COLLECTORS", "true").lower() == "true"
|
||||||
|
if DISABLE_DEFAULT_COLLECTORS:
|
||||||
|
for collector in [GC_COLLECTOR, PLATFORM_COLLECTOR, PROCESS_COLLECTOR]:
|
||||||
|
try:
|
||||||
|
REGISTRY.unregister(collector)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prometheus Metrics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Server
|
||||||
|
server_up = Gauge(f"{METRIC_PREFIX}_server_up", "Whether the TS6 server is reachable (1=up, 0=down)")
|
||||||
|
server_uptime = Gauge(f"{METRIC_PREFIX}_server_uptime_seconds", "Server uptime in seconds")
|
||||||
|
server_version_info = Info(f"{METRIC_PREFIX}_server_version", "TeamSpeak server version info")
|
||||||
|
|
||||||
|
# Clients
|
||||||
|
clients_online = Gauge(f"{METRIC_PREFIX}_clients_online", "Number of clients currently online (excluding query clients)")
|
||||||
|
clients_max = Gauge(f"{METRIC_PREFIX}_clients_max", "Maximum allowed clients")
|
||||||
|
query_clients_online = Gauge(f"{METRIC_PREFIX}_query_clients_online", "Number of query clients online")
|
||||||
|
|
||||||
|
# Channels
|
||||||
|
channels_total = Gauge(f"{METRIC_PREFIX}_channels_total", "Total number of channels")
|
||||||
|
|
||||||
|
# Bandwidth
|
||||||
|
bytes_sent = Gauge(f"{METRIC_PREFIX}_bytes_sent_total", "Total bytes sent by the server")
|
||||||
|
bytes_received = Gauge(f"{METRIC_PREFIX}_bytes_received_total", "Total bytes received by the server")
|
||||||
|
packets_sent = Gauge(f"{METRIC_PREFIX}_packets_sent_total", "Total packets sent")
|
||||||
|
packets_received = Gauge(f"{METRIC_PREFIX}_packets_received_total", "Total packets received")
|
||||||
|
|
||||||
|
# File Transfer
|
||||||
|
ft_bytes_sent = Gauge(f"{METRIC_PREFIX}_file_transfer_bytes_sent_total", "Total file transfer bytes sent")
|
||||||
|
ft_bytes_received = Gauge(f"{METRIC_PREFIX}_file_transfer_bytes_received_total", "Total file transfer bytes received")
|
||||||
|
|
||||||
|
# Quality
|
||||||
|
avg_ping = Gauge(f"{METRIC_PREFIX}_average_ping_seconds", "Average client ping in seconds")
|
||||||
|
avg_packet_loss = Gauge(f"{METRIC_PREFIX}_average_packet_loss", "Average client packet loss ratio")
|
||||||
|
|
||||||
|
# Bans
|
||||||
|
bans_total = Gauge(f"{METRIC_PREFIX}_bans_total", "Total number of active bans")
|
||||||
|
|
||||||
|
# Server Groups
|
||||||
|
server_groups_total = Gauge(f"{METRIC_PREFIX}_server_groups_total", "Total number of server groups")
|
||||||
|
|
||||||
|
# Per-client info (using labels)
|
||||||
|
client_info_gauge = Gauge(
|
||||||
|
f"{METRIC_PREFIX}_client_connected",
|
||||||
|
"Connected client info (1 = connected)",
|
||||||
|
["client_id", "nickname", "platform", "version", "country", "channel_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scrape metrics
|
||||||
|
scrape_duration = Gauge(f"{METRIC_PREFIX}_scrape_duration_seconds", "Duration of the last scrape in seconds")
|
||||||
|
scrape_errors = Counter(f"{METRIC_PREFIX}_scrape_errors_total", "Total number of scrape errors")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collector
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def safe_int(value, default=0):
|
||||||
|
"""Safely convert a value to int."""
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def safe_float(value, default=0.0):
|
||||||
|
"""Safely convert a value to float."""
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def collect_metrics(client: TS6Client):
|
||||||
|
"""Collect all metrics from the TS6 server."""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if server is alive
|
||||||
|
alive = client.is_alive()
|
||||||
|
server_up.set(1 if alive else 0)
|
||||||
|
|
||||||
|
if not alive:
|
||||||
|
logger.warning("TS6 server is not reachable")
|
||||||
|
scrape_errors.inc()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Server version (only needs to be set once, but low cost)
|
||||||
|
try:
|
||||||
|
ver = client.version()
|
||||||
|
if ver:
|
||||||
|
server_version_info.info({
|
||||||
|
"version": ver.get("version", "unknown"),
|
||||||
|
"build": str(ver.get("build", "unknown")),
|
||||||
|
"platform": ver.get("platform", "unknown"),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not get version: %s", e)
|
||||||
|
|
||||||
|
# Server info
|
||||||
|
try:
|
||||||
|
info = client.server_info()
|
||||||
|
if info:
|
||||||
|
server_uptime.set(safe_int(info.get("virtualserver_uptime", 0)))
|
||||||
|
clients_online.set(safe_int(info.get("virtualserver_clientsonline", 0)) - safe_int(info.get("virtualserver_queryclientsonline", 0)))
|
||||||
|
clients_max.set(safe_int(info.get("virtualserver_maxclients", 0)))
|
||||||
|
query_clients_online.set(safe_int(info.get("virtualserver_queryclientsonline", 0)))
|
||||||
|
channels_total.set(safe_int(info.get("virtualserver_channelsonline", 0)))
|
||||||
|
|
||||||
|
# Bandwidth
|
||||||
|
bytes_sent.set(safe_int(info.get("connection_bytes_sent_total", 0)))
|
||||||
|
bytes_received.set(safe_int(info.get("connection_bytes_received_total", 0)))
|
||||||
|
packets_sent.set(safe_int(info.get("connection_packets_sent_total", 0)))
|
||||||
|
packets_received.set(safe_int(info.get("connection_packets_received_total", 0)))
|
||||||
|
|
||||||
|
# File transfer
|
||||||
|
ft_bytes_sent.set(safe_int(info.get("connection_filetransfer_bytes_sent_total", 0)))
|
||||||
|
ft_bytes_received.set(safe_int(info.get("connection_filetransfer_bytes_received_total", 0)))
|
||||||
|
|
||||||
|
# Quality
|
||||||
|
avg_ping.set(safe_float(info.get("virtualserver_total_ping", 0.0)) / 1000.0)
|
||||||
|
avg_packet_loss.set(safe_float(info.get("virtualserver_total_packetloss_total", 0.0)))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error collecting server info: %s", e)
|
||||||
|
scrape_errors.inc()
|
||||||
|
|
||||||
|
# Bans
|
||||||
|
try:
|
||||||
|
bans = client.ban_list()
|
||||||
|
bans_total.set(len(bans))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not get ban list: %s", e)
|
||||||
|
bans_total.set(0)
|
||||||
|
|
||||||
|
# Server groups
|
||||||
|
try:
|
||||||
|
groups = client.server_group_list()
|
||||||
|
server_groups_total.set(len(groups))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not get server groups: %s", e)
|
||||||
|
|
||||||
|
# Per-client metrics
|
||||||
|
try:
|
||||||
|
# Clear previous client labels
|
||||||
|
client_info_gauge._metrics.clear()
|
||||||
|
|
||||||
|
clients = client.client_list()
|
||||||
|
for c in clients:
|
||||||
|
# Skip query clients (client_type=1)
|
||||||
|
if safe_int(c.get("client_type", 0)) == 1:
|
||||||
|
continue
|
||||||
|
|
||||||
|
client_info_gauge.labels(
|
||||||
|
client_id=c.get("clid", ""),
|
||||||
|
nickname=c.get("client_nickname", "unknown"),
|
||||||
|
platform=c.get("client_platform", "unknown"),
|
||||||
|
version=c.get("client_version", "unknown"),
|
||||||
|
country=c.get("client_country", ""),
|
||||||
|
channel_id=c.get("cid", ""),
|
||||||
|
).set(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not get client list: %s", e)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Unexpected error during collection: %s", e)
|
||||||
|
server_up.set(0)
|
||||||
|
scrape_errors.inc()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
scrape_duration.set(duration)
|
||||||
|
logger.info(
|
||||||
|
"Scrape completed in %.3fs | clients=%s channels=%s",
|
||||||
|
duration,
|
||||||
|
clients_online._value.get() if hasattr(clients_online._value, 'get') else '?',
|
||||||
|
channels_total._value.get() if hasattr(channels_total._value, 'get') else '?',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("TeamSpeak 6 Prometheus Exporter")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("TS6 Host: %s:%s", TS6_HOST, TS6_QUERY_PORT)
|
||||||
|
logger.info("Server ID: %s", TS6_SERVER_ID)
|
||||||
|
logger.info("Exporter Port: %s", EXPORTER_PORT)
|
||||||
|
logger.info("Poll Interval: %ss", POLL_INTERVAL)
|
||||||
|
logger.info("=" * 60)
|
||||||
|
|
||||||
|
client = TS6Client(
|
||||||
|
host=TS6_HOST,
|
||||||
|
port=TS6_QUERY_PORT,
|
||||||
|
api_key=TS6_API_KEY,
|
||||||
|
server_id=TS6_SERVER_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start Prometheus HTTP server
|
||||||
|
start_http_server(EXPORTER_PORT)
|
||||||
|
logger.info("Metrics server started on http://0.0.0.0:%s/metrics", EXPORTER_PORT)
|
||||||
|
|
||||||
|
# Graceful shutdown
|
||||||
|
running = True
|
||||||
|
|
||||||
|
def shutdown(signum, frame):
|
||||||
|
nonlocal running
|
||||||
|
logger.info("Received signal %s, shutting down...", signum)
|
||||||
|
running = False
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, shutdown)
|
||||||
|
signal.signal(signal.SIGINT, shutdown)
|
||||||
|
|
||||||
|
# Main polling loop
|
||||||
|
while running:
|
||||||
|
collect_metrics(client)
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
logger.info("Exporter stopped.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
621
grafana/dashboard.json
Normal file
621
grafana/dashboard.json
Normal file
@ -0,0 +1,621 @@
|
|||||||
|
{
|
||||||
|
"__inputs": [
|
||||||
|
{
|
||||||
|
"name": "DS_PROMETHEUS",
|
||||||
|
"label": "Prometheus",
|
||||||
|
"description": "",
|
||||||
|
"type": "datasource",
|
||||||
|
"pluginId": "prometheus",
|
||||||
|
"pluginName": "Prometheus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"__elements": {},
|
||||||
|
"__requires": [
|
||||||
|
{
|
||||||
|
"type": "grafana",
|
||||||
|
"id": "grafana",
|
||||||
|
"name": "Grafana",
|
||||||
|
"version": "10.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "datasource",
|
||||||
|
"id": "prometheus",
|
||||||
|
"name": "Prometheus",
|
||||||
|
"version": "1.0.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"annotations": {
|
||||||
|
"list": []
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"fiscalYearStartMonth": 0,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"id": null,
|
||||||
|
"links": [],
|
||||||
|
"liveNow": false,
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||||
|
"id": 100,
|
||||||
|
"title": "Server Overview",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"mappings": [
|
||||||
|
{ "options": { "0": { "color": "red", "text": "OFFLINE" }, "1": { "color": "green", "text": "ONLINE" } }, "type": "value" }
|
||||||
|
],
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "red", "value": null },
|
||||||
|
{ "color": "green", "value": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 },
|
||||||
|
"id": 1,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Server Status",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_server_up", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "dtdurations",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "blue", "value": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 },
|
||||||
|
"id": 2,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Uptime",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_server_uptime_seconds", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 20 },
|
||||||
|
{ "color": "red", "value": 50 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 },
|
||||||
|
"id": 3,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "background_solid",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Clients Online",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_clients_online", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "purple", "value": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 },
|
||||||
|
"id": 4,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Max Clients",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_clients_max", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "cyan", "value": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 },
|
||||||
|
"id": 5,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Channels",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_channels_total", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "orange", "value": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 },
|
||||||
|
"id": 6,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Active Bans",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_bans_total", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
|
||||||
|
"id": 101,
|
||||||
|
"title": "Clients",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 20,
|
||||||
|
"gradientMode": "scheme",
|
||||||
|
"spanNulls": true,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"pointSize": 5,
|
||||||
|
"showPoints": "auto"
|
||||||
|
},
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"unit": "short"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 16, "x": 0, "y": 6 },
|
||||||
|
"id": 10,
|
||||||
|
"options": {
|
||||||
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
|
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||||
|
},
|
||||||
|
"title": "Clients Online Over Time",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_clients_online", "legendFormat": "Clients Online", "refId": "A" },
|
||||||
|
{ "expr": "ts6_query_clients_online", "legendFormat": "Query Clients", "refId": "B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"filterable": true
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" },
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [{ "color": "green", "value": null }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 6 },
|
||||||
|
"id": 11,
|
||||||
|
"options": {
|
||||||
|
"showHeader": true,
|
||||||
|
"sortBy": [{ "desc": false, "displayName": "nickname" }]
|
||||||
|
},
|
||||||
|
"title": "Connected Clients",
|
||||||
|
"type": "table",
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "ts6_client_connected",
|
||||||
|
"format": "table",
|
||||||
|
"instant": true,
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "organize",
|
||||||
|
"options": {
|
||||||
|
"excludeByName": { "Time": true, "Value": true, "__name__": true, "instance": true, "job": true, "client_id": false },
|
||||||
|
"renameByName": {
|
||||||
|
"client_id": "ID",
|
||||||
|
"nickname": "Nickname",
|
||||||
|
"platform": "Platform",
|
||||||
|
"version": "Version",
|
||||||
|
"country": "Country",
|
||||||
|
"channel_id": "Channel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 },
|
||||||
|
"id": 102,
|
||||||
|
"title": "Bandwidth & Network",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 15,
|
||||||
|
"gradientMode": "scheme",
|
||||||
|
"spanNulls": true,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"pointSize": 5,
|
||||||
|
"showPoints": "never"
|
||||||
|
},
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
|
||||||
|
"id": 20,
|
||||||
|
"options": {
|
||||||
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
|
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||||
|
},
|
||||||
|
"title": "Bandwidth (Bytes/s)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(ts6_bytes_sent_total[5m])", "legendFormat": "Sent", "refId": "A" },
|
||||||
|
{ "expr": "rate(ts6_bytes_received_total[5m])", "legendFormat": "Received", "refId": "B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 15,
|
||||||
|
"gradientMode": "scheme",
|
||||||
|
"spanNulls": true,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"pointSize": 5,
|
||||||
|
"showPoints": "never"
|
||||||
|
},
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"unit": "pps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
|
||||||
|
"id": 21,
|
||||||
|
"options": {
|
||||||
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
|
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||||
|
},
|
||||||
|
"title": "Packets/s",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(ts6_packets_sent_total[5m])", "legendFormat": "Sent", "refId": "A" },
|
||||||
|
{ "expr": "rate(ts6_packets_received_total[5m])", "legendFormat": "Received", "refId": "B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 },
|
||||||
|
"id": 103,
|
||||||
|
"title": "Quality",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"min": 0,
|
||||||
|
"max": 0.5,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 0.05 },
|
||||||
|
{ "color": "orange", "value": 0.1 },
|
||||||
|
{ "color": "red", "value": 0.2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 6, "w": 8, "x": 0, "y": 24 },
|
||||||
|
"id": 30,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"showThresholdLabels": false,
|
||||||
|
"showThresholdMarkers": true
|
||||||
|
},
|
||||||
|
"title": "Average Ping",
|
||||||
|
"type": "gauge",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_average_ping_seconds", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percentunit",
|
||||||
|
"min": 0,
|
||||||
|
"max": 1,
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 0.01 },
|
||||||
|
{ "color": "orange", "value": 0.05 },
|
||||||
|
{ "color": "red", "value": 0.1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 6, "w": 8, "x": 8, "y": 24 },
|
||||||
|
"id": 31,
|
||||||
|
"options": {
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"showThresholdLabels": false,
|
||||||
|
"showThresholdMarkers": true
|
||||||
|
},
|
||||||
|
"title": "Average Packet Loss",
|
||||||
|
"type": "gauge",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_average_packet_loss", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"custom": {
|
||||||
|
"lineWidth": 2,
|
||||||
|
"fillOpacity": 10,
|
||||||
|
"gradientMode": "scheme",
|
||||||
|
"spanNulls": true,
|
||||||
|
"drawStyle": "line",
|
||||||
|
"pointSize": 5,
|
||||||
|
"showPoints": "never"
|
||||||
|
},
|
||||||
|
"color": { "mode": "palette-classic" },
|
||||||
|
"unit": "Bps"
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 6, "w": 8, "x": 16, "y": 24 },
|
||||||
|
"id": 32,
|
||||||
|
"options": {
|
||||||
|
"legend": { "displayMode": "list", "placement": "bottom" },
|
||||||
|
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||||
|
},
|
||||||
|
"title": "File Transfer Bandwidth",
|
||||||
|
"type": "timeseries",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(ts6_file_transfer_bytes_sent_total[5m])", "legendFormat": "Sent", "refId": "A" },
|
||||||
|
{ "expr": "rate(ts6_file_transfer_bytes_received_total[5m])", "legendFormat": "Received", "refId": "B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collapsed": false,
|
||||||
|
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 30 },
|
||||||
|
"id": 104,
|
||||||
|
"title": "Exporter Health",
|
||||||
|
"type": "row"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "s",
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "yellow", "value": 1 },
|
||||||
|
{ "color": "red", "value": 5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 31 },
|
||||||
|
"id": 40,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Scrape Duration",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_scrape_duration_seconds", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "green", "value": null },
|
||||||
|
{ "color": "red", "value": 1 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 31 },
|
||||||
|
"id": 41,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "area",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Scrape Errors",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_scrape_errors_total", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "blue", "value": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 31 },
|
||||||
|
"id": 42,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Server Groups",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_server_groups_total", "refId": "A" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"thresholds": {
|
||||||
|
"mode": "absolute",
|
||||||
|
"steps": [
|
||||||
|
{ "color": "blue", "value": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": { "mode": "thresholds" }
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 31 },
|
||||||
|
"id": 43,
|
||||||
|
"options": {
|
||||||
|
"colorMode": "value",
|
||||||
|
"graphMode": "none",
|
||||||
|
"justifyMode": "center",
|
||||||
|
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||||
|
"textMode": "value"
|
||||||
|
},
|
||||||
|
"title": "Query Clients",
|
||||||
|
"type": "stat",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "ts6_query_clients_online", "refId": "A" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"tags": ["teamspeak", "ts6", "gaming"],
|
||||||
|
"templating": { "list": [] },
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"timepicker": {},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "TeamSpeak 6 Server",
|
||||||
|
"uid": "ts6-server-dashboard",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
prometheus-client>=0.20.0,<1.0.0
|
||||||
|
requests>=2.31.0,<3.0.0
|
||||||
139
ts6_client.py
Normal file
139
ts6_client.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
TeamSpeak 6 WebQuery HTTP API Client.
|
||||||
|
|
||||||
|
Communicates with the TS6 server via the WebQuery HTTP interface
|
||||||
|
to retrieve server information, client lists, channels, bans, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TS6Client:
|
||||||
|
"""Client for the TeamSpeak 6 WebQuery HTTP API."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int, api_key: str, server_id: int = 1, timeout: int = 10):
|
||||||
|
self.base_url = f"http://{host}:{port}"
|
||||||
|
self.server_id = server_id
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
"x-api-key": self.api_key,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _request(self, command: str, params: dict = None, use_sid: bool = True) -> list[dict]:
|
||||||
|
"""Execute a WebQuery command and return parsed response."""
|
||||||
|
if use_sid:
|
||||||
|
url = f"{self.base_url}/{self.server_id}/{command}"
|
||||||
|
else:
|
||||||
|
url = f"{self.base_url}/{command}"
|
||||||
|
|
||||||
|
if params:
|
||||||
|
query_string = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in params.items())
|
||||||
|
url = f"{url}?{query_string}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.get(url, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if "body" in data:
|
||||||
|
body = data["body"]
|
||||||
|
if isinstance(body, list):
|
||||||
|
return body
|
||||||
|
return [body] if body else []
|
||||||
|
|
||||||
|
if "status" in data and data["status"].get("code", 0) != 0:
|
||||||
|
error_msg = data["status"].get("message", "Unknown error")
|
||||||
|
logger.error("TS6 API error for '%s': %s", command, error_msg)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
logger.error("Cannot connect to TS6 WebQuery at %s", self.base_url)
|
||||||
|
raise
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error("Timeout connecting to TS6 WebQuery at %s", self.base_url)
|
||||||
|
raise
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error("Request error for '%s': %s", command, e)
|
||||||
|
raise
|
||||||
|
except ValueError:
|
||||||
|
# JSON decode error — try raw text parsing
|
||||||
|
return self._parse_raw_response(response.text)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_raw_response(text: str) -> list[dict]:
|
||||||
|
"""Parse legacy-style key=value response format."""
|
||||||
|
results = []
|
||||||
|
for entry in text.split("|"):
|
||||||
|
item = {}
|
||||||
|
for pair in entry.strip().split(" "):
|
||||||
|
if "=" in pair:
|
||||||
|
key, value = pair.split("=", 1)
|
||||||
|
# Unescape TS6 special chars
|
||||||
|
value = value.replace("\\s", " ").replace("\\p", "|").replace("\\/", "/")
|
||||||
|
item[key] = value
|
||||||
|
else:
|
||||||
|
item[pair] = ""
|
||||||
|
if item:
|
||||||
|
results.append(item)
|
||||||
|
return results
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
"""Check if the TS6 server is reachable."""
|
||||||
|
try:
|
||||||
|
self._request("version", use_sid=False)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def version(self) -> dict:
|
||||||
|
"""Get server version info."""
|
||||||
|
result = self._request("version", use_sid=False)
|
||||||
|
return result[0] if result else {}
|
||||||
|
|
||||||
|
def server_info(self) -> dict:
|
||||||
|
"""Get detailed server information."""
|
||||||
|
result = self._request("serverinfo")
|
||||||
|
return result[0] if result else {}
|
||||||
|
|
||||||
|
def client_list(self) -> list[dict]:
|
||||||
|
"""Get list of connected clients with extended info."""
|
||||||
|
return self._request("clientlist", params={
|
||||||
|
"-uid": "",
|
||||||
|
"-away": "",
|
||||||
|
"-voice": "",
|
||||||
|
"-groups": "",
|
||||||
|
"-info": "",
|
||||||
|
"-country": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
def channel_list(self) -> list[dict]:
|
||||||
|
"""Get list of channels."""
|
||||||
|
return self._request("channellist", params={
|
||||||
|
"-topic": "",
|
||||||
|
"-flags": "",
|
||||||
|
"-voice": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
def ban_list(self) -> list[dict]:
|
||||||
|
"""Get list of active bans."""
|
||||||
|
try:
|
||||||
|
return self._request("banlist")
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def server_group_list(self) -> list[dict]:
|
||||||
|
"""Get list of server groups."""
|
||||||
|
return self._request("servergrouplist")
|
||||||
|
|
||||||
|
def whoami(self) -> dict:
|
||||||
|
"""Get info about the current query connection."""
|
||||||
|
result = self._request("whoami")
|
||||||
|
return result[0] if result else {}
|
||||||
Loading…
x
Reference in New Issue
Block a user