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