feat: add VNC check, SSH key paste, resources panel, sliders, and self-service billing

- VNC panel auto-hides when VNC is disabled on the server
- SSH key paste textarea at checkout with API key creation during provisioning
- Resources panel with current allocation, traffic progress bar, and upgrade link
- changePackage() now applies individual resource modifications from configurable options
- Order form configurable option dropdowns replaced with styled range sliders
- Self-service billing: credit balance, usage breakdown, credit top-up from client area
- Self-service config options (mode, auto top-off threshold/amount) on products
- Auto top-off via WHMCS cron when credit falls below threshold
- CHANGELOG.md covering all versions from 0.0.6 to present

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
EZSCALE
2026-02-07 14:25:43 -06:00
parent 49fdd9e49b
commit 1e471affd0
13 changed files with 915 additions and 505 deletions

View File

@@ -43,6 +43,27 @@ function VirtFusionDirect_ConfigOptions()
"Description" => "The default number of IPv4 addresses to assign to each server.",
"Default" => "1",
],
"selfServiceMode" => [
"FriendlyName" => "Self-Service Mode",
"Type" => "dropdown",
"Options" => "0|Disabled,1|Hourly,2|Resource Packs,3|Both",
"Description" => "Enable VirtFusion self-service billing for users created by this product.",
"Default" => "0",
],
"autoTopOffThreshold" => [
"FriendlyName" => "Auto Top-Off Threshold",
"Type" => "text",
"Size" => "10",
"Description" => "Credit balance below which auto top-off triggers during cron. 0 = disabled.",
"Default" => "0",
],
"autoTopOffAmount" => [
"FriendlyName" => "Auto Top-Off Amount",
"Type" => "text",
"Size" => "10",
"Description" => "Credit amount to add when auto top-off triggers.",
"Default" => "100",
],
];
}
@@ -238,6 +259,32 @@ function VirtFusionDirect_UsageUpdate(array $params)
->where('id', $service->id)
->update($update);
}
// Self-service auto top-off
$product = \WHMCS\Database\Capsule::table('tblproducts')
->where('id', $service->packageid)
->first();
if ($product) {
$threshold = (float) ($product->configoption5 ?? 0);
$topOffAmount = (float) ($product->configoption6 ?? 0);
if ($threshold > 0 && $topOffAmount > 0) {
$usageData = $module->getSelfServiceUsage($service->id);
if ($usageData) {
$usageInner = $usageData['data'] ?? $usageData;
$credit = $usageInner['credit'] ?? $usageInner['balance'] ?? null;
if ($credit !== null && (float) $credit < $threshold) {
$module->addSelfServiceCredit($service->id, $topOffAmount, 'Auto top-off');
\WHMCS\Module\Server\VirtFusionDirect\Log::insert(
'UsageUpdate:autoTopOff',
['serviceId' => $service->id, 'credit' => $credit, 'threshold' => $threshold],
['amount' => $topOffAmount]
);
}
}
}
}
} catch (\Exception $e) {
// Log but continue processing other services
\WHMCS\Module\Server\VirtFusionDirect\Log::insert('UsageUpdate:service:' . $service->id, [], $e->getMessage());

View File

@@ -176,133 +176,6 @@ switch ($action) {
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
break;
// =================================================================
// Firewall Management
//
// VirtFusion uses a ruleset-based system. Individual rules cannot
// be added/deleted via the API. Rulesets are created in admin panel
// and applied to servers by ID.
// =================================================================
/**
* Get firewall status, rules, and assigned rulesets.
*/
case 'firewallStatus':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$interface = isset($_GET['interface']) ? preg_replace('/[^a-z]/', '', $_GET['interface']) : 'primary';
$result = $vf->getFirewallStatus($serviceID, $interface);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve firewall status'], true, true, 500);
break;
/**
* Enable firewall.
*/
case 'firewallEnable':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$interface = isset($_GET['interface']) ? preg_replace('/[^a-z]/', '', $_GET['interface']) : 'primary';
$result = $vf->enableFirewall($serviceID, $interface);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Firewall enabled successfully']], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Failed to enable firewall'], true, true, 500);
break;
/**
* Disable firewall.
*/
case 'firewallDisable':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$interface = isset($_GET['interface']) ? preg_replace('/[^a-z]/', '', $_GET['interface']) : 'primary';
$result = $vf->disableFirewall($serviceID, $interface);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Firewall disabled successfully']], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Failed to disable firewall'], true, true, 500);
break;
/**
* Apply/sync firewall rules (re-applies currently assigned rulesets).
*/
case 'firewallApplyRules':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$interface = isset($_GET['interface']) ? preg_replace('/[^a-z]/', '', $_GET['interface']) : 'primary';
$result = $vf->applyFirewallRules($serviceID, $interface);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Firewall rules applied successfully']], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Failed to apply firewall rules'], true, true, 500);
break;
/**
* Apply specific firewall rulesets by ID.
* Expects comma-separated ruleset IDs in the 'rulesets' parameter.
*/
case 'firewallApplyRulesets':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$rulesetsParam = isset($_GET['rulesets']) ? trim($_GET['rulesets']) : '';
if (empty($rulesetsParam)) {
$vf->output(['success' => false, 'errors' => 'No ruleset IDs provided'], true, true, 400);
}
$rulesetIds = array_values(array_filter(array_map('intval', explode(',', $rulesetsParam)), function ($id) {
return $id > 0;
}));
if (empty($rulesetIds)) {
$vf->output(['success' => false, 'errors' => 'Invalid ruleset IDs'], true, true, 400);
}
$interface = isset($_GET['interface']) ? preg_replace('/[^a-z]/', '', $_GET['interface']) : 'primary';
$result = $vf->applyFirewallRulesets($serviceID, $rulesetIds, $interface);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Firewall rulesets applied successfully']], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Failed to apply firewall rulesets'], true, true, 500);
break;
// =================================================================
// IP Address Management
// =================================================================
@@ -445,6 +318,75 @@ switch ($action) {
$vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500);
break;
// =================================================================
// Self Service — Credit & Usage
// =================================================================
/**
* Get self-service usage data.
*/
case 'selfServiceUsage':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$result = $vf->getSelfServiceUsage($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service usage data'], true, true, 500);
break;
/**
* Get self-service billing report.
*/
case 'selfServiceReport':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$result = $vf->getSelfServiceReport($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
break;
/**
* Add self-service credit.
*/
case 'selfServiceAddCredit':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
}
$tokens = isset($_GET['tokens']) ? (float) $_GET['tokens'] : 0;
if ($tokens <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
}
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
}
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
break;
default:
$vf->output(['success' => false, 'errors' => 'invalid action'], true, true, 400);
}

