feat: major enhancement — OS gallery, server rename, traffic chart, backups, VNC toggle, password reset, Redis caching, UX improvements

- Remove client IP removal capability (keep backend methods removed too)
- Add copy-to-clipboard buttons for IP addresses with tooltip feedback
- Replace OS dropdown with tile gallery (grouped, searchable, brand colors, EOL badges) in rebuild panel and checkout page
- Add inline server rename with friendly name generator and RFC 1123 validation
- Add traffic statistics canvas chart with responsive resize in resources panel
- Add backup listing timeline in manage panel with show-all expansion
- Add VNC enable/disable toggle with connection details and password copy
- Add server root password reset with auto-clipboard copy (never displayed)
- Add skeleton loading placeholders, action cooldowns (power 3s, rebuild 30s), progress indicator with elapsed timer
- Sanitize all client-facing error messages (no raw API errors exposed)
- Convert all state-mutating AJAX calls from GET to POST
- Add explicit break after all output() calls in client.php
- Add Redis-backed API response caching (Cache.php): OS templates 10min, traffic/backups 2min, currencies 30min, packages 10min
- Add GitHub Actions workflow for weekly VirtFusion API change detection
- Move cache busting step after semantic-release in publish workflow
- Add endpoint doc generator script and OpenAPI baseline placeholder
- Improve hostname generation entropy (bin2hex random_bytes)
- Add .superpowers/ to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Prophet731
2026-03-19 05:40:32 -05:00
parent 538974e0fe
commit 90a97c4afb
13 changed files with 1647 additions and 249 deletions

65
.github/workflows/api-sync-check.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: VirtFusion API Change Detection
on:
schedule:
- cron: '0 9 * * 1' # Monday 9am UTC
workflow_dispatch:
jobs:
check-api:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Download current API spec
run: curl -sSL -o /tmp/openapi-current.yaml https://docs.virtfusion.com/api/openapi.yaml
- name: Compare with baseline
id: diff
run: |
if [ ! -f docs/openapi-baseline.yaml ]; then
echo "No baseline found — creating initial baseline"
cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml
echo "changed=initial" >> "$GITHUB_OUTPUT"
elif ! diff -q docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /dev/null 2>&1; then
echo "API spec has changed"
diff docs/openapi-baseline.yaml /tmp/openapi-current.yaml > /tmp/api-diff.txt || true
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "No changes detected"
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
- name: Create issue on change
if: steps.diff.outputs.changed == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const diff = fs.readFileSync('/tmp/api-diff.txt', 'utf8').substring(0, 60000);
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `VirtFusion API spec changed (${new Date().toISOString().split('T')[0]})`,
body: `The VirtFusion OpenAPI spec has been updated.\n\n<details><summary>Diff</summary>\n\n\`\`\`diff\n${diff}\n\`\`\`\n</details>\n\nReview the changes and update the module if needed.`,
labels: ['api-sync']
});
- name: Update baseline and create PR
if: steps.diff.outputs.changed == 'true' || steps.diff.outputs.changed == 'initial'
run: |
cp /tmp/openapi-current.yaml docs/openapi-baseline.yaml
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BRANCH="api-sync/$(date +%Y-%m-%d)"
git checkout -b "$BRANCH"
git add docs/openapi-baseline.yaml
git commit -m "chore: update VirtFusion API baseline spec"
git push origin "$BRANCH"
gh pr create --title "chore: update VirtFusion API baseline" --body "Automated update of the VirtFusion OpenAPI baseline spec." --base main
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -33,6 +33,17 @@ jobs:
# GITHUB_TOKEN is required for authentication
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate cache busting version hashes
run: |
CSS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/css/module.css | cut -c1-8)
JS_HASH=$(md5sum modules/servers/VirtFusionDirect/templates/js/module.js | cut -c1-8)
echo "{\"css\":\"$CSS_HASH\",\"js\":\"$JS_HASH\"}" > modules/servers/VirtFusionDirect/templates/version.json
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add modules/servers/VirtFusionDirect/templates/version.json
git diff --cached --quiet || git commit -m "chore: update asset version hashes [skip ci]"
git push || true
# To make this work, you must follow the Conventional Commits specification.
# Examples:
# - fix: correct a typo in the documentation

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/.idea/
/.superpowers/

View File

@@ -0,0 +1,7 @@
# VirtFusion OpenAPI Baseline
# This file will be auto-populated by the api-sync-check workflow
# on first run. Do not edit manually.
openapi: "3.0.0"
info:
title: VirtFusion API Baseline Placeholder
version: "0.0.0"

View File

