feat: initial release - TeamSpeak 6 Prometheus Exporter v1.0.0

This commit is contained in:
Bxio 2026-03-29 23:39:18 +01:00
commit eeeec364c0
11 changed files with 1431 additions and 0 deletions

13
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,244 @@
# TeamSpeak 6 Prometheus Exporter
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](https://gitea.zol.oixb.run/oixb.run/ts6-grafana)
[![Prometheus](https://img.shields.io/badge/Prometheus-exporter-E6522C?logo=prometheus&logoColor=white)](#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
View 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
View 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
View 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
View 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
View 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 {}