View File

@@ -173,6 +173,30 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
// Handle SSH keys
if (sshInputField) {
// Create the paste-key textarea (hidden initially if keys exist)
var sshPasteContainer = document.createElement('div');
sshPasteContainer.setAttribute('id', 'vf-ssh-paste-container');
sshPasteContainer.style.display = 'none';
sshPasteContainer.style.marginTop = '8px';
var pasteLabel = document.createElement('label');
pasteLabel.textContent = 'Paste your SSH public key:';
pasteLabel.style.display = 'block';
pasteLabel.style.marginBottom = '4px';
var pasteArea = document.createElement('textarea');
pasteArea.className = 'form-control';
pasteArea.setAttribute('id', 'vf-ssh-paste');
pasteArea.setAttribute('rows', '3');
pasteArea.setAttribute('placeholder', 'ssh-rsa AAAA... or ssh-ed25519 AAAA...');
pasteArea.addEventListener('input', function() {
sshInputField.value = this.value.trim();
});
sshPasteContainer.appendChild(pasteLabel);
sshPasteContainer.appendChild(pasteArea);
if (sshKeys.length > 0) {
var sshSelect = document.createElement('select');
sshSelect.className = 'form-control';
@@ -190,20 +214,102 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
sshSelect.appendChild(option);
});
// Add new key option
var addNewOption = document.createElement('option');
addNewOption.value = '__new__';
addNewOption.text = 'Add new key...';
sshSelect.appendChild(addNewOption);
sshSelect.addEventListener('change', function() {
sshInputField.value = this.value;
if (this.value === '__new__') {
sshPasteContainer.style.display = 'block';
sshInputField.value = '';
} else {
sshPasteContainer.style.display = 'none';
document.getElementById('vf-ssh-paste').value = '';
sshInputField.value = this.value;
}
});
sshInputField.parentNode.insertBefore(sshSelect, sshInputField.nextSibling);
sshSelect.parentNode.insertBefore(sshPasteContainer, sshSelect.nextSibling);
sshInputField.style.display = 'none';
} else {
// No existing keys — show the paste textarea directly
sshPasteContainer.style.display = 'block';
sshInputField.parentNode.insertBefore(sshPasteContainer, sshInputField.nextSibling);
sshInputField.style.display = 'none';
if (sshInputLabel) sshInputLabel.style.display = 'none';
// Also hide the parent container if it exists
var sshContainer = sshInputField.closest('.form-group');
if (sshContainer) sshContainer.style.display = 'none';
}
}
// Slider UI: enhance known configurable option selects with range sliders
var sliderResourceNames = ['Memory', 'CPU Cores', 'Storage', 'Bandwidth', 'Inbound Network Speed', 'Outbound Network Speed'];
var sliderUnits = {
'Memory': 'MB', 'CPU Cores': 'Core(s)', 'Storage': 'GB',
'Bandwidth': 'GB', 'Inbound Network Speed': 'Mbps', 'Outbound Network Speed': 'Mbps'
};
var configSelects = document.querySelectorAll('select[name^=\"configoption[\"]');
configSelects.forEach(function(sel) {
// Find the label for this select
var label = null;
var labelEl = sel.closest('.form-group, .row');
if (labelEl) {
label = labelEl.querySelector('label');
}
if (!label) return;
var labelText = label.textContent.trim();
var matchedResource = null;
sliderResourceNames.forEach(function(name) {
if (labelText.indexOf(name) !== -1) {
matchedResource = name;
}
});
if (!matchedResource) return;
var options = [];
for (var i = 0; i < sel.options.length; i++) {
options.push({
value: sel.options[i].value,
label: sel.options[i].text
});
}
if (options.length < 2) return;
var unit = sliderUnits[matchedResource] || '';
// Create slider container
var container = document.createElement('div');
container.className = 'vf-slider-container';
var valueDisplay = document.createElement('div');
valueDisplay.className = 'vf-slider-value';
valueDisplay.textContent = options[sel.selectedIndex || 0].label + (unit ? ' ' + unit : '');
var slider = document.createElement('input');
slider.type = 'range';
slider.className = 'vf-slider form-range';
slider.min = '0';
slider.max = String(options.length - 1);
slider.step = '1';
slider.value = String(sel.selectedIndex || 0);
slider.addEventListener('input', function() {
var idx = parseInt(this.value);
sel.selectedIndex = idx;
valueDisplay.textContent = options[idx].label + (unit ? ' ' + unit : '');
// Trigger change event on hidden select for WHMCS pricing
var evt = new Event('change', { bubbles: true });
sel.dispatchEvent(evt);
});
container.appendChild(valueDisplay);
container.appendChild(slider);
sel.parentNode.insertBefore(container, sel.nextSibling);
sel.style.display = 'none';
});
});
</script>
";

