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:
65
.github/workflows/api-sync-check.yml
vendored
Normal file
65
.github/workflows/api-sync-check.yml
vendored
Normal 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 }}
|
||||||
11
.github/workflows/publish-release.yml
vendored
11
.github/workflows/publish-release.yml
vendored
@@ -33,6 +33,17 @@ jobs:
|
|||||||
# GITHUB_TOKEN is required for authentication
|
# GITHUB_TOKEN is required for authentication
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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.
|
# To make this work, you must follow the Conventional Commits specification.
|
||||||
# Examples:
|
# Examples:
|
||||||
# - fix: correct a typo in the documentation
|
# - fix: correct a typo in the documentation
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/.idea/
|
/.idea/
|
||||||
|
/.superpowers/
|
||||||
7
docs/openapi-baseline.yaml
Normal file
7
docs/openapi-baseline.yaml
Normal 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"
|
||||||
@@ -23,12 +23,14 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$client) {
|
if (!$client) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $vf->resetUserPassword($serviceID, $client);
|
$data = $vf->resetUserPassword($serviceID, $client);
|
||||||
|
|
||||||
if ($data) {
|
if ($data) {
|
||||||
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
|
$vf->output(['success' => true, 'data' => $data->data], true, true, 200);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Password reset failed'], true, true, 500);
|
||||||
@@ -43,6 +45,7 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $vf->fetchServerData($serviceID);
|
$data = $vf->fetchServerData($serviceID);
|
||||||
@@ -50,6 +53,7 @@ switch ($action) {
|
|||||||
if ($data) {
|
if ($data) {
|
||||||
(new Module())->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
|
(new Module())->updateWhmcsServiceParamsOnServerObject($serviceID, $data);
|
||||||
$vf->output(['success' => true, 'data' => (new ServerResource())->process($data)], true, true, 200);
|
$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);
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve server data'], true, true, 500);
|
||||||
@@ -64,12 +68,14 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = $vf->fetchLoginTokens($serviceID);
|
$token = $vf->fetchLoginTokens($serviceID);
|
||||||
|
|
||||||
if ($token) {
|
if ($token) {
|
||||||
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
|
$vf->output(['success' => true, 'token_url' => $token], true, true, 200);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Unable to generate login token'], true, true, 500);
|
||||||
@@ -84,19 +90,22 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$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'];
|
$allowedActions = ['boot', 'shutdown', 'restart', 'poweroff'];
|
||||||
|
|
||||||
if (!in_array($powerAction, $allowedActions, true)) {
|
if (!in_array($powerAction, $allowedActions, true)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
|
$vf->output(['success' => false, 'errors' => 'Invalid power action'], true, true, 400);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->serverPowerAction($serviceID, $powerAction);
|
$result = $vf->serverPowerAction($serviceID, $powerAction);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$vf->output(['success' => true, 'data' => ['action' => $powerAction, 'message' => 'Power action queued successfully']], true, true, 200);
|
$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);
|
$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)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$osId = isset($_GET['osId']) ? (int) $_GET['osId'] : 0;
|
$osId = isset($_POST['osId']) ? (int) $_POST['osId'] : 0;
|
||||||
$hostname = isset($_GET['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_GET['hostname']) : null;
|
$hostname = isset($_POST['hostname']) ? preg_replace('/[^a-zA-Z0-9.\-]/', '', $_POST['hostname']) : null;
|
||||||
|
|
||||||
if ($osId <= 0) {
|
if ($osId <= 0) {
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
|
$vf->output(['success' => false, 'errors' => 'Invalid operating system ID'], true, true, 400);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
|
$result = $vf->rebuildServer($serviceID, $osId, $hostname);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$vf->output(['success' => true, 'data' => ['message' => 'Server rebuild initiated successfully']], true, true, 200);
|
$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);
|
$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)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$newName = isset($_GET['name']) ? trim($_GET['name']) : '';
|
$newName = isset($_POST['name']) ? trim($_POST['name']) : '';
|
||||||
$newName = htmlspecialchars($newName, ENT_QUOTES, 'UTF-8');
|
|
||||||
|
|
||||||
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);
|
$vf->output(['success' => false, 'errors' => 'Invalid server name'], true, true, 400);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->renameServer($serviceID, $newName);
|
$result = $vf->renameServer($serviceID, $newName);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
|
$vf->output(['success' => true, 'data' => ['message' => 'Server renamed successfully']], true, true, 200);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Server rename failed'], true, true, 500);
|
||||||
@@ -165,44 +179,95 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$templates = $vf->fetchOsTemplates($serviceID);
|
$templates = $vf->fetchOsTemplates($serviceID);
|
||||||
|
|
||||||
if ($templates !== false) {
|
if ($templates !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
|
$vf->output(['success' => true, 'data' => $templates], true, true, 200);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Unable to fetch OS templates'], true, true, 500);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// IP Address Management
|
// Server Password Reset
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an IPv4 address.
|
* Reset server root password.
|
||||||
*/
|
*/
|
||||||
case 'removeIPv4':
|
case 'resetServerPassword':
|
||||||
|
|
||||||
$serviceID = $vf->validateServiceID(true);
|
$serviceID = $vf->validateServiceID(true);
|
||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$ipAddress = isset($_GET['ip']) ? trim($_GET['ip']) : '';
|
$result = $vf->resetServerPassword($serviceID);
|
||||||
if (!filter_var($ipAddress, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid IPv4 address'], true, true, 400);
|
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;
|
break;
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -218,17 +283,42 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->getVncConsole($serviceID);
|
$result = $vf->getVncConsole($serviceID);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
$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);
|
$vf->output(['success' => false, 'errors' => 'VNC console unavailable. The server may be powered off or VNC is not supported.'], true, true, 500);
|
||||||
break;
|
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
|
// Self Service — Credit & Usage
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -242,12 +332,14 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->getSelfServiceUsage($serviceID);
|
$result = $vf->getSelfServiceUsage($serviceID);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
$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);
|
$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)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->getSelfServiceReport($serviceID);
|
$result = $vf->getSelfServiceReport($serviceID);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Unable to retrieve self-service report'], true, true, 500);
|
||||||
@@ -282,17 +376,20 @@ switch ($action) {
|
|||||||
|
|
||||||
if (!$vf->validateUserOwnsService($serviceID)) {
|
if (!$vf->validateUserOwnsService($serviceID)) {
|
||||||
$vf->output(['success' => false, 'errors' => 'service <> owner mismatch'], true, true, 403);
|
$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) {
|
if ($tokens <= 0) {
|
||||||
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
|
$vf->output(['success' => false, 'errors' => 'Invalid credit amount. Must be a positive number.'], true, true, 400);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
|
$result = $vf->addSelfServiceCredit($serviceID, $tokens);
|
||||||
|
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
$vf->output(['success' => true, 'data' => $result], true, true, 200);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
|
$vf->output(['success' => false, 'errors' => 'Failed to add credit'], true, true, 500);
|
||||||
|
|||||||
@@ -86,19 +86,46 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
return null;
|
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) {
|
foreach ($templates_data['data'] as $osCategory) {
|
||||||
|
$catTemplates = [];
|
||||||
foreach ($osCategory['templates'] as $template) {
|
foreach ($osCategory['templates'] as $template) {
|
||||||
$optionValue = $template['id'];
|
$catTemplates[] = [
|
||||||
$optionLabel = htmlspecialchars($template['name'] . " " . $template['version'] . " " . $template['variant'], ENT_QUOTES, 'UTF-8');
|
'id' => $template['id'],
|
||||||
$dropdownOptions[] = ['id' => $optionValue, 'name' => $optionLabel];
|
'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) {
|
$galleryData = ['baseUrl' => $baseUrl, 'categories' => $categories];
|
||||||
return strcmp($a['name'], $b['name']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$sshKeys = [];
|
$sshKeys = [];
|
||||||
$sshKeysOptions = [];
|
$sshKeysOptions = [];
|
||||||
@@ -139,10 +166,11 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
$systemUrl = Database::getSystemUrl();
|
$systemUrl = Database::getSystemUrl();
|
||||||
|
|
||||||
return "
|
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 src=\"" . htmlspecialchars($systemUrl, ENT_QUOTES, 'UTF-8') . "modules/servers/VirtFusionDirect/templates/js/keygen.js?v=20260207\"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
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 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 . "]\"]');
|
var osInputField = document.querySelector('[name=\"customfield[" . (int) $osFieldId . "]\"]');
|
||||||
@@ -151,28 +179,136 @@ add_hook('ClientAreaFooterOutput', 1, function ($vars) {
|
|||||||
|
|
||||||
if (!osInputField) return;
|
if (!osInputField) return;
|
||||||
|
|
||||||
// Create OS dropdown
|
// Brand color map (must match vfOsBrandColors in module.js)
|
||||||
var osSelect = document.createElement('select');
|
var brandColors = {
|
||||||
osSelect.className = 'form-control';
|
'ubuntu':'#E95420','debian':'#A81D33','rocky':'#10B981','centos':'#932279',
|
||||||
osSelect.setAttribute('id', 'vf-os-select');
|
'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');
|
// Build gallery container
|
||||||
defaultOption.value = '';
|
var galleryWrap = document.createElement('div');
|
||||||
defaultOption.text = '-- Select Operating System --';
|
galleryWrap.style.marginTop = '8px';
|
||||||
osSelect.appendChild(defaultOption);
|
|
||||||
|
|
||||||
osTemplates.forEach(function(template) {
|
var searchInput = document.createElement('input');
|
||||||
var option = document.createElement('option');
|
searchInput.type = 'text';
|
||||||
option.value = template.id;
|
searchInput.className = 'form-control vf-os-search';
|
||||||
option.text = template.name;
|
searchInput.placeholder = 'Search templates...';
|
||||||
osSelect.appendChild(option);
|
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() {
|
grid.appendChild(card);
|
||||||
osInputField.value = this.value;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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';
|
osInputField.style.display = 'none';
|
||||||
|
|
||||||
// Handle SSH keys
|
// Handle SSH keys
|
||||||
|
|||||||
131
modules/servers/VirtFusionDirect/lib/Cache.php
Normal file
131
modules/servers/VirtFusionDirect/lib/Cache.php
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ class ConfigureService extends Module
|
|||||||
*/
|
*/
|
||||||
public function fetchPackageId(string $packageName): ?int
|
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;
|
if (!$this->cp) return null;
|
||||||
|
|
||||||
$request = $this->initCurl($this->cp['token']);
|
$request = $this->initCurl($this->cp['token']);
|
||||||
@@ -38,6 +44,7 @@ class ConfigureService extends Module
|
|||||||
|
|
||||||
foreach ($packages['data'] as $package) {
|
foreach ($packages['data'] as $package) {
|
||||||
if ($package['name'] === $packageName && $package['enabled'] === true) {
|
if ($package['name'] === $packageName && $package['enabled'] === true) {
|
||||||
|
Cache::set($cacheKey, $package['id'], 600);
|
||||||
return $package['id'];
|
return $package['id'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,6 +79,12 @@ class ConfigureService extends Module
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'tpl:' . $serverPackageId;
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->cp) return null;
|
if (!$this->cp) return null;
|
||||||
|
|
||||||
$request = $this->initCurl($this->cp['token']);
|
$request = $this->initCurl($this->cp['token']);
|
||||||
@@ -80,7 +93,9 @@ class ConfigureService extends Module
|
|||||||
sprintf("%s/media/templates/fromServerPackageSpec/%d", $this->cp['url'], $serverPackageId)
|
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']);
|
$request = $this->initCurl($this->cp['token']);
|
||||||
|
|
||||||
// Generate a random 8 character hostname
|
// Generate a hostname with sufficient entropy to avoid collisions
|
||||||
$hostname = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, 8);
|
$hostname = 'vps-' . bin2hex(random_bytes(4));
|
||||||
|
|
||||||
$sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
|
$sshKeyValue = $vars['customfields']['Initial SSH Key'] ?? null;
|
||||||
$sshKeyId = null;
|
$sshKeyId = null;
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ class Module
|
|||||||
|
|
||||||
$httpCode = $request->getRequestInfo('http_code');
|
$httpCode = $request->getRequestInfo('http_code');
|
||||||
if ($httpCode == 200 || $httpCode == 201) {
|
if ($httpCode == 200 || $httpCode == 201) {
|
||||||
|
Cache::forgetPattern('backups:' . (int) $service->server_id);
|
||||||
return json_decode($data) ?: (object) ['success' => true];
|
return json_decode($data) ?: (object) ['success' => true];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,6 +293,12 @@ class Module
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'os:' . (int) $product->configoption2;
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
$request = $this->initCurl($cp['token']);
|
$request = $this->initCurl($cp['token']);
|
||||||
$data = $request->get($cp['url'] . '/media/templates/fromServerPackageSpec/' . (int) $product->configoption2);
|
$data = $request->get($cp['url'] . '/media/templates/fromServerPackageSpec/' . (int) $product->configoption2);
|
||||||
|
|
||||||
@@ -299,20 +306,94 @@ class Module
|
|||||||
|
|
||||||
if ($request->getRequestInfo('http_code') == '200') {
|
if ($request->getRequestInfo('http_code') == '200') {
|
||||||
$templates = json_decode($data, true);
|
$templates = json_decode($data, true);
|
||||||
$result = [];
|
$baseUrl = rtrim(str_replace('/api/v1', '', $cp['url']), '/');
|
||||||
|
$categories = [];
|
||||||
|
$otherTemplates = [];
|
||||||
|
|
||||||
if (isset($templates['data'])) {
|
if (isset($templates['data'])) {
|
||||||
foreach ($templates['data'] as $osCategory) {
|
foreach ($templates['data'] as $osCategory) {
|
||||||
|
$catTemplates = [];
|
||||||
foreach ($osCategory['templates'] as $template) {
|
foreach ($osCategory['templates'] as $template) {
|
||||||
$result[] = [
|
$catTemplates[] = [
|
||||||
'id' => $template['id'],
|
'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;
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,117 +435,48 @@ class Module
|
|||||||
return false;
|
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
|
// 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.
|
* Assign a backup plan to a server.
|
||||||
*
|
*
|
||||||
@@ -539,6 +551,39 @@ class Module
|
|||||||
return false;
|
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
|
// Resource Modification
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -630,6 +675,37 @@ class Module
|
|||||||
return ['valid' => false, 'errors' => $errors];
|
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)
|
public function resetUserPassword($serviceID, $clientID)
|
||||||
{
|
{
|
||||||
$serviceID = (int) $serviceID;
|
$serviceID = (int) $serviceID;
|
||||||
@@ -839,6 +915,12 @@ class Module
|
|||||||
*/
|
*/
|
||||||
public function getSelfServiceCurrencies($serviceID)
|
public function getSelfServiceCurrencies($serviceID)
|
||||||
{
|
{
|
||||||
|
$cacheKey = 'ss_currencies';
|
||||||
|
$cached = Cache::get($cacheKey);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
$serviceID = (int) $serviceID;
|
$serviceID = (int) $serviceID;
|
||||||
$whmcsService = Database::getWhmcsService($serviceID);
|
$whmcsService = Database::getWhmcsService($serviceID);
|
||||||
if (!$whmcsService) return false;
|
if (!$whmcsService) return false;
|
||||||
@@ -852,7 +934,9 @@ class Module
|
|||||||
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
Log::insert(__FUNCTION__, $request->getRequestInfo(), $data);
|
||||||
|
|
||||||
if ($request->getRequestInfo('http_code') == 200) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,47 @@
|
|||||||
display: inline;
|
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 */
|
/* Loader */
|
||||||
#vf-server-info-loader {
|
#vf-server-info-loader {
|
||||||
min-height: 136px;
|
min-height: 136px;
|
||||||
@@ -134,9 +175,186 @@
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.vf-ip-remove {
|
/* Backup Timeline */
|
||||||
font-size: 0.7rem;
|
.vf-timeline {
|
||||||
padding: 0.15rem 0.4rem;
|
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 */
|
/* Resource panel */
|
||||||
@@ -186,6 +404,38 @@
|
|||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
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 */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.vf-power-buttons {
|
.vf-power-buttons {
|
||||||
|
|||||||
@@ -8,8 +8,38 @@
|
|||||||
* - Password reset
|
* - Password reset
|
||||||
* - Server rebuild
|
* - Server rebuild
|
||||||
* - OS template loading
|
* - 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) {
|
function vfServerData(serviceId, systemUrl) {
|
||||||
$("#vf-server-info-error").hide();
|
$("#vf-server-info-error").hide();
|
||||||
$.ajax({
|
$.ajax({
|
||||||
@@ -18,7 +48,7 @@ function vfServerData(serviceId, systemUrl) {
|
|||||||
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=serverData"
|
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=serverData"
|
||||||
}).done(function (response) {
|
}).done(function (response) {
|
||||||
if (response.success) {
|
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-hostname").text(response.data.hostname);
|
||||||
$("#vf-data-server-memory").text(response.data.memory);
|
$("#vf-data-server-memory").text(response.data.memory);
|
||||||
$("#vf-data-server-traffic").text(response.data.traffic);
|
$("#vf-data-server-traffic").text(response.data.traffic);
|
||||||
@@ -93,9 +123,7 @@ function vfServerData(serviceId, systemUrl) {
|
|||||||
$.each(ipv4Arr, function (i, ip) {
|
$.each(ipv4Arr, function (i, ip) {
|
||||||
var row = $('<div class="vf-ip-row"></div>');
|
var row = $('<div class="vf-ip-row"></div>');
|
||||||
row.append('<span class="vf-ip-address">' + $('<span>').text(ip).html() + '</span>');
|
row.append('<span class="vf-ip-address">' + $('<span>').text(ip).html() + '</span>');
|
||||||
if (i > 0) {
|
row.append(vfCopyButton(ip));
|
||||||
row.append(' <button class="btn btn-sm btn-outline-danger vf-ip-remove" onclick="vfRemoveIP(\'' + serviceId + '\',\'' + systemUrl + '\',\'removeIPv4\',\'' + encodeURIComponent(ip) + '\')">Remove</button>');
|
|
||||||
}
|
|
||||||
ipv4List.append(row);
|
ipv4List.append(row);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -106,6 +134,7 @@ function vfServerData(serviceId, systemUrl) {
|
|||||||
$.each(ipv6Arr, function (i, subnet) {
|
$.each(ipv6Arr, function (i, subnet) {
|
||||||
var row = $('<div class="vf-ip-row"></div>');
|
var row = $('<div class="vf-ip-row"></div>');
|
||||||
row.append('<span class="vf-ip-address">' + $('<span>').text(subnet).html() + '</span>');
|
row.append('<span class="vf-ip-address">' + $('<span>').text(subnet).html() + '</span>');
|
||||||
|
row.append(vfCopyButton(subnet));
|
||||||
ipv6List.append(row);
|
ipv6List.append(row);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -148,7 +177,7 @@ function vfServerDataAdmin(serviceId, systemUrl) {
|
|||||||
$("#vf-server-info").show();
|
$("#vf-server-info").show();
|
||||||
} else {
|
} else {
|
||||||
$("#vf-server-info-error").show();
|
$("#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();
|
$("#vf-server-info").hide();
|
||||||
}
|
}
|
||||||
}).fail(function () {
|
}).fail(function () {
|
||||||
@@ -163,7 +192,7 @@ function vfUserPasswordReset(serviceId, systemUrl) {
|
|||||||
$("#vf-password-reset-error").hide();
|
$("#vf-password-reset-error").hide();
|
||||||
$("#vf-password-reset-success").hide();
|
$("#vf-password-reset-success").hide();
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "GET",
|
type: "POST",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=resetPassword"
|
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=resetPassword"
|
||||||
}).done(function (response) {
|
}).done(function (response) {
|
||||||
@@ -236,16 +265,17 @@ function vfPowerAction(serviceId, systemUrl, action) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "GET",
|
type: "POST",
|
||||||
dataType: "json",
|
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) {
|
}).done(function (response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
alertDiv.removeClass("alert-danger").addClass("alert-success");
|
alertDiv.removeClass("alert-danger").addClass("alert-success");
|
||||||
alertDiv.text(response.data.message || (actionLabels[action] + " server..."));
|
alertDiv.text(response.data.message || (actionLabels[action] + " server..."));
|
||||||
} else {
|
} else {
|
||||||
alertDiv.removeClass("alert-success").addClass("alert-danger");
|
alertDiv.removeClass("alert-success").addClass("alert-danger");
|
||||||
alertDiv.text(response.errors || "Power action failed.");
|
alertDiv.text("Power action failed. Please try again.");
|
||||||
}
|
}
|
||||||
alertDiv.show();
|
alertDiv.show();
|
||||||
}).fail(function () {
|
}).fail(function () {
|
||||||
@@ -254,30 +284,125 @@ function vfPowerAction(serviceId, systemUrl, action) {
|
|||||||
alertDiv.show();
|
alertDiv.show();
|
||||||
}).always(function () {
|
}).always(function () {
|
||||||
spinner.hide();
|
spinner.hide();
|
||||||
|
// Cooldown: keep buttons disabled for 3 seconds
|
||||||
|
setTimeout(function () {
|
||||||
$(".vf-btn-power").prop("disabled", false);
|
$(".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) {
|
function vfLoadOsTemplates(serviceId, systemUrl) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "GET",
|
type: "GET",
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=osTemplates"
|
url: systemUrl + "modules/servers/VirtFusionDirect/client.php?serviceID=" + encodeURIComponent(serviceId) + "&action=osTemplates"
|
||||||
}).done(function (response) {
|
}).done(function (response) {
|
||||||
var select = $("#vf-rebuild-os");
|
$("#vf-os-gallery-loader").hide();
|
||||||
select.empty();
|
if (response.success && response.data) {
|
||||||
if (response.success && response.data && response.data.length > 0) {
|
vfRenderOsGallery("#vf-os-gallery", response.data, "#vf-rebuild-os");
|
||||||
select.append('<option value="">-- Select Operating System --</option>');
|
|
||||||
$.each(response.data, function (i, template) {
|
// Bind search after gallery is rendered
|
||||||
select.append('<option value="' + template.id + '">' + $('<span>').text(template.name).html() + '</option>');
|
$("#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 {
|
} 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 () {
|
}).fail(function () {
|
||||||
var select = $("#vf-rebuild-os");
|
$("#vf-os-gallery-loader").hide();
|
||||||
select.empty();
|
$("#vf-os-gallery").append($('<p class="text-danger"></p>').text("Error loading templates")).show();
|
||||||
select.append('<option value="">Error loading templates</option>');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,18 +424,20 @@ function vfRebuildServer(serviceId, systemUrl) {
|
|||||||
$("#vf-rebuild-button").prop("disabled", true);
|
$("#vf-rebuild-button").prop("disabled", true);
|
||||||
$("#vf-rebuild-spinner").show();
|
$("#vf-rebuild-spinner").show();
|
||||||
alertDiv.hide();
|
alertDiv.hide();
|
||||||
|
vfShowProgress("Rebuilding server...");
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "GET",
|
type: "POST",
|
||||||
dataType: "json",
|
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) {
|
}).done(function (response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
alertDiv.removeClass("alert-danger").addClass("alert-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.");
|
alertDiv.text(response.data.message || "Server rebuild initiated. You will receive an email when the process is complete.");
|
||||||
} else {
|
} else {
|
||||||
alertDiv.removeClass("alert-success").addClass("alert-danger");
|
alertDiv.removeClass("alert-success").addClass("alert-danger");
|
||||||
alertDiv.text(response.errors || "Rebuild failed.");
|
alertDiv.text("Rebuild failed. Please try again.");
|
||||||
}
|
}
|
||||||
alertDiv.show();
|
alertDiv.show();
|
||||||
}).fail(function () {
|
}).fail(function () {
|
||||||
@@ -318,8 +445,12 @@ function vfRebuildServer(serviceId, systemUrl) {
|
|||||||
alertDiv.text("An error occurred. Please try again.");
|
alertDiv.text("An error occurred. Please try again.");
|
||||||
alertDiv.show();
|
alertDiv.show();
|
||||||
}).always(function () {
|
}).always(function () {
|
||||||
|
vfHideProgress();
|
||||||
$("#vf-rebuild-spinner").hide();
|
$("#vf-rebuild-spinner").hide();
|
||||||
|
// Cooldown: keep button disabled for 30 seconds after rebuild
|
||||||
|
setTimeout(function () {
|
||||||
$("#vf-rebuild-button").prop("disabled", false);
|
$("#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
|
// VNC Console
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -420,7 +515,7 @@ function vfOpenVnc(serviceId, systemUrl) {
|
|||||||
} else {
|
} else {
|
||||||
vncWindow.close();
|
vncWindow.close();
|
||||||
alertDiv.removeClass("alert-success").addClass("alert-danger");
|
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();
|
alertDiv.show();
|
||||||
}
|
}
|
||||||
}).fail(function () {
|
}).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
|
// Self Service — Credit & Usage
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -524,9 +674,10 @@ function vfAddCredit(serviceId, systemUrl) {
|
|||||||
alertDiv.hide();
|
alertDiv.hide();
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: "GET",
|
type: "POST",
|
||||||
dataType: "json",
|
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) {
|
}).done(function (response) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
alertDiv.removeClass("alert-danger").addClass("alert-success");
|
alertDiv.removeClass("alert-danger").addClass("alert-success");
|
||||||
@@ -537,7 +688,7 @@ function vfAddCredit(serviceId, systemUrl) {
|
|||||||
vfLoadSelfServiceUsage(serviceId, systemUrl);
|
vfLoadSelfServiceUsage(serviceId, systemUrl);
|
||||||
} else {
|
} else {
|
||||||
alertDiv.removeClass("alert-success").addClass("alert-danger");
|
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();
|
alertDiv.show();
|
||||||
}
|
}
|
||||||
}).fail(function () {
|
}).fail(function () {
|
||||||
@@ -549,3 +700,331 @@ function vfAddCredit(serviceId, systemUrl) {
|
|||||||
btn.prop("disabled", false);
|
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;">↻ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<link href="{$systemURL}modules/servers/VirtFusionDirect/templates/css/module.css?v=20260207" rel="stylesheet">
|
<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=20260207"></script>
|
<script src="{$systemURL}modules/servers/VirtFusionDirect/templates/js/module.js?v=20260319"></script>
|
||||||
|
|
||||||
{if $serviceStatus eq 'Active'}
|
{if $serviceStatus eq 'Active'}
|
||||||
|
|
||||||
@@ -12,9 +12,27 @@
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body card-body p-4">
|
<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-container">
|
||||||
<div id="vf-server-info-loader" class="d-flex align-items-center justify-content-center">
|
<div id="vf-server-info-loader">
|
||||||
<div class="spinner-border"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<script>vfServerData('{$serviceid}', '{$systemURL}');</script>
|
<script>vfServerData('{$serviceid}', '{$systemURL}');</script>
|
||||||
@@ -27,7 +45,15 @@
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="row p-1">
|
<div class="row p-1">
|
||||||
<div class="col-xs-4 col-4 text-right vf-bold">Name:</div>
|
<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">↻</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>
|
||||||
<div class="row p-1">
|
<div class="row p-1">
|
||||||
<div class="col-xs-4 col-4 text-right vf-bold">Hostname:</div>
|
<div class="col-xs-4 col-4 text-right vf-bold">Hostname:</div>
|
||||||
@@ -136,6 +162,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,16 +197,16 @@
|
|||||||
<div class="alert alert-warning">
|
<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.
|
<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>
|
||||||
<div class="row">
|
<input type="hidden" id="vf-rebuild-os" value="">
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="form-group mb-3">
|
<div class="form-group mb-3">
|
||||||
<label for="vf-rebuild-os">Operating System</label>
|
<label>Operating System</label>
|
||||||
<select id="vf-rebuild-os" class="form-control">
|
<input type="text" id="vf-os-search" class="form-control vf-os-search" placeholder="Search templates...">
|
||||||
<option value="">Loading...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="vf-os-gallery-loader" class="mb-3">
|
||||||
|
<div class="vf-skeleton" style="height:120px;"></div>
|
||||||
</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">
|
<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>
|
<span id="vf-rebuild-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||||
Rebuild Server
|
Rebuild Server
|
||||||
@@ -235,6 +282,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,10 +308,37 @@
|
|||||||
<div class="panel-body card-body p-4">
|
<div class="panel-body card-body p-4">
|
||||||
<div id="vf-vnc-alert" class="alert" style="display: none;"></div>
|
<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>
|
<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">
|
<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>
|
<span id="vf-vnc-spinner" class="spinner-border spinner-border-sm vf-spinner-margin" style="display:none;"></span>
|
||||||
Open Console
|
Open Console
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
33
scripts/generate-endpoint-doc.sh
Executable file
33
scripts/generate-endpoint-doc.sh
Executable 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
|
||||||
Reference in New Issue
Block a user