Files
virtfusion-whmcs-module/modules/servers/VirtFusionDirect/templates/js/module.js
Prophet731 27cbe40c52
Some checks failed
Publish Release / release (push) Failing after 16s
chore(release): 1.5.0
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.
2026-04-28 22:07:27 -04:00

1854 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* VirtFusion Direct Provisioning Module - Client JavaScript
*
* ========================================================================
* ARCHITECTURE
* ========================================================================
*
* This file is the single client-side script that powers both:
* - The client area (service overview panel, loaded on every service page)
* - The admin services tab (server info + rDNS widget)
*
* It uses vanilla JS + jQuery. jQuery is available because WHMCS's built-in
* admin UI depends on it; we inherit that dependency rather than adding a
* new one. The order form hooks (keygen.js, OS-gallery injector in hooks.php)
* use vanilla JS only because those run on pre-auth checkout pages where
* jQuery availability varies by theme.
*
* CONVENTION: every function is prefixed with "vf" to avoid collisions with
* whatever else the page loads. Internal helpers start with "_vf".
*
* ========================================================================
* SECTIONS (roughly in order below)
* ========================================================================
*
* Shared Helpers — vfUrl, vfShowAlert
* Progress Indicator — vfShowProgress / vfHideProgress
* Server Data Display — vfServerData, vfServerDataAdmin
* Power Management — vfPowerAction
* SSO Login — vfLoginAsServerOwner
* Password Reset — vfUserPasswordReset, vfResetServerPassword
* Server Rebuild — vfRebuildServer, vfLoadOsTemplates, vfRenderOsGallery
* Server Rename — vfRenameServer, vfShowNameDropdown
* Traffic / Backups — vfLoadTrafficStats, vfDrawTrafficChart, vfLoadBackups
* VNC Console — vfOpenVnc
* Self-Service Billing — vfLoadSelfServiceUsage, vfAddCredit
* Reverse DNS (PowerDNS) — vfLoadRdns, vfRenderRdnsPanel, vfUpdateRdns,
* vfAdminLoadRdns, vfAdminReconcileRdns
*
* ========================================================================
* AJAX REQUEST SHAPE
* ========================================================================
*
* URL: {systemUrl}modules/servers/VirtFusionDirect/{endpoint}.php
* ?serviceID={id}&action={action}
* where endpoint is "client" (default) or "admin".
*
* Method: GET for reads, POST for writes (server-side requirePost() gate
* enforces this for rDNS mutations; other mutations rely on $_POST
* being empty for GET → validation fails naturally).
*
* Response:
* { success: true, data: { ... } }
* { success: false, errors: "human message" }
*
* ========================================================================
* ERROR HANDLING
* ========================================================================
*
* Every AJAX call handles three outcomes:
* 1. Network failure (.fail) → show a generic error in the panel's alert div
* 2. Server returned success:false → show response.errors to the user
* 3. Server returned success:true → render data into the DOM
*
* Error text ALWAYS comes from the server (we don't invent user-facing error
* copy client-side). That way a server-side change to error phrasing
* propagates everywhere without JS changes.
*
* ========================================================================
* DOM UPDATE PATTERNS
* ========================================================================
*
* Read actions render into named containers with id="vf-data-*".
* Status badges use CSS classes "vf-badge-*" for color coding.
* Text content is always set via .text() not .html() to prevent XSS
* from whatever the API returned. Exception: panels built entirely
* from server-trusted structured data use .append() with new jQuery
* elements, not string concatenation.
*
* Handles client-side interactions for:
* - Server data display
* - Power management (boot, shutdown, restart, power off)
* - Control panel login (SSO)
* - Password reset
* - Server rebuild
* - OS template loading
* - Traffic statistics
* - Backup listing
* - VNC management
* - Server naming
* - Reverse DNS (PowerDNS addon)
*/
// =========================================================================
// Shared Helpers
// =========================================================================
function vfUrl(systemUrl, serviceId, action, endpoint) {
return (systemUrl || "") + "modules/servers/VirtFusionDirect/" + (endpoint || "client") + ".php?serviceID=" + encodeURIComponent(serviceId) + "&action=" + encodeURIComponent(action);
}
function vfShowAlert(alertDiv, type, message) {
alertDiv.removeClass("alert-danger alert-success alert-warning alert");
alertDiv.addClass("alert alert-" + type);
alertDiv.text(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 <input> 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
// =========================================================================
var _vfProgressTimer = null;
function vfShowProgress(label) {
var startTime = Date.now();
$("#vf-action-progress-text").text(label);
$("#vf-action-progress-timer").text("0s");
$("#vf-action-progress").show();
_vfProgressTimer = setInterval(function () {
var elapsed = Math.floor((Date.now() - startTime) / 1000);
$("#vf-action-progress-timer").text(elapsed + "s");
}, 1000);
}
function vfHideProgress() {
if (_vfProgressTimer) {
clearInterval(_vfProgressTimer);
_vfProgressTimer = null;
}
$("#vf-action-progress").hide();
}
function vfServerData(serviceId, systemUrl) {
$("#vf-server-info-error").hide();
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "serverData")
}).done(function (response) {
if (response.success) {
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");
var status = (response.data.status || "unknown").toLowerCase();
statusBadge.text(status.charAt(0).toUpperCase() + status.slice(1));
statusBadge.removeClass("vf-badge-active vf-badge-suspended vf-badge-awaiting");
if (status === "active" || status === "running") {
statusBadge.addClass("vf-badge-active");
} else if (status === "suspended") {
statusBadge.addClass("vf-badge-suspended");
} else {
statusBadge.addClass("vf-badge-awaiting");
}
// 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;
$("#vf-res-memory").text(d.memory || "-");
$("#vf-res-cpu").text(d.cpu || "-");
$("#vf-res-storage").text(d.storage || "-");
var trafficUsed = d.trafficUsedRaw || 0;
var trafficTotal = d.trafficRaw || 0;
if (trafficTotal > 0) {
$("#vf-res-traffic").text(trafficUsed + " / " + trafficTotal + " GB");
var pct = Math.min(100, Math.round((trafficUsed / trafficTotal) * 100));
$("#vf-res-traffic-bar").css("width", pct + "%").removeClass("bg-danger bg-warning");
if (pct > 90) {
$("#vf-res-traffic-bar").addClass("bg-danger");
} else if (pct > 70) {
$("#vf-res-traffic-bar").addClass("bg-warning");
}
} else {
$("#vf-res-traffic").text(d.traffic || "Unmetered");
$("#vf-res-traffic-bar").css("width", "0%");
}
$("#vf-resources-panel").show();
// Re-apply mask state to the IP cells we just (re)rendered.
vfApplyIpMask();
$("#vf-server-info").show();
} else {
$("#vf-server-info-error").show();
$("#vf-server-info").hide();
}
}).fail(function () {
$("#vf-server-info-error").show();
}).always(function () {
$("#vf-server-info-loader-container").hide();
});
}
function vfServerDataAdmin(serviceId, systemUrl) {
$("#vf-loader").show();
$("#vf-server-info").hide();
$("#vf-server-info-error").hide();
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "serverData", "admin")
}).done(function (response) {
if (response.success) {
$("#vf-data-server-name").text(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-storage").text(response.data.storage);
$("#vf-data-server-cpu").text(response.data.cpu);
var pnAdmin = response.data.primaryNetwork || {};
$("#vf-data-server-ipv4").text(pnAdmin.ipv4 || "-");
$("#vf-data-server-ipv6").text(pnAdmin.ipv6 || "-");
$("#vf-server-info").show();
} else {
$("#vf-server-info-error").show();
$("#vf-server-info-error-message").text("Unable to retrieve server information.");
$("#vf-server-info").hide();
}
}).fail(function () {
$("#vf-server-info-error").show();
}).always(function () {
$("#vf-loader").hide();
});
}
function vfUserPasswordReset(serviceId, systemUrl) {
$("#vf-password-reset-button-spinner").show();
$("#vf-password-reset-error").hide();
$("#vf-password-reset-success").hide();
$.ajax({
type: "POST",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "resetPassword")
}).done(function (response) {
if (response.success) {
$("#vf-password-reset-success").show();
$("#vf-data-user-email").text(response.data.email);
$("#vf-data-user-password").text(response.data.password);
} else {
$("#vf-password-reset-error").show();
}
}).fail(function () {
$("#vf-password-reset-error").show();
}).always(function () {
$("#vf-password-reset-button-spinner").hide();
});
}
function vfLoginAsServerOwner(serviceId, systemUrl, newWindow) {
newWindow = newWindow !== false;
vfLoginError(false);
$("#vf-login-button").prop("disabled", true);
$("#vf-login-button-spinner").show();
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "loginAsServerOwner")
}).done(function (response) {
if (response.success && response.token_url) {
if (newWindow) {
window.open(response.token_url);
} else {
window.location.href = response.token_url;
}
} else {
vfLoginError(true);
}
}).fail(function () {
vfLoginError(true);
}).always(function () {
$("#vf-login-button-spinner").hide();
$("#vf-login-button").prop("disabled", false);
});
}
function vfLoginError(show, message) {
message = message || "Unable to open the control panel. Please try again later.";
if (show) {
$("#vf-login-error").text(message);
$("#vf-login-error").show();
} else {
$("#vf-login-error").hide();
}
}
function vfPowerAction(serviceId, systemUrl, action) {
var btn = $("#vf-power-" + action);
var spinner = btn.find(".vf-btn-spinner");
var alertDiv = $("#vf-power-alert");
// Disable all power buttons during action
$(".vf-btn-power").prop("disabled", true);
spinner.show();
alertDiv.hide();
var actionLabels = {
boot: "Starting",
shutdown: "Shutting down",
restart: "Restarting",
poweroff: "Forcing off"
};
$.ajax({
type: "POST",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "powerAction"),
data: { powerAction: action }
}).done(function (response) {
if (response.success) {
vfShowAlert(alertDiv, "success",response.data.message || (actionLabels[action] + " server..."));
} else {
vfShowAlert(alertDiv, "danger","Power action failed. Please try again.");
}
alertDiv.show();
}).fail(function () {
vfShowAlert(alertDiv, "danger","An error occurred. Please try again.");
}).always(function () {
spinner.hide();
// Cooldown: keep buttons disabled for 3 seconds
setTimeout(function () {
$(".vf-btn-power").prop("disabled", false);
}, 3000);
});
}
var vfOsBrandColors = {
"ubuntu": "#E95420", "debian": "#A81D33", "rocky": "#10B981", "centos": "#932279",
"almalinux": "#0F4266", "alma": "#0F4266", "windows": "#0078D4", "fedora": "#51A2DA",
"arch": "#1793D1", "opensuse": "#73BA25", "suse": "#73BA25", "freebsd": "#AB2B28",
"oracle": "#F80000", "rhel": "#EE0000", "red hat": "#EE0000", "cloudlinux": "#0095D9",
"gentoo": "#54487A", "slackware": "#000", "nixos": "#7EBAE4", "alpine": "#0D597F"
};
function vfGetBrandColor(name) {
var lower = (name || "").toLowerCase();
for (var key in vfOsBrandColors) {
if (lower.indexOf(key) !== -1) return vfOsBrandColors[key];
}
return "#6c757d";
}
function vfRenderOsGallery(container, data, hiddenInput) {
var $container = $(container);
$container.empty();
if (!data || !data.categories || data.categories.length === 0) {
$container.append($('<p class="text-muted"></p>').text("No templates available"));
$container.show();
return;
}
var baseUrl = data.baseUrl || "";
$.each(data.categories, function (ci, category) {
var section = $('<div class="vf-os-category"></div>').attr("data-category", ci);
var brandColor = vfGetBrandColor(category.name);
// Accordion header
var header = $('<div class="vf-os-category-header"></div>');
var iconSpan = $('<span class="vf-os-category-icon"></span>');
if (category.icon && baseUrl) {
var catImg = $('<img alt="">').attr("src", baseUrl + "/img/logo/" + encodeURIComponent(category.icon));
catImg.on("error", function () {
$(this).parent().css("background", brandColor);
$(this).replaceWith($('<span></span>').text((category.name || "?")[0].toUpperCase()));
});
iconSpan.append(catImg);
} 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 = $('<span></span>').text(category.name + " (" + category.templates.length + ")");
var arrow = $('<span class="vf-os-category-arrow">' + (ci === 0 ? '&#9660;' : '&#9654;') + '</span>');
header.append(iconSpan).append(titleSpan).append(arrow);
section.append(header);
// Collapsible grid — first category open by default
var grid = $('<div class="vf-os-grid"></div>');
if (ci !== 0) grid.hide();
header.on("click", function () {
var isVisible = grid.is(":visible");
// Collapse all other categories
$container.find(".vf-os-grid").slideUp(200);
$container.find(".vf-os-category-arrow").html('&#9654;');
// Toggle this one
if (!isVisible) {
grid.slideDown(200);
arrow.html('&#9660;');
}
});
$.each(category.templates, function (ti, tpl) {
var label = tpl.name + (tpl.version ? " " + tpl.version : "") + (tpl.variant ? " " + tpl.variant : "");
var card = $('<div class="vf-os-card"></div>')
.attr("data-id", tpl.id)
.attr("data-search", label.toLowerCase());
if (tpl.eol) card.addClass("vf-os-card-eol");
var iconDiv = $('<div class="vf-os-icon"></div>');
if (tpl.icon && baseUrl) {
var tplImg = $('<img alt="">').attr("src", baseUrl + "/img/logo/" + encodeURIComponent(tpl.icon));
tplImg.on("error", function () {
$(this).parent().css("background", brandColor);
$(this).replaceWith($('<span></span>').text((tpl.name || "?")[0].toUpperCase()));
});
iconDiv.append(tplImg);
} else {
iconDiv.css("background", brandColor);
iconDiv.append($('<span></span>').text((tpl.name || "?")[0].toUpperCase()));
}
card.append(iconDiv);
card.append($('<div class="vf-os-label"></div>').text(tpl.name));
card.append($('<div class="vf-os-version"></div>').text((tpl.version || "") + (tpl.variant ? " " + tpl.variant : "")));
if (tpl.eol) {
card.append($('<span class="vf-os-eol-badge"></span>').text("EOL"));
}
card.on("click", function () {
$container.find(".vf-os-card").removeClass("vf-os-card-selected");
$(this).addClass("vf-os-card-selected");
$(hiddenInput).val(tpl.id);
var details = $("#vf-os-details");
details.empty();
details.append($('<strong></strong>').text(label));
if (tpl.description) {
details.append($('<p class="mb-0 mt-1 text-muted"></p>').text(tpl.description));
}
details.show();
});
grid.append(card);
});
section.append(grid);
$container.append(section);
});
$container.show();
}
function vfLoadOsTemplates(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "osTemplates")
}).done(function (response) {
$("#vf-os-gallery-loader").hide();
if (response.success && response.data) {
vfRenderOsGallery("#vf-os-gallery", response.data, "#vf-rebuild-os");
// Bind search after gallery is rendered
$("#vf-os-search").on("keyup", function () {
var query = $(this).val().toLowerCase();
$("#vf-os-gallery .vf-os-card").each(function () {
var match = $(this).data("search").indexOf(query) !== -1;
$(this).toggle(match);
});
$("#vf-os-gallery .vf-os-category").each(function () {
var hasVisible = $(this).find(".vf-os-card:visible").length > 0;
$(this).toggle(hasVisible);
});
});
} else {
$("#vf-os-gallery").append($('<p class="text-muted"></p>').text("No templates available")).show();
}
}).fail(function () {
$("#vf-os-gallery-loader").hide();
$("#vf-os-gallery").append($('<p class="text-danger"></p>').text("Error loading templates")).show();
});
}
function vfRebuildServer(serviceId, systemUrl) {
var osId = $("#vf-rebuild-os").val();
var alertDiv = $("#vf-rebuild-alert");
if (!osId) {
vfShowAlert(alertDiv, "danger","Please select an operating system.");
return;
}
if (!confirm("Are you sure you want to rebuild this server? ALL DATA WILL BE ERASED. This action cannot be undone.")) {
return;
}
$("#vf-rebuild-button").prop("disabled", true);
$("#vf-rebuild-spinner").show();
alertDiv.hide();
vfShowProgress("Rebuilding server...");
$.ajax({
type: "POST",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "rebuild"),
data: { osId: osId }
}).done(function (response) {
if (response.success) {
vfShowAlert(alertDiv, "success",response.data.message || "Server rebuild initiated. You will receive an email when the process is complete.");
} else {
vfShowAlert(alertDiv, "danger","Rebuild failed. Please try again.");
}
alertDiv.show();
}).fail(function () {
vfShowAlert(alertDiv, "danger","An error occurred. Please try again.");
}).always(function () {
vfHideProgress();
$("#vf-rebuild-spinner").hide();
// Cooldown: keep button disabled for 30 seconds after rebuild
setTimeout(function () {
$("#vf-rebuild-button").prop("disabled", false);
}, 30000);
});
}
function impersonateServerOwner(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "impersonateServerOwner", "admin")
}).done(function (response) {
if (response.success && response.user) {
window.open(response.url + "/_imp/in/" + response.user.id + "/-");
}
});
}
// =========================================================================
// 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");
var alertDiv = $("#vf-vnc-alert");
btn.prop("disabled", true);
spinner.show();
alertDiv.hide();
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.");
spinner.hide();
btn.prop("disabled", false);
return;
}
// 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();
try { vncWindow.focus(); } catch (e) { /* may throw if popup closed */ }
spinner.hide();
btn.prop("disabled", false);
}
function vfCopyVncPassword(serviceId, systemUrl) {
var confirmSpan = $("#vf-vnc-copy-confirm");
$.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;
var password = data.password || "";
if (password) {
navigator.clipboard.writeText(password).then(function () {
confirmSpan.text("Copied!").show();
setTimeout(function () { confirmSpan.hide(); }, 2000);
}).catch(function () {
confirmSpan.text("Copy failed").show();
setTimeout(function () { confirmSpan.hide(); }, 2000);
});
}
}
});
}
// =========================================================================
// Self Service — Credit & Usage
// =========================================================================
function vfLoadSelfServiceUsage(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "selfServiceUsage")
}).done(function (response) {
if (response.success && response.data) {
var data = response.data.data || response.data;
// Credit balance
var balance = "-";
if (data.credit !== undefined) {
balance = parseFloat(data.credit).toFixed(2);
} else if (data.balance !== undefined) {
balance = parseFloat(data.balance).toFixed(2);
}
$("#vf-ss-credit-balance").text(balance);
// Usage breakdown
var tbody = $("#vf-ss-usage-table");
tbody.empty();
var items = data.usage || data.items || [];
if (Array.isArray(items) && items.length > 0) {
$.each(items, function (i, item) {
var desc = item.description || item.name || item.server || "Item";
var cost = item.cost !== undefined ? parseFloat(item.cost).toFixed(2) : "-";
tbody.append('<tr><td>' + $('<span>').text(desc).html() + '</td><td class="text-right">' + $('<span>').text(cost).html() + '</td></tr>');
});
} else {
tbody.append('<tr><td colspan="2" class="text-muted">No usage data available</td></tr>');
}
$("#vf-selfservice-content").show();
$("#vf-selfservice-panel").show();
}
}).fail(function () {
// Self-service not available — keep panel hidden
}).always(function () {
$("#vf-selfservice-loader").hide();
});
}
function vfAddCredit(serviceId, systemUrl) {
var amount = $("#vf-ss-credit-amount").val();
var alertDiv = $("#vf-selfservice-alert");
var btn = $("#vf-ss-add-credit-btn");
var spinner = $("#vf-ss-add-credit-spinner");
if (!amount || parseFloat(amount) <= 0) {
vfShowAlert(alertDiv, "danger","Please enter a valid positive amount.");
return;
}
btn.prop("disabled", true);
spinner.show();
alertDiv.hide();
$.ajax({
type: "POST",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "selfServiceAddCredit"),
data: { tokens: amount }
}).done(function (response) {
if (response.success) {
vfShowAlert(alertDiv, "success","Credit added successfully.");
$("#vf-ss-credit-amount").val("");
// Refresh usage data
vfLoadSelfServiceUsage(serviceId, systemUrl);
} else {
vfShowAlert(alertDiv, "danger","Failed to add credit. Please try again.");
}
}).fail(function () {
vfShowAlert(alertDiv, "danger","An error occurred. Please try again.");
}).always(function () {
spinner.hide();
btn.prop("disabled", false);
});
}
// =========================================================================
// Server Password Reset
// =========================================================================
function vfResetServerPassword(serviceId, systemUrl) {
if (!confirm("Are you sure you want to reset the server root password? This will change the password immediately.")) {
return;
}
var btn = $("#vf-server-password-btn");
var spinner = $("#vf-server-password-spinner");
var alertDiv = $("#vf-server-password-alert");
btn.prop("disabled", true);
spinner.show();
alertDiv.hide();
$.ajax({
type: "POST",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "resetServerPassword")
}).done(function (response) {
if (response.success && response.data) {
var data = response.data.data || response.data;
var password = data.password || data.newPassword || "";
if (password) {
navigator.clipboard.writeText(password).then(function () {
vfShowAlert(alertDiv, "success","New password copied to clipboard.");
}).catch(function () {
vfShowAlert(alertDiv, "warning","Password reset successful. Unable to copy to clipboard automatically.");
});
} else {
vfShowAlert(alertDiv, "success","Password reset initiated. Check your email for the new credentials.");
}
} else {
vfShowAlert(alertDiv, "danger","Password reset failed. Please try again.");
}
}).fail(function () {
vfShowAlert(alertDiv, "danger","An error occurred. Please try again.");
}).always(function () {
spinner.hide();
btn.prop("disabled", false);
});
}
// =========================================================================
// Backup Listing
// =========================================================================
function vfLoadBackups(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "backups")
}).done(function (response) {
if (response.success && response.data) {
var backups = response.data.data || response.data;
if (!Array.isArray(backups)) backups = [];
if (backups.length > 0) {
var timeline = $("#vf-backups-timeline");
timeline.empty();
$.each(backups, function (i, backup) {
var rawDate = backup.created_at || backup.date || "";
var date = rawDate;
try { if (rawDate) date = new Date(rawDate).toLocaleString(); } catch (e) {}
var size = backup.size ? (backup.size >= 1024 ? (backup.size / 1024).toFixed(2) + " GB" : backup.size + " MB") : "-";
var status = backup.status || "completed";
var dotClass = status === "completed" ? "vf-timeline-dot-success" : "vf-timeline-dot-pending";
var item = $('<div class="vf-timeline-item"></div>');
if (i >= 10) item.addClass("vf-timeline-item-hidden").hide();
item.append('<div class="vf-timeline-dot ' + dotClass + '"></div>');
item.append($('<div class="vf-timeline-content"></div>')
.append($('<div class="vf-bold"></div>').text(date))
.append($('<div class="text-muted"></div>').text("Size: " + size + " | Status: " + status))
);
timeline.append(item);
});
if (backups.length > 10) {
$("#vf-backups-show-all").show();
}
$("#vf-backups-section").show();
}
}
}).always(function () {
$("#vf-backups-loader").hide();
});
}
// =========================================================================
// Traffic Statistics Chart
// =========================================================================
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 = 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 = H;
if (!entries || entries.length === 0) {
ctx.fillStyle = "#888";
ctx.font = "13px sans-serif";
ctx.textAlign = "center";
ctx.fillText("No traffic data available", w / 2, h / 2);
return;
}
var maxVal = 0;
entries.forEach(function (e) {
var total = (e.inbound || 0) + (e.outbound || 0);
if (total > maxVal) maxVal = total;
});
if (maxVal === 0) maxVal = 1;
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 grid + GB/TB labels
ctx.strokeStyle = "#dee2e6";
ctx.lineWidth = 1;
for (var i = 0; i <= 4; i++) {
var y = padding.top + chartH - (chartH * i / 4);
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(w - padding.right, y);
ctx.stroke();
ctx.fillStyle = "#888";
ctx.font = "10px sans-serif";
ctx.textAlign = "right";
var labelVal = (maxVal * i / 4);
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;
var inH = (inVal / maxVal) * chartH;
var outH = (outVal / maxVal) * chartH;
var x = padding.left + idx * barGroupW + (barGroupW - barW * 2 - 2) / 2;
ctx.fillStyle = "#337ab7";
ctx.fillRect(x, chartBottomY - inH, barW, inH);
ctx.fillStyle = "#28a745";
ctx.fillRect(x + barW + 2, chartBottomY - outH, barW, outH);
// 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, chartBottomY + 16);
});
// 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) {
$.ajax({
type: "GET",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "trafficStats")
}).done(function (response) {
if (!response || !response.success) return;
// 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;
// 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);
});
});
}
// =========================================================================
// Server Naming
// =========================================================================
function vfGenerateFriendlyName() {
var adjectives = ["swift","bold","calm","keen","fair","brave","cool","sage","free","warm"];
var nouns = ["cloud","node","core","link","bolt","wave","star","peak","edge","dock"];
var adj = adjectives[Math.floor(Math.random() * adjectives.length)];
var noun = nouns[Math.floor(Math.random() * nouns.length)];
var num = String(Math.floor(Math.random() * 90) + 10);
return adj + "-" + noun + "-" + num;
}
function vfShowNameDropdown(serviceId, systemUrl) {
var dropdown = $("#vf-name-dropdown");
dropdown.empty();
for (var i = 0; i < 4; i++) {
var name = vfGenerateFriendlyName();
var opt = $('<div class="vf-name-option"></div>').text(name);
(function (n) {
opt.on("click", function () {
$("#vf-rename-input").val(n);
dropdown.hide();
});
})(name);
dropdown.append(opt);
}
var refreshBtn = $('<div class="vf-name-option text-muted" style="text-align:center;cursor:pointer;">&#x21bb; More options</div>');
refreshBtn.on("click", function () {
vfShowNameDropdown(serviceId, systemUrl);
});
dropdown.append(refreshBtn);
dropdown.show();
}
function vfRenameServer(serviceId, systemUrl) {
// 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();
// 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;
}
// 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",
dataType: "json",
url: vfUrl(systemUrl, serviceId, "rename"),
data: { name: name }
}).done(function (response) {
if (response.success) {
vfShowAlert(alertDiv, "success", "Server renamed successfully.");
} else {
vfShowAlert(alertDiv, "danger", (response && response.errors) || "Rename failed. Please try again.");
}
alertDiv.show();
}).fail(function () {
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);
});
}
// =========================================================================
// Utility — Copy to Clipboard
// =========================================================================
function vfCopyButton(text) {
var btn = $('<button type="button" class="btn btn-sm vf-ip-copy" title="Copy"></button>');
btn.html('<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6z"/><path d="M2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2z"/></svg>');
btn.on("click", function () {
var $this = $(this);
navigator.clipboard.writeText(text).then(function () {
$this.html('<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.485 1.929a.75.75 0 0 1 .086 1.057l-7.5 9a.75.75 0 0 1-1.1.043l-3.5-3.5a.75.75 0 0 1 1.06-1.06l2.915 2.915 6.982-8.382a.75.75 0 0 1 1.057-.073z"/></svg>');
var tooltip = $('<span class="vf-copy-tooltip">Copied!</span>');
$this.parent().append(tooltip);
setTimeout(function () {
tooltip.remove();
$this.html('<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H6z"/><path d="M2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1H2z"/></svg>');
}, 1500);
}).catch(function () {
var tooltip = $('<span class="vf-copy-tooltip" style="background:#dc3545;">Failed</span>');
$this.parent().append(tooltip);
setTimeout(function () { tooltip.remove(); }, 1500);
});
});
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 = $('<div class="vf-ip-cell-row"></div>');
row.append($('<span class="vf-ip vf-ip-address"></span>').text(addr));
row.append(vfCopyButton(addr));
cell.append(row);
});
}
// =========================================================================
// Reverse DNS (PowerDNS)
// =========================================================================
//
// Feature gate: this section only activates when the VirtFusionDns addon is
// installed AND enabled. The PHP side renders the rDNS panel in overview.tpl
// only when $rdnsEnabled is true; if the panel isn't in the DOM, these
// functions are never called.
//
// Admin-side counterparts (vfAdminLoadRdns, vfAdminReconcileRdns) target
// admin.php instead of client.php and are used by the rdnsSection() admin
// widget rendered via AdminHTML::rdnsSection().
//
// Status badge colours match what most operators expect:
// OK (green) = PTR present, forward DNS agrees (FCrDNS passes)
// unverified (amber) = PTR present but forward DNS no longer agrees
// missing (gray) = No PTR exists yet
// no-zone (red) = The IP's reverse zone isn't hosted in PowerDNS
// error (red) = PowerDNS unreachable or similar
//
// The server-side always decides the status; we just colour it.
/** Badge metadata used by vfRdnsBadge(). Kept here so colours/labels are tweakable in one place. */
var VF_RDNS_STATUS = {
"ok": { label: "OK", bg: "#28a745", fg: "#fff" },
"unverified": { label: "unverified", bg: "#f0ad4e", fg: "#000" },
"missing": { label: "no PTR", bg: "#6c757d", fg: "#fff" },
"no-zone": { label: "no zone", bg: "#dc3545", fg: "#fff" },
"error": { label: "error", bg: "#dc3545", fg: "#fff" },
"disabled": { label: "disabled", bg: "#6c757d", fg: "#fff" },
"subnet-only": { label: "subnet", bg: "#17a2b8", fg: "#fff" }
};
function vfRdnsBadge(status) {
var s = VF_RDNS_STATUS[status] || VF_RDNS_STATUS["error"];
var span = $('<span class="vf-rdns-badge"></span>');
span.text(s.label);
span.css({ background: s.bg, color: s.fg });
return span;
}
function vfLoadRdns(serviceId, systemUrl) {
var list = $("#vf-rdns-list");
$.ajax({
url: vfUrl(systemUrl, serviceId, "rdnsList"),
method: "GET",
dataType: "json"
}).done(function (resp) {
if (!resp || !resp.success) {
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
return;
}
if (!resp.data.enabled) {
list.closest(".panel").hide();
return;
}
vfRenderRdnsPanel(serviceId, systemUrl, resp.data.ips || []);
}).fail(function () {
list.html('<div class="text-muted">Unable to load reverse DNS.</div>');
});
}
function vfRenderRdnsPanel(serviceId, systemUrl, ips) {
var list = $("#vf-rdns-list");
list.empty();
if (!ips.length) {
list.html('<div class="text-muted">No IP addresses assigned to this server yet.</div>');
return;
}
ips.forEach(function (row) {
// Subnet-only rows (IPv6 /64 allocations) render as a distinct informational
// anchor with an expandable "Add host PTR" form — the customer types a
// specific address inside the subnet + hostname, backend verifies containment.
if (row.status === "subnet-only") {
list.append(vfRenderSubnetRow(serviceId, systemUrl, row));
return;
}
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 = $('<div class="vf-rdns-row"></div>');
// .vf-ip class makes the address subject to vfApplyIpMask() (screenshot mode).
var ipLabel = $('<div class="vf-rdns-ip vf-ip"></div>').text(row.ip);
var badge = vfRdnsBadge(row.status);
// .vf-sensitive lets the screenshot mask blur the PTR hostname value via
// CSS text-security when "Mask Sensitive" is toggled on.
var input = $('<input type="text" class="form-control form-control-sm vf-rdns-input vf-sensitive" maxlength="253" placeholder="host.example.com (blank to delete)">');
input.val(row.ptr || "");
var saveBtn = $('<button type="button" class="btn btn-sm btn-primary">Save</button>');
var msg = $('<div class="vf-rdns-msg"></div>');
saveBtn.on("click", function () {
vfUpdateRdns(serviceId, systemUrl, row.ip, input, saveBtn, msg, badge);
});
input.on("keydown", function (e) {
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
});
var editor = $('<div class="vf-rdns-edit"></div>').append(input).append(saveBtn);
return wrap.append(ipLabel).append(editor).append(badge).append(msg);
}
/**
* 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
* case. Adding a host PTR is a power-user operation (needs a pre-existing AAAA
* record) so surfacing it as a secondary action is UX-appropriate.
*/
function vfRenderSubnetRow(serviceId, systemUrl, row) {
var wrap = $('<div class="vf-rdns-row vf-rdns-subnet-row"></div>');
// .vf-ip class makes the subnet address subject to vfApplyIpMask().
var label = $('<div class="vf-rdns-ip vf-ip"></div>').text(row.subnet + "/" + row.cidr);
var badge = vfRdnsBadge(row.status);
var toggleBtn = $('<button type="button" class="btn btn-sm btn-outline-secondary">+ Add host PTR</button>');
var form = $('<div class="vf-rdns-subnet-form" style="display:none;"></div>');
// 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 = $('<input type="text" class="form-control form-control-sm vf-rdns-input vf-sensitive" placeholder="Host IPv6 address inside this subnet (e.g. 2001:db8::10)">');
var ptrInput = $('<input type="text" class="form-control form-control-sm vf-rdns-input vf-sensitive" maxlength="253" placeholder="Hostname for PTR (e.g. mail.example.com)">');
var addBtn = $('<button type="button" class="btn btn-sm btn-primary">Add PTR</button>');
var cancelBtn = $('<button type="button" class="btn btn-sm btn-link">Cancel</button>');
var msg = $('<div class="vf-rdns-msg"></div>');
toggleBtn.on("click", function () {
form.toggle();
toggleBtn.text(form.is(":visible") ? " Hide" : "+ Add host PTR");
});
cancelBtn.on("click", function () {
form.hide();
toggleBtn.text("+ Add host PTR");
ipInput.val(""); ptrInput.val(""); msg.hide();
});
addBtn.on("click", function () {
var ip = (ipInput.val() || "").trim();
var ptr = (ptrInput.val() || "").trim();
if (!ip) { msg.text("Enter a host IPv6 address.").css("color", "#dc3545").show(); return; }
if (!ptr) { msg.text("Enter a hostname for the PTR.").css("color", "#dc3545").show(); return; }
// Same server-side validation guards apply; we reuse the normal update flow.
vfUpdateRdns(serviceId, systemUrl, ip, ptrInput, addBtn, msg, null, function () {
// On success, refresh the whole panel so the new host PTR shows up as its own row
// alongside the subnet it came from.
setTimeout(function () { vfLoadRdns(serviceId, systemUrl); }, 1500);
});
});
ipInput.on("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); ptrInput.focus(); } });
ptrInput.on("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); addBtn.click(); } });
var inputsRow = $('<div class="vf-rdns-subnet-inputs"></div>').append(ipInput).append(ptrInput);
var actionsRow = $('<div class="vf-rdns-subnet-actions"></div>').append(addBtn).append(cancelBtn);
form.append(inputsRow).append(actionsRow).append(msg);
var editorWrap = $('<div class="vf-rdns-edit"></div>').append(toggleBtn);
return wrap.append(label).append(editorWrap).append(badge).append(form);
}
function vfUpdateRdns(serviceId, systemUrl, ip, input, saveBtn, msg, badge, onSuccess) {
var ptr = (input.val() || "").trim();
// Light client-side regex mirrors the server-side one — strict enforcement is on the server.
if (ptr !== "" && !/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\.?$/.test(ptr)) {
msg.text("Invalid hostname.").css("color", "#dc3545").show();
return;
}
saveBtn.prop("disabled", true);
msg.hide();
$.ajax({
url: vfUrl(systemUrl, serviceId, "rdnsUpdate"),
method: "POST",
data: { ip: ip, ptr: ptr },
dataType: "json"
}).done(function (resp) {
saveBtn.prop("disabled", false);
if (resp && resp.success) {
var verb = (ptr === "") ? "deleted" : "saved";
msg.text("rDNS " + verb + ".").css("color", "#28a745").show();
setTimeout(function () { msg.fadeOut(); }, 2500);
// Badge may be null (e.g. when called from the subnet row's Add-PTR form
// which has no per-row badge to update). Guard rather than crash.
if (badge) {
// Optimistically update the badge; a background refresh will correct it.
if (ptr === "") {
badge.replaceWith(vfRdnsBadge("missing"));
} else {
badge.replaceWith(vfRdnsBadge("ok"));
}
}
if (typeof onSuccess === "function") { onSuccess(); }
} else {
var err = (resp && resp.errors) ? resp.errors : "Save failed.";
msg.text(err).css("color", "#dc3545").show();
}
}).fail(function (xhr) {
saveBtn.prop("disabled", false);
var err = "Save failed.";
try {
var r = JSON.parse(xhr.responseText);
if (r && r.errors) err = r.errors;
} catch (e) {}
msg.text(err).css("color", "#dc3545").show();
});
}
// Admin-side wrappers — different endpoint ("admin"), no ownership check on server side.
function vfAdminLoadRdns(serviceId, systemUrl) {
var list = $("#vf-rdns-list");
$.ajax({
url: vfUrl(systemUrl, serviceId, "rdnsStatus", "admin"),
method: "GET",
dataType: "json"
}).done(function (resp) {
if (!resp || !resp.success) {
list.html('<em class="text-muted">Unable to load PTR state.</em>');
return;
}
if (!resp.data.enabled) {
list.html('<em class="text-muted">Reverse DNS addon is not activated.</em>');
return;
}
list.empty();
if (!resp.data.ips.length) {
list.html('<em class="text-muted">No IPs assigned.</em>');
return;
}
resp.data.ips.forEach(function (row) {
var line = $('<div class="vf-rdns-admin-row"></div>');
$('<span class="vf-rdns-ip-admin"></span>').text(row.ip).appendTo(line);
$('<span class="vf-rdns-ptr-admin"></span>').text(row.ptr || "(no PTR)").appendTo(line);
vfRdnsBadge(row.status).appendTo(line);
list.append(line);
});
}).fail(function () {
list.html('<em class="text-muted">Unable to load PTR state.</em>');
});
}
function vfAdminReconcileRdns(serviceId, systemUrl, force) {
var out = $("#vf-rdns-report");
out.text("Reconciling…").css("color", "#555");
$.ajax({
url: vfUrl(systemUrl, serviceId, "rdnsReconcile", "admin"),
method: "POST",
data: { force: force ? 1 : 0 },
dataType: "json"
}).done(function (resp) {
if (resp && resp.success) {
var s = resp.data;
var parts = [];
["added", "reset", "preserved", "forward_missing", "no_zone", "errors"].forEach(function (k) {
if (s[k] > 0) parts.push(k + "=" + s[k]);
});
out.text(parts.length ? parts.join(" ") : "no changes needed").css("color", "#28a745");
vfAdminLoadRdns(serviceId, systemUrl);
} else {
out.text((resp && resp.errors) ? resp.errors : "Reconcile failed").css("color", "#dc3545");
}
}).fail(function () {
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 <a> elements
// inside a flat .list-group div — there's no per-item <li> wrapper. Older
// themes may use <li><a/></li>. Try <li> 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 = $('<div class="vf-fs-row mb-3"></div>');
var head = $('<div class="d-flex justify-content-between vf-small mb-1"></div>');
head.append($('<span class="vf-bold"></span>').text(fs.mountpoint).attr("title", fs.name + " (" + fs.type + ")"));
head.append($('<span class="text-muted"></span>').text(
_vfFormatBytes(fs.usedBytes) + " / " + _vfFormatBytes(fs.totalBytes) +
" (" + pct.toFixed(0) + "%)"
));
row.append(head);
var bar = $('<div class="progress" style="height:8px;"></div>');
bar.append($('<div class="progress-bar"></div>').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 <script> in overview.tpl)
// captures the args the panel needs for refresh. We piggyback by walking
// the DOM for the script's args isn't reliable across themes — instead,
// expose an init hook the inline script can call.
window.vfStartLiveStatsRefresh = function (sid, url) {
serviceId = sid;
systemUrl = url;
if (timer) clearInterval(timer);
timer = setInterval(tick, REFRESH_MS);
};
document.addEventListener("visibilitychange", function () {
// No need to do anything special; tick() short-circuits when hidden.
if (!document.hidden && serviceId) tick();
});
})();