View File

@@ -130,9 +130,10 @@ class ConfigureService extends Module
/**
* @param int $id
* @param array $vars
* @param int|null $vfUserId VirtFusion user ID (for creating SSH keys from raw public key)
* @return bool
*/
public function initServerBuild(int $id, array $vars): bool
public function initServerBuild(int $id, array $vars, ?int $vfUserId = null): bool
{
if (!$this->cp) return false;
@@ -141,17 +142,27 @@ class ConfigureService extends Module
// Generate a random 8 character hostname
$hostname = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 8);
$sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
$sshKeyId = null;
if (!empty($sshKeyValue)) {
if (is_numeric($sshKeyValue)) {
// Existing SSH key ID
$sshKeyId = (int) $sshKeyValue;
} elseif (preg_match('/^ssh-/', $sshKeyValue) && $vfUserId) {
// Raw public key — create it via API
$sshKeyId = $this->createUserSshKey($vfUserId, $sshKeyValue);
}
}
$inputData = [
"operatingSystemId" => $vars['customfields']['Initial Operating System'] ?? null,
"name" => $hostname,
"sshKeys" => [
$vars['customfields']['Initial SSH Key'] ?? null
],
'email' => true
];
if (empty($vars['customfields']['Initial SSH Key'] ?? null)) {
unset($inputData['sshKeys']);
if ($sshKeyId) {
$inputData['sshKeys'] = [$sshKeyId];
}
$request->addOption(CURLOPT_POSTFIELDS, json_encode($inputData));
@@ -165,4 +176,37 @@ class ConfigureService extends Module
return ($httpCode == 200 || $httpCode == 201);
}
/**
* Create an SSH key for a VirtFusion user from a raw public key string.
*
* @param int $userId VirtFusion user ID
* @param string $publicKey Raw SSH public key (ssh-rsa ..., ssh-ed25519 ..., etc.)
* @return int|null Created key ID or null on failure
*/
public function createUserSshKey(int $userId, string $publicKey): ?int
{
if (!$this->cp) return null;
$request = $this->initCurl($this->cp['token']);
$keyData = [
'userId' => $userId,
'name' => 'WHMCS-' . date('Y-m-d'),
'publicKey' => trim($publicKey),
];
$request->addOption(CURLOPT_POSTFIELDS, json_encode($keyData));
$response = $request->post($this->cp['url'] . '/ssh_keys');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $response);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
$data = json_decode($response, true);
return $data['data']['id'] ?? null;
}
return null;
}
}