@@ -23,12 +23,14 @@ switch ($action) {
if (!$client) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$data = $vf->resetUserPassword($serviceID, $client);
if ($data) {
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
@@ -43,6 +45,7 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$data = $vf->fetchServerData($serviceID);
@@ -50,6 +53,7 @@ switch ($action) {
if ($data) {
(new Module())->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
@@ -64,12 +68,14 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$token = $vf->fetchLoginTokens($serviceID);
if ($token) {
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
@@ -84,19 +90,22 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$powerAction = isset($_GET['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_GET['powerAction']) : '';
$powerAction = isset($_POST['powerAction']) ? preg_replace('/[^a-zA-Z]/', '', $_POST['powerAction']) : '';
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
if (!in_array($powerAction, $allowedActions, true)) {
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
break;
}
$result = $vf->serverPowerAction($serviceID, $powerAction);
if ($result) {
$vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Power action failed. The server may be locked or unavailable.'], true, true, 500);
@@ -111,19 +120,22 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$osId = isset($_GET['osId']) ? (int) $_GET['osId'] : 0;
$hostname = isset($_GET['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_GET['hostname']) : null;
$osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
$hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
if ($osId <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
break;
}
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Server rebuild initiated successfully']], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Server rebuild failed. The server may be locked or unavailable.'], true, true, 500);
@@ -138,19 +150,21 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$newName = isset($_GET['name']) ? trim($_GET['name']) : '';
$newName = htmlspecialchars($newName, ENT_QUOTES, 'UTF-8');
$newName = isset($_POST['name']) ? trim($_POST['name']) : '';
if (empty($newName) || strlen($newName) > 255) {
if (empty($newName) || strlen($newName) > 63 || !preg_match('/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/', $newName)) {
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
break;
}
$result = $vf->renameServer($serviceID, $newName);
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
@@ -165,44 +179,95 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$templates = $vf->fetchOsTemplates($serviceID);
if ($templates !== false) {
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
break;
// =================================================================
// IP Address Management
// Server Password Reset
// =================================================================
/**
* Remove an IPv4 address.
* Reset server root password.
*/
case 'removeIPv4':
case 'resetServerPassword':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$ipAddress = isset($_GET['ip']) ? trim($_GET['ip']) : '';
if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$vf->output(['success' => false, 'errors' => 'Invalid IPv4 address'], true, true, 400);
$result = $vf->resetServerPassword($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$result = $vf->removeIPv4($serviceID, $ipAddress);
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
break;
if ($result) {
$vf->output(['success' => true, 'data' => ['message' => 'IPv4 address removed successfully']], true, true, 200);
// =================================================================
// Backup Listing
// =================================================================
/**
* Get server backups.
*/
case 'backups':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$vf->output(['success' => false, 'errors' => 'Failed to remove IPv4 address'], true, true, 500);
$result = $vf->getServerBackups($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve backups'], true, true, 500);
break;
// =================================================================
// Traffic Statistics
// =================================================================
/**
* Get traffic statistics for a server.
*/
case 'trafficStats':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getTrafficStats($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve traffic statistics'], true, true, 500);
break;
// =================================================================
@@ -218,17 +283,42 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getVncConsole($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500);
break;
/**
* Toggle VNC on/off.
*/
case 'toggleVnc':
$serviceID = $vf->validateServiceID(true);
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1';
$result = $vf->toggleVnc($serviceID, $enabled);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Failed to toggle VNC'], true, true, 500);
break;
// =================================================================
// Self Service — Credit & Usage
// =================================================================
@@ -242,12 +332,14 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getSelfServiceUsage($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service usage data'], true, true, 500);
@@ -262,12 +354,14 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$result = $vf->getSelfServiceReport($serviceID);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
@@ -282,17 +376,20 @@ switch ($action) {
if (!$vf->validateUserOwnsService($serviceID)) {
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
break;
}
$tokens = isset($_GET['tokens']) ? (float) $_GET['tokens'] : 0;
$tokens = isset($_POST['tokens']) ? (float) $_POST['tokens'] : 0;
if ($tokens <= 0) {
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
break;
}
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
if ($result !== false) {
$vf->output(['success' => true, 'data' => $result], true, true, 200);
break;
}
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);

View File

@@ -86,19 +86,46 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
return null;
}
$dropdownOptions = [];
$baseUrl = '';
$firstServer = \WHMCS\Database\Capsule::table('tblservers')
->where('type', 'VirtFusionDirect')
->where('disabled', 0)
->first();
if ($firstServer) {
$baseUrl = rtrim('https://' . $firstServer->hostname, '/');
}
$categories = [];
$otherTemplates = [];
foreach ($templates_data['data'] as $osCategory) {
$catTemplates = [];
foreach ($osCategory['templates'] as $template) {
$optionValue = $template['id'];
$optionLabel = htmlspecialchars($template['name'] . " " . $template['version'] . " " . $template['variant'], ENT_QUOTES, 'UTF-8');
$dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel];
$catTemplates[] = [
'id' => $template['id'],
'name' => htmlspecialchars($template['name'], ENT_QUOTES, 'UTF-8'),
'version' => htmlspecialchars($template['version'] ?? '', ENT_QUOTES, 'UTF-8'),
'variant' => htmlspecialchars($template['variant'] ?? '', ENT_QUOTES, 'UTF-8'),
'icon' => $template['icon'] ?? null,
'eol' => $template['eol'] ?? false,
'description' => htmlspecialchars($template['description'] ?? '', ENT_QUOTES, 'UTF-8'),
];
}
if (count($catTemplates) <= 1) {
$otherTemplates = array_merge($otherTemplates, $catTemplates);
} else {
$categories[] = [
'name' => htmlspecialchars($osCategory['name'] ?? 'Unknown', ENT_QUOTES, 'UTF-8'),
'icon' => $osCategory['icon'] ?? null,
'templates' => $catTemplates,
];
}
}
if (!empty($otherTemplates)) {
$categories[] = ['name' => 'Other', 'icon' => null, 'templates' => $otherTemplates];
}
usort($dropdownOptions, function ($a, $b) {
return strcmp($a['name'], $b['name']);
});
$galleryData = ['baseUrl' => $baseUrl, 'categories' => $categories];
$sshKeys = [];
$sshKeysOptions = [];
@@ -139,10 +166,11 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
$systemUrl = Database::getSystemUrl();
return "
<link href=\"" . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . "modules/servers/VirtFusionDirect/templates/css/module.css?v=20260319\" rel=\"stylesheet\">
<script src=\"" . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . "modules/servers/VirtFusionDirect/templates/js/keygen.js?v=20260207\"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var osTemplates = " . json_encode($dropdownOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
var osGalleryData = " . json_encode($galleryData, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
var sshKeys = " . json_encode($sshKeysOptions, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ";
var osInputField = document.querySelector('[name=\"customfield[" . (int) $osFieldId . "]\"]');
@@ -151,28 +179,136 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
if (!osInputField) return;
// Create OS dropdown
var osSelect = document.createElement('select');
osSelect.className = 'form-control';
osSelect.setAttribute('id', 'vf-os-select');
// Brand color map (must match vfOsBrandColors in module.js)
var brandColors = {
'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 getBrandColor(name) {
var l = (name || '').toLowerCase();
for (var k in brandColors) { if (l.indexOf(k) !== -1) return brandColors[k]; }
return '#6c757d';
}
var defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.text = '-- Select Operating System --';
osSelect.appendChild(defaultOption);
// Build gallery container
var galleryWrap = document.createElement('div');
galleryWrap.style.marginTop = '8px';
osTemplates.forEach(function(template) {
var option = document.createElement('option');
option.value = template.id;
option.text = template.name;
osSelect.appendChild(option);
var searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'form-control vf-os-search';
searchInput.placeholder = 'Search templates...';
galleryWrap.appendChild(searchInput);
var galleryContainer = document.createElement('div');
galleryContainer.setAttribute('id', 'vf-checkout-os-gallery');
galleryContainer.style.marginTop = '8px';
if (osGalleryData.categories && osGalleryData.categories.length > 0) {
osGalleryData.categories.forEach(function(cat) {
var section = document.createElement('div');
section.className = 'vf-os-category';
var title = document.createElement('h5');
title.className = 'vf-os-category-title';
title.textContent = cat.name;
section.appendChild(title);
var grid = document.createElement('div');
grid.className = 'vf-os-grid';
cat.templates.forEach(function(tpl) {
var fullLabel = tpl.name + (tpl.version ? ' ' + tpl.version : '') + (tpl.variant ? ' ' + tpl.variant : '');
var card = document.createElement('div');
card.className = 'vf-os-card' + (tpl.eol ? ' vf-os-card-eol' : '');
card.setAttribute('data-id', tpl.id);
card.setAttribute('data-search', fullLabel.toLowerCase());
var iconDiv = document.createElement('div');
iconDiv.className = 'vf-os-icon';
iconDiv.style.background = getBrandColor(cat.name || tpl.name);
if (tpl.icon && osGalleryData.baseUrl) {
var img = document.createElement('img');
img.src = osGalleryData.baseUrl + '/storage/os/' + encodeURIComponent(tpl.icon);
img.alt = '';
img.onerror = function() {
this.parentNode.textContent = '';
var sp = document.createElement('span');
sp.textContent = (tpl.name || '?')[0].toUpperCase();
this.parentNode.appendChild(sp);
};
iconDiv.appendChild(img);
} else {
var sp = document.createElement('span');
sp.textContent = (tpl.name || '?')[0].toUpperCase();
iconDiv.appendChild(sp);
}
card.appendChild(iconDiv);
var labelDiv = document.createElement('div');
labelDiv.className = 'vf-os-label';
labelDiv.textContent = tpl.name;
card.appendChild(labelDiv);
var verDiv = document.createElement('div');
verDiv.className = 'vf-os-version';
verDiv.textContent = (tpl.version || '') + (tpl.variant ? ' ' + tpl.variant : '');
card.appendChild(verDiv);
if (tpl.eol) {
var eolBadge = document.createElement('span');
eolBadge.className = 'vf-os-eol-badge';
eolBadge.textContent = 'EOL';
card.appendChild(eolBadge);
}
card.addEventListener('click', function() {
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) { c.classList.remove('vf-os-card-selected'); });
card.classList.add('vf-os-card-selected');
osInputField.value = tpl.id;
galleryContainer.style.borderColor = '';
});
osSelect.addEventListener('change', function() {
osInputField.value = this.value;
grid.appendChild(card);
});
osInputField.parentNode.insertBefore(osSelect, osInputField.nextSibling);
section.appendChild(grid);
galleryContainer.appendChild(section);
});
}
galleryWrap.appendChild(galleryContainer);
// Search handler
searchInput.addEventListener('keyup', function() {
var q = this.value.toLowerCase();
galleryContainer.querySelectorAll('.vf-os-card').forEach(function(c) {
c.style.display = c.getAttribute('data-search').indexOf(q) !== -1 ? '' : 'none';
});
galleryContainer.querySelectorAll('.vf-os-category').forEach(function(s) {
var cards = s.querySelectorAll('.vf-os-card');
var hasVisible = false;
cards.forEach(function(c) { if (c.style.display !== 'none') hasVisible = true; });
s.style.display = hasVisible ? '' : 'none';
});
});
// Validation: red border if no selection on form submit
var form = osInputField.closest('form');
if (form) {
form.addEventListener('submit', function(e) {
if (!osInputField.value) {
galleryContainer.style.border = '2px solid #dc3545';
galleryContainer.style.borderRadius = '8px';
galleryContainer.style.padding = '4px';
galleryContainer.scrollIntoView({behavior: 'smooth', block: 'center'});
}
});
}
osInputField.parentNode.insertBefore(galleryWrap, osInputField.nextSibling);
osInputField.style.display = 'none';
// Handle SSH keys

View File

@@ -0,0 +1,131 @@
<?php
namespace WHMCS\Module\Server\VirtFusionDirect;
class Cache
{
const PREFIX = 'vfd:';
/** @var \Redis|null */
private static $redis = null;
/** @var bool */
private static $available = true;
/**
* Get a Redis connection, or null if unavailable.
*
* @return \Redis|null
*/
private static function redis()
{
if (!self::$available) {
return null;
}
if (self::$redis !== null) {
return self::$redis;
}
if (!class_exists('Redis')) {
self::$available = false;
return null;
}
try {
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379, 1.0);
self::$redis = $redis;
return $redis;
} catch (\Exception $e) {
self::$available = false;
return null;
}
}
/**
* Get a cached value.
*
* @param string $key
* @return mixed|null Returns null on miss
*/
public static function get($key)
{
$redis = self::redis();
if (!$redis) {
return null;
}
try {
$data = $redis->get(self::PREFIX . $key);
if ($data === false) {
return null;
}
return json_decode($data, true);
} catch (\Exception $e) {
return null;
}
}
/**
* Store a value in cache.
*
* @param string $key
* @param mixed $value
* @param int $ttl Time-to-live in seconds
*/
public static function set($key, $value, $ttl = 300)
{
$redis = self::redis();
if (!$redis) {
return;
}
try {
$redis->setex(self::PREFIX . $key, $ttl, json_encode($value));
} catch (\Exception $e) {
// Silently fail — caching is optional
}
}
/**
* Delete a cached value.
*
* @param string $key
*/
public static function forget($key)
{
$redis = self::redis();
if (!$redis) {
return;
}
try {
$redis->del(self::PREFIX . $key);
} catch (\Exception $e) {
// Silently fail
}
}
/**
* Delete all cache keys matching a pattern.
*
* @param string $pattern Glob pattern (e.g., "os:*")
*/
public static function forgetPattern($pattern)
{
$redis = self::redis();
if (!$redis) {
return;
}
try {
$keys = $redis->keys(self::PREFIX . $pattern);
if (!empty($keys)) {
$redis->del($keys);
}
} catch (\Exception $e) {
// Silently fail
}
}
}

View File

@@ -26,6 +26,12 @@ class ConfigureService extends Module
*/
public function fetchPackageId(string $packageName): ?int
{
$cacheKey = 'pkg_name:' . md5($packageName);
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
if (!$this->cp) return null;
$request = $this->initCurl($this->cp['token']);
@@ -38,6 +44,7 @@ class ConfigureService extends Module
foreach ($packages['data'] as $package) {
if ($package['name'] === $packageName && $package['enabled'] === true) {
Cache::set($cacheKey, $package['id'], 600);
return $package['id'];
}
}
@@ -72,6 +79,12 @@ class ConfigureService extends Module
return null;
}
$cacheKey = 'tpl:' . $serverPackageId;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
if (!$this->cp) return null;
$request = $this->initCurl($this->cp['token']);
@@ -80,7 +93,9 @@ class ConfigureService extends Module
sprintf("%s/media/templates/fromServerPackageSpec/%d", $this->cp['url'], $serverPackageId)
);
return $this->decodeResponseFromJson($response);
$result = $this->decodeResponseFromJson($response);
Cache::set($cacheKey, $result, 600);
return $result;
}
/**
@@ -139,8 +154,8 @@ class ConfigureService extends Module
$request = $this->initCurl($this->cp['token']);
// Generate a random 8 character hostname
$hostname = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 8);
// Generate a hostname with sufficient entropy to avoid collisions
$hostname = 'vps-' . bin2hex(random_bytes(4));
$sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
$sshKeyId = null;

View File

@@ -225,6 +225,7 @@ class Module
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
Cache::forgetPattern('backups:' . (int) $service->server_id);
return json_decode($data) ?: (object) ['success' => true];
}
}
@@ -292,6 +293,12 @@ class Module
return false;
}
$cacheKey = 'os:' . (int) $product->configoption2;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$request = $this->initCurl($cp['token']);
$data = $request->get($cp['url'] . '/media/templates/fromServerPackageSpec/' . (int) $product->configoption2);
@@ -299,20 +306,94 @@ class Module
if ($request->getRequestInfo('http_code') == '200') {
$templates = json_decode($data, true);
$result = [];
$baseUrl = rtrim(str_replace('/api/v1', '', $cp['url']), '/');
$categories = [];
$otherTemplates = [];
if (isset($templates['data'])) {
foreach ($templates['data'] as $osCategory) {
$catTemplates = [];
foreach ($osCategory['templates'] as $template) {
$result[] = [
$catTemplates[] = [
'id' => $template['id'],
'name' => $template['name'] . ' ' . $template['version'] . ' ' . $template['variant'],
'name' => $template['name'],
'version' => $template['version'] ?? '',
'variant' => $template['variant'] ?? '',
'icon' => $template['icon'] ?? null,
'eol' => $template['eol'] ?? false,
'type' => $template['type'] ?? '',
'description' => $template['description'] ?? '',
];
}
if (count($catTemplates) <= 1) {
$otherTemplates = array_merge($otherTemplates, $catTemplates);
} else {
$categories[] = [
'name' => $osCategory['name'] ?? 'Unknown',
'icon' => $osCategory['icon'] ?? null,
'templates' => $catTemplates,
];
}
}
usort($result, function ($a, $b) {
return strcmp($a['name'], $b['name']);
});
if (!empty($otherTemplates)) {
$categories[] = [
'name' => 'Other',
'icon' => null,
'templates' => $otherTemplates,
];
}
}
$result = [
'baseUrl' => $baseUrl,
'categories' => $categories,
];
Cache::set($cacheKey, $result, 600);
return $result;
}
}
return false;
}
// =========================================================================
// Traffic Statistics
// =========================================================================
/**
* Get traffic statistics for a server.
*
* @param int $serviceID
* @return array|false
*/
public function getTrafficStats($serviceID)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if ($service) {
$cacheKey = 'traffic:' . (int) $service->server_id;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$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 . '/traffic');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 120);
return $result;
}
}
@@ -354,117 +435,48 @@ class Module
return false;
}
/**
* Remove an IPv4 address from a server.
*
* @param int $serviceID
* @param string $ipAddress The IPv4 address to remove
* @return object|false
*/
public function removeIPv4($serviceID, $ipAddress)
{
$serviceID = (int) $serviceID;
$ipAddress = filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
if (!$ipAddress) {
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(['address' => $ipAddress]));
$data = $request->delete($cp['url'] . '/servers/' . (int) $service->server_id . '/ipv4');
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;
}
/**
* Add an IPv6 subnet to a server.
*
* @param int $serviceID
* @return object|false
*/
public function addIPv6($serviceID)
{
$serviceID = (int) $serviceID;
$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 . '/ipv6');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data) ?: (object) ['success' => true];
}
}
return false;
}
/**
* Remove an IPv6 subnet from a server.
*
* @param int $serviceID
* @param string $subnet The IPv6 subnet to remove
* @return object|false
*/
public function removeIPv6($serviceID, $subnet)
{
$serviceID = (int) $serviceID;
$subnet = trim($subnet);
if (empty($subnet)) {
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(['subnet' => $subnet]));
$data = $request->delete($cp['url'] . '/servers/' . (int) $service->server_id . '/ipv6');
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;
}
// =========================================================================
// Backup Management
// =========================================================================
/**
* Get backup list for a server.
*
* @param int $serviceID
* @return array|false
*/
public function getServerBackups($serviceID)
{
$serviceID = (int) $serviceID;
$service = Database::getSystemService($serviceID);
if ($service) {
$cacheKey = 'backups:' . (int) $service->server_id;
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$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'] . '/backups/server/' . (int) $service->server_id);
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 120);
return $result;
}
}
return false;
}
/**
* Assign a backup plan to a server.
*
@@ -539,6 +551,39 @@ class Module
return false;
}
/**
* Toggle VNC on/off for a server.
*
* @param int $serviceID
* @param bool $enabled
* @return array|false
*/
public function toggleVnc($serviceID, $enabled)
{
$serviceID = (int) $serviceID;
$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(['enabled' => (bool) $enabled]));
$data = $request->post($cp['url'] . '/servers/' . (int) $service->server_id . '/vnc');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 204) {
return json_decode($data, true) ?: ['success' => true];
}
}
return false;
}
// =========================================================================
// Resource Modification
// =========================================================================
@@ -630,6 +675,37 @@ class Module
return ['valid' => false, 'errors' => $errors];
}
/**
* Reset the server's root password.
*
* @param int $serviceID
* @return array|false
*/
public function resetServerPassword($serviceID)
{
$serviceID = (int) $serviceID;
$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 . '/resetPassword');
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
$httpCode = $request->getRequestInfo('http_code');
if ($httpCode == 200 || $httpCode == 201) {
return json_decode($data, true);
}
}
return false;
}
public function resetUserPassword($serviceID, $clientID)
{
$serviceID = (int) $serviceID;
@@ -839,6 +915,12 @@ class Module
*/
public function getSelfServiceCurrencies($serviceID)
{
$cacheKey = 'ss_currencies';
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$serviceID = (int) $serviceID;
$whmcsService = Database::getWhmcsService($serviceID);
if (!$whmcsService) return false;
@@ -852,7 +934,9 @@ class Module
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
if ($request->getRequestInfo('http_code') == 200) {
return json_decode($data, true);
$result = json_decode($data, true);
Cache::set($cacheKey, $result, 1800);
return $result;
}
return false;
}

View File

@@ -89,6 +89,47 @@
display: inline;
}
/* Skeleton Loading */
.vf-skeleton {
background: linear-gradient(90deg, #e9ecef 25%, #f4f4f4 50%, #e9ecef 75%);
background-size: 200% 100%;
animation: vf-skeleton-pulse 1.5s ease-in-out infinite;
border-radius: 4px;
}
.vf-skeleton-line {
height: 14px;
margin-bottom: 10px;
border-radius: 4px;
}
.vf-skeleton-line-short {
width: 60%;
}
.vf-skeleton-line-medium {
width: 80%;
}
@keyframes vf-skeleton-pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Action Progress Banner */
#vf-action-progress {
background: #337ab7;
color: #fff;
padding: 8px 16px;
border-radius: 6px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
font-size: 0.85rem;
}
#vf-action-progress .spinner-border {
width: 16px;
height: 16px;
border-width: 2px;
}
/* Loader */
#vf-server-info-loader {
min-height: 136px;
@@ -134,9 +175,186 @@
font-family: monospace;
font-size: 0.9rem;
}
.vf-ip-remove {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
/* Backup Timeline */
.vf-timeline {
position: relative;
padding-left: 20px;
border-left: 2px solid #dee2e6;
margin-left: 8px;
}
.vf-timeline-item {
position: relative;
padding: 8px 0 8px 12px;
}
.vf-timeline-dot {
position: absolute;
left: -27px;
top: 12px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #fff;
}
.vf-timeline-dot-success {
background: #28a745;
}
.vf-timeline-dot-pending {
background: #ffc107;
}
.vf-timeline-content {
font-size: 0.85rem;
}
/* Server Name Dropdown */
#vf-name-dropdown {
position: relative;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
margin-top: 4px;
max-width: 250px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 10;
}
.vf-name-option {
padding: 6px 12px;
cursor: pointer;
font-family: monospace;
font-size: 0.85rem;
border-bottom: 1px solid #f0f0f0;
}
.vf-name-option:last-child {
border-bottom: none;
}
.vf-name-option:hover {
background: rgba(51,122,183,0.06);
}
/* Copy to Clipboard */
.vf-ip-copy {
padding: 2px 5px;
line-height: 1;
color: #6c757d;
background: none;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
vertical-align: middle;
}
.vf-ip-copy:hover {
color: #337ab7;
background: rgba(51,122,183,0.08);
border-color: rgba(51,122,183,0.2);
}
.vf-copy-tooltip {
position: absolute;
margin-left: 4px;
padding: 2px 8px;
font-size: 0.75rem;
color: #fff;
background: #28a745;
border-radius: 3px;
white-space: nowrap;
animation: vf-fade-in 0.2s ease;
}
@keyframes vf-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* OS Template Gallery */
.vf-os-category-title {
text-transform: uppercase;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.5px;
border-bottom: 1px solid #dee2e6;
padding-bottom: 6px;
margin-top: 16px;
margin-bottom: 10px;
}
.vf-os-category:first-child .vf-os-category-title {
margin-top: 0;
}
.vf-os-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.vf-os-card {
width: 120px;
border: 2px solid #dee2e6;
border-radius: 8px;
padding: 10px 8px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
}
.vf-os-card:hover {
border-color: #337ab7;
}
.vf-os-card-selected {
border-color: #337ab7;
background: rgba(51,122,183,0.06);
box-shadow: 0 0 0 1px rgba(51,122,183,0.3);
}
.vf-os-card-eol {
opacity: 0.6;
}
.vf-os-icon {
width: 40px;
height: 40px;
border-radius: 8px;
margin: 0 auto 6px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: 1.1rem;
}
.vf-os-icon img {
width: 24px;
height: 24px;
object-fit: contain;
}
.vf-os-label {
font-weight: 600;
font-size: 12px;
line-height: 1.2;
}
.vf-os-version {
font-size: 10px;
color: #888;
line-height: 1.2;
}
.vf-os-eol-badge {
display: inline-block;
background: #dc3545;
color: #fff;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
margin-top: 3px;
}
.vf-os-details {
border-top: 1px solid #dee2e6;
padding-top: 10px;
}
.vf-os-search {
margin-bottom: 10px;
}
@media (max-width: 768px) {
.vf-os-grid {
gap: 6px;
}
.vf-os-card {
width: calc(50% - 3px);
min-width: 100px;
}
}
/* Resource panel */
@@ -186,6 +404,38 @@
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
/* Toggle Switch */
.vf-toggle-input {
display: none;
}
.vf-toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
background: #ccc;
border-radius: 10px;
transition: background 0.2s;
flex-shrink: 0;
}
.vf-toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.vf-toggle-input:checked + .vf-toggle-switch {
background: #28a745;
}
.vf-toggle-input:checked + .vf-toggle-switch::after {
transform: translateX(16px);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.vf-power-buttons {

View File

@@ -8,8 +8,38 @@
* - Password reset
* - Server rebuild
* - OS template loading
* - Traffic statistics
* - Backup listing
* - VNC management
* - Server naming
*/
// =========================================================================
// 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({
@@ -18,7 +48,7 @@ function vfServerData(serviceId, systemUrl) {
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=serverData"
}).done(function (response) {
if (response.success) {
$("#vf-data-server-name").text(response.data.name);
$("#vf-rename-input").val(response.data.name);
$("#vf-data-server-hostname").text(response.data.hostname);
$("#vf-data-server-memory").text(response.data.memory);
$("#vf-data-server-traffic").text(response.data.traffic);
@@ -93,9 +123,7 @@ function vfServerData(serviceId, systemUrl) {
$.each(ipv4Arr, function (i, ip) {
var row = $('<div class="vf-ip-row"></div>');
row.append('<span class="vf-ip-address">' + $('<span>').text(ip).html() + '</span>');
if (i > 0) {
row.append(' <button class="btn btn-sm btn-outline-danger vf-ip-remove" onclick="vfRemoveIP(\'' + serviceId + '\',\'' + systemUrl + '\',\'removeIPv4\',\'' + encodeURIComponent(ip) + '\')">Remove</button>');
}
row.append(vfCopyButton(ip));
ipv4List.append(row);
});
} else {
@@ -106,6 +134,7 @@ function vfServerData(serviceId, systemUrl) {
$.each(ipv6Arr, function (i, subnet) {
var row = $('<div class="vf-ip-row"></div>');
row.append('<span class="vf-ip-address">' + $('<span>').text(subnet).html() + '</span>');
row.append(vfCopyButton(subnet));
ipv6List.append(row);
});
} else {
@@ -148,7 +177,7 @@ function vfServerDataAdmin(serviceId, systemUrl) {
$("#vf-server-info").show();
} else {
$("#vf-server-info-error").show();
$("#vf-server-info-error-message").text(response.errors);
$("#vf-server-info-error-message").text("Unable to retrieve server information.");
$("#vf-server-info").hide();
}
}).fail(function () {
@@ -163,7 +192,7 @@ function vfUserPasswordReset(serviceId, systemUrl) {
$("#vf-password-reset-error").hide();
$("#vf-password-reset-success").hide();
$.ajax({
type: "GET",
type: "POST",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=resetPassword"
}).done(function (response) {
@@ -236,16 +265,17 @@ function vfPowerAction(serviceId, systemUrl, action) {
};
$.ajax({
type: "GET",
type: "POST",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=powerAction&powerAction=" + encodeURIComponent(action)
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=powerAction",
data: { powerAction: action }
}).done(function (response) {
if (response.success) {
alertDiv.removeClass("alert-danger").addClass("alert-success");
alertDiv.text(response.data.message || (actionLabels[action] + " server..."));
} else {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text(response.errors || "Power action failed.");
alertDiv.text("Power action failed. Please try again.");
}
alertDiv.show();
}).fail(function () {
@@ -254,30 +284,125 @@ function vfPowerAction(serviceId, systemUrl, action) {
alertDiv.show();
}).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 title = $('<h5 class="vf-os-category-title"></h5>').text(category.name);
section.append(title);
var grid = $('<div class="vf-os-grid"></div>');
$.each(category.templates, function (ti, tpl) {
var label = tpl.name + (tpl.version ? " " + tpl.version : "") + (tpl.variant ? " " + tpl.variant : "");
var brandColor = vfGetBrandColor(category.name || tpl.name);
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>').css("background", brandColor);
if (tpl.icon && baseUrl) {
var img = $('<img alt="">').attr("src", baseUrl + "/storage/os/" + encodeURIComponent(tpl.icon));
img.on("error", function () {
$(this).parent().empty().append($('<span></span>').text((tpl.name || "?")[0].toUpperCase()));
});
iconDiv.append(img);
} else {
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: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=osTemplates"
}).done(function (response) {
var select = $("#vf-rebuild-os");
select.empty();
if (response.success && response.data && response.data.length > 0) {
select.append('<option value="">-- Select Operating System --</option>');
$.each(response.data, function (i, template) {
select.append('<option value="' + template.id + '">' + $('<span>').text(template.name).html() + '</option>');
$("#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 {
select.append('<option value="">No templates available</option>');
$("#vf-os-gallery").append($('<p class="text-muted"></p>').text("No templates available")).show();
}
}).fail(function () {
var select = $("#vf-rebuild-os");
select.empty();
select.append('<option value="">Error loading templates</option>');
$("#vf-os-gallery-loader").hide();
$("#vf-os-gallery").append($('<p class="text-danger"></p>').text("Error loading templates")).show();
});
}
@@ -299,18 +424,20 @@ function vfRebuildServer(serviceId, systemUrl) {
$("#vf-rebuild-button").prop("disabled", true);
$("#vf-rebuild-spinner").show();
alertDiv.hide();
vfShowProgress("Rebuilding server...");
$.ajax({
type: "GET",
type: "POST",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=rebuild&osId=" + encodeURIComponent(osId)
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=rebuild",
data: { osId: osId }
}).done(function (response) {
if (response.success) {
alertDiv.removeClass("alert-danger").addClass("alert-success");
alertDiv.text(response.data.message || "Server rebuild initiated. You will receive an email when the process is complete.");
} else {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text(response.errors || "Rebuild failed.");
alertDiv.text("Rebuild failed. Please try again.");
}
alertDiv.show();
}).fail(function () {
@@ -318,8 +445,12 @@ function vfRebuildServer(serviceId, systemUrl) {
alertDiv.text("An error occurred. Please try again.");
alertDiv.show();
}).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);
});
}
@@ -335,42 +466,6 @@ function impersonateServerOwner(serviceId, systemUrl) {
});
}
// =========================================================================
// Network / IP Management
// =========================================================================
function vfRemoveIP(serviceId, systemUrl, action, identifier) {
if (!confirm("Are you sure you want to remove this IP address?")) {
return;
}
var alertDiv = $("#vf-network-alert");
alertDiv.hide();
var paramName = action === "removeIPv4" ? "ip" : "subnet";
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=" + encodeURIComponent(action) + "&" + paramName + "=" + identifier
}).done(function (response) {
if (response.success) {
alertDiv.removeClass("alert-danger").addClass("alert-success");
alertDiv.text(response.data.message || "IP address removed successfully.");
alertDiv.show();
vfServerData(serviceId, systemUrl);
} else {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text(response.errors || "Failed to remove IP address.");
alertDiv.show();
}
}).fail(function () {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text("An error occurred. Please try again.");
alertDiv.show();
});
}
// =========================================================================
// VNC Console
// =========================================================================
@@ -420,7 +515,7 @@ function vfOpenVnc(serviceId, systemUrl) {
} else {
vncWindow.close();
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text(response.errors || "VNC console is not available.");
alertDiv.text("VNC console is not available.");
alertDiv.show();
}
}).fail(function () {
@@ -434,6 +529,61 @@ function vfOpenVnc(serviceId, systemUrl) {
});
}
function vfToggleVnc(serviceId, systemUrl, enabled) {
var toggle = $("#vf-vnc-toggle");
toggle.prop("disabled", true);
$.ajax({
type: "POST",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=toggleVnc",
data: { enabled: enabled ? "1" : "0" }
}).done(function (response) {
if (response.success) {
if (enabled && response.data) {
var data = response.data.data || response.data;
if (data.ip || data.host) {
$("#vf-vnc-ip").text(data.ip || data.host || "-");
$("#vf-vnc-port").text(data.port || "-");
$("#vf-vnc-details").show();
}
} else {
$("#vf-vnc-details").hide();
}
} else {
toggle.prop("checked", !enabled);
}
}).fail(function () {
toggle.prop("checked", !enabled);
}).always(function () {
toggle.prop("disabled", false);
});
}
function vfCopyVncPassword(serviceId, systemUrl) {
var confirmSpan = $("#vf-vnc-copy-confirm");
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=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
// =========================================================================
@@ -524,9 +674,10 @@ function vfAddCredit(serviceId, systemUrl) {
alertDiv.hide();
$.ajax({
type: "GET",
type: "POST",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=selfServiceAddCredit&tokens=" + encodeURIComponent(amount)
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=selfServiceAddCredit",
data: { tokens: amount }
}).done(function (response) {
if (response.success) {
alertDiv.removeClass("alert-danger").addClass("alert-success");
@@ -537,7 +688,7 @@ function vfAddCredit(serviceId, systemUrl) {
vfLoadSelfServiceUsage(serviceId, systemUrl);
} else {
alertDiv.removeClass("alert-success").addClass("alert-danger");
alertDiv.text(response.errors || "Failed to add credit.");
alertDiv.text("Failed to add credit. Please try again.");
alertDiv.show();
}
}).fail(function () {
@@ -549,3 +700,331 @@ function vfAddCredit(serviceId, systemUrl) {
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: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=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 () {
alertDiv.removeClass("alert-danger").addClass("alert alert-success");
alertDiv.text("New password copied to clipboard.");
alertDiv.show();
}).catch(function () {
alertDiv.removeClass("alert-danger").addClass("alert alert-warning");
alertDiv.text("Password reset successful. Unable to copy to clipboard automatically.");
alertDiv.show();
});
} else {
alertDiv.removeClass("alert-danger").addClass("alert alert-success");
alertDiv.text("Password reset initiated. Check your email for the new credentials.");
alertDiv.show();
}
} else {
alertDiv.removeClass("alert-success").addClass("alert alert-danger");
alertDiv.text("Password reset failed. Please try again.");
alertDiv.show();
}
}).fail(function () {
alertDiv.removeClass("alert-success").addClass("alert alert-danger");
alertDiv.text("An error occurred. Please try again.");
alertDiv.show();
}).always(function () {
spinner.hide();
btn.prop("disabled", false);
});
}
// =========================================================================
// Backup Listing
// =========================================================================
function vfLoadBackups(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=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;
var dpr = window.devicePixelRatio || 1;
var rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = 200 * dpr;
canvas.style.height = "200px";
canvas.style.width = "100%";
var ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
var w = rect.width;
var h = 200;
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: 30, left: 50 };
var chartW = w - padding.left - padding.right;
var chartH = h - padding.top - padding.bottom;
var barGroupW = chartW / entries.length;
var barW = Math.max(4, (barGroupW * 0.35));
// Y axis
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);
}
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, padding.top + chartH - inH, barW, inH);
ctx.fillStyle = "#28a745";
ctx.fillRect(x + barW + 2, padding.top + chartH - outH, barW, outH);
// X label
ctx.fillStyle = "#888";
ctx.font = "10px sans-serif";
ctx.textAlign = "center";
ctx.fillText(e.label || (idx + 1), padding.left + idx * barGroupW + barGroupW / 2, h - 8);
});
// Legend
ctx.fillStyle = "#337ab7";
ctx.fillRect(padding.left, h - 15, 10, 10);
ctx.fillStyle = "#888";
ctx.font = "10px sans-serif";
ctx.textAlign = "left";
ctx.fillText("In", padding.left + 14, h - 6);
ctx.fillStyle = "#28a745";
ctx.fillRect(padding.left + 32, h - 15, 10, 10);
ctx.fillStyle = "#888";
ctx.fillText("Out", padding.left + 46, h - 6);
}
function vfLoadTrafficStats(serviceId, systemUrl) {
$.ajax({
type: "GET",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=trafficStats"
}).done(function (response) {
if (response.success && response.data) {
var data = response.data.data || response.data;
var entries = data.entries || data.traffic || [];
var used = data.used || data.totalUsed || 0;
var limit = data.limit || data.allowance || 0;
if (entries.length > 0 || used > 0) {
vfDrawTrafficChart("vf-traffic-chart", entries);
$("#vf-traffic-used").text(used >= 1024 ? (used / 1024).toFixed(2) + " TB" : used + " GB");
$("#vf-traffic-limit").text(limit > 0 ? (limit >= 1024 ? (limit / 1024).toFixed(2) + " TB" : limit + " GB") : "Unlimited");
var remaining = limit > 0 ? Math.max(0, limit - used) : 0;
$("#vf-traffic-remaining").text(limit > 0 ? (remaining >= 1024 ? (remaining / 1024).toFixed(2) + " TB" : remaining + " GB") : "-");
$("#vf-traffic-chart-section").show();
// Debounced resize redraw
var resizeTimer;
$(window).on("resize.vfTraffic", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
vfDrawTrafficChart("vf-traffic-chart", entries);
}, 250);
});
}
}
});
}
// =========================================================================
// 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) {
var name = $("#vf-rename-input").val().trim().toLowerCase();
var alertDiv = $("#vf-rename-alert");
alertDiv.hide();
if (!name || !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
alertDiv.removeClass("alert-success").addClass("alert alert-danger");
alertDiv.text("Invalid name. Use lowercase letters, numbers, and hyphens (2-63 chars, must start/end with alphanumeric).");
alertDiv.show();
return;
}
var btn = $("#vf-rename-save");
btn.prop("disabled", true);
$.ajax({
type: "POST",
dataType: "json",
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=rename",
data: { name: name }
}).done(function (response) {
if (response.success) {
alertDiv.removeClass("alert-danger").addClass("alert alert-success");
alertDiv.text("Server renamed successfully.");
} else {
alertDiv.removeClass("alert-success").addClass("alert alert-danger");
alertDiv.text("Rename failed. Please try again.");
}
alertDiv.show();
}).fail(function () {
alertDiv.removeClass("alert-success").addClass("alert alert-danger");
alertDiv.text("An error occurred. Please try again.");
alertDiv.show();
}).always(function () {
btn.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;
}

View File

@@ -1,5 +1,5 @@
<link href="{$systemURL}modules/servers/VirtFusionDirect/templates/css/module.css?v=20260207" rel="stylesheet">
<script src="{$systemURL}modules/servers/VirtFusionDirect/templates/js/module.js?v=20260207"></script>
<link href="{$systemURL}modules/servers/VirtFusionDirect/templates/css/module.css?v=20260319" rel="stylesheet">
<script src="{$systemURL}modules/servers/VirtFusionDirect/templates/js/module.js?v=20260319"></script>
{if $serviceStatus eq 'Active'}
@@ -12,9 +12,27 @@
</h3>
</div>
<div class="panel-body card-body p-4">
<div id="vf-action-progress" style="display:none;">
<div class="spinner-border spinner-border-sm text-light"></div>
<span id="vf-action-progress-text"></span>
<span id="vf-action-progress-timer" class="ml-auto" style="margin-left:auto;"></span>
</div>
<div id="vf-server-info-loader-container">
<div id="vf-server-info-loader" class="d-flex align-items-center justify-content-center">
<div class="spinner-border"></div>
<div id="vf-server-info-loader">
<div class="row">
<div class="col-md-6">
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
</div>
<div class="col-md-6">
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-medium"></div>
<div class="vf-skeleton vf-skeleton-line vf-skeleton-line-short"></div>
</div>
</div>
</div>
</div>
<script>vfServerData('{$serviceid}', '{$systemURL}');</script>
@@ -27,7 +45,15 @@
<div class="col-md-6">
<div class="row p-1">
<div class="col-xs-4 col-4 text-right vf-bold">Name:</div>
<div class="col-xs-8 col-8" id="vf-data-server-name"></div>
<div class="col-xs-8 col-8">
<div class="d-flex" style="display:flex; gap:6px; align-items:center;">
<input type="text" id="vf-rename-input" class="form-control form-control-sm" maxlength="63" style="max-width:200px;" placeholder="Server name">
<button id="vf-randomise-btn" onclick="vfShowNameDropdown('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-outline-secondary" title="Randomise">&#x21bb;</button>
<button id="vf-rename-save" onclick="vfRenameServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-sm btn-primary">Save</button>
</div>
<div id="vf-name-dropdown" style="display:none;"></div>
<div id="vf-rename-alert" class="mt-1" style="display:none;"></div>
</div>
</div>
<div class="row p-1">
<div class="col-xs-4 col-4 text-right vf-bold">Hostname:</div>
@@ -136,6 +162,27 @@
</button>
</div>
{/if}
<div class="col-12">
<hr>
<div id="vf-server-password-alert" class="alert" style="display:none;"></div>
<p class="vf-small text-muted">Reset the server's root password. The new password will be copied to your clipboard automatically.</p>
<button id="vf-server-password-btn" onclick="vfResetServerPassword('{$serviceid}','{$systemURL}')" type="button" class="btn btn-warning text-uppercase d-flex align-items-center">
<span id="vf-server-password-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
Reset Server Password
</button>
</div>
<div class="col-12" id="vf-backups-section" style="display:none;">
<hr>
<h5 class="vf-bold">Backups</h5>
<div id="vf-backups-loader"><div class="spinner-border spinner-border-sm"></div></div>
<div id="vf-backups-timeline" class="vf-timeline"></div>
<button id="vf-backups-show-all" class="btn btn-sm btn-link" style="display:none;" onclick="$('.vf-timeline-item-hidden').show(); $(this).hide();">Show all</button>
</div>
<script>
if (typeof vfLoadBackups === 'function') {
vfLoadBackups('{$serviceid}', '{$systemURL}');
}
</script>
</div>
</div>
</div>
@@ -150,16 +197,16 @@
<div class="alert alert-warning">
<strong>Warning:</strong> Rebuilding your server will erase all data on the server and reinstall the operating system. This action cannot be undone.
</div>
<div class="row">
<div class="col-md-6">
<input type="hidden" id="vf-rebuild-os" value="">
<div class="form-group mb-3">
<label for="vf-rebuild-os">Operating System</label>
<select id="vf-rebuild-os" class="form-control">
<option value="">Loading...</option>
</select>
</div>
<label>Operating System</label>
<input type="text" id="vf-os-search" class="form-control vf-os-search" placeholder="Search templates...">
</div>
<div id="vf-os-gallery-loader" class="mb-3">
<div class="vf-skeleton" style="height:120px;"></div>
</div>
<div id="vf-os-gallery" class="mb-3" style="display:none;"></div>
<div id="vf-os-details" class="mb-3" style="display:none;"></div>
<button id="vf-rebuild-button" onclick="vfRebuildServer('{$serviceid}','{$systemURL}')" type="button" class="btn btn-danger text-uppercase d-flex align-items-center">
<span id="vf-rebuild-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
Rebuild Server
@@ -235,6 +282,21 @@
</div>
</div>
</div>
<div id="vf-traffic-chart-section" style="display:none;">
<hr>
<h5 class="vf-bold mb-2">Traffic Usage</h5>
<canvas id="vf-traffic-chart" style="width:100%; height:200px;"></canvas>
<div class="row mt-2 text-center">
<div class="col-4"><small class="text-muted">Used</small><div id="vf-traffic-used" class="vf-bold">-</div></div>
<div class="col-4"><small class="text-muted">Limit</small><div id="vf-traffic-limit" class="vf-bold">-</div></div>
<div class="col-4"><small class="text-muted">Remaining</small><div id="vf-traffic-remaining" class="vf-bold">-</div></div>
</div>
</div>
<script>
if (typeof vfLoadTrafficStats === 'function') {
vfLoadTrafficStats('{$serviceid}', '{$systemURL}');
}
</script>
</div>
</div>
@@ -246,10 +308,37 @@
<div class="panel-body card-body p-4">
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
<p>Access your server's console directly in your browser. The server must be running for VNC access.</p>
<div class="d-flex align-items-center mb-3" style="display:flex; gap:12px; align-items:center;">
<button id="vf-vnc-button" onclick="vfOpenVnc('{$serviceid}','{$systemURL}')" type="button" class="btn btn-primary text-uppercase d-flex align-items-center">
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
Open Console
</button>
<label class="vf-toggle-label mb-0" style="display:flex; align-items:center; gap:6px; cursor:pointer;">
<input type="checkbox" id="vf-vnc-toggle" class="vf-toggle-input" onchange="vfToggleVnc('{$serviceid}','{$systemURL}', this.checked)">
<span class="vf-toggle-switch"></span>
<span class="vf-small">VNC Enabled</span>
</label>
</div>
<div id="vf-vnc-details" style="display:none;">
<div class="row">
<div class="col-md-6">
<div class="row p-1">
<div class="col-4 text-right vf-bold vf-small">IP:</div>
<div class="col-8 vf-small" id="vf-vnc-ip">-</div>
</div>
<div class="row p-1">
<div class="col-4 text-right vf-bold vf-small">Port:</div>
<div class="col-8 vf-small" id="vf-vnc-port">-</div>
</div>
</div>
<div class="col-md-6">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="vfCopyVncPassword('{$serviceid}','{$systemURL}')">
Copy VNC Password
</button>
<span id="vf-vnc-copy-confirm" class="text-success vf-small" style="display:none;">Copied!</span>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
#!/bin/bash
# Generate API endpoint documentation from PHP source files
# Usage: bash scripts/generate-endpoint-doc.sh > docs/API-ENDPOINTS.md
MODULE_DIR="modules/servers/VirtFusionDirect"
echo "# VirtFusion WHMCS Module — API Endpoints"
echo ""
echo "Auto-generated from source code. Do not edit manually."
echo ""
echo "| Endpoint Pattern | HTTP Method | PHP File | Function |"
echo "|---|---|---|---|"
# Extract API URL patterns from PHP files
grep -rn "->get\|->post\|->put\|->patch\|->delete" "$MODULE_DIR/lib/" 2>/dev/null | \
grep -oP "(?<=>)(get|post|put|patch|delete)\(.*?'[^']*'" | \
while IFS= read -r line; do
method=$(echo "$line" | grep -oP "^(get|post|put|patch|delete)" | tr '[:lower:]' '[:upper:]')
url=$(echo "$line" | grep -oP "'[^']*'" | tr -d "'")
echo "| \`$url\` | $method | - | - |"
done
echo ""
echo "## Client Endpoints (client.php)"
echo ""
echo "| Action | Description |"
echo "|---|---|"
grep -n "case '" "$MODULE_DIR/client.php" 2>/dev/null | \
while IFS= read -r line; do
action=$(echo "$line" | grep -oP "case '\K[^']+")
echo "| \`$action\` | - |"
done