Major client-area overhaul, WHMCS 9 + VirtFusion v7 compatibility, and a
hardening pass on every destructive client.php endpoint.
Tested against WHMCS 9.0.3 + VirtFusion v7.0.0 Build 9.
Features
- "On This Page" jump-link group injected into the WHMCS Actions sidebar
via ClientAreaPrimarySidebar; auto-hides links for hidden panels.
- Monthly traffic chart (last 12 months) with rx/tx bars and centered
legend; replaces the dead canvas that read non-existent JSON paths.
- Live Stats panel: CPU, memory, disk I/O from remoteState; 30s refresh
while the panel is visible AND the page has focus.
- Filesystem usage rows in the Resources panel from qemu-guest-agent
fsinfo; pseudo-FS filtered out.
- Server Overview meta chips: data-center location with country flag,
OS template/agent name with kernel on hover, "Created N days ago".
- Hypervisor maintenance banner at the top of the page.
- Mask Sensitive screenshot mode: IPv4 keeps first two octets, IPv6
keeps first two hextets, hostnames keep first char per dot-label.
Inputs masked via text-security: disc; covers Server Name + Hostname
+ IP cells + rDNS panel rows.
- Per-IP copy buttons folded into the Server Overview cells (replaces
the deleted standalone Network panel).
- VNC viewer popup served from a same-origin authenticated route
(client.php?action=vncViewer) — POST + requireSameOrigin, rotates
the wss token on every open, X-Frame-Options DENY, strict CSP.
Bug Fixes
- UsageUpdate cron silently no-op'd: read server.usage.traffic.used
which doesn't exist. Bandwidth now from /servers/{id}/traffic;
disk usage from remoteState.agent.fsinfo.
- WHMCS 9 multi-service order short-circuit: AfterModuleCreate's
AcceptOrder fired after the first service and terminated the batch
loop, orphaning siblings. Defer until every VF service in the order
has a server_id.
- Orphaned services produced six generic 500s; new
requireProvisionedService() helper emits one clean 409 with an
actionable message. Wired into all 17 client.php cases.
- Server Overview Traffic showed "- / Unlimited"; now renders real
bytes and "Unmetered" (limit=0 is per-period uncapped, not feature-off).
- Rename endpoint moved to PUT /servers/{id}/modify/name in VF v7
(was 404'ing); response is HTTP 201 not 200/204.
- Rename was force-lowercasing the input; relaxed validation to
preserve case + freeze the input row mid-flight to prevent
double-submits.
- "Other" OS category icon override removed; uses VirtFusion's icon
instead of a hardcoded SVG.
- Save button squish on the rename row fixed via flex-wrap layout.
Security
- CSRF protection (requirePost + requireSameOrigin) added to every
destructive POST: rebuild, resetPassword, resetServerPassword,
powerAction, rename, selfServiceAddCredit, toggleVnc, vncViewer.
Previously only rdnsUpdate had it.
- Open-redirect defence in Module::fetchLoginTokens — refuses to
return a redirect URL whose host doesn't match the configured VF
panel hostname.
- Per-action rate limiting via new Module::requireRateLimit helper
(Cache-backed): rebuild 60s, resetPassword/resetServerPassword 30s,
powerAction 10s, vncViewer/toggleVnc/selfServiceAddCredit 5s.
- vncViewer route delivers strict Content-Security-Policy
(default-src none, script-src self + VF panel, connect-src wss VF
panel, frame-ancestors none).
- IPv6 examples in placeholder/comments switched to the IANA
documentation prefix 2001:db8::/32 (RFC 3849).
Removed
- Network panel (duplicated Server Overview IP rows).
- VNC enable/disable toggle (VF firewall flag is non-functional;
toggle was misleading).
- Network Speed row in Resources panel (always 0 from VF API).
Internal
- Module::fetchServerData now passes ?remoteState=true.
- ServerResource::process exposes osName/osPretty/osKernel/osDistro/
osIcon/location/locationIcon/hypervisorMaintenance/createdAt/
builtAt/live.* fields.
- Module::toggleVnc corrected to send {vnc:bool} (the actual API
param) instead of {enabled:bool} (silent no-op).
- Module::getVncConsole + toggleVnc return baseUrl alongside the
envelope so the viewer route can build the wss URL.
- Panel margins tightened mb-3 → mb-2 across all 11 panels.
This commit is contained in:
@@ -358,12 +358,20 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
||||
foreach ($services as $service) {
|
||||
try {
|
||||
$systemService = Database::getSystemService($service->id);
|
||||
if (! $systemService) {
|
||||
if (! $systemService || empty($systemService->server_id)) {
|
||||
// No VirtFusion server linked to this WHMCS service yet —
|
||||
// either provisioning hasn't happened or it failed mid-create.
|
||||
// Skipping is correct: there is nothing to read usage from.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch server settings (limits + storage profile) with remoteState=true
|
||||
// so the qemu-agent fsinfo block is included for disk usage. The agent
|
||||
// is best-effort — guests without qemu-agent installed will have no
|
||||
// fsinfo, in which case we simply skip the diskused write rather than
|
||||
// zeroing it.
|
||||
$request = $module->initCurl($cp['token']);
|
||||
$data = $request->get($cp['url'] . '/servers/' . (int) $systemService->server_id);
|
||||
$data = $request->get($cp['url'] . '/servers/' . (int) $systemService->server_id . '?remoteState=true');
|
||||
|
||||
if ($request->getRequestInfo('http_code') != 200) {
|
||||
continue;
|
||||
@@ -377,19 +385,43 @@ function VirtFusionDirect_UsageUpdate(array $params)
|
||||
$server = $serverData['data'];
|
||||
$update = [];
|
||||
|
||||
// Disk usage (WHMCS expects MB)
|
||||
if (isset($server['usage']['storage']['used'])) {
|
||||
$update['diskused'] = round($server['usage']['storage']['used'] / 1048576);
|
||||
// Disk usage (WHMCS expects MB) — derived from qemu-agent fsinfo when
|
||||
// available. Sum across all reported filesystems (root + any extra
|
||||
// mounts) and convert bytes -> MB. If the agent isn't running we get
|
||||
// no fsinfo entries and leave diskused untouched.
|
||||
$fsinfo = $server['remoteState']['agent']['fsinfo'] ?? null;
|
||||
if (is_array($fsinfo) && $fsinfo !== []) {
|
||||
$diskUsedBytes = 0;
|
||||
foreach ($fsinfo as $fs) {
|
||||
if (isset($fs['used-bytes']) && is_numeric($fs['used-bytes'])) {
|
||||
$diskUsedBytes += (int) $fs['used-bytes'];
|
||||
}
|
||||
}
|
||||
if ($diskUsedBytes > 0) {
|
||||
$update['diskused'] = (int) round($diskUsedBytes / 1048576);
|
||||
}
|
||||
}
|
||||
if (isset($server['settings']['resources']['storage'])) {
|
||||
// settings.resources.storage is in GB; WHMCS disklimit is MB.
|
||||
$update['disklimit'] = (int) $server['settings']['resources']['storage'] * 1024;
|
||||
}
|
||||
|
||||
// Bandwidth usage (WHMCS expects MB)
|
||||
if (isset($server['usage']['traffic']['used'])) {
|
||||
$update['bwused'] = round($server['usage']['traffic']['used'] / 1048576);
|
||||
// Bandwidth usage (WHMCS expects MB) — fetched from the dedicated
|
||||
// /servers/{id}/traffic endpoint, which is the canonical source for
|
||||
// billing-period totals. The /servers/{id} response only exposes the
|
||||
// current period's window (start/end/limit), not the byte counter.
|
||||
$trafficRequest = $module->initCurl($cp['token']);
|
||||
$trafficData = $trafficRequest->get($cp['url'] . '/servers/' . (int) $systemService->server_id . '/traffic');
|
||||
if ($trafficRequest->getRequestInfo('http_code') == 200) {
|
||||
$trafficJson = json_decode($trafficData, true);
|
||||
$currentPeriod = $trafficJson['data']['monthly'][0] ?? null;
|
||||
if (is_array($currentPeriod) && isset($currentPeriod['total']) && is_numeric($currentPeriod['total'])) {
|
||||
$update['bwused'] = (int) round($currentPeriod['total'] / 1048576);
|
||||
}
|
||||
}
|
||||
if (isset($server['settings']['resources']['traffic'])) {
|
||||
// settings.resources.traffic is in GB; 0 means unlimited, which
|
||||
// WHMCS represents the same way (0 bwlimit = no cap).
|
||||
$trafficGB = (int) $server['settings']['resources']['traffic'];
|
||||
$update['bwlimit'] = $trafficGB > 0 ? $trafficGB * 1024 : 0;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ require dirname(__DIR__, 3) . '/init.php';
|
||||
* the user sees a generic 500.
|
||||
*/
|
||||
|
||||
use WHMCS\Database\Capsule;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Log;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\Module;
|
||||
use WHMCS\Module\Server\VirtFusionDirect\PowerDns\Config as PowerDnsConfig;
|
||||
@@ -74,6 +75,13 @@ try {
|
||||
*/
|
||||
case 'resetPassword':
|
||||
|
||||
// Destructive: rotates the customer's VirtFusion login password.
|
||||
// Gated by POST + same-origin (anti-CSRF) and a 30 s rate limit
|
||||
// so a runaway / malicious script can't lock out the customer
|
||||
// by spamming password resets.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
$client = $vf->validateUserOwnsService($serviceID);
|
||||
|
||||
@@ -82,6 +90,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('resetPassword:' . $serviceID, 30);
|
||||
|
||||
$data = $vf->resetUserPassword($serviceID, $client);
|
||||
|
||||
if ($data) {
|
||||
@@ -104,6 +115,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$data = $vf->fetchServerData($serviceID);
|
||||
|
||||
if ($data) {
|
||||
@@ -127,6 +140,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$token = $vf->fetchLoginTokens($serviceID);
|
||||
|
||||
if ($token) {
|
||||
@@ -142,6 +157,12 @@ try {
|
||||
*/
|
||||
case 'powerAction':
|
||||
|
||||
// Destructive: poweroff/restart can interrupt running workloads.
|
||||
// Anti-CSRF + 10 s rate limit (short — power actions can legitimately
|
||||
// cycle quickly when an admin is testing).
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -149,6 +170,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('power:' . $serviceID, 10);
|
||||
|
||||
$powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
|
||||
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
|
||||
|
||||
@@ -172,6 +196,13 @@ try {
|
||||
*/
|
||||
case 'rebuild':
|
||||
|
||||
// Most-destructive client action — wipes the server. Strict
|
||||
// anti-CSRF (a malicious page tricking the customer into
|
||||
// rebuilding their own server destroys data) + 60 s rate limit
|
||||
// (no legitimate flow needs more than one rebuild per minute).
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -179,6 +210,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('rebuild:' . $serviceID, 60);
|
||||
|
||||
$osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
|
||||
$hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
|
||||
|
||||
@@ -202,6 +236,10 @@ try {
|
||||
*/
|
||||
case 'rename':
|
||||
|
||||
// Mutation: anti-CSRF. No rate limit — name changes are cheap.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -209,9 +247,14 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$newName = isset($_POST['name']) ? trim($_POST['name']) : '';
|
||||
|
||||
if (empty($newName) || strlen($newName) > 63 || ! preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/', $newName)) {
|
||||
// VF "name" is a display label, not a DNS hostname — preserve
|
||||
// case + accept any printable string up to 63 chars. The only
|
||||
// hard rejects are empty, oversized, and control characters.
|
||||
if ($newName === '' || strlen($newName) > 63 || preg_match('/[\x00-\x1F\x7F]/', $newName)) {
|
||||
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
|
||||
break;
|
||||
}
|
||||
@@ -238,6 +281,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$templates = $vf->fetchOsTemplates($serviceID);
|
||||
|
||||
if ($templates !== false) {
|
||||
@@ -257,6 +302,11 @@ try {
|
||||
*/
|
||||
case 'resetServerPassword':
|
||||
|
||||
// Destructive: rotates the VPS root password. Anti-CSRF + 30 s
|
||||
// rate limit so a hostile script can't lock out the customer.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -264,6 +314,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('resetServerPassword:' . $serviceID, 30);
|
||||
|
||||
$result = $vf->resetServerPassword($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -290,6 +343,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getServerBackups($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -316,6 +371,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getTrafficStats($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -342,6 +399,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getVncConsole($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -353,9 +412,36 @@ try {
|
||||
break;
|
||||
|
||||
/**
|
||||
* Toggle VNC on/off.
|
||||
* Render the noVNC viewer HTML page.
|
||||
*
|
||||
* SECURITY MODEL
|
||||
* --------------
|
||||
* This is the popup target instead of a blob URL — it keeps the
|
||||
* wss token out of any URL the customer can copy/share. The page
|
||||
* is gated by the same client.php protections every other action
|
||||
* uses:
|
||||
* - WHMCS session required (isAuthenticated)
|
||||
* - validateUserOwnsService prevents cross-customer access
|
||||
* (any other customer hitting this URL with their session
|
||||
* gets a 403)
|
||||
* - requireProvisionedService blocks orphan services
|
||||
*
|
||||
* Each request rotates the wss token by POSTing to VirtFusion's
|
||||
* /vnc endpoint with vnc:true — older tokens VirtFusion was
|
||||
* tracking are superseded, so a leaked token from a previous
|
||||
* popup open is no longer usable after the next click.
|
||||
*
|
||||
* Method is POST (not GET) so we can require same-origin and
|
||||
* avoid the GET-with-side-effects anti-pattern. JS opens the
|
||||
* popup via a hidden form-submit (see vfOpenVnc in module.js).
|
||||
*
|
||||
* Output is text/html (NOT the JSON the other actions use),
|
||||
* directly delivered to the popup window.
|
||||
*/
|
||||
case 'toggleVnc':
|
||||
case 'vncViewer':
|
||||
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
@@ -364,6 +450,110 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
// 5 s rate limit — protects against runaway-script token rotation
|
||||
// bursts. A legitimate user clicking Open Console twice in a row
|
||||
// (e.g. popup got closed) waits at most 5 s.
|
||||
$vf->requireRateLimit('vncViewer:' . $serviceID, 5);
|
||||
|
||||
// Rotate credentials by toggling vnc=true (idempotent — VF returns
|
||||
// a fresh token + password on each call). Falls back to a plain
|
||||
// GET if the rotate call fails so the customer still gets a
|
||||
// viewer with the existing creds.
|
||||
$vncData = $vf->toggleVnc($serviceID, true);
|
||||
if ($vncData === false) {
|
||||
$vncData = $vf->getVncConsole($serviceID);
|
||||
}
|
||||
|
||||
if ($vncData === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!DOCTYPE html><html><head><title>VNC Console</title></head><body style="font-family:sans-serif;padding:40px;text-align:center;color:#aaa;background:#111;">Unable to obtain VNC credentials. The server may be powered off.</body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Drill the response shape the same way module.js used to —
|
||||
// wrapper.data.vnc holds the credentials; wrapper.baseUrl is
|
||||
// added by Module::toggleVnc / Module::getVncConsole.
|
||||
$apiRoot = isset($vncData['data']) ? $vncData['data'] : $vncData;
|
||||
$vnc = $apiRoot['vnc'] ?? [];
|
||||
$baseUrl = $vncData['baseUrl'] ?? '';
|
||||
$wssPath = $vnc['wss']['url'] ?? '';
|
||||
$password = $vnc['password'] ?? '';
|
||||
|
||||
if ($baseUrl === '' || $wssPath === '') {
|
||||
http_response_code(500);
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo '<!DOCTYPE html><html><head><title>VNC Console</title></head><body style="font-family:sans-serif;padding:40px;text-align:center;color:#aaa;background:#111;">VNC credentials missing from the API response.</body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Look up the server name for the popup title (best-effort —
|
||||
// doesn't gate rendering).
|
||||
$serverName = '';
|
||||
|
||||
try {
|
||||
$hosting = Capsule::table('tblhosting')->where('id', $serviceID)->first(['domain']);
|
||||
$serverName = $hosting && $hosting->domain ? (string) $hosting->domain : '';
|
||||
} catch (Throwable $e) { /* non-fatal */
|
||||
}
|
||||
|
||||
$vfHost = preg_replace('~^https?://~', '', rtrim($baseUrl, '/'));
|
||||
$vncJsSrc = $baseUrl . '/vnc/vnc.js';
|
||||
|
||||
$esc = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
// Don't let the page be embedded by other origins or cached
|
||||
// intermediaries — the rotated token must not stick around.
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Cache-Control: no-store, no-cache, must-revalidate, private');
|
||||
header('Pragma: no-cache');
|
||||
// CSP — only the VirtFusion panel can serve scripts (vnc.js bundle)
|
||||
// and only the wss endpoint on that host accepts our WebSocket.
|
||||
// Self is needed for the inline script that runs the noVNC bundle.
|
||||
header("Content-Security-Policy: default-src 'none'; script-src 'self' " . $baseUrl . '; connect-src wss://' . $vfHost . ' ' . $baseUrl . "; img-src 'self' data: " . $baseUrl . "; style-src 'self' 'unsafe-inline'; frame-ancestors 'none';");
|
||||
?><!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>VNC — <?= $esc($serverName) ?></title>
|
||||
<style>html,body{margin:0;padding:0;background:#000;height:100%;font-family:sans-serif;color:#aaa;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<input type="hidden" id="con" value="wss://<?= $esc($vfHost . $wssPath) ?>">
|
||||
<input type="hidden" id="pass" value="<?= $esc($password) ?>">
|
||||
<input type="hidden" id="server-name" value="<?= $esc($serverName) ?>">
|
||||
<div id="noVNC_container" style="position:fixed;inset:0;"></div>
|
||||
<script src="<?= $esc($vncJsSrc) ?>"></script>
|
||||
</body>
|
||||
</html><?php
|
||||
exit;
|
||||
break;
|
||||
|
||||
/**
|
||||
* Toggle VNC on/off.
|
||||
*
|
||||
* Dead path as of 1.5.0 (UI no longer exposes a toggle — see
|
||||
* VNC notes in CLAUDE.md). Kept for backwards-compat in case any
|
||||
* out-of-tree caller invokes it; gated as if it were live so
|
||||
* leaving it here doesn't widen the attack surface.
|
||||
*/
|
||||
case 'toggleVnc':
|
||||
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('toggleVnc:' . $serviceID, 5);
|
||||
|
||||
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
|
||||
$result = $vf->toggleVnc($serviceID, $enabled);
|
||||
|
||||
@@ -391,6 +581,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getSelfServiceUsage($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -413,6 +605,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
$result = $vf->getSelfServiceReport($serviceID);
|
||||
|
||||
if ($result !== false) {
|
||||
@@ -428,6 +622,13 @@ try {
|
||||
*/
|
||||
case 'selfServiceAddCredit':
|
||||
|
||||
// Money-affecting mutation: anti-CSRF + 5 s rate limit so a
|
||||
// hostile script can't accidentally trigger duplicate charges
|
||||
// by spamming credit-adds. The actual amount is also validated
|
||||
// and money-bound on the WHMCS side, but defence-in-depth.
|
||||
$vf->requirePost();
|
||||
$vf->requireSameOrigin();
|
||||
|
||||
$serviceID = $vf->validateServiceID(true);
|
||||
|
||||
if (! $vf->validateUserOwnsService($serviceID)) {
|
||||
@@ -435,6 +636,9 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
$vf->requireRateLimit('selfServiceAddCredit:' . $serviceID, 5);
|
||||
|
||||
$tokens = isset($_POST['tokens']) ? (float) $_POST['tokens'] : 0;
|
||||
if ($tokens <= 0) {
|
||||
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
|
||||
@@ -471,6 +675,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
// Reads are permitted for Active + Suspended (a suspended user can still see their rDNS);
|
||||
// Terminated/Pending/Cancelled/Fraud return a clear 400 upfront.
|
||||
$vf->requireServiceStatus($serviceID, ['Active', 'Suspended']);
|
||||
@@ -511,6 +717,8 @@ try {
|
||||
break;
|
||||
}
|
||||
|
||||
$vf->requireProvisionedService($serviceID);
|
||||
|
||||
// Writes require an Active service — Suspended/Terminated/etc. cannot mutate rDNS.
|
||||
$vf->requireServiceStatus($serviceID, ['Active']);
|
||||
|
||||
|
||||
@@ -144,16 +144,46 @@ add_hook('AfterModuleCreate', 1, function ($vars) {
|
||||
if ($orderId > 0) {
|
||||
$order = Capsule::table('tblorders')->where('id', $orderId)->first();
|
||||
if ($order && strcasecmp((string) $order->status, 'Pending') === 0) {
|
||||
$resp = localAPI('AcceptOrder', [
|
||||
'orderid' => $orderId,
|
||||
'autosetup' => false, // already provisioned; don't re-run CreateAccount
|
||||
'sendemail' => true,
|
||||
]);
|
||||
Log::insert(
|
||||
'AutoAcceptOrder',
|
||||
['orderid' => $orderId, 'serviceid' => $serviceId],
|
||||
$resp,
|
||||
);
|
||||
// WHMCS 9 regression guard: WHMCS 9's batch order-acceptance
|
||||
// loop terminates once the order leaves Pending status.
|
||||
// Calling AcceptOrder after the first sibling completes
|
||||
// therefore short-circuits provisioning of the rest of the
|
||||
// order's services — they end up Active in tblhosting with
|
||||
// no mod_virtfusion_direct row and no server in VirtFusion.
|
||||
// Defer the AcceptOrder until every VF service in this
|
||||
// order has provisioned; the hook fires once per service,
|
||||
// so the last one to complete will see no unprovisioned
|
||||
// siblings and trigger the accept. WHMCS 8 wasn't affected
|
||||
// (its loop ignored order status mid-batch), but deferring
|
||||
// there is harmless — same end state, just later timing.
|
||||
$unprovisionedSiblings = Capsule::table('tblhosting AS h')
|
||||
->join('tblproducts AS p', 'h.packageid', '=', 'p.id')
|
||||
->leftJoin('mod_virtfusion_direct AS m', 'h.id', '=', 'm.service_id')
|
||||
->where('h.orderid', $orderId)
|
||||
->where('h.id', '!=', $serviceId)
|
||||
->where('p.servertype', 'VirtFusionDirect')
|
||||
->where('h.domainstatus', 'Pending')
|
||||
->whereNull('m.server_id')
|
||||
->count();
|
||||
|
||||
if ($unprovisionedSiblings > 0) {
|
||||
Log::insert(
|
||||
'AutoAcceptOrder:deferred',
|
||||
['orderid' => $orderId, 'serviceid' => $serviceId, 'unprovisioned_siblings' => $unprovisionedSiblings],
|
||||
'Order has more VirtFusionDirect services awaiting provisioning; AcceptOrder will fire after the last one',
|
||||
);
|
||||
} else {
|
||||
$resp = localAPI('AcceptOrder', [
|
||||
'orderid' => $orderId,
|
||||
'autosetup' => false, // already provisioned; don't re-run CreateAccount
|
||||
'sendemail' => true,
|
||||
]);
|
||||
Log::insert(
|
||||
'AutoAcceptOrder',
|
||||
['orderid' => $orderId, 'serviceid' => $serviceId],
|
||||
$resp,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,10 +475,10 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
||||
catImg.alt = '';
|
||||
catImg.onerror = function() { this.parentNode.style.background = catColor; this.parentNode.textContent = (cat.name || '?')[0].toUpperCase(); };
|
||||
catIcon.appendChild(catImg);
|
||||
} else if (cat.name === 'Other') {
|
||||
catIcon.style.background = '#6c757d';
|
||||
catIcon.innerHTML = '<svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"#fff\"><path d=\"M3 2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm1 2h8v2H4V4zm0 3h8v1H4V7zm0 2h5v1H4V9z\"/></svg>';
|
||||
} else {
|
||||
// No icon (synthetic singletons bucket, etc.) — fall back
|
||||
// to brand-color circle with the first letter, matching
|
||||
// the client-area renderer.
|
||||
catIcon.style.background = catColor;
|
||||
catIcon.textContent = (cat.name || '?')[0].toUpperCase();
|
||||
}
|
||||
@@ -821,3 +851,78 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Inject a "On This Page" jump-link group into the client area sidebar
|
||||
* when the customer is viewing a VirtFusionDirect product details page.
|
||||
*
|
||||
* Replaces the previous inline horizontal nav strip — sidebar placement
|
||||
* keeps the links visible while scrolling the long product details page.
|
||||
*
|
||||
* Static rendering: every known section anchor is added regardless of
|
||||
* whether its panel is visible. JS (vfBuildSectionNav in module.js) walks
|
||||
* the rendered links post-load and hides the parent <li> for any target
|
||||
* panel that isn't visible (Resources/VNC/Self-Service when their data
|
||||
* hasn't loaded; rDNS when PowerDNS isn't enabled at the template level).
|
||||
*
|
||||
* Filtered to productdetails for VF services so we don't pollute the
|
||||
* sidebar on unrelated pages or non-VF service detail pages.
|
||||
*/
|
||||
add_hook('ClientAreaPrimarySidebar', 1, function ($primarySidebar) {
|
||||
try {
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$serviceId = (int) ($_REQUEST['id'] ?? 0);
|
||||
if ($action !== 'productdetails' || $serviceId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify this is a VirtFusionDirect service before adding our links.
|
||||
$isVf = Capsule::table('tblhosting AS h')
|
||||
->join('tblproducts AS p', 'h.packageid', '=', 'p.id')
|
||||
->where('h.id', $serviceId)
|
||||
->where('p.servertype', 'VirtFusionDirect')
|
||||
->exists();
|
||||
if (! $isVf) {
|
||||
return;
|
||||
}
|
||||
|
||||
// High order pushes us below the standard "Manage Product" entries.
|
||||
$jump = $primarySidebar->addChild('VfJumpTo', [
|
||||
'label' => 'On This Page',
|
||||
'order' => 80,
|
||||
]);
|
||||
|
||||
// VNC deliberately excluded — its panel sits at the very top of
|
||||
// the page, so a sidebar jump-link would just scroll the customer
|
||||
// past everything else they care about. The other entries are
|
||||
// ordered to match the page's vertical flow.
|
||||
$items = [
|
||||
['Overview', 'vf-sec-overview'],
|
||||
['Traffic', 'vf-sec-traffic'],
|
||||
['Live Stats', 'vf-sec-livestats'],
|
||||
['Power', 'vf-sec-power'],
|
||||
['Manage', 'vf-sec-manage'],
|
||||
['Rebuild', 'vf-sec-rebuild'],
|
||||
['Reverse DNS', 'vf-sec-rdns'],
|
||||
['Resources', 'vf-resources-panel'],
|
||||
['Billing & Usage', 'vf-selfservice-panel'],
|
||||
['Billing Overview', 'vf-sec-billing'],
|
||||
];
|
||||
|
||||
foreach ($items as $i => $item) {
|
||||
$child = $jump->addChild('vfsec-' . $item[1], [
|
||||
'label' => $item[0],
|
||||
'uri' => '#' . $item[1],
|
||||
'order' => ($i + 1) * 10,
|
||||
]);
|
||||
// data-vf-target lets the smooth-scroll handler in module.js find
|
||||
// these links generically (same selector covers both inline and
|
||||
// sidebar nav). Class is duplicated for legacy CSS that may key
|
||||
// on .vf-nav-link.
|
||||
$child->setAttribute('data-vf-target', $item[1]);
|
||||
$child->setClass('vf-nav-link');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Silent failure — sidebar customisation must never break the page.
|
||||
}
|
||||
});
|
||||
|
||||
@@ -191,7 +191,21 @@ class Module
|
||||
if ($ctx['request']->getRequestInfo('http_code') == '200') {
|
||||
$data = json_decode($data);
|
||||
if (isset($data->data->authentication->endpoint_complete)) {
|
||||
return $ctx['cp']['base_url'] . $data->data->authentication->endpoint_complete;
|
||||
$ssoUrl = $ctx['cp']['base_url'] . $data->data->authentication->endpoint_complete;
|
||||
// Open-redirect defence: assert the URL we're about to send
|
||||
// the customer to is on the configured VirtFusion host. If
|
||||
// the API response, the cp_base_url config, or someone
|
||||
// tampering with tblservers managed to point us elsewhere,
|
||||
// refuse rather than 302 to an arbitrary destination.
|
||||
$expectedHost = parse_url($ctx['cp']['base_url'], PHP_URL_HOST);
|
||||
$actualHost = parse_url($ssoUrl, PHP_URL_HOST);
|
||||
if (! $expectedHost || strcasecmp((string) $expectedHost, (string) $actualHost) !== 0) {
|
||||
Log::insert(__FUNCTION__ . ':host_mismatch', ['expected' => $expectedHost, 'actual' => $actualHost], 'SSO redirect rejected');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $ssoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,14 +288,46 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId']);
|
||||
// ?remoteState=true asks VirtFusion to introspect libvirt + (when
|
||||
// available) qemu-guest-agent on the guest, returning live CPU/memory
|
||||
// gauges, disk I/O counters, and per-mount filesystem usage under
|
||||
// remoteState.{cpu,memory,disk,agent.fsinfo}. This is heavier than
|
||||
// the bare /servers/{id} call (libvirt round-trip on the hypervisor)
|
||||
// — acceptable on the page-load path at our scale; revisit caching
|
||||
// if hypervisor load becomes a concern.
|
||||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '?remoteState=true');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
if ($ctx['request']->getRequestInfo('http_code') == '200') {
|
||||
return json_decode($data);
|
||||
if ($ctx['request']->getRequestInfo('http_code') != '200') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
$serverObj = json_decode($data);
|
||||
|
||||
// Merge billing-period traffic onto the server object. The
|
||||
// /servers/{id} response exposes only the period WINDOW
|
||||
// (settings.resources.traffic = limit GB; traffic.public.currentPeriod
|
||||
// = start/end/limit) — the actual byte counter for the period lives
|
||||
// on the dedicated /servers/{id}/traffic endpoint at
|
||||
// data.monthly[0].total. We surface it as ->data->trafficUsedBytes
|
||||
// so ServerResource (and any future consumer) has one stable path
|
||||
// to read from. Non-fatal: if the secondary call fails, the field
|
||||
// stays absent and ServerResource falls back to its "-" sentinel.
|
||||
try {
|
||||
$trafficReq = $this->initCurl($ctx['cp']['token']);
|
||||
$trafficResp = $trafficReq->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/traffic');
|
||||
if ($trafficReq->getRequestInfo('http_code') == '200') {
|
||||
$trafficJson = json_decode($trafficResp);
|
||||
$current = $trafficJson->data->monthly[0] ?? null;
|
||||
if ($current && isset($current->total) && is_numeric($current->total)) {
|
||||
$serverObj->data->trafficUsedBytes = (int) $current->total;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::insert(__FUNCTION__ . ':traffic', [], $e->getMessage());
|
||||
}
|
||||
|
||||
return $serverObj;
|
||||
} catch (\Exception $e) {
|
||||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||
|
||||
@@ -402,12 +448,17 @@ class Module
|
||||
}
|
||||
}
|
||||
|
||||
// VirtFusion v7+ moved this endpoint from PATCH /servers/{id}/name
|
||||
// to PUT /servers/{id}/modify/name (consistent with the rest of
|
||||
// the /modify/* family). The old path returns 404 on v7 panels.
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName]));
|
||||
$data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name');
|
||||
$data = $ctx['request']->put($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/modify/name');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||||
$success = $httpCode == 200 || $httpCode == 204;
|
||||
// VF v7 returns 201 (Created) on rename — older versions returned
|
||||
// 200/204. Accept all three so we cover the version range.
|
||||
$success = $httpCode == 200 || $httpCode == 201 || $httpCode == 204;
|
||||
|
||||
if ($success && $serverObject !== null && PowerDns\Config::isEnabled()) {
|
||||
// Sync PTRs: only records whose current content equals the old hostname
|
||||
@@ -507,19 +558,29 @@ class Module
|
||||
}
|
||||
|
||||
if (count($catTemplates) <= 1) {
|
||||
// Track the "Other"-category icon from VF so the singleton
|
||||
// bucket below can reuse it instead of falling back to the
|
||||
// generic letter placeholder.
|
||||
if (($osCategory['name'] ?? '') === 'Other' && ! isset($otherIcon)) {
|
||||
$otherIcon = $osCategory['icon'] ?? null;
|
||||
}
|
||||
$otherTemplates = array_merge($otherTemplates, $catTemplates);
|
||||
} else {
|
||||
$catName = $osCategory['name'] ?? 'Unknown';
|
||||
// Use VF's category icon as-is for every category, including
|
||||
// "Other" — the historic override that forced a generic icon
|
||||
// was reverted; whatever the API returns (linux_logo.png in
|
||||
// our setup) is the canonical source.
|
||||
$categories[] = [
|
||||
'name' => $esc($catName),
|
||||
'icon' => ($catName === 'Other') ? null : ($osCategory['icon'] ?? null),
|
||||
'icon' => $osCategory['icon'] ?? null,
|
||||
'templates' => $catTemplates,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($otherTemplates)) {
|
||||
$categories[] = ['name' => 'Other', 'icon' => null, 'templates' => $otherTemplates];
|
||||
$categories[] = ['name' => 'Other', 'icon' => $otherIcon ?? null, 'templates' => $otherTemplates];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
@@ -631,7 +692,19 @@ class Module
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
if ($ctx['request']->getRequestInfo('http_code') == 200) {
|
||||
return json_decode($data, true);
|
||||
$result = json_decode($data, true);
|
||||
if (! is_array($result)) {
|
||||
return false;
|
||||
}
|
||||
// The VirtFusion API returns the noVNC viewer path as
|
||||
// data.vnc.wss.url (e.g. "/vnc/?token=...") — a relative
|
||||
// path. The browser needs the full URL, so we expose the
|
||||
// VF base URL alongside the API payload. Same pattern used
|
||||
// by fetchOsTemplates so the OS gallery can build full
|
||||
// logo URLs.
|
||||
$result['baseUrl'] = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -657,13 +730,25 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['enabled' => (bool) $enabled]));
|
||||
// Body param is "vnc" — NOT "enabled". The API silently no-ops
|
||||
// an unknown key, which is why earlier toggle clicks appeared to
|
||||
// do nothing. Confirmed against the official endpoint signature.
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['vnc' => (bool) $enabled]));
|
||||
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/vnc');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||||
if ($httpCode == 200 || $httpCode == 204) {
|
||||
return json_decode($data, true) ?: ['success' => true];
|
||||
$result = json_decode($data, true) ?: ['success' => true];
|
||||
// Mirror getVncConsole() so the JS popup-opener can build the
|
||||
// full wss:// URL from data.vnc.wss.url + baseUrl. Without
|
||||
// this the response only carries the relative path and the
|
||||
// popup goes nowhere.
|
||||
if (is_array($result)) {
|
||||
$result['baseUrl'] = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1049,6 +1134,39 @@ class Module
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-(serviceID, action) rate limit. Emits 429 if hit; otherwise stamps
|
||||
* a token in the cache that expires after $windowSec.
|
||||
*
|
||||
* Defence-in-depth against:
|
||||
* - runaway client scripts hammering destructive actions on the
|
||||
* customer's own service (rebuild, power-off, password reset)
|
||||
* - cumulative VirtFusion API load from a misbehaving customer
|
||||
*
|
||||
* Uses the existing Cache class so it inherits Redis (when available)
|
||||
* or atomic filesystem fallback. Keys are namespaced under "rl:" to
|
||||
* avoid collisions with other Cache users.
|
||||
*
|
||||
* @param string $key Action/scope identifier (e.g. "power:1234")
|
||||
* @param int $windowSec Minimum seconds between calls
|
||||
* @return bool|void true if not rate-limited; emits 429 + exits otherwise
|
||||
*/
|
||||
public function requireRateLimit(string $key, int $windowSec)
|
||||
{
|
||||
$cacheKey = 'rl:' . $key;
|
||||
if (Cache::get($cacheKey) !== null) {
|
||||
$this->output(
|
||||
['success' => false, 'errors' => 'Too many requests. Please wait a moment and try again.'],
|
||||
true,
|
||||
true,
|
||||
429,
|
||||
);
|
||||
}
|
||||
Cache::set($cacheKey, 1, $windowSec);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the WHMCS service is in a status where client-initiated writes make sense.
|
||||
*
|
||||
@@ -1089,6 +1207,45 @@ class Module
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the WHMCS service has a corresponding VirtFusion server linked.
|
||||
*
|
||||
* A service can exist in tblhosting (Active, paid for, etc.) without ever
|
||||
* having been successfully provisioned in VirtFusion — typically when the
|
||||
* order was accepted but the CreateAccount call failed or never ran. In
|
||||
* that state, mod_virtfusion_direct has either no row for the service or
|
||||
* a row with NULL server_id.
|
||||
*
|
||||
* Without this guard, every downstream feature method (getTrafficStats,
|
||||
* getServerBackups, etc.) silently returns false because resolveServiceContext
|
||||
* can't build a valid request, and client.php translates that into a generic
|
||||
* "Unable to retrieve X" 500 — which gives the client a broken UI with no
|
||||
* indication of the real problem. With the guard, the client gets a single
|
||||
* clear 409 explaining the state, and our log shows the unprovisioned
|
||||
* lookup attempt instead of N misleading "Unable to..." entries.
|
||||
*
|
||||
* Returns 409 Conflict because the request is well-formed but the server's
|
||||
* current state (no provisioned VF server) prevents fulfilment — the same
|
||||
* semantics WHMCS itself uses when a service is in the wrong status.
|
||||
*
|
||||
* @param int $serviceID WHMCS service ID
|
||||
* @return bool|void true on success; emits 409 JSON and exits otherwise
|
||||
*/
|
||||
public function requireProvisionedService(int $serviceID)
|
||||
{
|
||||
$row = Database::getSystemService($serviceID);
|
||||
if (! $row || empty($row->server_id)) {
|
||||
$this->output(
|
||||
['success' => false, 'errors' => 'Server has not been provisioned yet. Please contact support if this is unexpected.'],
|
||||
true,
|
||||
true,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-configured Curl instance with JSON Accept/Content-Type headers
|
||||
* and a Bearer token for authenticating against the VirtFusion API.
|
||||
|
||||
@@ -64,15 +64,23 @@ class ServerResource
|
||||
if ($server['settings']['resources']['traffic'] > 0) {
|
||||
$traffic = $server['settings']['resources']['traffic'] . ' GB';
|
||||
} else {
|
||||
$traffic = 'Unlimited';
|
||||
// limit=0 in VirtFusion means "no cap on this period". We
|
||||
// surface that as "Unmetered" rather than "Unlimited" — limits
|
||||
// exist (the period still rolls over monthly, traffic is still
|
||||
// counted), the customer just isn't billed for overage.
|
||||
$traffic = 'Unmetered';
|
||||
}
|
||||
}
|
||||
|
||||
// trafficUsedBytes is merged onto the response by Module::fetchServerData()
|
||||
// from the dedicated /servers/{id}/traffic endpoint. Reading it directly
|
||||
// (rather than the non-existent server.usage.traffic.used path that we
|
||||
// historically referenced) is what unblocks the "X GB / Unmetered" display
|
||||
// for unmetered plans — there IS usage to show even when there's no cap.
|
||||
$trafficUsed = '-';
|
||||
if (isset($server['usage']['traffic']['used'])) {
|
||||
$trafficUsed = round($server['usage']['traffic']['used'] / 1073741824, 2) . ' GB';
|
||||
} elseif (isset($server['settings']['resources']['traffic']) && $server['settings']['resources']['traffic'] > 0) {
|
||||
$trafficUsed = '0 GB';
|
||||
if (isset($server['trafficUsedBytes']) && is_numeric($server['trafficUsedBytes'])) {
|
||||
$bytes = (int) $server['trafficUsedBytes'];
|
||||
$trafficUsed = ($bytes > 0 ? round($bytes / 1073741824, 2) : 0) . ' GB';
|
||||
}
|
||||
|
||||
$data = [
|
||||
@@ -94,18 +102,55 @@ class ServerResource
|
||||
'ipv6Unformatted' => [],
|
||||
'mac' => '-',
|
||||
],
|
||||
'networkSpeed' => [
|
||||
'inbound' => isset($server['settings']['resources']['networkSpeedInbound']) ? $server['settings']['resources']['networkSpeedInbound'] . ' Mbps' : '-',
|
||||
'outbound' => isset($server['settings']['resources']['networkSpeedOutbound']) ? $server['settings']['resources']['networkSpeedOutbound'] . ' Mbps' : '-',
|
||||
],
|
||||
'vncEnabled' => isset($server['vnc']['enabled']) ? (bool) $server['vnc']['enabled'] : false,
|
||||
'memoryRaw' => isset($server['settings']['resources']['memory']) ? (int) $server['settings']['resources']['memory'] : 0,
|
||||
'cpuRaw' => isset($server['settings']['resources']['cpuCores']) ? (int) $server['settings']['resources']['cpuCores'] : 0,
|
||||
'storageRaw' => isset($server['settings']['resources']['storage']) ? (int) $server['settings']['resources']['storage'] : 0,
|
||||
'trafficRaw' => isset($server['settings']['resources']['traffic']) ? (int) $server['settings']['resources']['traffic'] : 0,
|
||||
'trafficUsedRaw' => isset($server['usage']['traffic']['used']) ? round($server['usage']['traffic']['used'] / 1073741824, 2) : 0,
|
||||
'networkSpeedInboundRaw' => isset($server['settings']['resources']['networkSpeedInbound']) ? (int) $server['settings']['resources']['networkSpeedInbound'] : 0,
|
||||
'networkSpeedOutboundRaw' => isset($server['settings']['resources']['networkSpeedOutbound']) ? (int) $server['settings']['resources']['networkSpeedOutbound'] : 0,
|
||||
'trafficUsedRaw' => isset($server['trafficUsedBytes']) ? round((int) $server['trafficUsedBytes'] / 1073741824, 2) : 0,
|
||||
|
||||
// -- Identity / catalog ---------------------------------------
|
||||
// os.templateName is always present; qemuAgent.os.* only when
|
||||
// qemu-guest-agent is installed and running on the guest. Both
|
||||
// are surfaced; the template chooses which to emphasise.
|
||||
'osName' => $server['os']['templateName'] ?? '-',
|
||||
'osPretty' => $server['qemuAgent']['os']['pretty-name'] ?? null,
|
||||
'osKernel' => $server['qemuAgent']['os']['kernel-release'] ?? null,
|
||||
'osDistro' => $server['qemuAgent']['os']['id'] ?? null,
|
||||
'osIcon' => $server['qemuAgent']['os']['img'] ?? null,
|
||||
|
||||
// -- Data center / hypervisor ---------------------------------
|
||||
'location' => $server['hypervisor']['group']['name'] ?? '-',
|
||||
'locationIcon' => $server['hypervisor']['group']['icon'] ?? null,
|
||||
'hypervisorMaintenance' => (bool) ($server['hypervisor']['maintenance'] ?? false),
|
||||
|
||||
// -- Server lifetime ------------------------------------------
|
||||
'createdAt' => $server['created'] ?? null,
|
||||
'builtAt' => $server['built'] ?? null,
|
||||
|
||||
// -- Live state (requires ?remoteState=true on the upstream call) -
|
||||
// Fields default to null when the live block is absent — happens
|
||||
// when remoteState wasn't requested or the hypervisor couldn't
|
||||
// reach libvirt at fetch time. Templates must isset()-guard each.
|
||||
'live' => [
|
||||
'state' => $server['remoteState']['state'] ?? null,
|
||||
'cpu' => isset($server['remoteState']['cpu']) ? (float) $server['remoteState']['cpu'] : null,
|
||||
// memory.* values are kilobytes (libvirt convention).
|
||||
'memoryActualKB' => isset($server['remoteState']['memory']['actual']) ? (int) $server['remoteState']['memory']['actual'] : null,
|
||||
'memoryUnusedKB' => isset($server['remoteState']['memory']['unused']) ? (int) $server['remoteState']['memory']['unused'] : null,
|
||||
'memoryAvailableKB' => isset($server['remoteState']['memory']['available']) ? (int) $server['remoteState']['memory']['available'] : null,
|
||||
'memoryRssKB' => isset($server['remoteState']['memory']['rss']) ? (int) $server['remoteState']['memory']['rss'] : null,
|
||||
// disk.{drive}.{rd,wr,fl}.{reqs,bytes,times} — surfacing the
|
||||
// primary drive (vda) cumulative byte counters. JS can derive
|
||||
// throughput rates from successive samples.
|
||||
'diskRdBytes' => isset($server['remoteState']['disk']['vda']['rd.bytes']) ? (int) $server['remoteState']['disk']['vda']['rd.bytes'] : null,
|
||||
'diskWrBytes' => isset($server['remoteState']['disk']['vda']['wr.bytes']) ? (int) $server['remoteState']['disk']['vda']['wr.bytes'] : null,
|
||||
// Filesystems: only present when qemu-guest-agent is running
|
||||
// inside the VM. Each entry is normalised to {name, mountpoint,
|
||||
// type, usedBytes, totalBytes}; pseudo-FS (devtmpfs, proc, sys)
|
||||
// are filtered out — only real mounts the customer cares about.
|
||||
'filesystems' => self::extractFilesystems($server['remoteState']['agent']['fsinfo'] ?? null),
|
||||
],
|
||||
];
|
||||
|
||||
if (array_key_exists('network', $server)) {
|
||||
@@ -140,4 +185,67 @@ class ServerResource
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the qemu-guest-agent fsinfo array into customer-facing rows.
|
||||
*
|
||||
* Only "real" filesystems are returned — pseudo-FS like proc/sysfs/devtmpfs
|
||||
* have no meaning in a usage context. Returned entries are sorted with the
|
||||
* root mount first so the most relevant row leads in the UI.
|
||||
*
|
||||
* @param array|null $fsinfo remoteState.agent.fsinfo from the API
|
||||
* @return array List of {name, mountpoint, type, usedBytes, totalBytes}
|
||||
*/
|
||||
private static function extractFilesystems($fsinfo): array
|
||||
{
|
||||
if (! is_array($fsinfo) || $fsinfo === []) {
|
||||
return [];
|
||||
}
|
||||
// Filesystems we never want to show — they're kernel/runtime, not user storage.
|
||||
$skipTypes = ['proc', 'sysfs', 'devtmpfs', 'devpts', 'tmpfs', 'cgroup', 'cgroup2',
|
||||
'pstore', 'bpf', 'mqueue', 'debugfs', 'tracefs', 'securityfs',
|
||||
'configfs', 'fusectl', 'autofs', 'hugetlbfs', 'rpc_pipefs',
|
||||
'binfmt_misc', 'overlay', 'squashfs', 'ramfs', 'fuse.gvfsd-fuse',
|
||||
'efivarfs', 'selinuxfs'];
|
||||
$rows = [];
|
||||
foreach ($fsinfo as $fs) {
|
||||
if (! is_array($fs)) {
|
||||
continue;
|
||||
}
|
||||
$type = $fs['type'] ?? '';
|
||||
if (in_array($type, $skipTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
$mount = $fs['mountpoint'] ?? '';
|
||||
// Skip /boot* and /run* — useful in monitoring tools but noisy on
|
||||
// a customer-facing dashboard. Customers care about the root and
|
||||
// any data mounts.
|
||||
if ($mount === '/boot' || str_starts_with($mount, '/boot/')) {
|
||||
continue;
|
||||
}
|
||||
if ($mount === '/run' || str_starts_with($mount, '/run/')) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'name' => (string) ($fs['name'] ?? '-'),
|
||||
'mountpoint' => (string) $mount,
|
||||
'type' => (string) $type,
|
||||
'usedBytes' => isset($fs['used-bytes']) ? (int) $fs['used-bytes'] : 0,
|
||||
'totalBytes' => isset($fs['total-bytes']) ? (int) $fs['total-bytes'] : 0,
|
||||
];
|
||||
}
|
||||
// Root mount first; everything else by mountpoint alphabetical.
|
||||
usort($rows, function ($a, $b) {
|
||||
if ($a['mountpoint'] === '/') {
|
||||
return -1;
|
||||
}
|
||||
if ($b['mountpoint'] === '/') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return strcmp($a['mountpoint'], $b['mountpoint']);
|
||||
});
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,3 +574,205 @@
|
||||
.vf-rdns-subnet-form { padding-left: 0; }
|
||||
.vf-rdns-subnet-inputs { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ---------------- In-page Section Nav ---------------- */
|
||||
.vf-section-nav-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.vf-section-nav-body::before {
|
||||
content: "Jump to:";
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-right: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vf-nav-link {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #d6d8db;
|
||||
border-radius: 4px;
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.vf-nav-link:hover,
|
||||
.vf-nav-link:focus {
|
||||
background: #e9ecef;
|
||||
border-color: #adb5bd;
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.vf-section-nav-body::before { display: block; width: 100%; margin-bottom: 4px; }
|
||||
}
|
||||
|
||||
/* ---------------- Server Overview meta bar ---------------- */
|
||||
.vf-overview-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e6e8eb;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.vf-meta-chip {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #d6d8db;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vf-meta-chip-muted {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 3px 4px;
|
||||
}
|
||||
.vf-mask-ips-btn {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.vf-mask-ips-btn { margin-left: 0; width: 100%; }
|
||||
}
|
||||
|
||||
/* ---------------- Live Stats panel ---------------- */
|
||||
.vf-live-bar {
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
background: #e9ecef;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.vf-live-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
transition: width 0.5s ease, background 0.3s ease;
|
||||
}
|
||||
.vf-live-bar-fill.bg-warning {
|
||||
background: linear-gradient(90deg, #ffc107, #fd7e14);
|
||||
}
|
||||
.vf-live-bar-fill.bg-danger {
|
||||
background: linear-gradient(90deg, #dc3545, #c82333);
|
||||
}
|
||||
.vf-livestats-updated {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* ---------------- Filesystem rows ---------------- */
|
||||
.vf-fs-row .progress {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.vf-fs-row .progress-bar {
|
||||
background-color: #337ab7;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.vf-fs-row .progress-bar.bg-warning { background-color: #ffc107 !important; }
|
||||
.vf-fs-row .progress-bar.bg-danger { background-color: #dc3545 !important; }
|
||||
|
||||
/* ---------------- Layout: side-by-side panel grid ---------------- */
|
||||
/*
|
||||
* Used to lay out compact panels (Traffic + Live Stats) side-by-side on
|
||||
* wide screens. CSS Grid with auto-fit handles the case where one panel is
|
||||
* display:none (e.g. Live Stats hidden when remoteState is unavailable) —
|
||||
* the visible panel fills the row.
|
||||
*/
|
||||
.vf-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.vf-panel-grid > .panel,
|
||||
.vf-panel-grid > .card {
|
||||
margin-bottom: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------------- Server Overview rename row ---------------- */
|
||||
/*
|
||||
* Was previously a single flex row that squished the Save button on
|
||||
* narrower viewports. Wrap-on-overflow + min-widths keep buttons readable
|
||||
* regardless of cell width.
|
||||
*/
|
||||
.vf-rename-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.vf-rename-input-field {
|
||||
flex: 1 1 160px;
|
||||
min-width: 140px;
|
||||
max-width: 240px;
|
||||
}
|
||||
.vf-rename-btn-randomise,
|
||||
.vf-rename-btn-save {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
min-width: 38px;
|
||||
}
|
||||
.vf-rename-btn-save {
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
/* ---------------- IP cell rows (Server Overview) ---------------- */
|
||||
/*
|
||||
* Each IPv4/IPv6 address renders as a compact row: address span + copy
|
||||
* button. Replaces the standalone Network panel; the per-address copy
|
||||
* affordance moved here.
|
||||
*/
|
||||
.vf-ip-cell-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.vf-ip-cell-row .vf-ip-address {
|
||||
word-break: break-all;
|
||||
}
|
||||
.vf-ip-cell-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ---------------- Sensitive-input masking ---------------- */
|
||||
/*
|
||||
* Companion to the JS-based IP text masking. When body.vf-mask-active is
|
||||
* set, render the value of any input.vf-sensitive as discs so the actual
|
||||
* characters don't leak into a screenshot. Hover/focus restores the real
|
||||
* value for editing — the customer can still see what they're typing.
|
||||
*
|
||||
* `text-security` is widely supported under the -webkit- prefix (Chrome,
|
||||
* Edge, Safari) and as the unprefixed property in modern Firefox. Falls
|
||||
* through to `-webkit-text-security: disc` everywhere else; if a browser
|
||||
* truly doesn't honour it the screenshot mask just isn't applied to the
|
||||
* input field — the IP cells still mask, so the customer's worst case is
|
||||
* an unmasked rDNS hostname (failsafe-soft, not security-critical).
|
||||
*/
|
||||
body.vf-mask-active input.vf-sensitive {
|
||||
-webkit-text-security: disc;
|
||||
text-security: disc;
|
||||
font-family: text-security-disc, sans-serif;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
body.vf-mask-active input.vf-sensitive:focus,
|
||||
body.vf-mask-active input.vf-sensitive:hover {
|
||||
-webkit-text-security: none;
|
||||
text-security: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,39 @@
|
||||
|
||||
{if $serviceStatus eq 'Active'}
|
||||
|
||||
{* Hypervisor maintenance banner — populated by vfServerData. Hidden by
|
||||
default; surfaces only when hypervisor.maintenance=true so the customer
|
||||
knows operations may be unavailable. *}
|
||||
<div id="vf-maintenance-banner" class="alert alert-warning mb-3" style="display:none;">
|
||||
<strong>Hypervisor maintenance.</strong>
|
||||
Your server's hypervisor is currently in maintenance. Some operations may be temporarily unavailable.
|
||||
</div>
|
||||
|
||||
{* VNC Console — placed at the very top so it's the first action the
|
||||
customer reaches. No toggle (VirtFusion's VNC enable/disable was a
|
||||
broken firewall flag), no IP/port/password panel — just the button.
|
||||
Click → noVNC popup. *}
|
||||
<div id="vf-vnc-panel" class="panel card panel-default mb-2">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">VNC Console</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
|
||||
<p class="mb-3">Access your server's console directly in your browser. The server must be running for VNC access.</p>
|
||||
<button id="vf-vnc-button" onclick="vfOpenVnc('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary d-flex align-items-center">
|
||||
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||
Open Console
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Section navigation moved to the WHMCS Actions sidebar via the
|
||||
ClientAreaPrimarySidebar hook in hooks.php. The sidebar version stays
|
||||
visible while scrolling, which the inline strip never could. JS still
|
||||
walks the rendered links and hides ones whose target panels are hidden. *}
|
||||
|
||||
{* Server Overview Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-overview" class="panel card panel-default mb-2" data-vf-nav-label="Overview">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">
|
||||
Server Overview
|
||||
@@ -39,6 +70,20 @@
|
||||
<div id="vf-server-info-error">
|
||||
<div class="alert alert-warning mb-0">Information unavailable. Try again later.</div>
|
||||
</div>
|
||||
|
||||
{* Top meta bar — populated by JS once server data loads. Holds the
|
||||
data-center chip (flag + city), OS chip, lifetime chip, and the
|
||||
Mask IPs toggle. The toggle stays visible on every overview load
|
||||
regardless of which other chips have data. *}
|
||||
<div id="vf-overview-meta" class="vf-overview-meta mb-3" style="display:none;">
|
||||
<span id="vf-data-location" class="vf-meta-chip" style="display:none;"></span>
|
||||
<span id="vf-data-os" class="vf-meta-chip" style="display:none;"></span>
|
||||
<span id="vf-data-created" class="vf-meta-chip vf-meta-chip-muted" style="display:none;"></span>
|
||||
<button id="vf-mask-ips-btn" type="button" class="btn btn-sm btn-outline-secondary vf-mask-ips-btn" onclick="vfToggleIpMask()" title="Hide IPs and rDNS hostnames for screenshots">
|
||||
<span id="vf-mask-ips-label">Mask Sensitive</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="vf-server-info" class="row mb-2">
|
||||
<div class="col-12">
|
||||
<div class="row">
|
||||
@@ -46,10 +91,10 @@
|
||||
<div class="row p-1">
|
||||
<div class="col-xs-4 col-4 text-right vf-bold">Name:</div>
|
||||
<div class="col-xs-8 col-8">
|
||||
<div class="d-flex" style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="text" id="vf-rename-input" class="form-control form-control-sm" maxlength="63" style="max-width:200px;" placeholder="Server name">
|
||||
<button id="vf-randomise-btn" onclick="vfShowNameDropdown('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-outline-secondary" title="Randomise">↻</button>
|
||||
<button id="vf-rename-save" onclick="vfRenameServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-primary">Save</button>
|
||||
<div class="vf-rename-row">
|
||||
<input type="text" id="vf-rename-input" class="form-control form-control-sm vf-rename-input-field vf-sensitive" maxlength="63" placeholder="Server name">
|
||||
<button id="vf-randomise-btn" onclick="vfShowNameDropdown('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-outline-secondary vf-rename-btn-randomise" title="Randomise">↻</button>
|
||||
<button id="vf-rename-save" onclick="vfRenameServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-primary vf-rename-btn-save">Save</button>
|
||||
</div>
|
||||
<div id="vf-name-dropdown" style="display:none;"></div>
|
||||
<div id="vf-rename-alert" class="mt-1" style="display:none;"></div>
|
||||
@@ -57,7 +102,7 @@
|
||||
</div>
|
||||
<div class="row p-1">
|
||||
<div class="col-xs-4 col-4 text-right vf-bold">Hostname:</div>
|
||||
<div class="col-xs-8 col-8" id="vf-data-server-hostname"></div>
|
||||
<div class="col-xs-8 col-8 vf-sensitive" id="vf-data-server-hostname"></div>
|
||||
</div>
|
||||
<div class="row p-1">
|
||||
<div class="col-xs-4 col-4 text-right vf-bold">Memory:</div>
|
||||
@@ -93,11 +138,99 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Server Overview footer — Login to Control Panel SSO. Was briefly
|
||||
moved to the WHMCS Actions sidebar via _CustomActions, but the
|
||||
sidebar dispatch path didn't carry the SSO redirect through cleanly
|
||||
in this WHMCS 9 install. Inline button is reliable: vfLoginAsServerOwner
|
||||
opens a new tab and navigates it to the upstream SSO URL fetched
|
||||
via fetchLoginTokens. *}
|
||||
<div id="vf-overview-footer" class="vf-overview-footer mt-3 pt-3" style="border-top:1px solid #e6e8eb;">
|
||||
<div id="vf-login-error" class="alert alert-danger" style="display:none;"></div>
|
||||
<button id="vf-login-button" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',true)" type="button" class="btn btn-primary d-flex align-items-center">
|
||||
<span id="vf-login-button-spinner" class="spinner-border spinner-border-sm text-light vf-spinner-margin" style="display:none;"></span>
|
||||
Login to Control Panel
|
||||
</button>
|
||||
<p class="mb-0 mt-2 vf-small text-muted">Opens VirtFusion in a new tab. Trouble? <a href="#" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',false); return false;">Open in this tab instead</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Traffic Panel — last N months of monthly aggregates from VF. Renders
|
||||
full-width (own row) — side-by-side with Live Stats was tested and felt
|
||||
too cramped. *}
|
||||
<div id="vf-sec-traffic" class="panel card panel-default mb-2" style="display:none;" data-vf-nav-label="Traffic">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Traffic</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-traffic-chart-section">
|
||||
<canvas id="vf-traffic-chart" style="width:100%; height:240px;"></canvas>
|
||||
<div class="row mt-3 text-center">
|
||||
<div class="col-4"><small class="text-muted">This Period Used</small><div id="vf-traffic-used" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Period Limit</small><div id="vf-traffic-limit" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Remaining</small><div id="vf-traffic-remaining" class="vf-bold">-</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
if (typeof vfLoadTrafficStats === 'function') {
|
||||
vfLoadTrafficStats('{$serviceid}', '{$systemURL}');
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Live Stats Panel — CPU, memory, disk I/O sourced from VirtFusion's
|
||||
?remoteState=true introspection (libvirt + qemu-agent). Hidden by default;
|
||||
surfaces only when the upstream call returns a remoteState block. Auto-
|
||||
refreshes every 30s; refresh stops when the panel scrolls out of view to
|
||||
keep hypervisor load proportional to actual customer attention. *}
|
||||
<div id="vf-sec-livestats" class="panel card panel-default mb-2" style="display:none;" data-vf-nav-label="Live Stats">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">
|
||||
Live Stats
|
||||
<small class="text-muted vf-livestats-updated" id="vf-live-updated" style="float:right; font-size:11px; font-weight:normal;"></small>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="vf-bold mb-2">CPU</div>
|
||||
<div class="vf-live-gauge">
|
||||
<div class="vf-live-bar"><div id="vf-live-cpu-bar" class="vf-live-bar-fill" style="width:0%;"></div></div>
|
||||
<div class="d-flex justify-content-between vf-small mt-1">
|
||||
<span id="vf-live-cpu-pct">-</span>
|
||||
<span class="text-muted">load</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="vf-bold mb-2">Memory</div>
|
||||
<div class="vf-live-gauge">
|
||||
<div class="vf-live-bar"><div id="vf-live-mem-bar" class="vf-live-bar-fill" style="width:0%;"></div></div>
|
||||
<div class="d-flex justify-content-between vf-small mt-1">
|
||||
<span id="vf-live-mem-text">-</span>
|
||||
<span id="vf-live-mem-pct" class="text-muted">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="vf-bold mb-2">Disk I/O <small class="text-muted">(since boot)</small></div>
|
||||
<div class="d-flex justify-content-between vf-small">
|
||||
<span class="text-muted">Read</span>
|
||||
<span id="vf-live-disk-rd">-</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between vf-small mt-1">
|
||||
<span class="text-muted">Write</span>
|
||||
<span id="vf-live-disk-wr">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Power Management Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-power" class="panel card panel-default mb-2" data-vf-nav-label="Power">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Power Management</h3>
|
||||
</div>
|
||||
@@ -129,23 +262,16 @@
|
||||
</div>
|
||||
|
||||
{* Manage Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-manage" class="panel card panel-default mb-2" data-vf-nav-label="Manage">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Manage</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div id="vf-login-error" class="alert alert-danger"></div>
|
||||
<p>Manage your server via our dedicated control panel. You will be automatically authenticated and the control panel will open in a new window.</p>
|
||||
<button id="vf-login-button" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',true)" type="button" class="btn btn-primary text-uppercase d-flex align-items-center">
|
||||
<div id="vf-login-button-spinner" class="spinner-border spinner-border-sm text-light vf-spinner-margin"></div>
|
||||
Open Control Panel
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<p class="mb-0 pt-3 vf-small">Having trouble opening the control panel in a new window? <a href="#" onclick="vfLoginAsServerOwner('{$serviceid}','{$systemURL}',false); return false;">Click here</a> to open in this window.</p>
|
||||
</div>
|
||||
{* Inline "Open Control Panel" button removed — WHMCS already
|
||||
surfaces this in the Actions sidebar via the module's
|
||||
ServiceSingleSignOnLabel ("Login to VirtFusion Panel").
|
||||
Keeping both was a duplicate. *}
|
||||
{if $serverHostname}
|
||||
<div class="col-12">
|
||||
<hr>
|
||||
@@ -188,7 +314,7 @@
|
||||
</div>
|
||||
|
||||
{* Rebuild Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-rebuild" class="panel card panel-default mb-2" data-vf-nav-label="Rebuild">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Rebuild Server</h3>
|
||||
</div>
|
||||
@@ -215,31 +341,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* Network Management Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Network</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-network-alert" class="alert" style="display: none;"></div>
|
||||
<div id="vf-network-content" style="display: none;">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<h5 class="vf-bold">IPv4 Addresses</h5>
|
||||
<div id="vf-ipv4-list" class="mb-2"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5 class="vf-bold">IPv6 Subnets</h5>
|
||||
<div id="vf-ipv6-list" class="mb-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{* The standalone Network panel was removed — its IP list duplicated the
|
||||
Server Overview's IPv4/IPv6 rows. The unique value (per-IP copy buttons)
|
||||
was folded into the Overview cells via vfRenderIpCells in module.js. *}
|
||||
|
||||
{if $rdnsEnabled}
|
||||
{* Reverse DNS Panel *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-rdns" class="panel card panel-default mb-2" data-vf-nav-label="Reverse DNS">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Reverse DNS</h3>
|
||||
</div>
|
||||
@@ -260,7 +368,7 @@
|
||||
{/if}
|
||||
|
||||
{* Resources Panel — populated by JS after server data loads *}
|
||||
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div id="vf-resources-panel" class="panel card panel-default mb-2" style="display: none;" data-vf-nav-label="Resources">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Resources</h3>
|
||||
</div>
|
||||
@@ -296,77 +404,34 @@
|
||||
<div id="vf-res-traffic-bar" class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vf-resource-item mb-3">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="vf-bold">Network Speed</span>
|
||||
<span id="vf-res-network-speed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vf-traffic-chart-section" style="display:none;">
|
||||
{* Note: dedicated Traffic panel near the top of the page (vf-sec-traffic)
|
||||
handles the chart + period tile. Resources panel here just lists the
|
||||
configured limits — no chart duplication. Network speed row was
|
||||
removed: VirtFusion's API returns 0 for inAverage/inPeak/inBurst
|
||||
when speed isn't capped at the package level, which is the
|
||||
common case for our setup — there's nothing useful to show. *}
|
||||
|
||||
{* Filesystem usage — only renders when qemu-guest-agent is running on
|
||||
the guest. vfRenderFilesystems() shows or hides the section based
|
||||
on whether remoteState.agent.fsinfo came back populated. *}
|
||||
<div id="vf-fs-section" class="mt-4" style="display:none;">
|
||||
<hr>
|
||||
<h5 class="vf-bold mb-2">Traffic Usage</h5>
|
||||
<canvas id="vf-traffic-chart" style="width:100%; height:200px;"></canvas>
|
||||
<div class="row mt-2 text-center">
|
||||
<div class="col-4"><small class="text-muted">Used</small><div id="vf-traffic-used" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Limit</small><div id="vf-traffic-limit" class="vf-bold">-</div></div>
|
||||
<div class="col-4"><small class="text-muted">Remaining</small><div id="vf-traffic-remaining" class="vf-bold">-</div></div>
|
||||
</div>
|
||||
<h5 class="vf-bold mb-3">Filesystem Usage</h5>
|
||||
<div id="vf-fs-container"></div>
|
||||
<p class="vf-small text-muted mt-2 mb-0">Reported by qemu-guest-agent inside the VM. Install <code>qemu-guest-agent</code> if no filesystems show.</p>
|
||||
</div>
|
||||
<script>
|
||||
if (typeof vfLoadTrafficStats === 'function') {
|
||||
vfLoadTrafficStats('{$serviceid}', '{$systemURL}');
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{* VNC Console Panel — hidden by default, shown by JS if VNC is enabled *}
|
||||
<div id="vf-vnc-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">VNC Console</h3>
|
||||
</div>
|
||||
<div class="panel-body card-body p-4">
|
||||
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
|
||||
<p>Access your server's console directly in your browser. The server must be running for VNC access.</p>
|
||||
<div class="d-flex align-items-center mb-3" style="display:flex; gap:12px; align-items:center;">
|
||||
<button id="vf-vnc-button" onclick="vfOpenVnc('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary text-uppercase d-flex align-items-center">
|
||||
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||
Open Console
|
||||
</button>
|
||||
<label class="vf-toggle-label mb-0" style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||
<input type="checkbox" id="vf-vnc-toggle" class="vf-toggle-input" onchange="vfToggleVnc('{$serviceid}','{$systemURL}', this.checked)">
|
||||
<span class="vf-toggle-switch"></span>
|
||||
<span class="vf-small">VNC Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="vf-vnc-details" style="display:none;">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="row p-1">
|
||||
<div class="col-4 text-right vf-bold vf-small">IP:</div>
|
||||
<div class="col-8 vf-small" id="vf-vnc-ip">-</div>
|
||||
</div>
|
||||
<div class="row p-1">
|
||||
<div class="col-4 text-right vf-bold vf-small">Port:</div>
|
||||
<div class="col-8 vf-small" id="vf-vnc-port">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="vfCopyVncPassword('{$serviceid}','{$systemURL}')">
|
||||
Copy VNC Password
|
||||
</button>
|
||||
<span id="vf-vnc-copy-confirm" class="text-success vf-small" style="display:none;">Copied!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{* VNC panel relocated to the very top of the page (above Server Overview).
|
||||
See its definition there. This block is intentionally left as a comment
|
||||
marker so future readers know where the panel used to live. *}
|
||||
|
||||
{* Self Service — Billing & Usage Panel *}
|
||||
{if $selfServiceMode > 0}
|
||||
<div id="vf-selfservice-panel" class="panel card panel-default mb-3" style="display: none;">
|
||||
<div id="vf-selfservice-panel" class="panel card panel-default mb-2" style="display: none;" data-vf-nav-label="Billing & Usage">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Billing & Usage</h3>
|
||||
</div>
|
||||
@@ -416,7 +481,7 @@
|
||||
|
||||
{elseif $serviceStatus eq 'Suspended'}
|
||||
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div class="panel card panel-default mb-2">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Service Suspended</h3>
|
||||
</div>
|
||||
@@ -430,7 +495,7 @@
|
||||
{/if}
|
||||
|
||||
{* Billing Overview - Always visible *}
|
||||
<div class="panel card panel-default mb-3">
|
||||
<div id="vf-sec-billing" class="panel card panel-default mb-2" data-vf-nav-label="Billing Overview">
|
||||
<div class="panel-heading card-header">
|
||||
<h3 class="panel-title card-title m-0">Billing Overview</h3>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user