View File

@@ -319,218 +319,6 @@ class Module
return false;
}
// =========================================================================
// Firewall Management
//
// VirtFusion uses a ruleset-based firewall system. Individual rules cannot
// be created or deleted via the API. Instead, predefined rulesets (created
// in the VirtFusion admin panel) are applied to servers by ID.
//
// The {interface} parameter is "primary" or "secondary".
// =========================================================================
/**
* Get firewall status and rules for a server.
*
* @param int $serviceID
* @param string $interface Network interface: "primary" or "secondary"
* @return array|false
*/
public function getFirewallStatus($serviceID, $interface = 'primary')
{
$serviceID = (int) $serviceID;
$interface = $this->sanitizeFirewallInterface($interface);
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/servers/' . (int) $service->server_id . '/firewall/' . $interface);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
}
}
return false;
}
/**
* Enable firewall on a server.
*
* @param int $serviceID
* @param string $interface Network interface: "primary" or "secondary"
* @return object|false
*/
public function enableFirewall($serviceID, $interface = 'primary')
{
$serviceID = (int) $serviceID;
$interface = $this->sanitizeFirewallInterface($interface);
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/firewall/' . $interface . '/enable');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
/**
* Disable firewall on a server.
*
* @param int $serviceID
* @param string $interface Network interface: "primary" or "secondary"
* @return object|false
*/
public function disableFirewall($serviceID, $interface = 'primary')
{
$serviceID = (int) $serviceID;
$interface = $this->sanitizeFirewallInterface($interface);
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/firewall/' . $interface . '/disable');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
/**
* Apply firewall rulesets to a server.
*
* VirtFusion uses predefined rulesets (created in admin panel).
* Individual rules cannot be added/deleted via the API.
*
* @param int $serviceID
* @param array $rulesetIds Array of ruleset IDs to apply
* @param string $interface Network interface: "primary" or "secondary"
* @return object|false
*/
public function applyFirewallRulesets($serviceID, array $rulesetIds, $interface = 'primary')
{
$serviceID = (int) $serviceID;
$interface = $this->sanitizeFirewallInterface($interface);
// Validate and sanitize ruleset IDs
$rulesetIds = array_values(array_filter(array_map('intval', $rulesetIds), function ($id) {
return $id > 0;
}));
if (empty($rulesetIds)) {
return false;
}
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode(['rulesets' => $rulesetIds]));
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/firewall/' . $interface . '/rules');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
/**
* Backward-compatible wrapper for applying firewall rules.
* Syncs/applies existing ruleset assignments on the server.
*
* @param int $serviceID
* @param string $interface Network interface: "primary" or "secondary"
* @return object|false
*/
public function applyFirewallRules($serviceID, $interface = 'primary')
{
// Fetch current firewall status to get assigned rulesets
$status = $this->getFirewallStatus($serviceID, $interface);
if ($status && isset($status['data']['rulesets'])) {
$rulesetIds = array_column($status['data']['rulesets'], 'id');
if (!empty($rulesetIds)) {
return $this->applyFirewallRulesets($serviceID, $rulesetIds, $interface);
}
}
// If no rulesets found, try a direct re-apply via the enable cycle
$serviceID = (int) $serviceID;
$interface = $this->sanitizeFirewallInterface($interface);
$service = Database::getSystemService($serviceID);
if ($service) {
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode(['rulesets' => []]));
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/firewall/' . $interface . '/rules');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201 || $httpCode == 204) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
/**
* Sanitize firewall interface parameter.
*
* @param string $interface
* @return string "primary" or "secondary"
*/
private function sanitizeFirewallInterface($interface)
{
return in_array($interface, ['primary', 'secondary'], true) ? $interface : 'primary';
}
// =========================================================================
// IP Address Management
// =========================================================================
@@ -947,6 +735,128 @@ class Module
return $curl;
}
// =========================================================================
// Self Service — Credit & Usage
// =========================================================================
/**
* Get self-service usage data for a WHMCS client.
*
* @param int $serviceID
* @return array|false
*/
public function getSelfServiceUsage($serviceID)
{
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/selfService/usage/byUserExtRelationId/' . (int) $whmcsService->userid);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
}
return false;
}
/**
* Get self-service billing report for a WHMCS client.
*
* @param int $serviceID
* @return array|false
*/
public function getSelfServiceReport($serviceID)
{
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/selfService/report/byUserExtRelationId/' . (int) $whmcsService->userid);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
}
return false;
}
/**
* Add self-service credit for a WHMCS client.
*
* @param int $serviceID
* @param float $tokens Amount of credit tokens to add
* @param string $reference Reference text for the transaction
* @return array|false
*/
public function addSelfServiceCredit($serviceID, $tokens, $reference = '')
{
$serviceID = (int) $serviceID;
$tokens = (float) $tokens;
if ($tokens <= 0) {
return false;
}
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode([
'tokens' => $tokens,
'reference_1' => $reference ?: 'WHMCS Top-up',
'reference_2' => 'Service #' . $serviceID,
]));
$data = $request->post($cp['url'] . '/selfService/credit/byUserExtRelationId/' . (int) $whmcsService->userid);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data, true);
}
return false;
}
/**
* Get available self-service currencies.
*
* @param int $serviceID
* @return array|false
*/
public function getSelfServiceCurrencies($serviceID)
{
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
$cp = $this->getCP($whmcsService->server);
if (!$cp) return false;
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/selfService/currencies');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
}
return false;
}
/**
* Decodes a response from JSON into an associative array.
*

View File

@@ -68,13 +68,20 @@ class ModuleFunctions extends Module
$request = $this->initCurl($cp['token']);
$request->addOption(CURLOPT_POSTFIELDS, json_encode(
[
"name" => $user->firstname . ' ' . $user->lastname,
"email" => $user->email,
"extRelationId" => $user->id,
]
));
$userData = [
"name" => $user->firstname . ' ' . $user->lastname,
"email" => $user->email,
"extRelationId" => $user->id,
];
// Enable self-service billing if configured
$selfServiceMode = (int) ($params['configoption4'] ?? 0);
if ($selfServiceMode > 0) {
$userData['selfService'] = $selfServiceMode;
$userData['selfServiceHourlyCredit'] = in_array($selfServiceMode, [1, 3]);
}
$request->addOption(CURLOPT_POSTFIELDS, json_encode($userData));
$data = $request->post($cp['url'] . '/users');
@@ -153,7 +160,8 @@ class ModuleFunctions extends Module
// If the server is created successfully, we can initialize the server build.
$cs = new ConfigureService();
$cs->initServerBuild($data->data->id, $params);
$vfUserId = isset($data->data->owner->id) ? (int) $data->data->owner->id : null;
$cs->initServerBuild($data->data->id, $params, $vfUserId);
return 'success';
} else {
@@ -197,7 +205,7 @@ class ModuleFunctions extends Module
switch ($request->getRequestInfo('http_code')) {
case 204:
return 'success';
break;
case 404:
return 'The server or package was not found in VirtFusion (HTTP 404).';
case 423:
@@ -208,6 +216,33 @@ class ModuleFunctions extends Module
default:
return 'Update package request failed. VirtFusion API returned HTTP ' . $request->getRequestInfo('http_code');
}
// Apply individual resource modifications from configurable options
if (isset($params['configoptions']) && is_array($params['configoptions'])) {
$configOptionDefaultNaming = [
'memory' => 'Memory',
'cpuCores' => 'CPU Cores',
'traffic' => 'Bandwidth',
];
$configOptionCustomNaming = [];
if (file_exists(ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php')) {
$configOptionCustomNaming = require ROOTDIR . '/modules/servers/VirtFusionDirect/config/ConfigOptionMapping.php';
}
foreach ($configOptionDefaultNaming as $resource => $optionName) {
$currentOption = array_key_exists($resource, $configOptionCustomNaming) ? $configOptionCustomNaming[$resource] : $optionName;
if (isset($params['configoptions'][$currentOption]) && is_numeric($params['configoptions'][$currentOption])) {
$value = (int) $params['configoptions'][$currentOption];
if ($resource === 'memory' && $value < 1024) {
$value = $value * 1024;
}
$this->modifyResource($params['serviceid'], $resource, $value);
}
}
}
return 'success';
}
return 'Service not found in module database.';
}

View File

@@ -46,6 +46,14 @@ class ServerResource
'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,
];
if (array_key_exists('network', $server)) {

View File

@@ -139,6 +139,53 @@
padding: 0.15rem 0.4rem;
}
/* Resource panel */
.vf-resource-item .progress {
background-color: rgba(0,0,0,0.08);
border-radius: 4px;
}
/* Order form slider UI */
.vf-slider-container {
padding: 8px 0;
}
.vf-slider-value {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 4px;
text-align: center;
}
.vf-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #ddd;
border-radius: 3px;
outline: none;
cursor: pointer;
}
.vf-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #337ab7;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.vf-slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #337ab7;
cursor: pointer;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.vf-power-buttons {

View File

@@ -40,6 +40,43 @@ 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();
}
// 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 + "%");
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 || "Unlimited");
$("#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();
$("#vf-server-info").show();
} else {
$("#vf-server-info-error").show();
@@ -260,77 +297,6 @@ function impersonateServerOwner(serviceId, systemUrl) {
});
}
// =========================================================================
// Firewall Management
// =========================================================================
function vfLoadFirewallStatus(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=firewallStatus"
}).done(function (response) {
if (response.success) {
var badge = $("#vf-firewall-badge");
var data = response.data;
var enabled = data && data.data && data.data.enabled;
if (enabled) {
badge.text("Enabled").addClass("vf-badge-active");
} else {
badge.text("Disabled").addClass("vf-badge-awaiting");
}
$("#vf-firewall-content").show();
} else {
$("#vf-firewall-badge").text("Unknown").addClass("vf-badge-awaiting");
$("#vf-firewall-content").show();
}
}).fail(function () {
$("#vf-firewall-badge").text("Unavailable").addClass("vf-badge-awaiting");
$("#vf-firewall-content").show();
}).always(function () {
$("#vf-firewall-loader").hide();
});
}
function vfFirewallAction(serviceId, systemUrl, action) {
var btnId = {
firewallEnable: "#vf-firewall-enable",
firewallDisable: "#vf-firewall-disable",
firewallApplyRules: "#vf-firewall-apply"
};
var btn = $(btnId[action]);
var spinner = btn.find(".vf-btn-spinner");
var alertDiv = $("#vf-firewall-alert");
btn.prop("disabled", true);
spinner.show();
alertDiv.hide();
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=" + encodeURIComponent(action)
}).done(function (response) {
if (response.success) {
alertDiv.removeClass("alert-danger").addClass("alert-success");
alertDiv.text(response.data.message || "Firewall action completed.");
// Refresh status badge
vfLoadFirewallStatus(serviceId, systemUrl);
} else {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text(response.errors || "Firewall action failed.");
}
alertDiv.show();
}).fail(function () {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text("An error occurred. Please try again.");
alertDiv.show();
}).always(function () {
spinner.hide();
btn.prop("disabled", false);
});
}
// =========================================================================
// Network / IP Management
// =========================================================================
@@ -507,3 +473,119 @@ function vfOpenVnc(serviceId, systemUrl) {
btn.prop("disabled", false);
});
}
// =========================================================================
// Self Service — Credit & Usage
// =========================================================================
function vfLoadSelfServiceUsage(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=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 vfLoadSelfServiceReport(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=selfServiceReport"
}).done(function (response) {
if (response.success && response.data) {
var data = response.data.data || response.data;
var tbody = $("#vf-ss-usage-table");
tbody.empty();
var items = data.items || data.report || [];
if (Array.isArray(items) && items.length > 0) {
$.each(items, function (i, item) {
var desc = item.description || item.name || "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 report data available</td></tr>');
}
}
});
}
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) {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text("Please enter a valid positive amount.");
alertDiv.show();
return;
}
btn.prop("disabled", true);
spinner.show();
alertDiv.hide();
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=selfServiceAddCredit&tokens=" + encodeURIComponent(amount)
}).done(function (response) {
if (response.success) {
alertDiv.removeClass("alert-danger").addClass("alert-success");
alertDiv.text("Credit added successfully.");
alertDiv.show();
$("#vf-ss-credit-amount").val("");
// Refresh usage data
vfLoadSelfServiceUsage(serviceId, systemUrl);
} else {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text(response.errors || "Failed to add credit.");
alertDiv.show();
}
}).fail(function () {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text("An error occurred. Please try again.");
alertDiv.show();
}).always(function () {
spinner.hide();
btn.prop("disabled", false);
});
}

