From 27cbe40c52eb06f8fd15f6e9466a901820e7faec Mon Sep 17 00:00:00 2001 From: Prophet731 Date: Tue, 28 Apr 2026 22:07:27 -0400 Subject: [PATCH] chore(release): 1.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 72 ++ CLAUDE.md | 42 +- README.md | 86 +- .../VirtFusionDirect/VirtFusionDirect.php | 48 +- modules/servers/VirtFusionDirect/client.php | 214 ++++- modules/servers/VirtFusionDirect/hooks.php | 131 ++- .../servers/VirtFusionDirect/lib/Module.php | 181 +++- .../VirtFusionDirect/lib/ServerResource.php | 132 ++- .../VirtFusionDirect/templates/css/module.css | 202 +++++ .../VirtFusionDirect/templates/js/module.js | 851 ++++++++++++++---- .../VirtFusionDirect/templates/overview.tpl | 277 +++--- 11 files changed, 1873 insertions(+), 363 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1914ace..22cf3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,78 @@ All notable changes to the VirtFusion Direct Provisioning Module for WHMCS. +## [1.5.0] - 2026-04-28 + +> **Tested against:** WHMCS 9.0.3 and VirtFusion v7.0.0 Build 9. + +### Features + +- **In-page section navigation in the WHMCS Actions sidebar.** Customers viewing a VirtFusion service get an "On This Page" group in the sidebar with jump-links to every visible panel (Overview, Traffic, Live Stats, Power, Manage, Rebuild, Reverse DNS, Resources, Billing & Usage, Billing Overview). Rendered server-side via a `ClientAreaPrimarySidebar` hook that's gated to productdetails pages for VF services only — no impact on other modules. Each link smooth-scrolls to its target via a delegated click handler. Conditionally-shown panels (Live Stats when remoteState is unavailable, Reverse DNS when PowerDNS isn't configured, Resources/Self-Service before their data loads) auto-hide their corresponding sidebar links so customers don't see dead jumps. VNC is intentionally excluded — its panel is the first thing on the page already. Theme-agnostic — WHMCS handles sidebar rendering for Six, Twenty-One, Lagom, etc. + +- **Traffic chart panel showing the last 12 months of monthly aggregates.** New panel between Server Overview and Power Management, sourced from `/servers/{id}/traffic`'s `monthly[]` array. Renders side-by-side rx (blue) and tx (green) bars per month with month-or-month/year labels at the bottom and a centered legend with breathing room. Current period's used/limit/remaining tile sits below the chart. Replaces the previously broken in-resources chart that read non-existent `data.entries` / `data.used` paths and silently never rendered for any service since it was first added. + +- **Live Stats panel — CPU, memory, disk I/O with 30s auto-refresh.** Surfaces `remoteState.cpu`, `remoteState.memory.{actual,unused}`, and `remoteState.disk.vda.{rd.bytes,wr.bytes}` from VirtFusion's libvirt introspection. CPU and memory render as colored progress bars with `bg-warning` at 75% and `bg-danger` at 90%. Disk I/O shows cumulative bytes since boot. Refresh polls `serverData` every 30s while the panel is visible AND the page has focus — pauses on `visibilitychange` to avoid hammering libvirt when the customer alt-tabs away. Hidden entirely when the upstream call returns no remoteState block. + +- **Filesystem usage in the Resources panel.** Per-mount usage rows sourced from qemu-guest-agent's fsinfo block (requires `qemu-guest-agent` installed on the VM). Pseudo-FS (proc/sysfs/devtmpfs/tmpfs/cgroup/etc.) plus `/boot*` and `/run*` are filtered out — only customer-meaningful mounts show. Each row has a progress bar with the same 75%/90% warning thresholds. Hidden when the agent isn't running, with a one-line hint that customers can install qemu-guest-agent to populate the section. + +- **Server Overview meta bar — Location, OS, lifetime.** Top of the panel now shows three chips: data-center location with country flag emoji (from `hypervisor.group.{name,icon}`, country codes mapped via Regional Indicator Symbols), the OS template name preferring the qemu-agent's pretty name when available (with kernel version on hover), and a relative "Created N days ago" chip with the absolute timestamp on hover. + +- **Hypervisor maintenance banner.** Yellow alert at the very top of the page when `hypervisor.maintenance=true`. Prepares the customer to expect "operation may be unavailable" errors so they don't open support tickets for what's actually known maintenance. + +- **"Mask Sensitive" toggle for screenshot-safe viewing.** Button on the Server Overview meta bar. When active, masks IPv4 (keeps first two octets — `205.186.•••.•••`), IPv6 (keeps first two hextets — `2602:2f3:••••::•`), hostnames (keeps first char of each dot-separated label — `m•••.e••••••.c••`). Covers the Server Name input, Hostname row, IPv4/IPv6 cells in Server Overview, AND in the Reverse DNS panel rows. Input fields mask via CSS `text-security: disc` (preserves the underlying value for editing — focus reveals); text cells via attribute swap with the original cached on `data-vf-ip-original`. State persists in `sessionStorage` so screenshots taken across page refreshes stay masked. + +- **Per-IP copy buttons in Server Overview cells.** Each IPv4 / IPv6 address renders as a row with a copy button (replaces the standalone Network panel that duplicated this information). + +### Bug Fixes + +- **Critical: UsageUpdate cron silently never recorded any usage.** `VirtFusionDirect_UsageUpdate()` was reading `server.usage.traffic.used` and `server.usage.storage.used` — neither path exists in any VirtFusion API response, so `tblhosting.bwused` and `diskused` stayed at 0 forever for every service. Bandwidth now sourced from `/servers/{id}/traffic`'s `monthly[0].total` (canonical billing-period bytes); disk usage from `/servers/{id}?remoteState=true`'s `agent.fsinfo[]` summed (best-effort — only updates when qemu-guest-agent is running). Limits (`settings.resources.{storage,traffic}`) were already correct and untouched. Verified on live data: usage columns now populate correctly, unblocking suspend-on-overage rules and customer-facing usage bars. + +- **Critical: WHMCS 9 multi-service order short-circuit.** WHMCS 9 added a batch order-acceptance loop that terminates as soon as the order leaves Pending status — calling `localAPI('AcceptOrder')` mid-batch (which our `AfterModuleCreate` hook did to advance the order) caused subsequent VF services in the same order to be skipped, leaving them Active in `tblhosting` but with no `mod_virtfusion_direct` row and no actual VPS in VirtFusion. Fix: defer the auto-accept until every VF service in the order has been provisioned. The hook fires once per service, so the last one to complete sees no unprovisioned siblings and triggers the actual accept. WHMCS 8 was unaffected (its loop ignored order status mid-batch); deferring there is harmless. + +- **Service detail page returned generic 500s for orphaned services.** When a service exists in `tblhosting` but has no row (or NULL `server_id`) in `mod_virtfusion_direct` — e.g., the multi-service order bug above, or a failed CreateAccount — every client.php endpoint silently bailed via `resolveServiceContext()` and the customer saw a string of generic 500s with no actionable message. Added `Module::requireProvisionedService()` helper that emits a single clean 409 ("Server has not been provisioned yet. Please contact support if this is unexpected.") on the first endpoint call. Wired into all 17 client.php cases after `validateUserOwnsService()` so customers see one clear message instead of six broken sections. + +- **Traffic display in Server Overview showed `- / Unlimited` even for servers with measured usage.** Same root cause as the UsageUpdate bug: `ServerResource::process()` read the non-existent `usage.traffic.used` path. Fixed by having `Module::fetchServerData()` make a secondary call to `/servers/{id}/traffic` and merge the current period's total bytes onto the server object as `trafficUsedBytes`; ServerResource reads from that stable field. Also renamed "Unlimited" → "Unmetered" everywhere — limit=0 means no cap, but traffic is still tracked per period, so Unmetered is more accurate. + +- **VNC viewer popup didn't render — wrong URL pattern.** The `/vnc/?token=...` URL VirtFusion returns is the raw WebSocket endpoint and rejects HTTP GET with 405. The actual noVNC viewer is a tiny HTML shell that loads `/vnc/vnc.js`. Fixed by serving the shell ourselves from a same-origin authenticated PHP route (`client.php?action=vncViewer`) — see the Security section below for the full shape. + +- **VNC toggle was lying and was removed.** The PHP `Module::toggleVnc()` was POSTing `{enabled: bool}` to VirtFusion when the API parameter is actually `vnc: bool` — silent no-op. Even after fixing the param, the API's `vnc.enabled` response field stayed `false` regardless of toggle state, and the wss endpoint accepts connections regardless of the panel toggle (per VirtFusion's current implementation, the toggle only manages a firewall flag that's currently broken at their panel level). Toggle removed entirely; VNC is treated as always-available, gated by WHMCS session + service ownership at our layer. + +- **OS gallery's "Other" category icon was hardcoded to a generic SVG.** Forced override in `Module::groupOsTemplates()` and matching branches in `module.js` and `hooks.php` were nulling out the icon VirtFusion provides for the category. Reverted to use the upstream icon — applies in both the client-area Rebuild gallery and the checkout-side OS picker. Singleton-collected templates (those merged into a synthetic "Other" bucket) inherit the icon from VF's "Other" category if one was present in the source data. + +- **Save button squished on the Server Name rename row.** Single flex row with 6px gap and a 200px max-width input was packing the randomise + save buttons into too-narrow columns on mid-width viewports. Wrapped the inputs in a flex-wrap container with explicit min-widths so all three controls stay readable down to mobile. + +- **Server rename was force-lowercasing the input.** Customer typed "VPS-01", got "vps-01" back. Both client and server validation enforced an RFC-1123-style hostname regex, but VirtFusion's `name` field is a display label that accepts any printable string up to 63 chars. Validation relaxed to non-empty + length cap + reject control characters. Mid-flight, the input field, Save, and Randomise buttons all freeze together so customers can't double-submit or edit while the rename is in flight. + +- **Server rename hit the wrong VirtFusion endpoint** (and silently treated success as failure). The PHP module was using `PATCH /servers/{id}/name`, which VirtFusion v7 returns 404 for — the path moved to `PUT /servers/{id}/modify/name` (consistent with `/modify/memory`, `/modify/cpu`, etc.). Even after fixing the URL, success was treated as failure because v7 returns HTTP 201 (Created) on rename and our success whitelist only accepted 200/204. Fixed both: switched to PUT + new path, added 201 to the whitelist. + +### Security + +- **CSRF protection added to every destructive client.php action.** `rebuild`, `resetPassword`, `resetServerPassword`, `powerAction`, `rename`, `selfServiceAddCredit`, `toggleVnc`, and `vncViewer` now require `requirePost()` + `requireSameOrigin()`. Closes a class of attacks where a malicious page (or compromised ad slot) could embed a hidden form targeting `client.php?serviceID=X&action=rebuild` and destroy the customer's data on the next page load they made while logged in. Previously only `rdnsUpdate` carried these gates. + +- **Open-redirect defence on SSO.** `Module::fetchLoginTokens()` now validates that the URL constructed from VirtFusion's `endpoint_complete` resolves to the same hostname as the configured VirtFusion panel before returning it for redirect. Defends against a hostname-mismatched response (compromised VF panel, tampered `tblservers` row, etc.) being used to phish customers. + +- **VNC viewer popup served from a same-origin authenticated POST route.** Click → hidden form-submit (POST) to `client.php?action=vncViewer` → `requirePost()` + `requireSameOrigin()` + `isAuthenticated()` + `validateUserOwnsService()` + `requireProvisionedService()` → POST `/vnc {vnc:true}` to rotate the wss token → return `text/html` with the noVNC shell embedded. The wss URL never appears in any URL bar, browser history, or shareable link — it lives only in the HTTP response body delivered to a logged-in session. Each open rotates the token, so any prior credential exposure is short-lived. Response carries `X-Frame-Options: DENY`, `Cache-Control: no-store, private`, and a strict `Content-Security-Policy` (`default-src 'none'`, `script-src 'self' `, `connect-src wss:// `, `frame-ancestors 'none'`) so the viewer cannot be re-hosted, embedded, or opened in a way that loads scripts from anywhere outside our WHMCS host or the configured VirtFusion panel. + +- **Per-action rate limiting** on destructive endpoints via the new `Module::requireRateLimit()` helper (Cache-backed, Redis-or-filesystem). Limits: `rebuild` 60 s, `resetPassword` / `resetServerPassword` 30 s, `powerAction` 10 s, `vncViewer` / `toggleVnc` / `selfServiceAddCredit` 5 s — all per-(action, serviceID). Defence against runaway browser scripts and accidental double-submits hammering the VirtFusion API. Returns 429 with a clear "Too many requests" message instead of letting the request through. + +- **IP and hostname masking covers screenshot-sensitive cells.** See the Mask Sensitive feature above — reduces leakage when customers screen-share or attach screenshots for support. + +- **IPv6 examples in the Reverse DNS placeholder + comment switched to the IANA documentation prefix `2001:db8::/32`** (RFC 3849), eliminating an inadvertent reference to a deployer-specific IPv6 block that previously appeared in customer-facing UI. + +### Removed + +- **Network panel** (full removal) — duplicated Server Overview's IP rows. Per-IP copy buttons moved into the Overview cells via `vfRenderIpCells()`. +- **Network Speed row** from the Resources panel — VirtFusion's `inAverage`/`inPeak`/`inBurst` and matching out-fields all return 0 in our setup (network speed isn't capped at the package level), so the row was always empty. +- **VNC enable/disable toggle** — see Bug Fixes above. + +### Internal + +- **`Module::fetchServerData()` now passes `?remoteState=true`** on the upstream call so the response includes live CPU/memory, disk I/O counters, and qemu-agent fsinfo. Adds one libvirt round-trip per page load on the hypervisor side; revisit caching if hypervisor load becomes a concern. +- **`ServerResource::process()`** exposes new fields: `osName`, `osPretty`, `osKernel`, `osDistro`, `osIcon`, `location`, `locationIcon`, `hypervisorMaintenance`, `createdAt`, `builtAt`, plus a nested `live.{state, cpu, memoryActualKB, memoryUnusedKB, memoryAvailableKB, memoryRssKB, diskRdBytes, diskWrBytes, filesystems[]}` block. +- **`Module::toggleVnc()`** corrected to post `{vnc: bool}` (the actual API parameter) instead of `{enabled: bool}` (silent no-op). Also returns `baseUrl` alongside the API envelope so the new vncViewer route can build the wss URL without re-deriving it. +- **`Module::getVncConsole()`** also returns `baseUrl` for the same reason. +- **Panel margins tightened** from `mb-3` (16px) to `mb-2` (8px) across all 11 client-area panels for a tighter visual rhythm post-additions. + ## [1.4.4] - 2026-04-25 ### Bug Fixes diff --git a/CLAUDE.md b/CLAUDE.md index 1f593e5..4c11d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,16 +77,31 @@ The `publish-release.yml` workflow creates a GitHub/Gitea release with auto-gene ### Client-Side -- **`templates/overview.tpl`** — Smarty template for client area (server info, power, network, rebuild with OS gallery, resources with traffic chart, VNC toggle, self-service billing, billing overview, backups timeline, server rename, password reset) -- **`templates/js/module.js`** — Vanilla JS + jQuery handling AJAX calls, DOM updates, status badges, power actions, all management UIs. Key helpers: `vfUrl()` (URL builder), `vfShowAlert()` (alert display), `vfRenderOsGallery()` (accordion gallery), `vfDrawTrafficChart()` (canvas chart) +- **`templates/overview.tpl`** — Smarty template for client area. Panel order top-down: Hypervisor maintenance banner → VNC Console (button only) → Server Overview (with location/OS/lifetime meta chips, Mask Sensitive toggle, IP rows with copy buttons, Login to Control Panel footer) → Traffic chart (12-month aggregates) → Live Stats (CPU/memory/disk I/O, 30s refresh) → Power Management → Manage (password reset + backups) → Rebuild (OS gallery) → Reverse DNS (when PowerDNS enabled) → Resources (with filesystem usage when qemu-agent reports it) → Self-Service Billing (when configoption4>0) → Billing Overview. +- **`templates/js/module.js`** — Vanilla JS + jQuery handling AJAX calls, DOM updates, status badges, power actions, all management UIs. Key helpers: `vfUrl()` (URL builder), `vfShowAlert()` (alert display), `vfRenderOsGallery()` (accordion gallery), `vfDrawTrafficChart()` (canvas chart, monthly bars + centered legend), `vfRenderIpCells()` (IP-row + copy button), `vfRenderLiveStats()` / `vfRenderFilesystems()` (remoteState surfaces), `vfMaskString()` / `vfApplyIpMask()` / `vfToggleIpMask()` (Mask Sensitive — IPv4 keeps first 2 octets, IPv6 keeps first 2 hextets, hostnames keep first char per dot-label; inputs masked via `text-security: disc`), `vfBuildSectionNav()` (toggles sidebar Jump-To items based on visible-panel state). - **`templates/js/keygen.js`** — Client-side SSH Ed25519 key generator using Web Crypto API (loaded on checkout page) -- **`templates/css/module.css`** — Cross-theme styles with Bootstrap 3/4/5 dual class support (`panel card`, `panel-body card-body`) +- **`templates/css/module.css`** — Cross-theme styles with Bootstrap 3/4/5 dual class support (`panel card`, `panel-body card-body`). Panel margins are `mb-2` (8px) for tight stacking; `.vf-panel-grid` provides side-by-side panel layouts when used. + +### Sidebar Integration + +- **WHMCS Actions sidebar** receives an "On This Page" group via `ClientAreaPrimarySidebar` hook (in `hooks.php`), gated to productdetails for VF services only. Each entry carries `data-vf-target=` so the document-level smooth-scroll click handler in `module.js` picks it up regardless of theme markup. Visibility per entry is toggled by `vfBuildSectionNav()` after page load — works whether the theme renders sidebar items in `
  • ` wrappers (Six) or as bare `` elements (Twenty-One). +- **`ServiceSingleSignOnLabel` metadata** is set to "Login to VirtFusion Panel" but the auto-render of this in the WHMCS 9 sidebar is unreliable. The reliable surface is the inline "Login to Control Panel" button in the Server Overview footer, which calls `vfLoginAsServerOwner()` to fetch a one-shot SSO URL via `Module::fetchLoginTokens()` and opens it in a new tab. + +### VNC viewer security model + +- Customer clicks "Open Console" → `window.open('client.php?action=vncViewer&serviceID=X')`. +- The route checks `isAuthenticated()` + `validateUserOwnsService()` + `requireProvisionedService()`, then POSTs to `/servers/{id}/vnc {vnc:true}` to rotate the wss token, returning `text/html` with the noVNC shell embedded (hidden `#con` / `#pass` / `#server-name` inputs + ` + +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']); diff --git a/modules/servers/VirtFusionDirect/hooks.php b/modules/servers/VirtFusionDirect/hooks.php index cdf8530..6d4f665 100644 --- a/modules/servers/VirtFusionDirect/hooks.php +++ b/modules/servers/VirtFusionDirect/hooks.php @@ -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 = ''; } 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
  • 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. + } +}); diff --git a/modules/servers/VirtFusionDirect/lib/Module.php b/modules/servers/VirtFusionDirect/lib/Module.php index 3189ca1..0a3d875 100644 --- a/modules/servers/VirtFusionDirect/lib/Module.php +++ b/modules/servers/VirtFusionDirect/lib/Module.php @@ -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. diff --git a/modules/servers/VirtFusionDirect/lib/ServerResource.php b/modules/servers/VirtFusionDirect/lib/ServerResource.php index 02813f1..9f526c7 100644 --- a/modules/servers/VirtFusionDirect/lib/ServerResource.php +++ b/modules/servers/VirtFusionDirect/lib/ServerResource.php @@ -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; + } } diff --git a/modules/servers/VirtFusionDirect/templates/css/module.css b/modules/servers/VirtFusionDirect/templates/css/module.css index 626593f..748a93d 100644 --- a/modules/servers/VirtFusionDirect/templates/css/module.css +++ b/modules/servers/VirtFusionDirect/templates/css/module.css @@ -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; +} diff --git a/modules/servers/VirtFusionDirect/templates/js/module.js b/modules/servers/VirtFusionDirect/templates/js/module.js index 33e05aa..33eed3b 100644 --- a/modules/servers/VirtFusionDirect/templates/js/module.js +++ b/modules/servers/VirtFusionDirect/templates/js/module.js @@ -31,7 +31,7 @@ * Server Rebuild — vfRebuildServer, vfLoadOsTemplates, vfRenderOsGallery * Server Rename — vfRenameServer, vfShowNameDropdown * Traffic / Backups — vfLoadTrafficStats, vfDrawTrafficChart, vfLoadBackups - * VNC Console — vfOpenVnc, vfToggleVnc + * VNC Console — vfOpenVnc * Self-Service Billing — vfLoadSelfServiceUsage, vfAddCredit * Reverse DNS (PowerDNS) — vfLoadRdns, vfRenderRdnsPanel, vfUpdateRdns, * vfAdminLoadRdns, vfAdminReconcileRdns @@ -105,6 +105,145 @@ function vfShowAlert(alertDiv, type, message) { alertDiv.show(); } +// ------------------------------------------------------------------------- +// Display helpers — country flag emoji from ISO-2 code, relative-date string, +// and an IP-mask toggle for screenshots. +// ------------------------------------------------------------------------- + +// Convert a 2-letter country code ("us", "GB") into the corresponding flag +// emoji using Unicode Regional Indicator Symbols. Returns "" for invalid +// inputs so callers can safely concatenate without sanity checks. +function vfCountryFlag(code) { + if (!code || typeof code !== "string" || code.length !== 2) return ""; + var c = code.toUpperCase(); + var a = c.charCodeAt(0), b = c.charCodeAt(1); + if (a < 65 || a > 90 || b < 65 || b > 90) return ""; + var offset = 0x1F1E6 - 65; + try { return String.fromCodePoint(a + offset, b + offset); } + catch (e) { return ""; } +} + +// Produce a friendly relative-time string ("3 days ago", "in 2 hours") from +// any value Date can parse (ISO 8601 from the VF API works directly). +function vfRelativeDate(input) { + if (!input) return ""; + var t = new Date(input).getTime(); + if (isNaN(t)) return ""; + var seconds = Math.round((Date.now() - t) / 1000); + var future = seconds < 0; + var abs = Math.abs(seconds); + var units = [ + { s: 60, label: "second" }, + { s: 3600, label: "minute", div: 60 }, + { s: 86400, label: "hour", div: 3600 }, + { s: 604800, label: "day", div: 86400 }, + { s: 2629800, label: "week", div: 604800 }, + { s: 31557600, label: "month", div: 2629800 }, + { s: Infinity, label: "year", div: 31557600 } + ]; + for (var i = 0; i < units.length; i++) { + if (abs < units[i].s) { + var v = Math.max(1, Math.floor(units[i].div ? abs / units[i].div : abs)); + var unit = units[i].label + (v === 1 ? "" : "s"); + return future ? ("in " + v + " " + unit) : (v + " " + unit + " ago"); + } + } + return ""; +} + +// IP masking — keeps enough of the address visible to convey "same network" +// while hiding the host-identifying portion. Per IPv4: mask the last two +// octets (1.2.•••.•••). Per IPv6: keep the first two hextets and replace +// everything else with a placeholder, preserving any /CIDR suffix. Comma- +// separated lists are masked element-by-element. +// +// State persists in sessionStorage so the customer's preference survives a +// page refresh during a screenshot session. +function _vfMaskAny(s) { + var str = String(s == null ? "" : s).trim(); + if (!str) return str; + // IPv4 dotted-quad with optional CIDR. + var v4 = str.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}(\/\d+)?$/); + if (v4) return v4[1] + "." + v4[2] + ".•••.•••" + (v4[3] || ""); + // IPv6 — at least one ":" and only hex/colon/slash chars allowed (the + // strict regex avoids masking unrelated text like "Memory: 8 GB"). + if (str.indexOf(":") !== -1 && /^[0-9a-fA-F:\/]+$/.test(str)) { + var slash = str.indexOf("/"); + var cidr = slash !== -1 ? str.substring(slash) : ""; + var addr = slash !== -1 ? str.substring(0, slash) : str; + var parts = addr.split(":"); + var visible = []; + for (var i = 0; i < parts.length && visible.length < 2; i++) { + if (parts[i] !== "") visible.push(parts[i]); + } + if (visible.length === 0) { + return str.replace(/[0-9a-fA-F]/g, "•"); + } + return visible.join(":") + ":••••::•" + cidr; + } + // Hostname-shaped (alphanumeric + . _ -) with at least one letter. + // Mask each dot-separated label after its first character so the + // structure ("a.b.c") and TLD shape ("•••.com" → "•••.•••") stays + // hinted at without leaking the full hostname. + if (/^[a-zA-Z0-9._-]+$/.test(str) && /[a-zA-Z]/.test(str)) { + return str.split(".").map(function (part) { + if (part.length <= 1) return part; + return part[0] + part.slice(1).replace(/[a-zA-Z0-9_-]/g, "•"); + }).join("."); + } + // Not recognised — leave unchanged. + return str; +} + +function vfMaskString(s) { + if (!s) return ""; + var str = String(s).trim(); + if (str.indexOf(",") !== -1) { + return str.split(",").map(function (x) { return _vfMaskAny(x.trim()); }).join(", "); + } + return _vfMaskAny(str); +} + +function vfApplyIpMask() { + var masked = sessionStorage.getItem("vfIpMasked") === "1"; + var label = document.getElementById("vf-mask-ips-label"); + if (label) label.textContent = masked ? "Unmask" : "Mask Sensitive"; + + // Toggle a body-level class so CSS can mask fields (text-security + // on input.vf-sensitive). Text content (IPs in cells) is masked below + // via attribute swap because text-security doesn't apply to non-input + // elements without breaking layout/selection. + document.body.classList.toggle("vf-mask-active", masked); + + // .vf-ip / .vf-ip-address: IP-bearing text cells. + // .vf-sensitive (non-input): hostname/name text cells. + // Inputs marked .vf-sensitive are masked by CSS text-security and + // skipped here so we don't replace the editable value. + var nodes = document.querySelectorAll(".vf-ip, .vf-ip-address, .vf-sensitive"); + nodes.forEach(function (el) { + if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") return; + var orig = el.getAttribute("data-vf-ip-original"); + if (masked) { + // Cache original text on first mask (or refresh if upstream + // re-rendered the cell with new content while masked). + if (orig === null || (orig !== el.textContent && el.textContent.indexOf("•") === -1)) { + orig = el.textContent; + el.setAttribute("data-vf-ip-original", orig); + } + if (orig) el.textContent = vfMaskString(orig); + } else if (orig !== null) { + el.textContent = orig; + el.removeAttribute("data-vf-ip-original"); + } + }); +} + +function vfToggleIpMask() { + var masked = sessionStorage.getItem("vfIpMasked") === "1"; + sessionStorage.setItem("vfIpMasked", masked ? "0" : "1"); + vfApplyIpMask(); +} + // ========================================================================= // Progress Indicator // ========================================================================= @@ -139,16 +278,70 @@ function vfServerData(serviceId, systemUrl) { url: vfUrl(systemUrl, serviceId, "serverData") }).done(function (response) { if (response.success) { - $("#vf-rename-input").val(response.data.name); - $("#vf-data-server-hostname").text(response.data.hostname); - $("#vf-data-server-memory").text(response.data.memory); - $("#vf-data-server-traffic").text(response.data.traffic); - $("#vf-data-server-traffic-used").text(response.data.trafficUsed || "-"); - $("#vf-data-server-storage").text(response.data.storage); - $("#vf-data-server-cpu").text(response.data.cpu); - var pn = response.data.primaryNetwork || {}; - $("#vf-data-server-ipv4").text(pn.ipv4 || "-"); - $("#vf-data-server-ipv6").text(pn.ipv6 || "-"); + var data = response.data; + $("#vf-rename-input").val(data.name); + $("#vf-data-server-hostname").text(data.hostname); + $("#vf-data-server-memory").text(data.memory); + $("#vf-data-server-traffic").text(data.traffic); + $("#vf-data-server-traffic-used").text(data.trafficUsed || "-"); + $("#vf-data-server-storage").text(data.storage); + $("#vf-data-server-cpu").text(data.cpu); + var pn = data.primaryNetwork || {}; + // IPv4/IPv6 cells render as a stack of "address [copy]" rows so + // the customer can copy each address individually. The standalone + // Network panel was removed because it duplicated this; the + // copy-button utility moved here. Falls back to "-" when empty. + vfRenderIpCells("#vf-data-server-ipv4", pn.ipv4Unformatted || []); + vfRenderIpCells("#vf-data-server-ipv6", pn.ipv6Unformatted || []); + + // -- Top meta bar (location, OS, lifetime) ----------------- + $("#vf-overview-meta").show(); + if (data.location && data.location !== "-") { + var flag = vfCountryFlag(data.locationIcon); + $("#vf-data-location").show().html("") + .append(flag ? document.createTextNode(flag + " ") : "") + .append(document.createTextNode(data.location)); + } + if (data.osName && data.osName !== "-") { + var osChip = $("#vf-data-os").show().empty(); + // Prefer the qemu-agent's pretty name (more accurate point-in-time) + // and fall back to the template name otherwise. + var primaryLabel = data.osPretty || data.osName; + osChip.text(primaryLabel); + if (data.osKernel) { + osChip.attr("title", "Kernel: " + data.osKernel); + } + } + if (data.createdAt) { + $("#vf-data-created").show().text("Created " + vfRelativeDate(data.createdAt)) + .attr("title", new Date(data.createdAt).toLocaleString()); + } + + // -- Hypervisor maintenance banner ------------------------- + if (data.hypervisorMaintenance) { + $("#vf-maintenance-banner").show(); + } else { + $("#vf-maintenance-banner").hide(); + } + + // -- Live Stats panel + Filesystem rows -------------------- + // Both are derived from the same remoteState/agent payload, so + // we render them together. live.* fields are null when the + // upstream call didn't include remoteState — defensive guards + // hide each section independently in that case. + vfRenderLiveStats(data.live); + vfRenderFilesystems(data.live ? data.live.filesystems : []); + + // Kick off the 30s auto-refresh now that we have valid args. + // Subsequent vfServerData calls will reuse the same timer + // (vfStartLiveStatsRefresh clears + re-schedules each time). + if (typeof window.vfStartLiveStatsRefresh === "function") { + window.vfStartLiveStatsRefresh(serviceId, systemUrl); + } + + // Apply current mask state to the IPs we just rendered (and + // any other .vf-ip elements already on the page). + vfApplyIpMask(); // Update status badge var statusBadge = $("#vf-status-badge"); @@ -163,10 +356,10 @@ function vfServerData(serviceId, systemUrl) { statusBadge.addClass("vf-badge-awaiting"); } - // Show/hide VNC panel based on API response - if (response.data.vncEnabled) { - $("#vf-vnc-panel").show(); - } + // VNC has no useful enable/disable state from VF (the panel-side + // toggle was a firewall flag that's currently broken). Open + // Console is always available; details panel only appears after + // first Open Console click. // Populate resources panel var d = response.data; @@ -186,53 +379,14 @@ function vfServerData(serviceId, systemUrl) { $("#vf-res-traffic-bar").addClass("bg-warning"); } } else { - $("#vf-res-traffic").text(d.traffic || "Unlimited"); + $("#vf-res-traffic").text(d.traffic || "Unmetered"); $("#vf-res-traffic-bar").css("width", "0%"); } - var speedIn = d.networkSpeedInboundRaw || 0; - var speedOut = d.networkSpeedOutboundRaw || 0; - if (speedIn > 0 || speedOut > 0) { - $("#vf-res-network-speed").text(speedIn + " / " + speedOut + " Mbps"); - } else { - $("#vf-res-network-speed").text("-"); - } - $("#vf-resources-panel").show(); - // Populate network panel from server data - var ipv4List = $("#vf-ipv4-list"); - var ipv6List = $("#vf-ipv6-list"); - ipv4List.empty(); - ipv6List.empty(); - - var net = response.data.primaryNetwork || {}; - var ipv4Arr = net.ipv4Unformatted || []; - var ipv6Arr = net.ipv6Unformatted || []; - - if (ipv4Arr.length > 0) { - $.each(ipv4Arr, function (i, ip) { - var row = $('
    '); - row.append('' + $('').text(ip).html() + ''); - row.append(vfCopyButton(ip)); - ipv4List.append(row); - }); - } else { - ipv4List.append('No IPv4 addresses'); - } - - if (ipv6Arr.length > 0) { - $.each(ipv6Arr, function (i, subnet) { - var row = $('
    '); - row.append('' + $('').text(subnet).html() + ''); - row.append(vfCopyButton(subnet)); - ipv6List.append(row); - }); - } else { - ipv6List.append('No IPv6 subnets'); - } - - $("#vf-network-content").show(); + // Re-apply mask state to the IP cells we just (re)rendered. + vfApplyIpMask(); $("#vf-server-info").show(); } else { @@ -420,9 +574,10 @@ function vfRenderOsGallery(container, data, hiddenInput) { $(this).replaceWith($('').text((category.name || "?")[0].toUpperCase())); }); iconSpan.append(catImg); - } else if (category.name === "Other") { - iconSpan.css("background", "#6c757d").html(''); } else { + // No icon (e.g. synthetic singletons-bucket without an upstream + // icon) — render brand-color circle with the first letter, same + // as every other iconless category. iconSpan.css("background", brandColor).text((category.name || "?")[0].toUpperCase()); } var titleSpan = $('').text(category.name + " (" + category.templates.length + ")"); @@ -586,6 +741,18 @@ function impersonateServerOwner(serviceId, systemUrl) { // VNC Console // ========================================================================= +// Open the noVNC viewer in a popup window. The popup is the response to a +// POST submit to client.php?action=vncViewer — a same-origin, session- +// authenticated route that: +// - requires POST + same-origin (anti-CSRF; rejects cross-origin opens) +// - validates WHMCS session + service ownership server-side +// - rotates the wss token on every open (POST /vnc to VirtFusion) +// - returns the noVNC HTML shell with credentials embedded +// We use a hidden form submit (rather than window.open(url)) because: +// 1. POST keeps the request out of GET-with-side-effects territory +// 2. requireSameOrigin validates Origin/Referer, which only proper form +// POSTs reliably carry +// The wss token never appears in any URL the customer can copy or share. function vfOpenVnc(serviceId, systemUrl) { var btn = $("#vf-vnc-button"); var spinner = $("#vf-vnc-spinner"); @@ -595,77 +762,34 @@ function vfOpenVnc(serviceId, systemUrl) { spinner.show(); alertDiv.hide(); - // Open window immediately in click context to avoid popup blockers - var vncWindow = window.open("", "_blank"); + var popupName = "vfvnc_" + serviceId; + var popupFeatures = "width=1024,height=768,resizable=yes,scrollbars=yes,status=no,toolbar=no,location=no,menubar=no"; + + // Open the popup window in click context (browsers block popups opened + // from later async callbacks). The form submit below targets this window. + var vncWindow = window.open("about:blank", popupName, popupFeatures); if (!vncWindow) { - vfShowAlert(alertDiv, "danger","Popup blocked. Please allow popups for this site and try again."); + vfShowAlert(alertDiv, "danger", "Popup blocked. Please allow popups for this site and try again."); spinner.hide(); btn.prop("disabled", false); return; } - $.ajax({ - type: "GET", - dataType: "json", - url: vfUrl(systemUrl, serviceId, "vnc") - }).done(function (response) { - if (response.success && response.data) { - var data = response.data.data || response.data; - if (data.url) { - vncWindow.location.href = data.url; - } else if (data.host && data.port) { - // Build noVNC URL if available - var vncUrl = "https://" + data.host + ":" + data.port; - if (data.token) { - vncUrl += "?token=" + encodeURIComponent(data.token); - } - vncWindow.location.href = vncUrl; - } else { - vncWindow.close(); - vfShowAlert(alertDiv, "success","VNC session is ready. Check your VirtFusion control panel for access."); - } - } else { - vncWindow.close(); - vfShowAlert(alertDiv, "danger","VNC console is not available."); - } - }).fail(function () { - vncWindow.close(); - vfShowAlert(alertDiv, "danger","An error occurred. The server may be powered off."); - }).always(function () { - spinner.hide(); - btn.prop("disabled", false); - }); -} + // Build the hidden POST form — target=popupName routes the response into + // our popup window. Form is removed immediately after submit; the popup + // navigates to the rendered noVNC viewer and we don't need the form again. + var form = document.createElement("form"); + form.method = "POST"; + form.action = vfUrl(systemUrl, serviceId, "vncViewer"); + form.target = popupName; + form.style.display = "none"; + document.body.appendChild(form); + form.submit(); + form.remove(); -function vfToggleVnc(serviceId, systemUrl, enabled) { - var toggle = $("#vf-vnc-toggle"); - toggle.prop("disabled", true); - - $.ajax({ - type: "POST", - dataType: "json", - url: vfUrl(systemUrl, serviceId, "toggleVnc"), - data: { enabled: enabled ? "1" : "0" } - }).done(function (response) { - if (response.success) { - if (enabled && response.data) { - var data = response.data.data || response.data; - if (data.ip || data.host) { - $("#vf-vnc-ip").text(data.ip || data.host || "-"); - $("#vf-vnc-port").text(data.port || "-"); - $("#vf-vnc-details").show(); - } - } else { - $("#vf-vnc-details").hide(); - } - } else { - toggle.prop("checked", !enabled); - } - }).fail(function () { - toggle.prop("checked", !enabled); - }).always(function () { - toggle.prop("disabled", false); - }); + try { vncWindow.focus(); } catch (e) { /* may throw if popup closed */ } + spinner.hide(); + btn.prop("disabled", false); } function vfCopyVncPassword(serviceId, systemUrl) { @@ -877,17 +1001,23 @@ function vfDrawTrafficChart(canvasId, entries) { var canvas = document.getElementById(canvasId); if (!canvas || !canvas.getContext) return; + // Canvas height was 200 — too tight to fit chart bars + month labels + + // legend without overlap. Bumping to 240 and giving the bottom 60px of + // padding lets us stack: chart bars → month labels → centered legend + // with breathing room between each row. + var H = 240; + var dpr = window.devicePixelRatio || 1; var rect = canvas.parentElement.getBoundingClientRect(); canvas.width = rect.width * dpr; - canvas.height = 200 * dpr; - canvas.style.height = "200px"; + canvas.height = H * dpr; + canvas.style.height = H + "px"; canvas.style.width = "100%"; var ctx = canvas.getContext("2d"); ctx.scale(dpr, dpr); var w = rect.width; - var h = 200; + var h = H; if (!entries || entries.length === 0) { ctx.fillStyle = "#888"; @@ -904,13 +1034,14 @@ function vfDrawTrafficChart(canvasId, entries) { }); if (maxVal === 0) maxVal = 1; - var padding = { top: 10, right: 10, bottom: 30, left: 50 }; + var padding = { top: 10, right: 10, bottom: 60, left: 50 }; var chartW = w - padding.left - padding.right; var chartH = h - padding.top - padding.bottom; + var chartBottomY = padding.top + chartH; var barGroupW = chartW / entries.length; var barW = Math.max(4, (barGroupW * 0.35)); - // Y axis + // Y axis grid + GB/TB labels ctx.strokeStyle = "#dee2e6"; ctx.lineWidth = 1; for (var i = 0; i <= 4; i++) { @@ -926,6 +1057,7 @@ function vfDrawTrafficChart(canvasId, entries) { ctx.fillText(labelVal >= 1024 ? (labelVal / 1024).toFixed(1) + " TB" : labelVal.toFixed(0) + " GB", padding.left - 5, y + 3); } + // Bars + month label per group entries.forEach(function (e, idx) { var inVal = e.inbound || 0; var outVal = e.outbound || 0; @@ -934,29 +1066,69 @@ function vfDrawTrafficChart(canvasId, entries) { var x = padding.left + idx * barGroupW + (barGroupW - barW * 2 - 2) / 2; ctx.fillStyle = "#337ab7"; - ctx.fillRect(x, padding.top + chartH - inH, barW, inH); + ctx.fillRect(x, chartBottomY - inH, barW, inH); ctx.fillStyle = "#28a745"; - ctx.fillRect(x + barW + 2, padding.top + chartH - outH, barW, outH); + ctx.fillRect(x + barW + 2, chartBottomY - outH, barW, outH); - // X label + // Month label sits just below the chart baseline. ctx.fillStyle = "#888"; ctx.font = "10px sans-serif"; ctx.textAlign = "center"; - ctx.fillText(e.label || (idx + 1), padding.left + idx * barGroupW + barGroupW / 2, h - 8); + ctx.fillText(e.label || (idx + 1), padding.left + idx * barGroupW + barGroupW / 2, chartBottomY + 16); }); - // Legend - ctx.fillStyle = "#337ab7"; - ctx.fillRect(padding.left, h - 15, 10, 10); - ctx.fillStyle = "#888"; - ctx.font = "10px sans-serif"; - ctx.textAlign = "left"; - ctx.fillText("In", padding.left + 14, h - 6); - ctx.fillStyle = "#28a745"; - ctx.fillRect(padding.left + 32, h - 15, 10, 10); - ctx.fillStyle = "#888"; - ctx.fillText("Out", padding.left + 46, h - 6); + // Legend — centered horizontally with ~24px of padding above it (sits + // ~40px below the chart baseline, with month labels stacked between). + // Width is measured at draw time so the centering stays correct as labels + // change ("In/Out", or future longer labels). + var swatch = 10; + var swatchToText = 6; + var itemGap = 18; + ctx.font = "11px sans-serif"; + var items = [ + { color: "#337ab7", label: "In" }, + { color: "#28a745", label: "Out" } + ]; + var totalWidth = 0; + items.forEach(function (it, i) { + if (i > 0) totalWidth += itemGap; + totalWidth += swatch + swatchToText + ctx.measureText(it.label).width; + }); + var legendX = (w - totalWidth) / 2; + var legendY = chartBottomY + 40; + items.forEach(function (it) { + ctx.fillStyle = it.color; + ctx.fillRect(legendX, legendY - swatch + 1, swatch, swatch); + ctx.fillStyle = "#555"; + ctx.textAlign = "left"; + ctx.fillText(it.label, legendX + swatch + swatchToText, legendY); + legendX += swatch + swatchToText + ctx.measureText(it.label).width + itemGap; + }); +} + +// Format a GB value with sensible precision and a TB cutoff at 1024 GB. +function _vfFormatGB(gb) { + if (!isFinite(gb) || gb < 0) gb = 0; + if (gb >= 1024) return (gb / 1024).toFixed(2) + " TB"; + if (gb >= 100) return gb.toFixed(0) + " GB"; + if (gb >= 10) return gb.toFixed(1) + " GB"; + return gb.toFixed(2) + " GB"; +} + +// Build a short month label (e.g. "Apr") + 2-digit year suffix when the +// chart spans more than one year so the customer can tell "Mar 25" from +// "Mar 26". Input is the start string from VF — "YYYY-MM-DD HH:MM:SS". +function _vfMonthLabel(startStr, includeYear) { + if (!startStr) return ""; + var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + var parts = String(startStr).split(/[-\s:]/); + var y = parseInt(parts[0], 10); + var m = parseInt(parts[1], 10); + if (!(m >= 1 && m <= 12)) return ""; + var label = months[m - 1]; + if (includeYear && !isNaN(y)) label += " " + String(y).slice(2); + return label; } function vfLoadTrafficStats(serviceId, systemUrl) { @@ -965,30 +1137,61 @@ function vfLoadTrafficStats(serviceId, systemUrl) { dataType: "json", url: vfUrl(systemUrl, serviceId, "trafficStats") }).done(function (response) { - if (response.success && response.data) { - var data = response.data.data || response.data; - var entries = data.entries || data.traffic || []; - var used = data.used || data.totalUsed || 0; - var limit = data.limit || data.allowance || 0; + if (!response || !response.success) return; - if (entries.length > 0 || used > 0) { - vfDrawTrafficChart("vf-traffic-chart", entries); - $("#vf-traffic-used").text(used >= 1024 ? (used / 1024).toFixed(2) + " TB" : used + " GB"); - $("#vf-traffic-limit").text(limit > 0 ? (limit >= 1024 ? (limit / 1024).toFixed(2) + " TB" : limit + " GB") : "Unlimited"); - var remaining = limit > 0 ? Math.max(0, limit - used) : 0; - $("#vf-traffic-remaining").text(limit > 0 ? (remaining >= 1024 ? (remaining / 1024).toFixed(2) + " TB" : remaining + " GB") : "-"); - $("#vf-traffic-chart-section").show(); + // PHP wraps the API JSON: response.data is the wrapper, response.data.data + // is VirtFusion's "data" envelope. Defensive fallbacks cover both shapes + // in case getTrafficStats() ever changes how it surfaces the payload. + var apiRoot = (response.data && response.data.data) ? response.data.data : (response.data || {}); + var monthly = Array.isArray(apiRoot.monthly) ? apiRoot.monthly : []; + if (monthly.length === 0) return; - // Debounced resize redraw - var resizeTimer; - $(window).on("resize.vfTraffic", function () { - clearTimeout(resizeTimer); - resizeTimer = setTimeout(function () { - vfDrawTrafficChart("vf-traffic-chart", entries); - }, 250); - }); + // VF returns months in DESCENDING order (current is monthly[0]). For the + // chart we want chronological (oldest → newest), capped at the most + // recent 12 entries so the bars stay readable on smaller screens. + var sliced = monthly.slice(0, 12); + var crossesYear = false; + for (var i = 1; i < sliced.length; i++) { + if (String(sliced[i].start).slice(0, 4) !== String(sliced[0].start).slice(0, 4)) { + crossesYear = true; + break; } } + var byOldest = sliced.slice().reverse(); + var entries = byOldest.map(function (m) { + return { + label: _vfMonthLabel(m.start, crossesYear), + inbound: (m.rx || 0) / 1073741824, + outbound: (m.tx || 0) / 1073741824, + }; + }); + + // Current period summary tile uses the first entry (descending order). + var current = monthly[0]; + var usedGB = (current.total || 0) / 1073741824; + var limitGB = current.limit || 0; + var remainingGB = limitGB > 0 ? Math.max(0, limitGB - usedGB) : 0; + + $("#vf-traffic-used").text(_vfFormatGB(usedGB)); + $("#vf-traffic-limit").text(limitGB > 0 ? _vfFormatGB(limitGB) : "Unmetered"); + $("#vf-traffic-remaining").text(limitGB > 0 ? _vfFormatGB(remainingGB) : "-"); + + // Show the parent panel (hidden by default in the template) before + // sizing the canvas — getBoundingClientRect on a display:none parent + // returns 0 and the chart would render zero-width. + $("#vf-sec-traffic").show(); + vfDrawTrafficChart("vf-traffic-chart", entries); + + // Debounced resize redraw. .off() guards against multiple loads + // stacking handlers (defensive — vfLoadTrafficStats is only called + // once per page today, but cheap to be correct). + var resizeTimer; + $(window).off("resize.vfTraffic").on("resize.vfTraffic", function () { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(function () { + vfDrawTrafficChart("vf-traffic-chart", entries); + }, 200); + }); }); } @@ -1030,17 +1233,37 @@ function vfShowNameDropdown(serviceId, systemUrl) { } function vfRenameServer(serviceId, systemUrl) { - var name = $("#vf-rename-input").val().trim().toLowerCase(); + // Preserve case as the user typed it — VirtFusion's "name" is a display + // label, not a DNS hostname, so casing is meaningful (a customer typing + // "VPS-01" doesn't want it silently lower-cased to "vps-01"). + var name = $("#vf-rename-input").val().trim(); var alertDiv = $("#vf-rename-alert"); + var input = $("#vf-rename-input"); + var btn = $("#vf-rename-save"); + var randomiseBtn = $("#vf-randomise-btn"); alertDiv.hide(); - if (!name || !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) { - vfShowAlert(alertDiv, "danger","Invalid name. Use lowercase letters, numbers, and hyphens (2-63 chars, must start/end with alphanumeric)."); + // Loose validation — VF accepts virtually any printable string for the + // display name. We only enforce non-empty + length cap + reject control + // characters (matches what VF itself rejects). + if (!name) { + vfShowAlert(alertDiv, "danger", "Name cannot be empty."); + return; + } + if (name.length > 63) { + vfShowAlert(alertDiv, "danger", "Name too long (63 character maximum)."); + return; + } + if (/[\x00-\x1F\x7F]/.test(name)) { + vfShowAlert(alertDiv, "danger", "Name contains invalid control characters."); return; } - var btn = $("#vf-rename-save"); + // Disable the entire rename row until the request settles so the + // customer can't double-submit or edit mid-flight. + input.prop("disabled", true); btn.prop("disabled", true); + randomiseBtn.prop("disabled", true); $.ajax({ type: "POST", @@ -1049,15 +1272,17 @@ function vfRenameServer(serviceId, systemUrl) { data: { name: name } }).done(function (response) { if (response.success) { - vfShowAlert(alertDiv, "success","Server renamed successfully."); + vfShowAlert(alertDiv, "success", "Server renamed successfully."); } else { - vfShowAlert(alertDiv, "danger","Rename failed. Please try again."); + vfShowAlert(alertDiv, "danger", (response && response.errors) || "Rename failed. Please try again."); } alertDiv.show(); }).fail(function () { - vfShowAlert(alertDiv, "danger","An error occurred. Please try again."); + vfShowAlert(alertDiv, "danger", "An error occurred. Please try again."); }).always(function () { + input.prop("disabled", false); btn.prop("disabled", false); + randomiseBtn.prop("disabled", false); setTimeout(function () { alertDiv.fadeOut(); }, 3000); }); } @@ -1088,6 +1313,26 @@ function vfCopyButton(text) { return btn; } +// Render the IPv4 / IPv6 cell in Server Overview as a stack of compact +// rows: each row holds a single address (or v6 subnet) with a copy button. +// Falls back to "-" when the list is empty so the cell never renders empty. +// Marks each address span with .vf-ip so vfApplyIpMask() can mask it. +function vfRenderIpCells(selector, list) { + var cell = $(selector); + if (cell.length === 0) return; + cell.empty(); + if (!Array.isArray(list) || list.length === 0) { + cell.text("-"); + return; + } + list.forEach(function (addr) { + var row = $('
    '); + row.append($('').text(addr)); + row.append(vfCopyButton(addr)); + cell.append(row); + }); +} + // ========================================================================= // Reverse DNS (PowerDNS) // ========================================================================= @@ -1167,15 +1412,21 @@ function vfRenderRdnsPanel(serviceId, systemUrl, ips) { } list.append(vfRenderIpRow(serviceId, systemUrl, row)); }); + // rDNS rows are added to the DOM after the initial vfApplyIpMask() pass + // in vfServerData ran — re-apply now so the screenshot mask covers them. + vfApplyIpMask(); } /** Standard per-IP row with inline PTR editor. Used for v4 addresses + discrete v6 hosts. */ function vfRenderIpRow(serviceId, systemUrl, row) { var wrap = $('
    '); - var ipLabel = $('
    ').text(row.ip); + // .vf-ip class makes the address subject to vfApplyIpMask() (screenshot mode). + var ipLabel = $('
    ').text(row.ip); var badge = vfRdnsBadge(row.status); - var input = $(''); + // .vf-sensitive lets the screenshot mask blur the PTR hostname value via + // CSS text-security when "Mask Sensitive" is toggled on. + var input = $(''); input.val(row.ptr || ""); var saveBtn = $(''); @@ -1193,7 +1444,7 @@ function vfRenderIpRow(serviceId, systemUrl, row) { } /** - * Subnet-only row: shows "2602:2f3:0:5d::/64" with a collapsible "Add host PTR" form. + * Subnet-only row: shows "2001:db8::/64" with a collapsible "Add host PTR" form. * * Why collapsed by default: most customers won't set custom v6 PTRs, so burying * the form until explicitly requested keeps the panel uncluttered for the common @@ -1202,14 +1453,18 @@ function vfRenderIpRow(serviceId, systemUrl, row) { */ function vfRenderSubnetRow(serviceId, systemUrl, row) { var wrap = $('
    '); - var label = $('
    ').text(row.subnet + "/" + row.cidr); + // .vf-ip class makes the subnet address subject to vfApplyIpMask(). + var label = $('
    ').text(row.subnet + "/" + row.cidr); var badge = vfRdnsBadge(row.status); var toggleBtn = $(''); var form = $(''); - var ipInput = $(''); - var ptrInput = $(''); + // Both inputs hold sensitive customer-facing strings (a host IPv6 + a PTR + // hostname). vf-sensitive plus the body's vf-mask-active class hides + // their values via text-security in the screenshot mode. + var ipInput = $(''); + var ptrInput = $(''); var addBtn = $(''); var cancelBtn = $(''); var msg = $('
    '); @@ -1352,3 +1607,247 @@ function vfAdminReconcileRdns(serviceId, systemUrl, force) { out.text("Reconcile failed").css("color", "#dc3545"); }); } + +// ============================================================= +// In-page Section Navigation +// ============================================================= +// +// Renders a "Jump to:" strip at the top of the product details page that +// links to each visible panel. Panel discovery is data-attribute-driven — +// any element carrying [data-vf-nav-label="..."] becomes a nav target. That +// keeps the JS oblivious to which sections happen to exist for a given +// install (Reverse DNS depends on PowerDNS being enabled at the template +// level, Self-Service depends on configoption4, etc.). +// +// Several panels (Resources, VNC, Self-Service) are rendered as display:none +// and revealed by their own data-load callbacks. The MutationObserver picks +// those reveals up automatically; the staggered setTimeout fallbacks cover +// browsers/situations where the observer misses the initial paint. + +function _vfPanelIsVisible(el) { + if (!el) return false; + if (el.style && el.style.display === "none") return false; + return el.offsetParent !== null || el.offsetHeight > 0; +} + +function vfBuildSectionNav() { + // Map every known section to whether its panel is currently visible. + var panels = document.querySelectorAll("[data-vf-nav-label]"); + var visibleIds = {}; + panels.forEach(function (p) { + if (_vfPanelIsVisible(p) && p.id) visibleIds[p.id] = true; + }); + + // (Optional) inline horizontal strip — kept as a fallback if the host + // theme strips the WHMCS sidebar. If #vf-section-nav exists in the DOM + // we populate it; otherwise we silently skip and leave the sidebar + // version (rendered server-side via the ClientAreaPrimarySidebar hook) + // as the only nav. + var nav = document.getElementById("vf-section-nav"); + if (nav) { + var list = nav.querySelector("[data-vf-nav-list]"); + if (list) { + while (list.firstChild) list.removeChild(list.firstChild); + var visibleCount = 0; + panels.forEach(function (p) { + if (!visibleIds[p.id]) return; + var a = document.createElement("a"); + a.className = "vf-nav-link"; + a.href = "#" + p.id; + a.setAttribute("data-vf-target", p.id); + a.textContent = p.getAttribute("data-vf-nav-label") || p.id; + list.appendChild(a); + visibleCount++; + }); + nav.style.display = visibleCount > 1 ? "" : "none"; + } + } + + // Sidebar items are rendered statically by the PHP hook with every + // possible section. Toggle their visibility per panel state so customers + // don't see "Live Stats" or "Reverse DNS" jump-links for panels that + // aren't actually rendered on this page. + // + // WHMCS 9's Twenty-One theme renders sidebar children as bare
    elements + // inside a flat .list-group div — there's no per-item
  • wrapper. Older + // themes may use
  • . Try
  • first (preserves layout if the + // theme uses one), fall back to the link element itself. + document.querySelectorAll("[data-vf-target]").forEach(function (el) { + // Skip the inline-strip links — those are rebuilt above. + if (el.closest && el.closest("#vf-section-nav")) return; + var target = el.getAttribute("data-vf-target"); + var visible = !!visibleIds[target]; + var li = el.closest && el.closest("li"); + var hideTarget = li || el; + hideTarget.style.display = visible ? "" : "none"; + }); +} + +document.addEventListener("click", function (e) { + // Catch both inline and sidebar nav links — both carry data-vf-target. + var link = e.target && e.target.closest && e.target.closest("[data-vf-target]"); + if (!link) return; + var targetId = link.getAttribute("data-vf-target"); + var target = document.getElementById(targetId); + if (!target) return; + e.preventDefault(); + var top = target.getBoundingClientRect().top + window.pageYOffset - 16; + window.scrollTo({ top: top, behavior: "smooth" }); + if (history && history.replaceState) { + history.replaceState(null, "", "#" + targetId); + } +}); + +(function _vfInitSectionNav() { + function init() { + vfBuildSectionNav(); + [400, 1200, 2500].forEach(function (ms) { setTimeout(vfBuildSectionNav, ms); }); + try { + var obs = new MutationObserver(vfBuildSectionNav); + document.querySelectorAll("[data-vf-nav-label]").forEach(function (p) { + obs.observe(p, { attributes: true, attributeFilter: ["style", "class"] }); + }); + } catch (e) { /* MutationObserver missing — staggered timeouts cover us */ } + } + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); + +// ============================================================= +// Live Stats + Filesystem rendering +// ============================================================= + +// Format a byte count for human display (KB / MB / GB / TB). +function _vfFormatBytes(bytes) { + if (!isFinite(bytes) || bytes < 0) bytes = 0; + var units = ["B", "KB", "MB", "GB", "TB", "PB"]; + var u = 0; + var n = bytes; + while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; } + return (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2)) + " " + units[u]; +} + +function vfRenderLiveStats(live) { + if (!live || (live.cpu === null && live.memoryActualKB === null && live.diskRdBytes === null)) { + // No remoteState payload — keep the panel hidden. Section nav will + // skip it because it's display:none. + return; + } + $("#vf-sec-livestats").show(); + + // CPU — VirtFusion returns a percentage. Clamp to [0, 100] defensively. + var cpu = live.cpu; + if (cpu === null) { + $("#vf-live-cpu-pct").text("-"); + $("#vf-live-cpu-bar").css("width", "0%"); + } else { + var cpuPct = Math.max(0, Math.min(100, cpu)); + $("#vf-live-cpu-pct").text(cpuPct.toFixed(1) + "%"); + var cpuBar = $("#vf-live-cpu-bar").css("width", cpuPct + "%"); + cpuBar.removeClass("bg-warning bg-danger"); + if (cpuPct > 90) cpuBar.addClass("bg-danger"); + else if (cpuPct > 70) cpuBar.addClass("bg-warning"); + } + + // Memory — libvirt returns kilobytes. used = actual - unused; pct against actual. + var actual = live.memoryActualKB, unused = live.memoryUnusedKB; + if (actual !== null && unused !== null) { + var usedKB = Math.max(0, actual - unused); + var memPct = actual > 0 ? Math.min(100, (usedKB / actual) * 100) : 0; + $("#vf-live-mem-text").text(_vfFormatBytes(usedKB * 1024) + " / " + _vfFormatBytes(actual * 1024)); + $("#vf-live-mem-pct").text(memPct.toFixed(0) + "%"); + var memBar = $("#vf-live-mem-bar").css("width", memPct + "%"); + memBar.removeClass("bg-warning bg-danger"); + if (memPct > 90) memBar.addClass("bg-danger"); + else if (memPct > 75) memBar.addClass("bg-warning"); + } else { + $("#vf-live-mem-text").text("-"); + $("#vf-live-mem-pct").text(""); + $("#vf-live-mem-bar").css("width", "0%"); + } + + // Disk I/O — cumulative bytes since boot. + $("#vf-live-disk-rd").text(live.diskRdBytes === null ? "-" : _vfFormatBytes(live.diskRdBytes)); + $("#vf-live-disk-wr").text(live.diskWrBytes === null ? "-" : _vfFormatBytes(live.diskWrBytes)); + + var now = new Date(); + $("#vf-live-updated").text("Updated " + now.toLocaleTimeString()); +} + +function vfRenderFilesystems(filesystems) { + var container = $("#vf-fs-container"); + if (container.length === 0) return; + container.empty(); + if (!Array.isArray(filesystems) || filesystems.length === 0) { + $("#vf-fs-section").hide(); + return; + } + $("#vf-fs-section").show(); + filesystems.forEach(function (fs) { + var pct = fs.totalBytes > 0 ? Math.min(100, (fs.usedBytes / fs.totalBytes) * 100) : 0; + var barColor = pct > 90 ? "bg-danger" : (pct > 75 ? "bg-warning" : ""); + var row = $('
    '); + var head = $('
    '); + head.append($('').text(fs.mountpoint).attr("title", fs.name + " (" + fs.type + ")")); + head.append($('').text( + _vfFormatBytes(fs.usedBytes) + " / " + _vfFormatBytes(fs.totalBytes) + + " (" + pct.toFixed(0) + "%)" + )); + row.append(head); + var bar = $('
    '); + bar.append($('
    ').addClass(barColor).css("width", pct + "%")); + row.append(bar); + container.append(row); + }); +} + +// ------------------------------------------------------------- +// Live Stats auto-refresh +// ------------------------------------------------------------- +// +// Polls the serverData endpoint every 30 seconds while the Live Stats +// panel is visible AND the page has focus. Pausing on visibilitychange +// avoids hammering the hypervisor when the customer alt-tabs away. The +// underlying serverData call is the same one vfServerData uses, so cache +// hits in client.php (when added) would benefit both paths. + +(function _vfLiveStatsRefresh() { + var REFRESH_MS = 30000; + var timer = null; + var serviceId = null, systemUrl = null; + + function tick() { + if (!serviceId || document.hidden) return; + var panel = document.getElementById("vf-sec-livestats"); + if (!panel || panel.style.display === "none" || panel.offsetParent === null) return; + $.ajax({ + type: "GET", + dataType: "json", + url: vfUrl(systemUrl, serviceId, "serverData") + }).done(function (response) { + if (response && response.success && response.data) { + vfRenderLiveStats(response.data.live); + vfRenderFilesystems(response.data.live ? response.data.live.filesystems : []); + } + }); + } + + // The first vfServerData call (from the inline + + + +{* 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. *} + {* Power Management Panel *} -
    +

    Power Management

    @@ -129,23 +262,16 @@
    {* Manage Panel *} -
    +

    Manage

    -
    -
    -

    Manage your server via our dedicated control panel. You will be automatically authenticated and the control panel will open in a new window.

    - -
    -
    + {* 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}

    @@ -188,7 +314,7 @@
    {* Rebuild Panel *} -
    +

    Rebuild Server

    @@ -215,31 +341,13 @@
    -{* Network Management Panel *} -
    -
    -

    Network

    -
    -
    - - -
    -
    +{* 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 *} -
    +

    Reverse DNS

    @@ -260,7 +368,7 @@ {/if} {* Resources Panel — populated by JS after server data loads *} - -
    -
    - Network Speed - -
    -
    -
    -{* VNC Console Panel — hidden by default, shown by JS if VNC is enabled *} - +{* 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} -