Gamer-Server-Utility-Scripts/Mumble/stats.php
2025-07-29 14:52:58 -04:00

809 lines
30 KiB
PHP

<?php
// Minimal working PHP logic for live Mumble stats, no error handling
require_once "/usr/share/php/Ice.php";
if (!class_exists("Ice\\Value")) {
eval("namespace Ice; class Value {}");
}
if (!class_exists("Ice\\UserException")) {
eval("namespace Ice; class UserException extends \\Exception {}");
}
require_once __DIR__ . "/MumbleServer.php";
// --- CONFIGURATION ---
$iceConnectionString = "Meta:tcp -h 127.0.0.1 -p 6502"; // Change host/port as needed
$serverId = 1; // Change to your Mumble server ID
$iceSecret = "PASSWORD123"; // Your icesecretread value
$serverExpectedName = "Claytonia Gaming"; // Expected server name from configuration
$serverWebsite = "https://claytonia.net/"; // Server website
// For displaying resolved IP
$displayHostname = "voice.claytonia.net";
$displayIp = gethostbyname($displayHostname);
// --- DATA FETCHING ---
$serverName = $serverVersion = $uptime = $maxUsers = $currentUsers = null;
$serverHostname = null;
$serverPort = 64738; // Default Mumble port
$channels = [];
$users = [];
$userStats = [];
$userStatsFile = __DIR__ . "/mumble_users.json"; // File to store user statistics
// Main block for Ice and server operations
$ICE = null;
$meta = null;
$serverHostname = null;
$users = [];
$channels = [];
try {
$ICE = Ice\initialize();
$proxy = $ICE->stringToProxy($iceConnectionString);
if (class_exists("\\MumbleServer\\MetaPrxHelper")) {
$meta = \MumbleServer\MetaPrxHelper::checkedCast($proxy);
}
$context = ["secret" => $iceSecret];
$servers = $meta->getAllServers($context);
// Get server by ID or use first server in the list
$server = null;
try {
$server = $meta->getServer($serverId, $context);
} catch (Exception $e) {
if (count($servers) > 0) {
$server = $servers[0];
$serverId = $server->id($context);
}
}
if ($server) {
// Get server information
try {
$serverName = $server->getConf("registerName", $context);
if (empty($serverName)) {
$serverName =
$serverExpectedName ?: "Mumble Server #" . $serverId;
}
} catch (Exception $e) {
$serverName = $serverExpectedName ?: "Mumble Server #" . $serverId;
}
// Get server hostname
try {
$serverHostname = $server->getConf("registerHostname", $context);
} catch (Exception $e) {
$serverHostname = "voice.claytonia.net";
}
// Try to get port if available (default to 64738)
try {
$confPort = $server->getConf("registerPort", $context);
if (!empty($confPort) && is_numeric($confPort)) {
$serverPort = (int) $confPort;
}
} catch (Exception $e) {
$serverPort = 64738;
}
// Get version - note: getVersion doesn't work in this implementation
$serverVersion = "Mumble Server";
// Get uptime
try {
$uptimeSeconds = $server->getUptime($context);
$days = floor($uptimeSeconds / 86400);
$hours = floor(($uptimeSeconds % 86400) / 3600);
$minutes = floor(($uptimeSeconds % 3600) / 60);
$uptime = "";
if ($days > 0) {
$uptime .= "$days days, ";
}
if ($hours > 0 || $days > 0) {
$uptime .= "$hours hours, ";
}
$uptime .= "$minutes minutes";
} catch (Exception $e) {
$uptime = "Unknown";
}
// Get configuration values and users
try {
$maxUsers = $server->getConf("users", $context);
} catch (Exception $e) {
$maxUsers = "?";
}
try {
$rawUsers = $server->getUsers($context);
$currentUsers = count($rawUsers);
} catch (Exception $e) {
$rawUsers = [];
$currentUsers = 0;
}
// Get channels
try {
$rawChannels = $server->getChannels($context);
foreach ($rawChannels as $id => $channel) {
// Get users in this channel
$channelUsers = [];
foreach ($rawUsers as $uid => $user) {
$userChannel = null;
if (is_object($user) && isset($user->channel)) {
$userChannel = $user->channel;
} elseif (is_array($user) && isset($user["channel"])) {
$userChannel = $user["channel"];
}
if ($userChannel == $id) {
$channelUsers[] = $uid;
}
}
$channelName = "";
if (is_object($channel) && isset($channel->name)) {
$channelName = $channel->name;
} elseif (is_array($channel) && isset($channel["name"])) {
$channelName = $channel["name"];
}
$channels[] = [
"name" => $channelName,
"users" => count($channelUsers),
];
}
} catch (Exception $e) {
$rawChannels = [];
$channels = [];
}
// Process users and update stats
if (isset($userStatsFile) && file_exists($userStatsFile)) {
$userStats =
json_decode(file_get_contents($userStatsFile), true) ?: [];
} else {
$userStats = [];
}
$now = time();
$today = date("Y-m-d");
foreach ($rawUsers as $id => $user) {
$channelName = "Unknown";
$userName = "";
$userChannelId = null;
$userSession = null;
$userIdHash = null;
if (is_object($user)) {
$userName = isset($user->name) ? $user->name : "Unknown User";
$userChannelId = isset($user->channel) ? $user->channel : null;
$userSession = isset($user->session) ? $user->session : $id;
$userIdHash = isset($user->hash) ? $user->hash : md5($userName);
} else {
$userName = isset($user["name"])
? $user["name"]
: "Unknown User";
$userChannelId = isset($user["channel"])
? $user["channel"]
: null;
$userSession = isset($user["session"]) ? $user["session"] : $id;
$userIdHash = isset($user["hash"])
? $user["hash"]
: md5($userName);
}
if (
$userChannelId !== null &&
isset($rawChannels[$userChannelId])
) {
$channel = $rawChannels[$userChannelId];
if (is_object($channel) && isset($channel->name)) {
$channelName = $channel->name;
} elseif (is_array($channel) && isset($channel["name"])) {
$channelName = $channel["name"];
}
}
$users[] = [
"name" => $userName,
"channel" => $channelName,
];
}
// Update and save user stats
if (isset($userStatsFile)) {
foreach ($users as $user) {
$userName = $user["name"];
$userHash = md5($userName);
$userChannel = $user["channel"] ?? "Unknown";
if (!isset($userStats[$userHash])) {
$userStats[$userHash] = [
"name" => $userName,
"first_seen" => $now,
"last_seen" => $now,
"connect_count" => 1,
"days" => [
$today => 1,
],
// "channels" => [ $userChannel => 1 ],
"total_time" => 0,
// "sessions" => [[ "start" => $now, "end" => null, "channel" => $userChannel ]],
];
continue;
}
$userStats[$userHash]["last_seen"] = $now;
$userStats[$userHash]["days"][$today] = 1;
// Removed all channels/session management from stats page
$hasActiveSession = false;
// Removed all session/channel logic from stats page
}
// file_put_contents removed: stats page should never write to mumble_users.json
}
}
} catch (Exception $e) {
// No error output
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars(
$serverName ?: "Mumble Server"
) ?> - Mumble Stats</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background-color: #121212;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 16px;
line-height: 1.6;
letter-spacing: 0.01em;
font-weight: 500;
}
/* User stats table custom styling */
.user-stats-table {
background-color: #232323 !important;
color: #ffffff !important;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
margin-bottom: 0;
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.user-stats-table th, .user-stats-table td {
border: none !important;
color: #ffffff !important;
background: #232323 !important;
vertical-align: middle;
}
.user-stats-table th {
background: #1e1e1e !important;
color: #d9c6ff !important;
font-weight: 600;
text-shadow: 0 0 1px rgba(140,82,255,0.5);
font-size: 1.05rem;
border-bottom: 2px solid #2a2a2a !important;
}
.user-stats-table tbody tr {
background-color: #232323 !important;
transition: background-color 0.2s;
}
.user-stats-table tbody tr:hover {
background-color: #2a2a2a !important;
}
.user-stats-table td {
font-size: 1.01rem;
font-weight: 600;
text-shadow: 0 0 1px rgba(255,255,255,0.3);
padding: 0.75rem 1.25rem;
}
.user-stats-table .badge {
background-color: #8c52ff;
font-weight: 500;
}
.container {
max-width: 1200px;
}
.card {
background-color: #1e1e1e;
border: none;
border-left: 3px solid #8c52ff;
border-radius: 6px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0,0,0,0.3);
}
h1, h4, h5 {
color: #ffffff;
}
h1 .text-info {
color: #8c52ff !important;
}
.card h5 {
margin-bottom: 15px;
font-weight: 600;
color: #d9c6ff;
text-shadow: 0 0 1px rgba(140,82,255,0.5);
}
.list-group-item {
background-color: #2a2a2a;
color: #ffffff;
border-color: #333;
transition: background-color 0.2s;
font-size: 1.05rem;
padding: 0.75rem 1.25rem;
font-weight: 600;
text-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}
.list-group-item:hover {
background-color: #333;
}
.badge {
background-color: #8c52ff;
font-weight: 500;
}
.text-info {
color: #ffffff !important;
font-weight: 600;
text-shadow: 0 0 1px rgba(255,255,255,0.5);
}
.btn-primary {
background-color: #8c52ff;
border-color: #8c52ff;
transition: background-color 0.3s;
font-weight: 600;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.btn-primary:hover {
background-color: #7140d1;
border-color: #7140d1;
}
.alert-danger {
background-color: #3d1a1a;
color: #ff9e9e;
border-color: #5c2626;
font-weight: 600;
}
/* Add animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(15px); }
to { opacity: 1; transform: translateY(0); }
}
.row > div {
animation: fadeIn 0.5s ease-out forwards;
}
.row > div:nth-child(2) {
animation-delay: 0.1s;
}
.row > div:nth-child(3) {
animation-delay: 0.2s;
}
.row > div:nth-child(4) {
animation-delay: 0.3s;
}
/* Channel and user list styling improvements */
.list-group {
border-radius: 6px;
overflow: hidden;
}
.list-group-item:first-child {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.list-group-item:last-child {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
/* Connection details section */
.connection-details {
margin-top: 2rem;
}
.connection-details .card {
border-left-color: #3498db;
}
.connection-details h4 {
color: #3498db;
}
/* Stats boxes */
.stats-box {
text-align: center;
padding: 1.5rem 1rem;
}
.stats-box p {
font-size: 1.5rem;
font-weight: 700;
color: #ffffff;
text-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}
/* Server details */
.server-name {
display: inline-block;
background: linear-gradient(45deg, #8c52ff, #5c9ce6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Empty state styling */
.empty-state {
text-align: center;
padding: 2rem;
color: #ffffff;
font-style: italic;
}
/* Footer */
.footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #333;
font-size: 0.9rem;
color: #888;
text-align: center;
}
/* Dark scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1a1a1a;
}
::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4a4a;
}
</style>
</head>
<body>
<div class="container py-5">
<h1 class="mb-4"><?= htmlspecialchars(
isset($serverName) ? $serverName : "Mumble Server"
) ?> <small class="text-info">Stats</small></h1>
<div class="row mb-4">
<div class="col-md-3">
<div class="card p-3 mb-3">
<h5>Server Info</h5>
<p style="color: #ffffff; font-size: 1.1rem; font-weight: 600; text-shadow: 0 0 1px rgba(255,255,255,0.8);"><?= htmlspecialchars(
isset($serverVersion) ? $serverVersion : "Unknown"
) ?><br>
<?php if (!empty($serverWebsite)): ?>
<a href="<?= htmlspecialchars(
$serverWebsite
) ?>" target="_blank">Visit Website</a>
<?php endif; ?>
</p>
</div>
</div>
<div class="col-md-3">
<div class="card p-3 mb-3">
<h5>Uptime</h5>
<p style="color: #ffffff; font-size: 1.1rem; font-weight: 600; text-shadow: 0 0 1px rgba(255,255,255,0.8);"><?= htmlspecialchars(
isset($uptime) ? $uptime : "Unknown"
) ?></p>
</div>
</div>
<div class="col-md-3">
<div class="card p-3 mb-3">
<h5>Users</h5>
<p style="color: #ffffff; font-size: 1.1rem; font-weight: 600; text-shadow: 0 0 1px rgba(255,255,255,0.8);"><?= htmlspecialchars(
isset($currentUsers) ? $currentUsers : "0"
) . " / &infin;" ?></p>
</div>
</div>
<div class="col-md-3">
<div class="card p-3 mb-3">
<h5>Channels</h5>
<p style="color: #ffffff; font-size: 1.1rem; font-weight: 600; text-shadow: 0 0 1px rgba(255,255,255,0.8);"><?= htmlspecialchars(
(string) (isset($channels) && is_array($channels)
? count($channels)
: 0)
) ?></p>
</div>
</div>
</div>
<div class="row">
<div class="col-12 mb-4">
<div class="card p-3">
<h5>Connection Details</h5>
<div style="margin-bottom: 15px;">
<p style="color: #ffffff; font-size: 1.1rem; font-weight: 600; text-shadow: 0 0 1px rgba(255,255,255,0.8); margin-bottom: 10px;">
<strong style="color: #d9c6ff; display: inline-block; width: 80px;">Server:</strong>
<span style="color: #ffffff; letter-spacing: 0.03em; font-weight: 700; text-shadow: 0 0 1px rgba(255,255,255,0.8);"><?= htmlspecialchars(
$displayHostname
) ?></span>
</p>
<p style="color: #ffffff; font-size: 1.1rem; font-weight: 600; text-shadow: 0 0 1px rgba(255,255,255,0.8); margin-bottom: 15px;">
<strong style="color: #d9c6ff; display: inline-block; width: 80px;">Port:</strong>
<span style="color: #ffffff; letter-spacing: 0.03em; font-weight: 700; text-shadow: 0 0 1px rgba(255,255,255,0.8);"><?= htmlspecialchars(
$serverPort
) ?></span>
</p>
</div>
<p class="text-center">
<a href="mumble://<?= htmlspecialchars(
$displayHostname
) ?>:<?= htmlspecialchars(
$serverPort
) ?>" class="btn btn-primary" style="font-size: 1.1rem; padding: 0.7rem 2rem; font-weight: 700; letter-spacing: 0.05em; box-shadow: 0 4px 15px rgba(140, 82, 255, 0.3);">
Connect to Claytonia Mumble
</a>
</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card p-3 mb-3">
<h4>Channels</h4>
<ul class="list-group">
<?php if (empty($channels)): ?>
<li class="list-group-item">No channels available</li>
<?php else: ?>
<?php foreach ((array) $channels as $ch): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><?= htmlspecialchars($ch["name"]) ?></span>
<span class="badge bg-info"><?= $ch[
"users"
] ?> users</span>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="card p-3 mb-3">
<h4>Online Users</h4>
<ul class="list-group">
<?php if (empty($users)): ?>
<li class="list-group-item">No users online</li>
<?php else: ?>
<?php foreach ((array) $users as $user): ?>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><?= htmlspecialchars($user["name"]) ?></span>
<span class="text-info"><?= htmlspecialchars(
$user["channel"]
) ?></span>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
</div>
</div>
<!-- User Statistics -->
<div class="row">
<div class="col-12 mb-4">
<div class="card p-3">
<h5>User Statistics <small style="font-size: 0.7rem; color: #a175ff; font-weight: normal;"><?= file_exists(
__DIR__ . "/mumble_users.json"
)
? "Updated " .
date(
"Y-m-d H:i:s",
filemtime(__DIR__ . "/mumble_users.json")
)
: "No stats file" ?></small></h5>
<div class="table-responsive">
<table class="user-stats-table mb-0">
<thead>
<tr>
<th>User</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Connections</th>
<th>Days Active</th>
<th>Total Time</th>
</tr>
</thead>
<tbody>
<?php if (file_exists($userStatsFile)) {
$displayStats = json_decode(
file_get_contents($userStatsFile),
true
);
if (!empty($displayStats)):
uasort($displayStats, function ($a, $b) {
return $b["last_seen"] - $a["last_seen"];
});
foreach (
$displayStats
as $userHash => $stat
) {
$dayCount = isset($stat["days"])
? count($stat["days"])
: 0;
// Display total_time from JSON directly
$totalSeconds = isset(
$stat["total_time"]
)
? $stat["total_time"]
: 0;
$totalHours = round(
$totalSeconds / 3600,
1
);
$totalTime = $totalHours . " hours";
$isOnline = false;
if (
isset($stat["sessions"]) &&
!empty($stat["sessions"])
) {
$lastSession = end(
$stat["sessions"]
);
if (
$lastSession &&
isset($lastSession["end"]) &&
$lastSession["end"] === null
) {
$isOnline = true;
}
}
$rowStyle = $isOnline
? "background-color: rgba(140, 82, 255, 0.15);"
: "";
$nameStyle = $isOnline
? "color: #ffffff; font-weight: 700;"
: "color: #ffffff; font-weight: 600;";
?>
<tr style="<?= $rowStyle ?>">
<td style="<?= $nameStyle ?>">
<?= htmlspecialchars($stat["name"]) ?>
<?php if ($isOnline): ?>
<span class="badge" style="background-color: #28a745; font-size: 0.7rem; vertical-align: middle; margin-left: 5px;">Online</span>
<?php if (
isset($lastSession) &&
isset($lastSession["channel"]) &&
!empty($lastSession["channel"])
): ?>
<!-- Removed channel badge, as requested -->
<?php endif; ?>
<?php endif; ?>
</td>
<td><?= date(
"Y-m-d",
$stat["first_seen"]
) ?></td>
<td><?= date("Y-m-d", $stat["last_seen"]) ?></td>
<td><?= $stat["connect_count"] ?></td>
<td><?= $dayCount ?></td>
<td><?= $totalTime ?></td>
</tr>
<?php
}
else:
?>
<tr>
<td colspan="6" class="empty-state">No user statistics available yet</td>
</tr>
<?php
endif;
} else {
?>
<tr>
<td colspan="6" class="empty-state">Statistics file not found</td>
</tr>
<?php
} ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</body>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.user-stats-table').forEach(function (table) {
table.querySelectorAll('th').forEach(function (header, columnIndex) {
header.style.cursor = 'pointer';
header.addEventListener('click', function () {
const tbody = table.querySelector('tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
const isAsc = header.classList.toggle('asc');
header.classList.toggle('desc', !isAsc);
// Remove sort classes from other headers
table.querySelectorAll('th').forEach(function (th, i) {
if (i !== columnIndex) th.classList.remove('asc', 'desc');
});
rows.sort(function (a, b) {
let cellA = a.children[columnIndex].textContent.trim();
let cellB = b.children[columnIndex].textContent.trim();
// Handle date columns (YYYY-MM-DD)
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (dateRegex.test(cellA) && dateRegex.test(cellB)) {
const timeA = new Date(cellA).getTime();
const timeB = new Date(cellB).getTime();
return isAsc ? timeA - timeB : timeB - timeA;
}
// Handle time columns like "X hours"
const hoursRegex = /^(\d+(\.\d+)?)\s*hours?$/i;
const matchA = cellA.match(hoursRegex);
const matchB = cellB.match(hoursRegex);
if (matchA && matchB) {
const numA = parseFloat(matchA[1]);
const numB = parseFloat(matchB[1]);
return isAsc ? numA - numB : numB - numA;
}
// Try to compare as numbers, fallback to string
const numA = parseFloat(cellA.replace(/[^0-9.\-]/g, ''));
const numB = parseFloat(cellB.replace(/[^0-9.\-]/g, ''));
if (!isNaN(numA) && !isNaN(numB) && cellA !== "" && cellB !== "") {
return isAsc ? numA - numB : numB - numA;
}
return isAsc
? cellA.localeCompare(cellB)
: cellB.localeCompare(cellA);
});
rows.forEach(function (row) {
tbody.appendChild(row);
});
});
});
});
});
</script>
</html>