View File

@@ -168,44 +168,6 @@
</div>
</div>
{* Firewall Management Panel *}
<div class="panel card panel-default mb-3">
<div class="panel-heading card-header">
<h3 class="panel-title card-title m-0">
Firewall
<span id="vf-firewall-badge" class="vf-badge" style="float: right;"></span>
</h3>
</div>
<div class="panel-body card-body p-4">
<div id="vf-firewall-alert" class="alert" style="display: none;"></div>
<div id="vf-firewall-loader" class="d-flex align-items-center justify-content-center" style="min-height: 60px;">
<div class="spinner-border spinner-border-sm"></div>
</div>
<div id="vf-firewall-content" style="display: none;">
<div class="row mb-3">
<div class="col-12">
<div class="vf-power-buttons">
<button id="vf-firewall-enable" onclick="vfFirewallAction('{$serviceid}','{$systemURL}','firewallEnable')" type="button" class="btn btn-success vf-btn-power">
<span class="vf-btn-spinner spinner-border spinner-border-sm" style="display:none;"></span>
Enable
</button>
<button id="vf-firewall-disable" onclick="vfFirewallAction('{$serviceid}','{$systemURL}','firewallDisable')" type="button" class="btn btn-danger vf-btn-power">
<span class="vf-btn-spinner spinner-border spinner-border-sm" style="display:none;"></span>
Disable
</button>
<button id="vf-firewall-apply" onclick="vfFirewallAction('{$serviceid}','{$systemURL}','firewallApplyRules')" type="button" class="btn btn-primary vf-btn-power">
<span class="vf-btn-spinner spinner-border spinner-border-sm" style="display:none;"></span>
Apply Rules
</button>
</div>
</div>
</div>
<p class="vf-small text-muted mb-0">Manage your server firewall. Use the VirtFusion control panel for advanced rule configuration.</p>
</div>
<script>vfLoadFirewallStatus('{$serviceid}', '{$systemURL}');</script>
</div>
</div>
{* Network Management Panel *}
<div class="panel card panel-default mb-3">
<div class="panel-heading card-header">
@@ -240,8 +202,57 @@
</div>
</div>
{* VNC Console Panel *}
<div class="panel card panel-default mb-3">
{* Resources Panel — populated by JS after server data loads *}
<div id="vf-resources-panel" class="panel card panel-default mb-3" style="display: none;">
<div class="panel-heading card-header">
<h3 class="panel-title card-title m-0">Resources</h3>
</div>
<div class="panel-body card-body p-4">
<div class="row">
<div class="col-md-6">
<div class="vf-resource-item mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="vf-bold">Memory</span>
<span id="vf-res-memory"></span>
</div>
</div>
<div class="vf-resource-item mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="vf-bold">CPU Cores</span>
<span id="vf-res-cpu"></span>
</div>
</div>
<div class="vf-resource-item mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="vf-bold">Storage</span>
<span id="vf-res-storage"></span>
</div>
</div>
</div>
<div class="col-md-6">
<div class="vf-resource-item mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="vf-bold">Traffic</span>
<span id="vf-res-traffic"></span>
</div>
<div class="progress" style="height: 8px;">
<div id="vf-res-traffic-bar" class="progress-bar" role="progressbar" style="width: 0%"></div>
</div>
</div>
<div class="vf-resource-item mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="vf-bold">Network Speed</span>
<span id="vf-res-network-speed"></span>
</div>
</div>
</div>
</div>
<a href="clientarea.php?action=upgrade&id={$serviceid}" class="btn btn-outline-primary mt-2">Upgrade / Downgrade Resources</a>
</div>
</div>
{* VNC Console Panel — hidden by default, shown by JS if VNC is enabled *}
<div id="vf-vnc-panel" class="panel card panel-default mb-3" style="display: none;">
<div class="panel-heading card-header">
<h3 class="panel-title card-title m-0">VNC Console</h3>
</div>
@@ -255,6 +266,54 @@
</div>
</div>
{* Self Service — Billing & Usage Panel *}
<div id="vf-selfservice-panel" class="panel card panel-default mb-3" style="display: none;">
<div class="panel-heading card-header">
<h3 class="panel-title card-title m-0">Billing & Usage</h3>
</div>
<div class="panel-body card-body p-4">
<div id="vf-selfservice-alert" class="alert" style="display: none;"></div>
<div id="vf-selfservice-loader" class="d-flex align-items-center justify-content-center" style="min-height: 60px;">
<div class="spinner-border spinner-border-sm"></div>
</div>
<div id="vf-selfservice-content" style="display: none;">
<div class="row mb-3">
<div class="col-md-6">
<h5 class="vf-bold">Credit Balance</h5>
<div class="h4 mb-3" id="vf-ss-credit-balance">-</div>
</div>
<div class="col-md-6">
<h5 class="vf-bold">Add Credit</h5>
<div class="input-group mb-2">
<input type="number" id="vf-ss-credit-amount" class="form-control" placeholder="Amount" min="1" step="1">
<div class="input-group-append">
<button id="vf-ss-add-credit-btn" onclick="vfAddCredit('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary">
<span id="vf-ss-add-credit-spinner" class="spinner-border spinner-border-sm" style="display:none;"></span>
Add Credit
</button>
</div>
</div>
</div>
</div>
<h5 class="vf-bold">Usage Breakdown</h5>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Description</th>
<th class="text-right">Cost</th>
</tr>
</thead>
<tbody id="vf-ss-usage-table">
<tr><td colspan="2" class="text-muted">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<script>vfLoadSelfServiceUsage('{$serviceid}', '{$systemURL}');</script>
</div>
</div>
{elseif $serviceStatus eq 'Suspended'}
<div class="panel card panel-default mb-3">