Added fontawesome
Added detail client info (voice) Added host connection link Added compose.yml Adjusted Dockerfile Adjusted entrypoint.sh Adjusted README.md
This commit is contained in:
parent
b678f8c4bf
commit
cdbfe86d32
31 changed files with 1281 additions and 729 deletions
28
Dockerfile
28
Dockerfile
|
|
@ -1,25 +1,13 @@
|
|||
# -------------------------
|
||||
# 1) Builder stage
|
||||
# -------------------------
|
||||
FROM golang:1.20-alpine AS builder
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
# Install CA certificates required for building
|
||||
RUN apk add --no-cache ca-certificates
|
||||
ENV GOTOOLCHAIN=auto
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy local repository into the build image (portable across OSes)
|
||||
COPY . /app
|
||||
|
||||
# Ensure config.example exists; keep a fallback copy
|
||||
RUN cp cmd/server/config.example.json cmd/server/config.json || true
|
||||
|
||||
# Normalize go.mod 'go' directive to a valid major.minor form to avoid build errors
|
||||
RUN if [ -f go.mod ]; then \
|
||||
sed -E -i 's/^go[[:space:]]+[0-9]+(\.[0-9]+){1,2}$/go 1.20/' go.mod || true; \
|
||||
fi
|
||||
|
||||
# Build a static Linux binary for the server
|
||||
RUN cd cmd/server && CGO_ENABLED=0 GOOS=linux go build -o ts6viewer .
|
||||
|
||||
# -------------------------
|
||||
|
|
@ -27,22 +15,16 @@ RUN cd cmd/server && CGO_ENABLED=0 GOOS=linux go build -o ts6viewer .
|
|||
# -------------------------
|
||||
FROM alpine:latest
|
||||
|
||||
# Install CA certificates and gettext for envsubst
|
||||
RUN apk add --no-cache ca-certificates gettext
|
||||
RUN apk add --no-cache ca-certificates gettext openssh-client
|
||||
|
||||
# Set working directory to where the server expects config.json when using relative path
|
||||
WORKDIR /app/cmd/server
|
||||
|
||||
# Copy the built app and assets from builder
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Copy entrypoint script and ensure it's executable
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh || true
|
||||
RUN chmod +x /app/cmd/server/ts6viewer || true
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
RUN chmod +x /app/cmd/server/ts6viewer
|
||||
|
||||
# Expose the internal port the app listens on
|
||||
EXPOSE 8080
|
||||
|
||||
# Use the entrypoint to generate config and start the app
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
|
|
|||
161
README.md
161
README.md
|
|
@ -1,22 +1,23 @@
|
|||
# TS6 Viewer
|
||||
TS6 Viewer
|
||||
<p align="center">
|
||||
<img src="dark.png" alt="Dark Theme" width="45%">
|
||||
<img src="light.png" alt="Light Theme" width="45%">
|
||||
</p>
|
||||
|
||||
A lightweight, fast, and modern web viewer for **TeamSpeak 6** servers.
|
||||
TS6 Viewer connects to the TeamSpeak 6 WebQuery API, retrieves live server and channel data, and displays it in a clean, responsive web interface with optional dark/light themes.
|
||||
TS6 Viewer connects to **ServerQuery (SSH, Port 10022)** and displays live server, channel, and client information.
|
||||
|
||||
The viewer is designed to be:
|
||||
|
||||
- Simple to deploy
|
||||
- Fast and lightweight
|
||||
- Fully client‑side auto‑refreshing
|
||||
- Compatible with any TS6 server exposing WebQuery
|
||||
- Compatible with any TS6 server
|
||||
- Customizable via a single `config.json` file
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- Live TeamSpeak 6 server viewer
|
||||
|
|
@ -25,103 +26,157 @@ The viewer is designed to be:
|
|||
- Channel tree rendering with clients
|
||||
- Spacer and full‑width channel support
|
||||
- Caching + rate‑limit protection
|
||||
- Works on Windows, Linux, and Docker‑friendly environments
|
||||
- Pure **ServerQuery (SSH)** backend
|
||||
- Optional **voice status** (mute status, audio status, talking)
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
# ServerQuery (SSH)
|
||||
|
||||
The application uses a `config.json` file located in the same directory as the executable.
|
||||
TS6 Viewer communicates exclusively via ServerQuery over SSH.
|
||||
|
||||
### Example `config.json`
|
||||
Advantages:
|
||||
- Persistent SSH connection
|
||||
- Very fast, even with many clients
|
||||
- clientlist -voice provides all audio/mute/talker info in a single call
|
||||
- No REST overhead
|
||||
- No per-client requests
|
||||
- No rate limits
|
||||
|
||||
```json
|
||||
{
|
||||
"_comment": "TS6 Viewer Configuration File",
|
||||
"_comment2": "Rename this file to config.json and adjust the values to your setup.",
|
||||
---
|
||||
|
||||
"server_port": "8080",
|
||||
"_comment_server_port": "The port on which the TS6 Viewer web interface will be available.",
|
||||
# Configuration Files in the Project
|
||||
|
||||
"theme": "dark",
|
||||
"_comment_theme": "Choose between 'light' or 'dark' for the viewer theme.",
|
||||
The repository includes two important configuration templates:
|
||||
|
||||
"refresh_interval": 60,
|
||||
"_comment_refresh_interval": "How often the viewer should auto-refresh (in seconds).",
|
||||
### `config.example.json`
|
||||
This file is included in the project and serves as the template for your actual configuration.
|
||||
You can:
|
||||
|
||||
"teamspeak6": {
|
||||
"base_url": "http://192.168.178.195:57007",
|
||||
"_comment_base_url": "The WebQuery HTTP endpoint of your TeamSpeak 6 server. Usually http://<ip>:10080 or your mapped port.",
|
||||
- rename it manually to `config.json`, or
|
||||
- let Docker generate `config.json` automatically using environment variables.
|
||||
|
||||
"api_key": "geheim",
|
||||
"_comment_api_key": "Your TeamSpeak 6 ServerQuery API key. It is shown ONCE in the server logs on first startup. If lost, create a new one.",
|
||||
---
|
||||
|
||||
"server_id": 1,
|
||||
"_comment_server_id": "The ID of the virtual server you want to display. Default is usually 1."
|
||||
}
|
||||
}
|
||||
# Docker Support
|
||||
|
||||
The repository includes:
|
||||
|
||||
- Dockerfile.sh — multi‑stage build for Go + Alpine
|
||||
- entrypoint.sh — generates config.json dynamically using environment variables
|
||||
- docker-compose.yml — ready to run the viewer with one command
|
||||
|
||||
This allows you to run TS6 Viewer fully containerized.
|
||||
|
||||
---
|
||||
|
||||
## Dockerfile.sh explained
|
||||
|
||||
The Dockerfile uses two stages:
|
||||
|
||||
### 1) Builder Stage
|
||||
- Based on golang:1.20-alpine
|
||||
- Copies the entire repository into /app
|
||||
- Ensures config.example.json exists
|
||||
- Normalizes go.mod to avoid Go version parsing issues
|
||||
- Builds a static Linux binary: cmd/server/ts6viewer
|
||||
|
||||
### 2) Runtime Stage
|
||||
- Based on alpine:latest
|
||||
- Installs CA certificates + gettext (envsubst)
|
||||
- Copies the built binary and assets from the builder
|
||||
- Copies entrypoint.sh
|
||||
- Exposes port 8080
|
||||
- Starts the viewer via the entrypoint script
|
||||
|
||||
---
|
||||
|
||||
## entrypoint.sh explained
|
||||
|
||||
The entrypoint script:
|
||||
|
||||
1. Loads environment variables
|
||||
2. Applies defaults if variables are missing
|
||||
3. Uses envsubst to generate config.json from config.example.json
|
||||
4. Starts the TS6 Viewer binary
|
||||
|
||||
Environment variables include:
|
||||
|
||||
- SERVER_PORT
|
||||
- THEME
|
||||
- REFRESH_INTERVAL
|
||||
- HOST
|
||||
- PORT
|
||||
- USER
|
||||
- PASSWORD
|
||||
- ENABLE_VOICE_STATUS
|
||||
- SERVER_ID
|
||||
|
||||
This makes the Docker container fully configurable without editing files.
|
||||
|
||||
# docker-compose.yml
|
||||
|
||||
A ready‑to‑use compose file is included in the project.
|
||||
You can start the viewer with:
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Building the Program
|
||||
# Building the Program
|
||||
|
||||
The project is written in **Go**, so building is extremely simple.
|
||||
The project is written in Go.
|
||||
|
||||
### Prerequisites
|
||||
### Build on Linux
|
||||
|
||||
- Go 1.20 or newer
|
||||
- Git (optional)
|
||||
|
||||
---
|
||||
|
||||
## Build on Linux
|
||||
```bash
|
||||
```sh
|
||||
git clone https://github.com/Maxallica/ts6-viewer.git
|
||||
cd ts6viewer
|
||||
|
||||
go build -o ts6viewer
|
||||
```
|
||||
|
||||
## Build Windows binary on Linux
|
||||
```bash
|
||||
### Build Windows binary on Linux
|
||||
```sh
|
||||
cd "*/ts6-viewer/cmd/server"
|
||||
|
||||
GOOS=windows GOARCH=amd64 go build -o ts6viewer.exe
|
||||
```
|
||||
|
||||
## Running on Linux
|
||||
```bash
|
||||
### Running on Linux
|
||||
|
||||
```sh
|
||||
./ts6viewer
|
||||
```
|
||||
|
||||
## Build on Windows
|
||||
```bash
|
||||
### Build on Windows
|
||||
|
||||
```sh
|
||||
git clone https://github.com/Maxallica/ts6-viewer.git
|
||||
cd ts6viewer
|
||||
|
||||
go build -o ts6viewer.exe
|
||||
```
|
||||
|
||||
## Build Linux binary on Windows
|
||||
```bash
|
||||
cd "*/ts6-viewer/cmd/server"
|
||||
### Build Linux binary on Windows
|
||||
|
||||
```sh
|
||||
cd "*/ts6-viewer/cmd/server"
|
||||
$env:GOOS="linux"
|
||||
$env:GOARCH="amd64"
|
||||
go build -o ts6viewer
|
||||
```
|
||||
|
||||
## Running on Windows
|
||||
```bash
|
||||
### Running on Windows
|
||||
```sh
|
||||
.\ts6viewer.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigate to the ts6viewer page
|
||||
http(s)\://\<ip\>:\<port\>/ts6viewer
|
||||
## Navigate to the TS6 Viewer page
|
||||
|
||||
http(s)\/\/\<ip\>:\<port\>/ts6viewer
|
||||
|
||||
---
|
||||
|
||||
## ❤️ Made with love in Germany
|
||||
## ❤️ Made with love in Germany
|
||||
|
|
|
|||
|
|
@ -2,23 +2,36 @@
|
|||
"_comment": "TS6 Viewer Configuration File",
|
||||
"_comment2": "Rename this file to config.json and adjust the values to your setup.",
|
||||
|
||||
"server_port": "${SERVER_PORT}",
|
||||
"_comment_server_port": "The port on which the TS6 Viewer web interface will be available. Usually '8080'",
|
||||
"server_port": "${SERVER_PORT}",
|
||||
"_comment_server_port": "The port on which the TS6 Viewer web interface will be available.",
|
||||
|
||||
"theme": "${THEME}",
|
||||
"_comment_theme": "Choose between 'light' or 'dark' for the viewer theme. Usually 'dark'",
|
||||
"theme": "${THEME}",
|
||||
"_comment_theme": "Choose between 'light' or 'dark' for the viewer theme.",
|
||||
|
||||
"refresh_interval": "${REFRESH_INTERVAL}",
|
||||
"_comment_refresh_interval": "How often the viewer should auto-refresh (in seconds). Usually '60'",
|
||||
"_comment_refresh_interval": "How often the viewer should auto-refresh (in seconds).",
|
||||
|
||||
"teamspeak6": {
|
||||
"base_url": "${BASE_URL}",
|
||||
"_comment_base_url": "The WebQuery HTTP endpoint of your TeamSpeak 6 server. Usually http://192.168.178.2:10080.",
|
||||
"host_connection_link": "${HOST_CONNECTION_LINK}",
|
||||
"_comment_host_connection_link": "The URL or IP address of your TeamSpeak 6 server. This is used for display purposes and should match the actual server address.",
|
||||
|
||||
"api_key": "${API_KEY}",
|
||||
"_comment_api_key": "Your TeamSpeak 6 ServerQuery API key. It is shown ONCE in the server logs on first startup. If lost, you have to create a whole new server.",
|
||||
"teamspeak6": {
|
||||
"host": "${HOST}",
|
||||
"_comment_host": "The ServerQuery [ssh] host. Usually the IP / domain of your TeamSpeak 6 server or 'localhost' if TS6 Viewer runs on the same machine.",
|
||||
|
||||
"port": "${PORT}",
|
||||
"_comment_port": "The ServerQuery [ssh] port. Usually 10022 for SSH.",
|
||||
|
||||
"user": "${USER}",
|
||||
"_comment_user": "The ServerQuery [ssh] user. Usually 'serveradmin' by default.",
|
||||
|
||||
"password": "${PASSWORD}",
|
||||
"_comment_password": "The ServerQuery [ssh] password. It is shown ONCE in the server logs on first startup.",
|
||||
|
||||
"enable_voice_status": "${ENABLE_VOICE_STATUS}",
|
||||
"_comment_enable_voice_status": "Fetch microphone and audio output status for each client (TS6 -voice).",
|
||||
|
||||
"server_id": "${SERVER_ID}",
|
||||
"_comment_server_id": "The ID of the virtual server you want to display. Default is usually '1'."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
23
compose.yml
Normal file
23
compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
version: "3.9"
|
||||
|
||||
services:
|
||||
ts6viewer:
|
||||
image: ts6viewer:latest
|
||||
container_name: ts6viewer
|
||||
ports:
|
||||
- "9000:8080"
|
||||
|
||||
environment:
|
||||
SERVER_PORT: "8080"
|
||||
THEME: "dark"
|
||||
REFRESH_INTERVAL: "60"
|
||||
HOST_CONNECTION_LINK: ""
|
||||
|
||||
HOST: "192.168.178.2"
|
||||
PORT: "10022"
|
||||
USER: "serveradmin"
|
||||
PASSWORD: ""
|
||||
ENABLE_VOICE_STATUS: "true"
|
||||
SERVER_ID: "1"
|
||||
|
||||
restart: unless-stopped
|
||||
BIN
dark.png
BIN
dark.png
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 100 KiB |
|
|
@ -1,60 +1,53 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Paths
|
||||
EXAMPLE="/app/cmd/server/config.example.json"
|
||||
TARGET_REL="config.json" # relative path in WORKDIR (/app/cmd/server)
|
||||
TARGET_ABS="/app/cmd/server/config.json" # absolute path for clarity
|
||||
TARGET_ABS="/app/cmd/server/config.json"
|
||||
BINARY="/app/cmd/server/ts6viewer"
|
||||
|
||||
# Export variables used in the template (use environment variables only)
|
||||
export API_KEY="${API_KEY:-}"
|
||||
export SERVER_PORT="${SERVER_PORT:-8080}"
|
||||
export BASE_URL="${BASE_URL:-http://127.0.0.1:10080}"
|
||||
export SERVER_ID="${SERVER_ID:-1}"
|
||||
export THEME="${THEME:-dark}"
|
||||
export REFRESH_INTERVAL="${REFRESH_INTERVAL:-60}"
|
||||
export HOST_CONNECTION_LINK="${HOST_CONNECTION_LINK:-}"
|
||||
|
||||
# Minimal, useful debug output
|
||||
echo "=== STARTUP ==="
|
||||
# Mask API key for logs: show first 4 chars if present
|
||||
if [ -n "$API_KEY" ]; then
|
||||
echo "API_KEY=****${API_KEY#????}"
|
||||
else
|
||||
echo "API_KEY=(empty)"
|
||||
fi
|
||||
echo "PORT=${SERVER_PORT} BASE_URL=${BASE_URL} THEME=${THEME}"
|
||||
echo "Working dir: $(pwd)"
|
||||
echo
|
||||
export HOST="${HOST:-localhost}"
|
||||
export PORT="${PORT:-10022}"
|
||||
export USER="${USER:-serveradmin}"
|
||||
export PASSWORD="${PASSWORD:-}"
|
||||
export ENABLE_VOICE_STATUS="${ENABLE_VOICE_STATUS:-true}"
|
||||
export SERVER_ID="${SERVER_ID:-1}"
|
||||
|
||||
# Quick binary check
|
||||
if [ -x "$BINARY" ]; then
|
||||
echo "Binary: OK -> $BINARY"
|
||||
else
|
||||
echo "Binary: MISSING or not executable -> $BINARY" >&2
|
||||
echo "[entrypoint] starting TS6 Viewer"
|
||||
|
||||
if [ ! -x "$BINARY" ]; then
|
||||
echo "[entrypoint] ERROR: binary not found or not executable: $BINARY" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure example exists; if not, start the binary directly
|
||||
if [ ! -f "$EXAMPLE" ]; then
|
||||
echo "config.example.json not found at $EXAMPLE, starting binary directly" >&2
|
||||
echo "[entrypoint] WARNING: config.example.json not found, starting without generated config" >&2
|
||||
echo "[entrypoint] Starting server..."
|
||||
exec "$BINARY"
|
||||
fi
|
||||
|
||||
# Generate config.json from config.example.json using envsubst
|
||||
envsubst < "$EXAMPLE" > "$TARGET_ABS" || {
|
||||
echo "Failed to generate config.json" >&2
|
||||
if ! envsubst < "$EXAMPLE" > "$TARGET_ABS"; then
|
||||
echo "[entrypoint] ERROR: failed to generate config.json" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Ensure relative copy exists for relative loads
|
||||
cp -f "$TARGET_ABS" "$TARGET_REL" || true
|
||||
echo "[entrypoint] config.json generated"
|
||||
|
||||
# Show compact confirmation and a short preview of the generated config
|
||||
size=$(wc -c < "$TARGET_ABS" 2>/dev/null || echo 0)
|
||||
echo "Generated config.json (${size} bytes). Preview:"
|
||||
echo "-----"
|
||||
head -n 20 "$TARGET_ABS" || true
|
||||
echo "-----"
|
||||
echo "[entrypoint] Loaded configuration:"
|
||||
echo " SERVER_PORT=$SERVER_PORT"
|
||||
echo " THEME=$THEME"
|
||||
echo " REFRESH_INTERVAL=$REFRESH_INTERVAL"
|
||||
echo " HOST=$HOST"
|
||||
echo " PORT=$PORT"
|
||||
echo " USER=$USER"
|
||||
echo " PASSWORD=*********"
|
||||
echo " ENABLE_VOICE_STATUS=$ENABLE_VOICE_STATUS"
|
||||
echo " SERVER_ID=$SERVER_ID"
|
||||
|
||||
echo "[entrypoint] Starting server..."
|
||||
|
||||
echo "Starting server..."
|
||||
exec "$BINARY"
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -1,3 +1,7 @@
|
|||
module ts6-viewer
|
||||
|
||||
go 1.25.6
|
||||
|
||||
require golang.org/x/crypto v0.47.0
|
||||
|
||||
require golang.org/x/sys v0.40.0 // indirect
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -0,0 +1,6 @@
|
|||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
|
|
@ -1,103 +1,83 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"net"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
"ts6-viewer/internal/config"
|
||||
"ts6-viewer/internal/domain"
|
||||
"ts6-viewer/internal/mapper"
|
||||
"ts6-viewer/internal/ts6"
|
||||
"ts6-viewer/internal/view"
|
||||
)
|
||||
|
||||
// getIP extracts the IP address from the request.
|
||||
func getIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
ip := r.RemoteAddr
|
||||
if ipForwarded := r.Header.Get("X-Forwarded-For"); ipForwarded != "" {
|
||||
ip = ipForwarded
|
||||
}
|
||||
return host
|
||||
return ip
|
||||
}
|
||||
|
||||
// allowRequest checks rate limiting per IP.
|
||||
func allowRequest(ip string) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
last, exists := lastRequestTime[ip]
|
||||
|
||||
if exists && time.Since(last) < rateLimitWindow {
|
||||
now := time.Now()
|
||||
last, ok := lastRequestTime[ip]
|
||||
if ok && now.Sub(last) < rateLimitWindow {
|
||||
return false
|
||||
}
|
||||
|
||||
lastRequestTime[ip] = time.Now()
|
||||
lastRequestTime[ip] = now
|
||||
return true
|
||||
}
|
||||
|
||||
func getViewerData(cfg *config.Config, baseURL, apiKey, serverID string) (view.ViewerData, error) {
|
||||
// getViewerData fetches or returns cached viewer data.
|
||||
func getViewerData(cfg *config.Config, force bool) (view.VMTS6Viewer, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if time.Since(cacheTimestamp) < cacheTTL {
|
||||
if !force && time.Since(cacheTimestamp) < cacheTTL {
|
||||
log.Println("[HTTP] Returning cached viewer data")
|
||||
return cacheData, nil
|
||||
}
|
||||
|
||||
serverInfo, err := ts6.GetServerInfo(baseURL, apiKey, serverID)
|
||||
log.Println("[HTTP] Fetching new viewer data from TS6 server")
|
||||
sshClient, err := ts6.GetPersistentClient(cfg, cfg.Teamspeak6.ServerID)
|
||||
if err != nil {
|
||||
return view.ViewerData{}, err
|
||||
log.Printf("[HTTP] Failed to get SSH client: %v\n", err)
|
||||
return view.VMTS6Viewer{}, err
|
||||
}
|
||||
|
||||
apiClients, err := ts6.GetClientList(baseURL, apiKey, serverID)
|
||||
channels, err := ts6.GetChannelList(cfg, sshClient)
|
||||
if err != nil {
|
||||
return view.ViewerData{}, err
|
||||
log.Printf("[HTTP] Failed to get channels: %v\n", err)
|
||||
return view.VMTS6Viewer{}, err
|
||||
}
|
||||
|
||||
apiChannels, err := ts6.GetChannelList(baseURL, apiKey, serverID)
|
||||
clients, err := ts6.GetClientList(cfg, sshClient)
|
||||
if err != nil {
|
||||
return view.ViewerData{}, err
|
||||
log.Printf("[HTTP] Failed to get clients: %v\n", err)
|
||||
return view.VMTS6Viewer{}, err
|
||||
}
|
||||
|
||||
// API → Domain
|
||||
domainChannels := make([]*domain.Channel, 0, len(apiChannels))
|
||||
for _, ch := range apiChannels {
|
||||
domainChannels = append(domainChannels, mapper.MapAPIChannel(ch))
|
||||
info, err := ts6.GetServerInfo(cfg, sshClient)
|
||||
if err != nil {
|
||||
log.Printf("[HTTP] Failed to get server info: %v\n", err)
|
||||
return view.VMTS6Viewer{}, err
|
||||
}
|
||||
|
||||
fullClients := make([]*domain.FullClient, 0, len(apiClients))
|
||||
for _, c := range apiClients {
|
||||
// info, err := ts6.GetClientInfo(baseURL, apiKey, serverID, c.CLID)
|
||||
// if err != nil {
|
||||
// fmt.Println("clientinfo error:", err)
|
||||
// }
|
||||
|
||||
domainInfo := &domain.ClientInfo{
|
||||
MicMuted: false,
|
||||
OutputMuted: false,
|
||||
IsTalking: false,
|
||||
}
|
||||
|
||||
domainClient := mapper.MapAPIClient(c)
|
||||
// domainInfo := mapper.MapAPIClientInfo(info)
|
||||
|
||||
fullClients = append(fullClients, &domain.FullClient{
|
||||
Client: *domainClient,
|
||||
Info: domainInfo,
|
||||
})
|
||||
}
|
||||
|
||||
channelTree := domain.BuildChannelTree(domainChannels, fullClients)
|
||||
|
||||
// ServerInfo → Domain
|
||||
domainServer := mapper.MapAPIServer(serverInfo)
|
||||
|
||||
// Domain → View
|
||||
viewData := view.ViewerData{
|
||||
Server: mapper.MapServerToView(domainServer),
|
||||
ChannelTree: mapper.MapChannelTreeToView(channelTree),
|
||||
vmTS6Viewer := view.VMTS6Viewer{
|
||||
VMServer: view.BuildVMServer(cfg, info, clients),
|
||||
VMChannels: view.BuildVMChannels(channels, clients),
|
||||
Theme: cfg.Theme,
|
||||
RefreshInterval: cfg.RefreshInterval,
|
||||
}
|
||||
|
||||
// Set cache
|
||||
cacheData = viewData
|
||||
cacheData = vmTS6Viewer
|
||||
cacheTimestamp = time.Now()
|
||||
return viewData, nil
|
||||
log.Println("[HTTP] Viewer data updated and cached")
|
||||
|
||||
return vmTS6Viewer, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
cacheData view.ViewerData
|
||||
cacheData view.VMTS6Viewer
|
||||
cacheTimestamp time.Time
|
||||
cacheTTL = 5 * time.Second
|
||||
|
||||
|
|
@ -26,84 +26,101 @@ var (
|
|||
mu sync.Mutex
|
||||
)
|
||||
|
||||
// NewRouter sets up all HTTP routes and returns the router.
|
||||
func NewRouter(cfg config.Config) http.Handler {
|
||||
// parse refresh interval
|
||||
if ttl, err := time.ParseDuration(cfg.RefreshInterval + "s"); err == nil {
|
||||
cacheTTL = ttl
|
||||
log.Printf("[HTTP] Cache TTL set to %v\n", cacheTTL)
|
||||
}
|
||||
|
||||
baseURL := cfg.Teamspeak6.BaseURL
|
||||
apiKey := cfg.Teamspeak6.ApiKey
|
||||
serverID := cfg.Teamspeak6.ServerID
|
||||
refreshIntervalStr := cfg.RefreshInterval
|
||||
|
||||
refreshInterval, err := strconv.Atoi(refreshIntervalStr)
|
||||
if err != nil || refreshInterval <= 0 {
|
||||
refreshIntervalStr = "60"
|
||||
refreshInterval = 60
|
||||
}
|
||||
|
||||
log.Printf("[HTTP] Refresh interval: %d seconds\n", refreshInterval)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Load templates
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatal("cannot get working directory:", err)
|
||||
log.Fatal("[HTTP] Cannot get working directory:", err)
|
||||
}
|
||||
|
||||
tmplPath := filepath.Join(wd, "..", "..", "internal", "web", "templates", "ts6viewer.html")
|
||||
tmpl := template.Must(template.ParseFiles(tmplPath))
|
||||
log.Printf("[HTTP] Loaded template: %s\n", tmplPath)
|
||||
|
||||
// Static assets
|
||||
staticPath := filepath.Join(wd, "..", "..", "internal", "web", "static")
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticPath))))
|
||||
log.Printf("[HTTP] Static files served from: %s\n", staticPath)
|
||||
|
||||
// -----------------------------
|
||||
// JSON endpoint (/ts6viewer/data + /ts6viewer/data/)
|
||||
// JSON data endpoint
|
||||
// -----------------------------
|
||||
dataHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := getIP(r)
|
||||
log.Printf("[HTTP] /ts6viewer/data requested from IP: %s\n", ip)
|
||||
|
||||
var data view.ViewerData
|
||||
var data view.VMTS6Viewer
|
||||
var err error
|
||||
force := r.URL.Query().Get("force") == "1"
|
||||
if force {
|
||||
log.Printf("[HTTP] Force refresh requested by IP: %s\n", ip)
|
||||
}
|
||||
|
||||
if allowRequest(ip) {
|
||||
data, err = getViewerData(&cfg, baseURL, apiKey, serverID)
|
||||
data, err = getViewerData(&cfg, force)
|
||||
} else {
|
||||
log.Printf("[HTTP] Rate limit hit for IP: %s\n", ip)
|
||||
data = cacheData
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[HTTP] Error getting viewer data: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
log.Printf("[HTTP] Error encoding JSON response: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
mux.HandleFunc("/ts6viewer/data", dataHandler)
|
||||
mux.HandleFunc("/ts6viewer/data/", dataHandler)
|
||||
|
||||
// -----------------------------
|
||||
// HTML view (/ts6viewer + /ts6viewer/)
|
||||
// HTML view endpoint
|
||||
// -----------------------------
|
||||
viewHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := getIP(r)
|
||||
log.Printf("[HTTP] /ts6viewer requested from IP: %s\n", ip)
|
||||
|
||||
var data view.ViewerData
|
||||
var data view.VMTS6Viewer
|
||||
var err error
|
||||
|
||||
if allowRequest(ip) {
|
||||
data, err = getViewerData(&cfg, baseURL, apiKey, serverID)
|
||||
data, err = getViewerData(&cfg, true)
|
||||
} else {
|
||||
log.Printf("[HTTP] Rate limit hit for IP: %s\n", ip)
|
||||
data = cacheData
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[HTTP] Error getting viewer data: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
log.Println("Template error:", err)
|
||||
log.Printf("[HTTP] Template execution error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,9 +128,18 @@ func NewRouter(cfg config.Config) http.Handler {
|
|||
mux.HandleFunc("/ts6viewer/", viewHandler)
|
||||
|
||||
// -----------------------------
|
||||
// Health endpoint
|
||||
// Health check
|
||||
// -----------------------------
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[HTTP] /health check from IP: %s\n", getIP(r))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Root endpoint
|
||||
// -----------------------------
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[HTTP] / root requested from IP: %s\n", getIP(r))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("TS6Viewer is running!"))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@ import (
|
|||
)
|
||||
|
||||
type Config struct {
|
||||
ServerPort string `json:"server_port"`
|
||||
Theme string `json:"theme"`
|
||||
ServerPort string `json:"server_port"`
|
||||
HostConnectionLink string `json:"host_connection_link"`
|
||||
|
||||
Teamspeak6 struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
ApiKey string `json:"api_key"`
|
||||
ServerID string `json:"server_id"`
|
||||
Host string `json:"host"`
|
||||
Port string `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
EnableVoiceStatus string `json:"enable_voice_status"`
|
||||
ServerID string `json:"server_id"`
|
||||
} `json:"teamspeak6"`
|
||||
|
||||
Theme string `json:"theme"`
|
||||
RefreshInterval string `json:"refresh_interval"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
package domain
|
||||
|
||||
type ChannelType int
|
||||
|
||||
const (
|
||||
NormalChannel ChannelType = iota // Regular TS6 channel
|
||||
SolidSpacer // ___
|
||||
DashSpacer // ---
|
||||
DotSpacer // ...
|
||||
DashDotSpacer // -.-
|
||||
DashDotDotSpacer // -..
|
||||
AlignedSpacer // [cSpacer#], [lSpacer#], [rSpacer#]
|
||||
RepeatingSpacer // [*spacer#]X
|
||||
BlankSpacer // Empty spacer like [cSpacer0]
|
||||
)
|
||||
|
||||
type Aligned int
|
||||
|
||||
const (
|
||||
AlignLeft Aligned = iota
|
||||
AlignCenter
|
||||
AlignRight
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Name string
|
||||
Topic string
|
||||
|
||||
Type ChannelType
|
||||
Align Aligned
|
||||
Repeat bool
|
||||
|
||||
Children []*Channel
|
||||
Clients []*FullClient
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
ID string
|
||||
Nickname string
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
MicMuted bool
|
||||
OutputMuted bool
|
||||
IsTalking bool
|
||||
}
|
||||
|
||||
type FullClient struct {
|
||||
Client
|
||||
Info *ClientInfo
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Name string
|
||||
ClientsOnline string
|
||||
MaxClients string
|
||||
UptimePretty string
|
||||
ChannelsOnline string
|
||||
HostBannerURL string
|
||||
ClientConnections string
|
||||
}
|
||||
|
||||
type ViewerData struct {
|
||||
Server *Server
|
||||
ChannelTree []*Channel
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package mapper
|
||||
|
||||
import (
|
||||
"ts6-viewer/internal/domain"
|
||||
"ts6-viewer/internal/ts6"
|
||||
)
|
||||
|
||||
func MapAPIServer(server *ts6.ServerInfo) *domain.Server {
|
||||
uptimePretty := domain.MakeUptimePretty(server.Uptime)
|
||||
|
||||
return &domain.Server{
|
||||
Name: server.Name,
|
||||
ClientsOnline: server.ClientsOnline,
|
||||
MaxClients: server.MaxClients,
|
||||
UptimePretty: uptimePretty,
|
||||
ChannelsOnline: server.ChannelsOnline,
|
||||
HostBannerURL: server.HostBannerURL,
|
||||
ClientConnections: server.ClientConnections,
|
||||
}
|
||||
}
|
||||
|
||||
func MapAPIChannel(api ts6.Channel) *domain.Channel {
|
||||
chType, align, repeat, cleanName := domain.ParseChannelName(api.Name)
|
||||
|
||||
return &domain.Channel{
|
||||
ID: api.CID,
|
||||
ParentID: api.PID,
|
||||
Name: cleanName,
|
||||
Topic: api.Topic,
|
||||
Type: chType,
|
||||
Align: align,
|
||||
Repeat: repeat,
|
||||
}
|
||||
}
|
||||
|
||||
func MapAPIClient(c ts6.Client) *domain.Client {
|
||||
return &domain.Client{
|
||||
ID: c.CLID,
|
||||
Nickname: c.Nickname,
|
||||
ChannelID: c.CID,
|
||||
}
|
||||
}
|
||||
|
||||
func MapAPIClientInfo(info *ts6.ClientInfo) *domain.ClientInfo {
|
||||
if info == nil {
|
||||
return &domain.ClientInfo{
|
||||
MicMuted: false,
|
||||
OutputMuted: false,
|
||||
IsTalking: false,
|
||||
}
|
||||
}
|
||||
|
||||
return &domain.ClientInfo{
|
||||
MicMuted: info.InputMuted == "1" || info.InputHardware == "1",
|
||||
OutputMuted: info.OutputMuted == "1",
|
||||
IsTalking: info.IsTalker == "1",
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
package mapper
|
||||
|
||||
import (
|
||||
"ts6-viewer/internal/domain"
|
||||
"ts6-viewer/internal/view"
|
||||
)
|
||||
|
||||
func MapServerToView(s *domain.Server) *view.ServerView {
|
||||
return &view.ServerView{
|
||||
Name: s.Name,
|
||||
ClientsOnline: s.ClientsOnline,
|
||||
MaxClients: s.MaxClients,
|
||||
UptimePretty: s.UptimePretty,
|
||||
ChannelsOnline: s.ChannelsOnline,
|
||||
HostBannerURL: s.HostBannerURL,
|
||||
ClientConnections: s.ClientConnections,
|
||||
}
|
||||
}
|
||||
|
||||
func MapClientToView(c *domain.FullClient) *view.ClientView {
|
||||
v := &view.ClientView{
|
||||
Nickname: c.Nickname,
|
||||
}
|
||||
|
||||
if c.Info != nil {
|
||||
v.MicMuted = c.Info.MicMuted
|
||||
v.OutputMuted = c.Info.OutputMuted
|
||||
v.IsTalking = c.Info.IsTalking
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func MapChannelToView(ch *domain.Channel) *view.ChannelView {
|
||||
out := &view.ChannelView{
|
||||
Name: ch.Name,
|
||||
Type: ch.Type,
|
||||
Align: ch.Align,
|
||||
Repeat: ch.Repeat,
|
||||
}
|
||||
|
||||
for _, c := range ch.Clients {
|
||||
out.Clients = append(out.Clients, MapClientToView(c))
|
||||
}
|
||||
|
||||
for _, child := range ch.Children {
|
||||
out.Children = append(out.Children, MapChannelToView(child))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func MapChannelTreeToView(tree []*domain.Channel) []*view.ChannelView {
|
||||
out := make([]*view.ChannelView, 0, len(tree))
|
||||
for _, ch := range tree {
|
||||
out = append(out, MapChannelToView(ch))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -2,47 +2,108 @@ package ts6
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"ts6-viewer/internal/config"
|
||||
)
|
||||
|
||||
// Channel represents a TeamSpeak channel
|
||||
type Channel struct {
|
||||
CID string `json:"cid"`
|
||||
PID string `json:"pid"`
|
||||
ChannelOrder string `json:"channel_order"`
|
||||
Name string `json:"channel_name"`
|
||||
Topic string `json:"channel_topic"`
|
||||
FlagPermanent string `json:"channel_flag_permanent"`
|
||||
FlagSemiPermanent string `json:"channel_flag_semi_permanent"`
|
||||
FlagDefault string `json:"channel_flag_default"`
|
||||
FlagPassword string `json:"channel_flag_password"`
|
||||
MaxClients string `json:"channel_maxclients"`
|
||||
MaxFamilyClients string `json:"channel_maxfamilyclients"`
|
||||
NeededTalkPower string `json:"channel_needed_talk_power"`
|
||||
CID string
|
||||
PID string
|
||||
ChannelOrder string
|
||||
Name string
|
||||
Topic string
|
||||
FlagPermanent string
|
||||
FlagSemiPermanent string
|
||||
FlagDefault string
|
||||
FlagPassword string
|
||||
FlagMaxClientsUnlimited string
|
||||
FlagMaxFamilyClientsUnlimited string
|
||||
MaxClients string
|
||||
MaxFamilyClients string
|
||||
NeededTalkPower string
|
||||
Codec string
|
||||
CodecQuality string
|
||||
TotalClients string
|
||||
IconID string
|
||||
SecondsEmpty string
|
||||
}
|
||||
|
||||
// ChannelListResponse represents the TS6 API response
|
||||
type ChannelListResponse struct {
|
||||
Body []Channel `json:"body"`
|
||||
Status Status `json:"status"`
|
||||
}
|
||||
// GetChannelList retrieves all channels using ServerQuery (SSH)
|
||||
func GetChannelList(cfg *config.Config, ssh *SSHClient) ([]Channel, error) {
|
||||
|
||||
// GetChannelList returns all channels for a virtual server
|
||||
func GetChannelList(baseURL, apiKey string, serverID string) ([]Channel, error) {
|
||||
var resp ChannelListResponse
|
||||
|
||||
err := doGET(
|
||||
baseURL,
|
||||
apiKey,
|
||||
fmt.Sprintf("/%s/channellist", serverID),
|
||||
&resp,
|
||||
)
|
||||
raw, err := ssh.exec("channellist -topic -flags -limits -voice -icon -secondsempty")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to execute channellist: %w", err)
|
||||
}
|
||||
|
||||
if resp.Status.Code != 0 {
|
||||
return nil, fmt.Errorf("ts6 error %d: %s", resp.Status.Code, resp.Status.Message)
|
||||
parts := strings.Split(strings.TrimSpace(raw), "|")
|
||||
channels := make([]Channel, 0, len(parts))
|
||||
|
||||
for _, p := range parts {
|
||||
|
||||
fields := strings.Fields(p)
|
||||
ch := Channel{}
|
||||
|
||||
for _, f := range fields {
|
||||
if !strings.Contains(f, "=") {
|
||||
continue
|
||||
}
|
||||
|
||||
kv := strings.SplitN(f, "=", 2)
|
||||
key := kv[0]
|
||||
val := UnescapeTS6(kv[1])
|
||||
|
||||
switch key {
|
||||
|
||||
case "cid":
|
||||
ch.CID = val
|
||||
case "pid":
|
||||
ch.PID = val
|
||||
case "channel_order":
|
||||
ch.ChannelOrder = val
|
||||
case "channel_name":
|
||||
ch.Name = val
|
||||
case "channel_topic":
|
||||
ch.Topic = val
|
||||
|
||||
case "channel_flag_permanent":
|
||||
ch.FlagPermanent = val
|
||||
case "channel_flag_semi_permanent":
|
||||
ch.FlagSemiPermanent = val
|
||||
case "channel_flag_default":
|
||||
ch.FlagDefault = val
|
||||
case "channel_flag_password":
|
||||
ch.FlagPassword = val
|
||||
case "channel_flag_maxclients_unlimited":
|
||||
ch.FlagMaxClientsUnlimited = val
|
||||
case "channel_flag_maxfamilyclients_unlimited":
|
||||
ch.FlagMaxFamilyClientsUnlimited = val
|
||||
|
||||
case "channel_maxclients":
|
||||
ch.MaxClients = val
|
||||
case "channel_maxfamilyclients":
|
||||
ch.MaxFamilyClients = val
|
||||
|
||||
case "channel_needed_talk_power":
|
||||
ch.NeededTalkPower = val
|
||||
|
||||
case "channel_codec":
|
||||
ch.Codec = val
|
||||
case "channel_codec_quality":
|
||||
ch.CodecQuality = val
|
||||
case "total_clients":
|
||||
ch.TotalClients = val
|
||||
|
||||
case "channel_icon_id":
|
||||
ch.IconID = val
|
||||
|
||||
case "seconds_empty":
|
||||
ch.SecondsEmpty = val
|
||||
}
|
||||
}
|
||||
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
return channels, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,136 @@
|
|||
package ts6
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"ts6-viewer/internal/config"
|
||||
)
|
||||
|
||||
// Client represents an online TeamSpeak client
|
||||
type Client struct {
|
||||
CID string `json:"cid"`
|
||||
CLID string `json:"clid"`
|
||||
DatabaseID string `json:"client_database_id"`
|
||||
Nickname string `json:"client_nickname"`
|
||||
ClientType string `json:"client_type"`
|
||||
CLID string
|
||||
CID string
|
||||
DatabaseID string
|
||||
Nickname string
|
||||
Type string
|
||||
UniqueIdentifier string
|
||||
|
||||
Away string
|
||||
AwayMessage string
|
||||
|
||||
InputMuted string
|
||||
OutputMuted string
|
||||
OutputOnlyMuted string
|
||||
InputHardware string
|
||||
OutputHardware string
|
||||
TalkPower string
|
||||
IsTalking string
|
||||
|
||||
ServerGroups string
|
||||
ChannelGroupID string
|
||||
|
||||
IdleTime string
|
||||
ConnectionTime string
|
||||
|
||||
Country string
|
||||
IconID string
|
||||
|
||||
Version string
|
||||
Platform string
|
||||
}
|
||||
|
||||
// ClientListResponse represents the TS6 API response
|
||||
type ClientListResponse struct {
|
||||
Body []Client `json:"body"`
|
||||
Status Status `json:"status"`
|
||||
}
|
||||
func GetClientList(cfg *config.Config, ssh *SSHClient) ([]Client, error) {
|
||||
|
||||
// GetClientList returns all connected clients for a virtual server
|
||||
func GetClientList(baseURL, apiKey string, serverID string) ([]Client, error) {
|
||||
var raw map[string]any
|
||||
voiceCmd := ""
|
||||
if cfg.Teamspeak6.EnableVoiceStatus == "true" {
|
||||
voiceCmd = "-voice"
|
||||
}
|
||||
|
||||
err := doGET(
|
||||
baseURL,
|
||||
apiKey,
|
||||
fmt.Sprintf("/%s/clientlist", serverID),
|
||||
&raw,
|
||||
)
|
||||
raw, err := ssh.exec("clientlist -uid -away -groups -times -info -country -icon " + voiceCmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to execute clientlist: %w", err)
|
||||
}
|
||||
|
||||
var resp ClientListResponse
|
||||
b, _ := json.Marshal(raw)
|
||||
json.Unmarshal(b, &resp)
|
||||
parts := strings.Split(strings.TrimSpace(raw), "|")
|
||||
clients := make([]Client, 0, len(parts))
|
||||
|
||||
if resp.Status.Code != 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"ts6 error %d: %s",
|
||||
resp.Status.Code,
|
||||
resp.Status.Message,
|
||||
)
|
||||
}
|
||||
for _, p := range parts {
|
||||
|
||||
filtered := make([]Client, 0, len(resp.Body))
|
||||
for _, c := range resp.Body {
|
||||
if c.DatabaseID != "1" {
|
||||
filtered = append(filtered, c)
|
||||
fields := strings.Fields(p)
|
||||
cl := Client{}
|
||||
|
||||
for _, f := range fields {
|
||||
if !strings.Contains(f, "=") {
|
||||
continue
|
||||
}
|
||||
|
||||
kv := strings.SplitN(f, "=", 2)
|
||||
key := kv[0]
|
||||
val := UnescapeTS6(kv[1])
|
||||
|
||||
switch key {
|
||||
|
||||
case "clid":
|
||||
cl.CLID = val
|
||||
case "cid":
|
||||
cl.CID = val
|
||||
case "client_database_id":
|
||||
cl.DatabaseID = val
|
||||
case "client_nickname":
|
||||
cl.Nickname = val
|
||||
case "client_type":
|
||||
cl.Type = val
|
||||
case "client_unique_identifier":
|
||||
cl.UniqueIdentifier = val
|
||||
|
||||
case "client_away":
|
||||
cl.Away = val
|
||||
case "client_away_message":
|
||||
cl.AwayMessage = val
|
||||
|
||||
case "client_input_muted":
|
||||
cl.InputMuted = val
|
||||
case "client_output_muted":
|
||||
cl.OutputMuted = val
|
||||
case "client_outputonly_muted":
|
||||
cl.OutputOnlyMuted = val
|
||||
case "client_input_hardware":
|
||||
cl.InputHardware = val
|
||||
case "client_output_hardware":
|
||||
cl.OutputHardware = val
|
||||
case "client_talk_power":
|
||||
cl.TalkPower = val
|
||||
case "client_is_talking":
|
||||
cl.IsTalking = val
|
||||
|
||||
case "client_servergroups":
|
||||
cl.ServerGroups = val
|
||||
case "client_channel_group_id":
|
||||
cl.ChannelGroupID = val
|
||||
|
||||
case "client_idle_time":
|
||||
cl.IdleTime = val
|
||||
case "client_connection_connected_time":
|
||||
cl.ConnectionTime = val
|
||||
|
||||
case "client_country":
|
||||
cl.Country = val
|
||||
case "client_icon_id":
|
||||
cl.IconID = val
|
||||
|
||||
case "client_version":
|
||||
cl.Version = val
|
||||
case "client_platform":
|
||||
cl.Platform = val
|
||||
}
|
||||
}
|
||||
|
||||
// Query Clients rausfiltern
|
||||
if cl.Type == "1" {
|
||||
continue
|
||||
}
|
||||
|
||||
clients = append(clients, cl)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
return clients, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
package ts6
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ClientInfo struct {
|
||||
CID string `json:"cid"`
|
||||
CLID string `json:"clid"`
|
||||
DatabaseID string `json:"client_database_id"`
|
||||
Nickname string `json:"client_nickname"`
|
||||
ClientType string `json:"client_type"`
|
||||
|
||||
InputMuted string `json:"client_input_muted"`
|
||||
InputHardware string `json:"client_input_hardware"`
|
||||
OutputMuted string `json:"client_output_muted"`
|
||||
OutputOnlyMuted string `json:"client_outputonly_muted"`
|
||||
IsTalker string `json:"client_is_talker"`
|
||||
}
|
||||
|
||||
type ClientInfoResponse struct {
|
||||
Body ClientInfo `json:"body"`
|
||||
Status Status `json:"status"`
|
||||
}
|
||||
|
||||
func GetClientInfo(baseURL, apiKey, serverID, clid string) (*ClientInfo, error) {
|
||||
var raw map[string]any
|
||||
|
||||
err := doGET(
|
||||
baseURL,
|
||||
apiKey,
|
||||
fmt.Sprintf("/%s/clientinfo?clid=%s", serverID, clid),
|
||||
&raw,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp ClientInfoResponse
|
||||
b, _ := json.Marshal(raw)
|
||||
json.Unmarshal(b, &resp)
|
||||
|
||||
if resp.Status.Code != 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"ts6 error %d: %s",
|
||||
resp.Status.Code,
|
||||
resp.Status.Message,
|
||||
)
|
||||
}
|
||||
|
||||
return &resp.Body, nil
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package ts6
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// doGET performs an authenticated HTTP GET request against TS6
|
||||
func doGET(baseURL, apiKey, path string, out any) error {
|
||||
req, err := http.NewRequest(http.MethodGet, baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("http error: %s", resp.Status)
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
|
@ -3,67 +3,77 @@ package ts6
|
|||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"ts6-viewer/internal/config"
|
||||
)
|
||||
|
||||
// ServerInfo represents basic virtual server information
|
||||
type ServerInfo struct {
|
||||
ServerID string `json:"virtualserver_id"`
|
||||
Name string `json:"virtualserver_name"`
|
||||
Uptime string `json:"virtualserver_uptime"`
|
||||
ClientsOnline string `json:"virtualserver_clientsonline"`
|
||||
MaxClients string `json:"virtualserver_maxclients"`
|
||||
ChannelsOnline string `json:"virtualserver_channelsonline"`
|
||||
HostBannerURL string `json:"virtualserver_hostbanner_url"`
|
||||
HostBannerGfxURL string `json:"virtualserver_hostbanner_gfx_url"`
|
||||
NeededIdentitySecurity string `json:"virtualserver_needed_identity_security_level"`
|
||||
QueryClientConnections string `json:"virtualserver_query_client_connections"`
|
||||
ClientConnections string `json:"virtualserver_client_connections"`
|
||||
ServerID string
|
||||
Name string
|
||||
Uptime string
|
||||
ClientsOnline string
|
||||
MaxClients string
|
||||
ChannelsOnline string
|
||||
HostBannerURL string
|
||||
HostBannerGfxURL string
|
||||
NeededIdentitySecurity string
|
||||
QueryClientConnections string
|
||||
ClientConnections string
|
||||
}
|
||||
|
||||
// ServerInfoResponse represents the TS6 API response
|
||||
type ServerInfoResponse struct {
|
||||
Body []ServerInfo `json:"body"`
|
||||
Status Status `json:"status"`
|
||||
}
|
||||
|
||||
// GetServerInfo retrieves information about a virtual server
|
||||
func GetServerInfo(baseURL, apiKey string, serverID string) (*ServerInfo, error) {
|
||||
var resp ServerInfoResponse
|
||||
|
||||
err := doGET(
|
||||
baseURL,
|
||||
apiKey,
|
||||
fmt.Sprintf("/%s/serverinfo", serverID),
|
||||
&resp,
|
||||
)
|
||||
func GetServerInfo(cfg *config.Config, c *SSHClient) (*ServerInfo, error) {
|
||||
raw, err := c.exec("serverinfo")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to execute serverinfo: %w", err)
|
||||
}
|
||||
|
||||
if resp.Status.Code != 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"ts6 error %d: %s",
|
||||
resp.Status.Code,
|
||||
resp.Status.Message,
|
||||
)
|
||||
}
|
||||
blocks := strings.Split(raw, "|")
|
||||
first := strings.TrimSpace(blocks[0])
|
||||
fields := strings.Fields(first)
|
||||
|
||||
if len(resp.Body) != 1 {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected serverinfo result count: %d",
|
||||
len(resp.Body),
|
||||
)
|
||||
}
|
||||
info := &ServerInfo{}
|
||||
|
||||
info := resp.Body[0]
|
||||
for _, f := range fields {
|
||||
if !strings.Contains(f, "=") {
|
||||
continue
|
||||
}
|
||||
|
||||
kv := strings.SplitN(f, "=", 2)
|
||||
key := kv[0]
|
||||
val := UnescapeTS6(kv[1])
|
||||
|
||||
switch key {
|
||||
case "virtualserver_id":
|
||||
info.ServerID = val
|
||||
case "virtualserver_name":
|
||||
info.Name = val
|
||||
case "virtualserver_uptime":
|
||||
info.Uptime = val
|
||||
case "virtualserver_clientsonline":
|
||||
info.ClientsOnline = val
|
||||
case "virtualserver_maxclients":
|
||||
info.MaxClients = val
|
||||
case "virtualserver_channelsonline":
|
||||
info.ChannelsOnline = val
|
||||
case "virtualserver_hostbanner_url":
|
||||
info.HostBannerURL = val
|
||||
case "virtualserver_hostbanner_gfx_url":
|
||||
info.HostBannerGfxURL = val
|
||||
case "virtualserver_needed_identity_security_level":
|
||||
info.NeededIdentitySecurity = val
|
||||
case "virtualserver_query_client_connections":
|
||||
info.QueryClientConnections = val
|
||||
case "virtualserver_client_connections":
|
||||
info.ClientConnections = val
|
||||
}
|
||||
}
|
||||
|
||||
if n, err := strconv.Atoi(info.ClientsOnline); err == nil {
|
||||
n--
|
||||
if n < 0 {
|
||||
n = 0
|
||||
if n > 0 {
|
||||
n--
|
||||
}
|
||||
info.ClientsOnline = strconv.Itoa(n)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
return info, nil
|
||||
}
|
||||
|
|
|
|||
421
internal/ts6/ssh.go
Normal file
421
internal/ts6/ssh.go
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
package ts6
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ts6-viewer/internal/config"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// SSHClient represents a persistent SSH ServerQuery connection.
|
||||
type SSHClient struct {
|
||||
cfg *config.Config
|
||||
serverID string
|
||||
|
||||
ssh *ssh.Client
|
||||
session *ssh.Session
|
||||
stdin io.WriteCloser
|
||||
reader *bufio.Reader
|
||||
|
||||
mu sync.Mutex // protects command execution and reconnect
|
||||
}
|
||||
|
||||
var (
|
||||
globalClient *SSHClient
|
||||
globalMu sync.Mutex
|
||||
|
||||
floodWaitRegex = regexp.MustCompile(`wait (\d+)ms`)
|
||||
errIDRegex = regexp.MustCompile(`^error id=(\d+)`)
|
||||
)
|
||||
|
||||
func init() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// GetPersistentClient returns a singleton SSH connection.
|
||||
func GetPersistentClient(cfg *config.Config, serverID string) (*SSHClient, error) {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
if globalClient != nil && !globalClient.IsClosed() {
|
||||
log.Println("[SSH] Reusing existing persistent connection")
|
||||
return globalClient, nil
|
||||
}
|
||||
|
||||
log.Println("[SSH] Creating new persistent SSH connection")
|
||||
|
||||
client, err := newSSHClientWithUse(cfg, serverID)
|
||||
if err != nil {
|
||||
log.Printf("[SSH] Connection creation failed: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
globalClient = client
|
||||
log.Println("[SSH] Persistent SSH connection established")
|
||||
|
||||
return globalClient, nil
|
||||
}
|
||||
|
||||
// newSSHClientWithUse establishes a new SSH connection and selects the server.
|
||||
func newSSHClientWithUse(cfg *config.Config, serverID string) (*SSHClient, error) {
|
||||
client, err := newSSHClientBase(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := client.Use(serverID); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.cfg = cfg
|
||||
client.serverID = serverID
|
||||
|
||||
go client.keepAlive()
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Use selects the virtual server by ID.
|
||||
func (c *SSHClient) Use(serverID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.IsClosed() {
|
||||
return fmt.Errorf("ssh connection is closed")
|
||||
}
|
||||
|
||||
log.Printf("[SSH] Selecting virtual server: %s\n", serverID)
|
||||
|
||||
_, err := c.stdin.Write([]byte(fmt.Sprintf("use %s\n", serverID)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send use command: %w", err)
|
||||
}
|
||||
|
||||
timeout := time.After(5 * time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
return fmt.Errorf("timeout while waiting for use response")
|
||||
default:
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read use response: %w", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
if strings.HasPrefix(line, "error id=") {
|
||||
if line != "error id=0 msg=ok" {
|
||||
return fmt.Errorf("use command failed: %s", line)
|
||||
}
|
||||
log.Println("[SSH] Virtual server selected successfully")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newSSHClientBase creates a raw SSH connection and performs login.
|
||||
func newSSHClientBase(cfg *config.Config) (*SSHClient, error) {
|
||||
host := cfg.Teamspeak6.Host
|
||||
port := cfg.Teamspeak6.Port
|
||||
user := cfg.Teamspeak6.User
|
||||
password := cfg.Teamspeak6.Password
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
log.Printf("[SSH] Connecting to %s\n", addr)
|
||||
|
||||
sshConfig := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{ssh.Password(password)},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
rawConn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
log.Printf("[SSH] TCP dial failed: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sshConn, chans, reqs, err := ssh.NewClientConn(rawConn, addr, sshConfig)
|
||||
if err != nil {
|
||||
rawConn.Close()
|
||||
log.Printf("[SSH] SSH handshake failed: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := ssh.NewClient(sshConn, chans, reqs)
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := session.Shell(); err != nil {
|
||||
client.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &SSHClient{
|
||||
ssh: client,
|
||||
session: session,
|
||||
stdin: stdin,
|
||||
reader: bufio.NewReader(stdout),
|
||||
}
|
||||
|
||||
log.Println("[SSH] Waiting for welcome message")
|
||||
|
||||
for {
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
if strings.Contains(line, "Welcome") || strings.Contains(line, "TS3") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("[SSH] Sending login command")
|
||||
|
||||
if _, err := c.stdin.Write([]byte(fmt.Sprintf("login %s %s\n", user, password))); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "error id=") {
|
||||
if line != "error id=0 msg=ok" {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("login failed: %s", line)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[SSH] Login successful to %s\n", addr)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// keepAlive sends periodic version commands to prevent idle timeout.
|
||||
func (c *SSHClient) keepAlive() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if c.IsClosed() {
|
||||
return
|
||||
}
|
||||
log.Println("[SSH] Sending keepalive ping")
|
||||
_, err := c.Exec("version")
|
||||
if err != nil {
|
||||
log.Printf("[SSH] Keepalive failed: %v. Attempting reconnect\n", err)
|
||||
_ = c.reconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exec executes a ServerQuery command safely.
|
||||
func (c *SSHClient) Exec(cmd string) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
log.Printf("[SSH] Executing command: %s\n", cmd)
|
||||
return c.execSafe(cmd)
|
||||
}
|
||||
|
||||
// exec sends a raw command and reads the response.
|
||||
func (c *SSHClient) exec(cmd string) (string, error) {
|
||||
_, err := c.stdin.Write([]byte(cmd + "\n"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var lines []string
|
||||
var last string
|
||||
|
||||
for {
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
lines = append(lines, line)
|
||||
last = line
|
||||
if strings.HasPrefix(line, "error id=") {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
raw := strings.Join(lines, "\n")
|
||||
|
||||
if strings.HasPrefix(last, "error id=") && last != "error id=0 msg=ok" {
|
||||
return raw, fmt.Errorf("%s", last)
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// execSafe handles flood and reconnect logic.
|
||||
func (c *SSHClient) execSafe(cmd string) (string, error) {
|
||||
const (
|
||||
maxFloodRetries = 5
|
||||
maxReconnects = 2
|
||||
maxWaitMs = 10000
|
||||
jitterMs = 250
|
||||
)
|
||||
|
||||
floodRetries := 0
|
||||
reconnects := 0
|
||||
|
||||
for {
|
||||
raw, err := c.exec(cmd)
|
||||
if err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
if m := errIDRegex.FindStringSubmatch(err.Error()); len(m) == 2 {
|
||||
id, _ := strconv.Atoi(m[1])
|
||||
if id == 524 {
|
||||
wait := 1000
|
||||
if match := floodWaitRegex.FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
if ms, convErr := strconv.Atoi(match[1]); convErr == nil {
|
||||
wait = ms
|
||||
}
|
||||
}
|
||||
|
||||
backoff := wait * (1 << floodRetries)
|
||||
if backoff > maxWaitMs {
|
||||
backoff = maxWaitMs
|
||||
}
|
||||
|
||||
jitter := rand.Intn(jitterMs + 1)
|
||||
sleepMs := backoff + jitter
|
||||
|
||||
log.Printf("[SSH] Flood detected. Backing off %d ms\n", sleepMs)
|
||||
|
||||
time.Sleep(time.Duration(sleepMs) * time.Millisecond)
|
||||
|
||||
floodRetries++
|
||||
if floodRetries >= maxFloodRetries {
|
||||
if reconnects >= maxReconnects {
|
||||
return "", fmt.Errorf("max flood retries reached: %w", err)
|
||||
}
|
||||
log.Println("[SSH] Flood retry limit reached. Reconnecting")
|
||||
c.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_ = c.reconnect()
|
||||
floodRetries = 0
|
||||
reconnects++
|
||||
}
|
||||
continue
|
||||
}
|
||||
return raw, err
|
||||
}
|
||||
|
||||
if isConnectionError(err) {
|
||||
if reconnects >= maxReconnects {
|
||||
return "", err
|
||||
}
|
||||
log.Printf("[SSH] Connection error detected: %v. Reconnecting\n", err)
|
||||
c.Close()
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
_ = c.reconnect()
|
||||
reconnects++
|
||||
continue
|
||||
}
|
||||
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// reconnect recreates the SSH connection.
|
||||
func (c *SSHClient) reconnect() error {
|
||||
globalMu.Lock()
|
||||
defer globalMu.Unlock()
|
||||
|
||||
log.Println("[SSH] Attempting reconnect")
|
||||
|
||||
if c.cfg == nil {
|
||||
return fmt.Errorf("missing configuration for reconnect")
|
||||
}
|
||||
|
||||
newClient, err := newSSHClientWithUse(c.cfg, c.serverID)
|
||||
if err != nil {
|
||||
log.Printf("[SSH] Reconnect failed: %v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
globalClient = newClient
|
||||
log.Println("[SSH] Reconnect successful")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isConnectionError detects network-level failures.
|
||||
func isConnectionError(err error) bool {
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "eof") ||
|
||||
strings.Contains(msg, "broken pipe") ||
|
||||
strings.Contains(msg, "connection reset") ||
|
||||
strings.Contains(msg, "use of closed network connection")
|
||||
}
|
||||
|
||||
// IsClosed checks whether the client is closed.
|
||||
func (c *SSHClient) IsClosed() bool {
|
||||
return c == nil || c.ssh == nil
|
||||
}
|
||||
|
||||
// Close terminates the SSH session.
|
||||
func (c *SSHClient) Close() {
|
||||
log.Println("[SSH] Closing SSH connection")
|
||||
|
||||
if c.session != nil {
|
||||
_ = c.session.Close()
|
||||
c.session = nil
|
||||
}
|
||||
if c.ssh != nil {
|
||||
_ = c.ssh.Close()
|
||||
c.ssh = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
package ts6
|
||||
|
||||
// Status represents the standard TS6 API status block
|
||||
type Status struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
22
internal/ts6/utilities.go
Normal file
22
internal/ts6/utilities.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package ts6
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func UnescapeTS6(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`\s`, " ",
|
||||
`\p`, "|",
|
||||
`\/`, "/",
|
||||
`\:`, ":",
|
||||
`\.`, ".",
|
||||
`\(`, "(",
|
||||
`\)`, ")",
|
||||
`\?`, "?",
|
||||
`\!`, "!",
|
||||
`\-`, "-",
|
||||
`\_`, "_",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"ts6-viewer/internal/domain"
|
||||
)
|
||||
|
||||
type ViewerData struct {
|
||||
Server *ServerView
|
||||
ChannelTree []*ChannelView
|
||||
Theme string
|
||||
RefreshInterval string
|
||||
}
|
||||
|
||||
type ServerView struct {
|
||||
Name string
|
||||
ClientsOnline string
|
||||
MaxClients string
|
||||
UptimePretty string
|
||||
ChannelsOnline string
|
||||
HostBannerURL string
|
||||
ClientConnections string
|
||||
}
|
||||
|
||||
type ClientView struct {
|
||||
Nickname string
|
||||
Platform string
|
||||
Version string
|
||||
MicMuted bool
|
||||
OutputMuted bool
|
||||
IsTalking bool
|
||||
}
|
||||
|
||||
type ChannelView struct {
|
||||
Name string
|
||||
Type domain.ChannelType
|
||||
Align domain.Aligned
|
||||
Repeat bool
|
||||
Clients []*ClientView
|
||||
Children []*ChannelView
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package domain
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -9,7 +9,6 @@ import (
|
|||
|
||||
var reCmd = regexp.MustCompile(`(?i)\[([clr]|\*)?spacer([^\]]*?)\]`)
|
||||
|
||||
// ParseChannelName parses TeamSpeak spacer syntax.
|
||||
func ParseChannelName(name string) (ChannelType, Aligned, bool, string) {
|
||||
name = strings.TrimSpace(name)
|
||||
|
||||
|
|
@ -73,27 +72,3 @@ func MakeUptimePretty(secondsStr string) string {
|
|||
|
||||
return fmt.Sprintf("%dD %02d:%02d:%02d", days, hours, minutes, seconds)
|
||||
}
|
||||
|
||||
func BuildChannelTree(channels []*Channel, clients []*FullClient) []*Channel {
|
||||
lookup := make(map[string]*Channel)
|
||||
for _, ch := range channels {
|
||||
lookup[ch.ID] = ch
|
||||
}
|
||||
|
||||
for _, cl := range clients {
|
||||
if ch, ok := lookup[cl.ChannelID]; ok {
|
||||
ch.Clients = append(ch.Clients, cl)
|
||||
}
|
||||
}
|
||||
|
||||
var roots []*Channel
|
||||
for _, ch := range channels {
|
||||
if ch.ParentID == "0" {
|
||||
roots = append(roots, ch)
|
||||
} else if parent, ok := lookup[ch.ParentID]; ok {
|
||||
parent.Children = append(parent.Children, ch)
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
98
internal/view/vm-builder.go
Normal file
98
internal/view/vm-builder.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package view
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"ts6-viewer/internal/config"
|
||||
"ts6-viewer/internal/ts6"
|
||||
)
|
||||
|
||||
type ChannelType int
|
||||
|
||||
const (
|
||||
NormalChannel ChannelType = iota // Regular TS6 channel
|
||||
SolidSpacer // ___
|
||||
DashSpacer // ---
|
||||
DotSpacer // ...
|
||||
DashDotSpacer // -.-
|
||||
DashDotDotSpacer // -..
|
||||
AlignedSpacer // [cSpacer#], [lSpacer#], [rSpacer#]
|
||||
RepeatingSpacer // [*spacer#]X
|
||||
BlankSpacer // Empty spacer like [cSpacer0]
|
||||
)
|
||||
|
||||
type Aligned int
|
||||
|
||||
const (
|
||||
AlignLeft Aligned = iota
|
||||
AlignCenter
|
||||
AlignRight
|
||||
)
|
||||
|
||||
func BuildVMChannels(channels []ts6.Channel, clients []ts6.Client) []*VMChannel {
|
||||
// Channels
|
||||
viewMap := make(map[string]*VMChannel)
|
||||
for _, ch := range channels {
|
||||
viewMap[ch.CID] = BuildVMChannel(ch)
|
||||
}
|
||||
|
||||
// Clients
|
||||
for _, c := range clients {
|
||||
if vch, ok := viewMap[c.CID]; ok {
|
||||
vch.Clients = append(vch.Clients, BuildVMClient(c))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort clients in each channel alphabetically
|
||||
for _, vch := range viewMap {
|
||||
sort.Slice(vch.Clients, func(i, j int) bool {
|
||||
return vch.Clients[i].Nickname < vch.Clients[j].Nickname
|
||||
})
|
||||
}
|
||||
|
||||
// Build tree
|
||||
var roots []*VMChannel
|
||||
for _, ch := range channels {
|
||||
vch := viewMap[ch.CID]
|
||||
if ch.PID == "0" {
|
||||
roots = append(roots, vch)
|
||||
} else if parent, ok := viewMap[ch.PID]; ok {
|
||||
parent.Children = append(parent.Children, vch)
|
||||
}
|
||||
}
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
func BuildVMServer(cfg *config.Config, info *ts6.ServerInfo, clients []ts6.Client) *VMServer {
|
||||
return &VMServer{
|
||||
Name: info.Name,
|
||||
ClientsOnline: strconv.Itoa(len(clients)),
|
||||
MaxClients: info.MaxClients,
|
||||
UptimePretty: MakeUptimePretty(info.Uptime),
|
||||
ChannelsOnline: info.ChannelsOnline,
|
||||
HostBannerURL: info.HostBannerURL,
|
||||
HostConnectionLink: cfg.HostConnectionLink,
|
||||
ClientConnections: info.ClientConnections,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildVMChannel(ch ts6.Channel) *VMChannel {
|
||||
chType, align, repeat, cleanName := ParseChannelName(ch.Name)
|
||||
|
||||
return &VMChannel{
|
||||
Name: cleanName,
|
||||
Type: chType,
|
||||
Align: align,
|
||||
Repeat: repeat,
|
||||
}
|
||||
}
|
||||
|
||||
func BuildVMClient(c ts6.Client) *VMClient {
|
||||
return &VMClient{
|
||||
Nickname: c.Nickname,
|
||||
MicMuted: c.InputMuted == "1" || c.InputHardware == "0",
|
||||
OutputMuted: c.OutputMuted == "1",
|
||||
IsTalking: c.IsTalking == "1",
|
||||
}
|
||||
}
|
||||
37
internal/view/vm.go
Normal file
37
internal/view/vm.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package view
|
||||
|
||||
type VMTS6Viewer struct {
|
||||
VMServer *VMServer
|
||||
VMChannels []*VMChannel
|
||||
Theme string
|
||||
RefreshInterval string
|
||||
}
|
||||
|
||||
type VMServer struct {
|
||||
Name string
|
||||
ClientsOnline string
|
||||
MaxClients string
|
||||
UptimePretty string
|
||||
ChannelsOnline string
|
||||
HostBannerURL string
|
||||
HostConnectionLink string
|
||||
ClientConnections string
|
||||
}
|
||||
|
||||
type VMClient struct {
|
||||
Nickname string
|
||||
Platform string
|
||||
Version string
|
||||
MicMuted bool
|
||||
OutputMuted bool
|
||||
IsTalking bool
|
||||
}
|
||||
|
||||
type VMChannel struct {
|
||||
Name string
|
||||
Type ChannelType
|
||||
Align Aligned
|
||||
Repeat bool
|
||||
Clients []*VMClient
|
||||
Children []*VMChannel
|
||||
}
|
||||
|
|
@ -58,14 +58,14 @@ h1, h2 {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-url {
|
||||
.banner-url, .host-connection-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.banner-url a {
|
||||
.banner-url a, .host-connection-link a {
|
||||
display: inline-block;
|
||||
color: #6aa9ff;
|
||||
text-decoration: none;
|
||||
|
|
@ -106,15 +106,36 @@ h1, h2 {
|
|||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 50%;
|
||||
.status-online {
|
||||
color: #4CAF50;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
padding-left: 1px;
|
||||
padding-right: 0.5px;
|
||||
}
|
||||
|
||||
.status-mic {
|
||||
color: #888;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-mic::before {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
.status-audio {
|
||||
color: #888;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-audio::before {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
|
@ -132,9 +153,9 @@ h1, h2 {
|
|||
line-height: 16px;
|
||||
}
|
||||
|
||||
.spacer-left { text-align: left; }
|
||||
.spacer-left { text-align: left; }
|
||||
.spacer-center { text-align: center; }
|
||||
.spacer-right { text-align: right; }
|
||||
.spacer-right { text-align: right; }
|
||||
|
||||
.blank-spacer {
|
||||
height: 16px;
|
||||
|
|
@ -166,11 +187,6 @@ h1, h2 {
|
|||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
max-width: 90%;
|
||||
font-size: 16px;
|
||||
|
|
|
|||
|
|
@ -63,14 +63,14 @@ h1, h2 {
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-url {
|
||||
.banner-url, .host-connection-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.banner-url a {
|
||||
.banner-url a, .host-connection-link a {
|
||||
display: inline-block;
|
||||
color: #6aa9ff;
|
||||
text-decoration: none;
|
||||
|
|
@ -106,15 +106,36 @@ h1, h2 {
|
|||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 50%;
|
||||
.status-online {
|
||||
color: #4CAF50;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
padding-left: 1px;
|
||||
padding-right: 0.5px;
|
||||
}
|
||||
|
||||
.status-mic {
|
||||
color: #777;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-mic::before {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
.status-audio {
|
||||
color: #777;
|
||||
margin-right: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-audio::before {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
|
@ -132,9 +153,9 @@ h1, h2 {
|
|||
line-height: 16px;
|
||||
}
|
||||
|
||||
.spacer-left { text-align: left; }
|
||||
.spacer-left { text-align: left; }
|
||||
.spacer-center { text-align: center; }
|
||||
.spacer-right { text-align: right; }
|
||||
.spacer-right { text-align: right; }
|
||||
|
||||
.blank-spacer {
|
||||
height: 16px;
|
||||
|
|
@ -166,11 +187,6 @@ h1, h2 {
|
|||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
max-width: 90%;
|
||||
font-size: 16px;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ setInterval(() => {
|
|||
counter = refreshTime;
|
||||
refreshText.textContent = counter;
|
||||
sessionStorage.setItem("refreshCounter", counter);
|
||||
fetchViewerData();
|
||||
fetchViewerData(true);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
|
|
@ -111,41 +111,64 @@ function debug(msg) {
|
|||
box.innerHTML += msg + "<br>";
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// Fetch viewer data from backend
|
||||
// ==========================================
|
||||
async function fetchViewerData() {
|
||||
async function fetchViewerData(force = false) {
|
||||
const url = force ? "/ts6viewer/data?force=1" : "/ts6viewer/data";
|
||||
|
||||
try {
|
||||
const response = await fetch("/ts6viewer/data");
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
updateServerInfo(data.Server);
|
||||
updateChannelTree(data.ChannelTree);
|
||||
|
||||
updateServerInfo(data.VMServer);
|
||||
updateChannelTree(data.VMChannels);
|
||||
updateAllSpacers();
|
||||
} catch (err) {
|
||||
console.error("Polling error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// Update server info box
|
||||
// ==========================================
|
||||
function updateServerInfo(server) {
|
||||
document.querySelector(".server-info").innerHTML = `
|
||||
<h1 id='server-name'>${server.Name}</h1>
|
||||
function updateServerInfo(vmServer) {
|
||||
let bannerHtml = "";
|
||||
if (vmServer.HostBannerURL && vmServer.HostBannerURL.trim() !== "") {
|
||||
bannerHtml = `
|
||||
<div>
|
||||
<div class="banner-url">
|
||||
<a href="${vmServer.HostBannerURL}">
|
||||
${vmServer.HostBannerURL}
|
||||
</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
<div><span>User: </span> ${server.ClientsOnline} / ${server.MaxClients}</div>
|
||||
<div><span>Client Connections:</span> ${server.ClientConnections}</div>
|
||||
<div><span>Uptime:</span> ${server.UptimePretty}</div>
|
||||
<div><span>ChannelsOnline:</span> ${server.ChannelsOnline}</div>
|
||||
<div><div class='.banner-url'><a href="${server.HostBannerURL}">${server.HostBannerURL}</a></div></div>
|
||||
let serverNameHtml = "";
|
||||
if (vmServer.HostConnectionLink && vmServer.HostConnectionLink.trim() !== "") {
|
||||
serverNameHtml = `
|
||||
<h1 id="server-name">
|
||||
<a href="ts3server://${vmServer.HostConnectionLink}">
|
||||
${vmServer.Name}
|
||||
</a>
|
||||
</h1>`;
|
||||
} else {
|
||||
serverNameHtml = `<h1 id="server-name">${vmServer.Name}</h1>`;
|
||||
}
|
||||
|
||||
document.querySelector(".server-info").innerHTML = `
|
||||
${serverNameHtml}
|
||||
|
||||
<div><span>User: </span> ${vmServer.ClientsOnline} / ${vmServer.MaxClients}</div>
|
||||
<div><span>Client Connections:</span> ${vmServer.ClientConnections}</div>
|
||||
<div><span>Uptime:</span> ${vmServer.UptimePretty}</div>
|
||||
<div><span>ChannelsOnline:</span> ${vmServer.ChannelsOnline}</div>
|
||||
|
||||
${bannerHtml}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// Render channel tree
|
||||
// ==========================================
|
||||
|
|
@ -188,13 +211,25 @@ function renderChannel(ch) {
|
|||
}
|
||||
|
||||
html += '>' + ch.Name + '</div>';
|
||||
|
||||
|
||||
if (ch.Clients && ch.Clients.length > 0) {
|
||||
html += '<div class="children">';
|
||||
for (const c of ch.Clients) {
|
||||
html += '<div class="row client"><span class="status-dot"></span>' +
|
||||
c.Nickname +
|
||||
let icon = '<i class="fa-solid fa-circle status-online"></i>';
|
||||
|
||||
if (c.OutputMuted) {
|
||||
icon = '<i class="fa-solid fa-volume-xmark status-audio"></i>';
|
||||
} else if (c.MicMuted) {
|
||||
icon = '<i class="fa-solid fa-microphone-slash status-mic"></i>';
|
||||
} else {
|
||||
icon = '<i class="fa-solid fa-circle status-online"></i>';
|
||||
}
|
||||
|
||||
html += '<div class="row client">' +
|
||||
icon +
|
||||
'<span class="client-name">' + c.Nickname + '</span>' +
|
||||
'</div>';
|
||||
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
|
@ -210,7 +245,6 @@ function renderChannel(ch) {
|
|||
return html;
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// Initial load
|
||||
// ==========================================
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@
|
|||
|
||||
<link rel="stylesheet" href="/static/{{.Theme}}.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -46,17 +50,31 @@
|
|||
</button>
|
||||
|
||||
<div class="server-info">
|
||||
<h1 id="server-name">{{.Server.Name}}</h1>
|
||||
{{ if .VMServer.HostConnectionLink }}
|
||||
<h1 id="server-name">
|
||||
<a href="ts3server://{{ .VMServer.HostConnectionLink }}">
|
||||
{{ .VMServer.Name }}
|
||||
</a>
|
||||
</h1>
|
||||
{{ else }}
|
||||
<h1 id="server-name">{{ .VMServer.Name }}</h1>
|
||||
{{ end }}
|
||||
|
||||
<div><span>User: </span> {{.Server.ClientsOnline}} / {{.Server.MaxClients}}</div>
|
||||
<div><span>Client Connections:</span> {{.Server.ClientConnections}}</div>
|
||||
<div><span>Uptime:</span> {{.Server.UptimePretty}}</div>
|
||||
<div><span>ChannelsOnline:</span> {{.Server.ChannelsOnline}}</div>
|
||||
<div><div class=".banner-url"><a href="{{.Server.HostBannerURL}}">{{.Server.HostBannerURL}}</a></div></div>
|
||||
<div><span>User: </span> {{.VMServer.ClientsOnline}} / {{.VMServer.MaxClients}}</div>
|
||||
<div><span>Client Connections:</span> {{.VMServer.ClientConnections}}</div>
|
||||
<div><span>Uptime:</span> {{.VMServer.UptimePretty}}</div>
|
||||
<div><span>ChannelsOnline:</span> {{.VMServer.ChannelsOnline}}</div>
|
||||
{{ if .VMServer.HostBannerURL }}
|
||||
<div>
|
||||
<div class="banner-url">
|
||||
<a href="{{ .VMServer.HostBannerURL }}">{{ .VMServer.HostBannerURL }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div id="channels">
|
||||
{{range .ChannelTree}}
|
||||
{{range .VMChannels}}
|
||||
{{template "channel" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
|
|
|
|||
BIN
light.png
BIN
light.png
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 89 KiB |
Loading…
Add table
Add a link
Reference in a new issue