From e4ce6767eaf236c3065cfb418ca8944c4155afbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Wed, 30 Oct 2024 13:58:09 +0100 Subject: [PATCH 01/13] Add power control buttons --- modules/servers/novoserve/novoserve.php | 150 ++++++++++++++++-------- 1 file changed, 104 insertions(+), 46 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 96861ce..6742544 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -92,25 +92,114 @@ function novoserve_AdminServicesTabFields(array $params): array // Create API object; $api = new Client($apiKey, $apiSecret); + $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); + return [ + 'NovoServe Module' => 'IPMI' + ]; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + $params, + $e->getMessage(), + $e->getTraceAsString() + ); + + return ['NovoServe Module' => 'Could not initialize NovoServe module, error: ' . $e->getMessage()]; + } +} + +/* + * Add buttons to the admin side to manage power functions as well + */ +function novoserve_AdminCustomButtonArray(): array +{ + return [ + 'Power On' => 'poweron', + 'Reset' => 'reset', + 'Power Off' => 'poweroff', + 'Cold Boot' => 'coldboot', + ]; +} + +function novoserve_poweron(array $params): string +{ + return doPowerAction(getApiClient($params), getServerTag($params), 'poweron'); +} +function novoserve_reset(array $params): string +{ + return doPowerAction(getApiClient($params), getServerTag($params), 'reset'); +} +function novoserve_poweroff(array $params): string +{ + return doPowerAction(getApiClient($params), getServerTag($params), 'poweroff'); +} +function novoserve_coldboot(array $params): string +{ + return doPowerAction(getApiClient($params), getServerTag($params), 'coldboot'); +} + +function getApiClient(array $params): Client +{ + $apiKey = $params['configoption1']; + $apiSecret = $params['configoption2']; + return new Client($apiKey, $apiSecret); +} + +function getServerTag(array $params): ServerTag +{ + return new ServerTag($params['username']); +} + +function doPowerAction(Client $api, ServerTag $serverTag, string $action): string +{ + $actions = [ + 'poweron' => 'Power on', + 'poweroff' => 'Power off', + 'coldboot' => 'Cold boot', + 'reset' => 'Reset', + ]; + if (!isset($actions[$action])) { + return 'Unknown power action'; + } + + try { + $api->post('server/' . $serverTag . '/' . $action); + return $actions[$action] . ' command executed.'; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + [], + $e->getMessage(), + $e->getTraceAsString() + ); + return 'Could not perform power action on server.'; + } +} + +function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string +{ + try { // Generate an IPMI link; $ipmiLink = $api->post('servers/' . $serverTag . '/ipmi-link', [ 'remoteIp' => ClientIpHelper::getClientIpAddress(), 'whitelabel' => $whiteLabel, ])['results'] ?? ''; - return ['NovoServe Module' => 'IPMI']; + return $ipmiLink; } catch (Exception $e) { logModuleCall( 'novoserve', __FUNCTION__, - $params, + [], $e->getMessage(), $e->getTraceAsString() ); - return ['NovoServe Module' => 'Could not generate IPMI link, error: ' . $e->getMessage()]; + return 'Could not generate IPMI link, error: ' . $e->getMessage(); } } @@ -146,12 +235,6 @@ function novoserve_ClientArea(array $params): array throw new Exception('Service is not active.'); } - // Get all service details; - $apiKey = $params['configoption1']; - $apiSecret = $params['configoption2']; - $whiteLabel = is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; - $serverTag = new ServerTag($params['username']); - $getPeriodStart = ''; $getPeriodEnd = ''; // Some over-engineered code to get the actual current traffic period; @@ -169,55 +252,30 @@ function novoserve_ClientArea(array $params): array } } + // Get all service details; + $whiteLabel = is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; + $serverTag = getServerTag($params); + // Create API object; - $api = new Client($apiKey, $apiSecret); + $api = getApiClient($params); // Process POST requests; if (!empty($_POST)) { - - // Power On; if (isset($_POST['poweron'])) { - $powerOperation = $api->post('servers/' . $serverTag . '/poweron', []); - $success = 'Power on command executed.'; + $success = doPowerAction($api, $serverTag, 'poweron'); } - - // Reset; - if (isset($_POST['reset'])) { - $powerOperation = $api->post('servers/' . $serverTag . '/reset', []); - $success = 'Reset command executed.'; - } - - // Power Off; if (isset($_POST['poweroff'])) { - $powerOperation = $api->post('servers/' . $serverTag . '/poweroff', []); - $success = 'Power off command executed.'; + $success = doPowerAction($api, $serverTag, 'poweroff'); + } + if (isset($_POST['reset'])) { + $success = doPowerAction($api, $serverTag, 'reset'); } - - // Cold boot; if (isset($_POST['coldboot'])) { - $powerOperation = $api->post('servers/' . $serverTag . '/coldboot', []); - $success = 'Cold boot command executed.'; + $success = doPowerAction($api, $serverTag, 'coldboot'); } - } - // Execute API requests; - try { - $ipmiLink = $api->post('servers/' . $serverTag . '/ipmi-link', [ - 'remoteIp' => ClientIpHelper::getClientIpAddress(), - 'whitelabel' => $whiteLabel, - ])['results'] ?? ''; - } catch (Exception $e) { - // Could not retrieve IPMI link/client IP, do not crash the entire page - logModuleCall( - 'novoserve', - __FUNCTION__, - $params, - $e->getMessage(), - $e->getTraceAsString(), - ); - $ipmiLink = ''; - } + $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); $getBandwidthGraph = $api->get('servers/' . $serverTag . '/bandwidth/graph', [ 'from' => strtotime($getPeriodStart), From 06a2470ab943e0cad3de2439055218de309e8ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Wed, 30 Oct 2024 15:13:56 +0100 Subject: [PATCH 02/13] Do not use the module command buttons, but do use the mechanics behind it (runModuleCommand) so we can have colours and warnings and group it with the ipmi link (which is difficult throught the module command buttons) --- modules/servers/novoserve/novoserve.php | 34 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 6742544..d60087b 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -95,7 +95,14 @@ function novoserve_AdminServicesTabFields(array $params): array $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); return [ - 'NovoServe Module' => 'IPMI' + 'NovoServe Module' => <<<"EOS" + + IPMI + + + + +EOS ]; } catch (Exception $e) { logModuleCall( @@ -112,16 +119,17 @@ function novoserve_AdminServicesTabFields(array $params): array /* * Add buttons to the admin side to manage power functions as well + * this is the official way, but we want the buttons to be together with the ipmi link, have the colours and have the warning. */ -function novoserve_AdminCustomButtonArray(): array -{ - return [ - 'Power On' => 'poweron', - 'Reset' => 'reset', - 'Power Off' => 'poweroff', - 'Cold Boot' => 'coldboot', - ]; -} +//function novoserve_AdminCustomButtonArray(): array +//{ +// return [ +// 'Power On' => 'poweron', +// 'Reset' => 'reset', +// 'Power Off' => 'poweroff', +// 'Cold Boot' => 'coldboot', +// ]; +//} function novoserve_poweron(array $params): string { @@ -181,9 +189,11 @@ function doPowerAction(Client $api, ServerTag $serverTag, string $action): strin function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string { + $link = 'servers/' . $serverTag . '/ipmi-link'; + $ipmiLink = 'none'; try { // Generate an IPMI link; - $ipmiLink = $api->post('servers/' . $serverTag . '/ipmi-link', [ + $ipmiLink = $api->post($link, [ 'remoteIp' => ClientIpHelper::getClientIpAddress(), 'whitelabel' => $whiteLabel, ])['results'] ?? ''; @@ -194,7 +204,7 @@ function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): str logModuleCall( 'novoserve', __FUNCTION__, - [], + ['link' => $link, 'result' => $ipmiLink], $e->getMessage(), $e->getTraceAsString() ); From 9abd47238cc6a8bbaf8b5794186a0af3219bf7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Wed, 30 Oct 2024 16:32:27 +0100 Subject: [PATCH 03/13] Move functions around, use correct endpoint --- modules/servers/novoserve/novoserve.php | 153 +++++++++++++----------- 1 file changed, 80 insertions(+), 73 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index d60087b..2e36f97 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -93,11 +93,15 @@ function novoserve_AdminServicesTabFields(array $params): array // Create API object; $api = new Client($apiKey, $apiSecret); $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); + if ($ipmiLink) { + $ipmiLinkButton = 'IPMI'; + } else { + $ipmiLinkButton = 'IPMI unavailable'; + } return [ 'NovoServe Module' => <<<"EOS" - - IPMI + $ipmiLinkButton @@ -133,84 +137,19 @@ function novoserve_AdminServicesTabFields(array $params): array function novoserve_poweron(array $params): string { - return doPowerAction(getApiClient($params), getServerTag($params), 'poweron'); + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweron'); } function novoserve_reset(array $params): string { - return doPowerAction(getApiClient($params), getServerTag($params), 'reset'); + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'reset'); } function novoserve_poweroff(array $params): string { - return doPowerAction(getApiClient($params), getServerTag($params), 'poweroff'); + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweroff'); } function novoserve_coldboot(array $params): string { - return doPowerAction(getApiClient($params), getServerTag($params), 'coldboot'); -} - -function getApiClient(array $params): Client -{ - $apiKey = $params['configoption1']; - $apiSecret = $params['configoption2']; - return new Client($apiKey, $apiSecret); -} - -function getServerTag(array $params): ServerTag -{ - return new ServerTag($params['username']); -} - -function doPowerAction(Client $api, ServerTag $serverTag, string $action): string -{ - $actions = [ - 'poweron' => 'Power on', - 'poweroff' => 'Power off', - 'coldboot' => 'Cold boot', - 'reset' => 'Reset', - ]; - if (!isset($actions[$action])) { - return 'Unknown power action'; - } - - try { - $api->post('server/' . $serverTag . '/' . $action); - return $actions[$action] . ' command executed.'; - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - [], - $e->getMessage(), - $e->getTraceAsString() - ); - return 'Could not perform power action on server.'; - } -} - -function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string -{ - $link = 'servers/' . $serverTag . '/ipmi-link'; - $ipmiLink = 'none'; - try { - // Generate an IPMI link; - $ipmiLink = $api->post($link, [ - 'remoteIp' => ClientIpHelper::getClientIpAddress(), - 'whitelabel' => $whiteLabel, - ])['results'] ?? ''; - - return $ipmiLink; - - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - ['link' => $link, 'result' => $ipmiLink], - $e->getMessage(), - $e->getTraceAsString() - ); - - return 'Could not generate IPMI link, error: ' . $e->getMessage(); - } + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'coldboot'); } /** @@ -264,10 +203,10 @@ function novoserve_ClientArea(array $params): array // Get all service details; $whiteLabel = is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; - $serverTag = getServerTag($params); + $serverTag = getServerTagFromParams($params); // Create API object; - $api = getApiClient($params); + $api = getApiClientFromParams($params); // Process POST requests; if (!empty($_POST)) { @@ -329,3 +268,71 @@ function novoserve_ClientArea(array $params): array ]; } } + + +/* + * Functions to talk to the NovoServe public API + */ + +function getApiClientFromParams(array $params): Client +{ + $apiKey = $params['configoption1']; + $apiSecret = $params['configoption2']; + return new Client($apiKey, $apiSecret); +} + +function getServerTagFromParams(array $params): ServerTag +{ + return new ServerTag($params['username']); +} + +function doPowerAction(Client $api, ServerTag $serverTag, string $action): string +{ + $actions = [ + 'poweron' => 'Power on', + 'poweroff' => 'Power off', + 'coldboot' => 'Cold boot', + 'reset' => 'Reset', + ]; + if (!isset($actions[$action])) { + return 'Unknown power action'; + } + + $link = 'servers/' . $serverTag . '/' . $action; + try { + $api->post($link); + return $actions[$action] . ' command executed.'; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + return 'Could not perform power action on server. ' . $e->getMessage(); + } +} + +function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string +{ + $link = 'servers/' . $serverTag . '/ipmi-link'; + try { + // Generate an IPMI link; + return $api->post($link, [ + 'remoteIp' => ClientIpHelper::getClientIpAddress(), + 'whitelabel' => $whiteLabel, + ])['results'] ?? ''; + + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + + return ''; + } +} From fed6dffd69ed5e2bd478cd9e10ca85a625740664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Thu, 31 Oct 2024 14:46:10 +0100 Subject: [PATCH 04/13] Add current power status --- modules/servers/novoserve/novoserve.php | 67 ++++++++++++++------ modules/servers/novoserve/templates/main.tpl | 2 +- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 2e36f97..fa3c0ef 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -98,10 +98,12 @@ function novoserve_AdminServicesTabFields(array $params): array } else { $ipmiLinkButton = 'IPMI unavailable'; } - + $powerStatus = getPowerStatus($api, $serverTag); return [ 'NovoServe Module' => <<<"EOS" $ipmiLinkButton + | + Power status: $powerStatus @@ -139,14 +141,17 @@ function novoserve_poweron(array $params): string { return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweron'); } + function novoserve_reset(array $params): string { return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'reset'); } + function novoserve_poweroff(array $params): string { return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweroff'); } + function novoserve_coldboot(array $params): string { return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'coldboot'); @@ -247,6 +252,7 @@ function novoserve_ClientArea(array $params): array 'success' => $success ?? false, 'serverTag' => $serverTag, 'serverHostname' => $params['domain'], + 'powerStatus' => getPowerStatus($api, $serverTag), 'ipmiLink' => $ipmiLink, 'bandwidthGraph' => $getBandwidthGraph['results']['image'], 'trafficUsage' => $getTrafficUsage['results'] @@ -274,6 +280,11 @@ function novoserve_ClientArea(array $params): array * Functions to talk to the NovoServe public API */ +function getServerTagFromParams(array $params): ServerTag +{ + return new ServerTag($params['username']); +} + function getApiClientFromParams(array $params): Client { $apiKey = $params['configoption1']; @@ -281,27 +292,11 @@ function getApiClientFromParams(array $params): Client return new Client($apiKey, $apiSecret); } -function getServerTagFromParams(array $params): ServerTag -{ - return new ServerTag($params['username']); -} - -function doPowerAction(Client $api, ServerTag $serverTag, string $action): string +function getPowerStatus(Client $api, ServerTag $serverTag): string { - $actions = [ - 'poweron' => 'Power on', - 'poweroff' => 'Power off', - 'coldboot' => 'Cold boot', - 'reset' => 'Reset', - ]; - if (!isset($actions[$action])) { - return 'Unknown power action'; - } - - $link = 'servers/' . $serverTag . '/' . $action; + $link = 'servers/' . $serverTag . '/power'; try { - $api->post($link); - return $actions[$action] . ' command executed.'; + return $api->get($link)['results'] ?? ''; } catch (Exception $e) { logModuleCall( 'novoserve', @@ -310,8 +305,10 @@ function doPowerAction(Client $api, ServerTag $serverTag, string $action): strin $e->getMessage(), $e->getTraceAsString() ); - return 'Could not perform power action on server. ' . $e->getMessage(); + + return ''; } + } function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string @@ -336,3 +333,31 @@ function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): str return ''; } } + +function doPowerAction(Client $api, ServerTag $serverTag, string $action): string +{ + $actions = [ + 'poweron' => 'Power on', + 'poweroff' => 'Power off', + 'coldboot' => 'Cold boot', + 'reset' => 'Reset', + ]; + if (!isset($actions[$action])) { + return 'Unknown power action'; + } + + $link = 'servers/' . $serverTag . '/' . $action; + try { + $api->post($link); + return $actions[$action] . ' command executed.'; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + return 'Could not perform power action on server. ' . $e->getMessage(); + } +} diff --git a/modules/servers/novoserve/templates/main.tpl b/modules/servers/novoserve/templates/main.tpl index d10222e..b460ff6 100644 --- a/modules/servers/novoserve/templates/main.tpl +++ b/modules/servers/novoserve/templates/main.tpl @@ -57,7 +57,7 @@
- Power Management + Power Management (server is currently '{$powerStatus}')
From 878d70a2bd995cbb02fcd64b3e1f32472cd0ef27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Thu, 31 Oct 2024 15:40:32 +0100 Subject: [PATCH 05/13] try some jquery to move progress indicator --- modules/servers/novoserve/novoserve.php | 26 ++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index fa3c0ef..154888b 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -102,12 +102,14 @@ function novoserve_AdminServicesTabFields(array $params): array return [ 'NovoServe Module' => <<<"EOS" $ipmiLinkButton - | - Power status: $powerStatus + Power status: $powerStatus + EOS ]; } catch (Exception $e) { @@ -127,15 +129,17 @@ function novoserve_AdminServicesTabFields(array $params): array * Add buttons to the admin side to manage power functions as well * this is the official way, but we want the buttons to be together with the ipmi link, have the colours and have the warning. */ -//function novoserve_AdminCustomButtonArray(): array -//{ -// return [ -// 'Power On' => 'poweron', -// 'Reset' => 'reset', -// 'Power Off' => 'poweroff', -// 'Cold Boot' => 'coldboot', -// ]; -//} +/* +function novoserve_AdminCustomButtonArray(): array +{ + return [ + 'Power On' => 'poweron', + 'Reset' => 'reset', + 'Power Off' => 'poweroff', + 'Cold Boot' => 'coldboot', + ]; +} +*/ function novoserve_poweron(array $params): string { From 79c21e8a884069afb3c551c8aa5c42caa025b535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Fri, 1 Nov 2024 14:52:34 +0100 Subject: [PATCH 06/13] use our own runNovoModuleCommand with loading indicator, hide the normal custom command row --- modules/servers/novoserve/novoserve.php | 56 +++++++++++++++++++++---- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 154888b..d3aa548 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -99,16 +99,56 @@ function novoserve_AdminServicesTabFields(array $params): array $ipmiLinkButton = 'IPMI unavailable'; } $powerStatus = getPowerStatus($api, $serverTag); + return [ 'NovoServe Module' => <<<"EOS" $ipmiLinkButton - - - - + + + + Power status: $powerStatus + EOS ]; @@ -300,7 +340,7 @@ function getPowerStatus(Client $api, ServerTag $serverTag): string { $link = 'servers/' . $serverTag . '/power'; try { - return $api->get($link)['results'] ?? ''; + return $api->get($link)['results'] ?? 'unknown'; } catch (Exception $e) { logModuleCall( 'novoserve', @@ -310,7 +350,7 @@ function getPowerStatus(Client $api, ServerTag $serverTag): string $e->getTraceAsString() ); - return ''; + return 'unknown'; } } @@ -362,6 +402,6 @@ function doPowerAction(Client $api, ServerTag $serverTag, string $action): strin $e->getMessage(), $e->getTraceAsString() ); - return 'Could not perform power action on server. ' . $e->getMessage(); + return 'Could not perform ' . $actions[$action] . ' action on server. ' . $e->getMessage(); } } From 1baef2e001e1a4f9c30f6a30b8aa5e78365a93df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Fri, 1 Nov 2024 16:42:13 +0100 Subject: [PATCH 07/13] cleaner version, only alter the Module Commands buttons to have colour and confirmation by --- modules/servers/novoserve/novoserve.php | 109 +++++++----------------- 1 file changed, 30 insertions(+), 79 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index d3aa548..0d23543 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -83,103 +83,54 @@ function novoserve_ConfigOptions(): array */ function novoserve_AdminServicesTabFields(array $params): array { - try { - // Get all service details; - $apiKey = $params['configoption1']; - $apiSecret = $params['configoption2']; - $whiteLabel = is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; - $serverTag = new ServerTag($params['username']); - - // Create API object; - $api = new Client($apiKey, $apiSecret); - $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); - if ($ipmiLink) { - $ipmiLinkButton = 'IPMI'; - } else { - $ipmiLinkButton = 'IPMI unavailable'; - } - $powerStatus = getPowerStatus($api, $serverTag); - - return [ - 'NovoServe Module' => <<<"EOS" - $ipmiLinkButton - - - - - Power status: $powerStatus - - + }); + EOS - ]; - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - $params, - $e->getMessage(), - $e->getTraceAsString() - ); - - return ['NovoServe Module' => 'Could not initialize NovoServe module, error: ' . $e->getMessage()]; - } + ]; } /* * Add buttons to the admin side to manage power functions as well * this is the official way, but we want the buttons to be together with the ipmi link, have the colours and have the warning. */ -/* function novoserve_AdminCustomButtonArray(): array { return [ + 'IPMI' => 'ipmi', 'Power On' => 'poweron', 'Reset' => 'reset', 'Power Off' => 'poweroff', 'Cold Boot' => 'coldboot', ]; } -*/ + +function novoserve_ipmi(array $params): string +{ + $whiteLabel = is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; + return 'window|' . getIpmiLink(getApiClientFromParams($params), getServerTagFromParams($params), $whiteLabel); +} function novoserve_poweron(array $params): string { From 89b21c1553e451d4bc3e54d3153784aaa8850e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Mon, 4 Nov 2024 11:01:55 +0100 Subject: [PATCH 08/13] add a power status "button" and disable ipmi button if no link can be created --- modules/servers/novoserve/novoserve.php | 86 +++++++++++++++++++------ 1 file changed, 65 insertions(+), 21 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 0d23543..45bb118 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -83,27 +83,60 @@ function novoserve_ConfigOptions(): array */ function novoserve_AdminServicesTabFields(array $params): array { + $api = getApiClientFromParams($params); + $serverTag = getServerTagFromParams($params); + $powerStatus = getPowerStatus($api, $serverTag); + $ipmiLink = getIpmiLink($api, $serverTag, getWhitelabelFromParams($params)); + $ipmiEnabled = $ipmiLink === '' ? 'false' : 'true'; + return [ 'NovoServe Module' => <<<"EOS" - @@ -113,12 +146,13 @@ function novoserve_AdminServicesTabFields(array $params): array /* * Add buttons to the admin side to manage power functions as well - * this is the official way, but we want the buttons to be together with the ipmi link, have the colours and have the warning. + * We do alter them a bit through javascript later on */ -function novoserve_AdminCustomButtonArray(): array +function novoserve_AdminCustomButtonArray(array $params): array { return [ 'IPMI' => 'ipmi', + 'Power Status' => 'powerstatus', 'Power On' => 'poweron', 'Reset' => 'reset', 'Power Off' => 'poweroff', @@ -128,8 +162,13 @@ function novoserve_AdminCustomButtonArray(): array function novoserve_ipmi(array $params): string { - $whiteLabel = is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; - return 'window|' . getIpmiLink(getApiClientFromParams($params), getServerTagFromParams($params), $whiteLabel); + + return 'window|' . getIpmiLink(getApiClientFromParams($params), getServerTagFromParams($params), getWhitelabelFromParams($params)); +} + +function novoserve_powerstatus(array $params): string +{ + return getPowerStatus(getApiClientFromParams($params), getServerTagFromParams($params)); } function novoserve_poweron(array $params): string @@ -202,7 +241,7 @@ function novoserve_ClientArea(array $params): array } // Get all service details; - $whiteLabel = is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; + $whiteLabel = getWhitelabelFromParams($params); $serverTag = getServerTagFromParams($params); // Create API object; @@ -287,6 +326,11 @@ function getApiClientFromParams(array $params): Client return new Client($apiKey, $apiSecret); } +function getWhitelabelFromParams(array $params): string +{ + return is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; +} + function getPowerStatus(Client $api, ServerTag $serverTag): string { $link = 'servers/' . $serverTag . '/power'; From ee78fd6809fdbc78700eb438aadc92629dd95473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Mon, 4 Nov 2024 12:09:29 +0100 Subject: [PATCH 09/13] ok, just use the command buttons for actions and use the tab field for ipmi and power status display - aka using things the way they should --- modules/servers/novoserve/novoserve.php | 458 +++++++++++------------- 1 file changed, 216 insertions(+), 242 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 45bb118..2bcbcea 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -85,17 +85,22 @@ function novoserve_AdminServicesTabFields(array $params): array { $api = getApiClientFromParams($params); $serverTag = getServerTagFromParams($params); - $powerStatus = getPowerStatus($api, $serverTag); $ipmiLink = getIpmiLink($api, $serverTag, getWhitelabelFromParams($params)); - $ipmiEnabled = $ipmiLink === '' ? 'false' : 'true'; + $powerStatus = getPowerStatus($api, $serverTag); + + if ($ipmiLink) { + $ipmiLinkButton = 'IPMI'; + } else { + $ipmiLinkButton = 'IPMI not available'; + } return [ 'NovoServe Module' => <<<"EOS" - @@ -151,8 +138,6 @@ function addConfirmation(button) { function novoserve_AdminCustomButtonArray(array $params): array { return [ - 'IPMI' => 'ipmi', - 'Power Status' => 'powerstatus', 'Power On' => 'poweron', 'Reset' => 'reset', 'Power Off' => 'poweroff', @@ -160,243 +145,232 @@ function novoserve_AdminCustomButtonArray(array $params): array ]; } -function novoserve_ipmi(array $params): string -{ - - return 'window|' . getIpmiLink(getApiClientFromParams($params), getServerTagFromParams($params), getWhitelabelFromParams($params)); -} - -function novoserve_powerstatus(array $params): string -{ - return getPowerStatus(getApiClientFromParams($params), getServerTagFromParams($params)); -} - -function novoserve_poweron(array $params): string -{ - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweron'); -} + function novoserve_poweron(array $params): string + { + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweron'); + } -function novoserve_reset(array $params): string -{ - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'reset'); -} + function novoserve_reset(array $params): string + { + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'reset'); + } -function novoserve_poweroff(array $params): string -{ - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweroff'); -} + function novoserve_poweroff(array $params): string + { + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweroff'); + } -function novoserve_coldboot(array $params): string -{ - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'coldboot'); -} + function novoserve_coldboot(array $params): string + { + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'coldboot'); + } -/** - * Client area output logic handling. - * This function is used to define module specific client area output. It should - * return an array consisting of a template file and optional additional - * template variables to make available to that template. - * The template file you return can be one of two types: - * * tabOverviewModuleOutputTemplate - The output of the template provided here - * will be displayed as part of the default product/service client area - * product overview page. - * * tabOverviewReplacementTemplate - Alternatively using this option allows you - * to entirely take control of the product/service overview page within the - * client area. - * Whichever option you choose, extra template variables are defined in the same - * way. This demonstrates the use of the full replacement. - * Please Note: Using tabOverviewReplacementTemplate means you should display - * the standard information such as pricing and billing details in your custom - * template or they will not be visible to the end user. - * - * @param array $params common module parameters - * - * @return array - * @see https://developers.whmcs.com/provisioning-modules/module-parameters/ - */ -function novoserve_ClientArea(array $params): array -{ - try { - // Stop if service is not active; - $serviceStatus = $params['status']; - if ($serviceStatus !== 'Active') { - throw new Exception('Service is not active.'); - } + /** + * Client area output logic handling. + * This function is used to define module specific client area output. It should + * return an array consisting of a template file and optional additional + * template variables to make available to that template. + * The template file you return can be one of two types: + * * tabOverviewModuleOutputTemplate - The output of the template provided here + * will be displayed as part of the default product/service client area + * product overview page. + * * tabOverviewReplacementTemplate - Alternatively using this option allows you + * to entirely take control of the product/service overview page within the + * client area. + * Whichever option you choose, extra template variables are defined in the same + * way. This demonstrates the use of the full replacement. + * Please Note: Using tabOverviewReplacementTemplate means you should display + * the standard information such as pricing and billing details in your custom + * template or they will not be visible to the end user. + * + * @param array $params common module parameters + * + * @return array + * @see https://developers.whmcs.com/provisioning-modules/module-parameters/ + */ + function novoserve_ClientArea(array $params): array + { + try { + // Stop if service is not active; + $serviceStatus = $params['status']; + if ($serviceStatus !== 'Active') { + throw new Exception('Service is not active.'); + } - $getPeriodStart = ''; - $getPeriodEnd = ''; - // Some over-engineered code to get the actual current traffic period; - if ($params['model']->billingcycle !== 'Free Account') { - $nextDueDateTime = new DateTime($params['model']->nextinvoicedate); - $nextDueDateDay = $nextDueDateTime->format('d'); - $nextDueDateTime = new DateTime(date('Y-m-') . $nextDueDateDay); // Create DateTime object, it will automatically bump the date if the day is not in this month; - $getPeriodEndDateTime = $nextDueDateTime; - $getPeriodStart = $getPeriodEndDateTime->modify('-1 month')->format('d-m-Y'); - - if (date('d') < $nextDueDateDay) { - $getPeriodEnd = $nextDueDateTime->format('d-m-Y'); - } else { - $getPeriodEnd = $nextDueDateTime->modify('+1 month')->format('d-m-Y'); + $getPeriodStart = ''; + $getPeriodEnd = ''; + // Some over-engineered code to get the actual current traffic period; + if ($params['model']->billingcycle !== 'Free Account') { + $nextDueDateTime = new DateTime($params['model']->nextinvoicedate); + $nextDueDateDay = $nextDueDateTime->format('d'); + $nextDueDateTime = new DateTime(date('Y-m-') . $nextDueDateDay); // Create DateTime object, it will automatically bump the date if the day is not in this month; + $getPeriodEndDateTime = $nextDueDateTime; + $getPeriodStart = $getPeriodEndDateTime->modify('-1 month')->format('d-m-Y'); + + if (date('d') < $nextDueDateDay) { + $getPeriodEnd = $nextDueDateTime->format('d-m-Y'); + } else { + $getPeriodEnd = $nextDueDateTime->modify('+1 month')->format('d-m-Y'); + } } - } - // Get all service details; - $whiteLabel = getWhitelabelFromParams($params); - $serverTag = getServerTagFromParams($params); + // Get all service details; + $whiteLabel = getWhitelabelFromParams($params); + $serverTag = getServerTagFromParams($params); - // Create API object; - $api = getApiClientFromParams($params); + // Create API object; + $api = getApiClientFromParams($params); - // Process POST requests; - if (!empty($_POST)) { - if (isset($_POST['poweron'])) { - $success = doPowerAction($api, $serverTag, 'poweron'); - } - if (isset($_POST['poweroff'])) { - $success = doPowerAction($api, $serverTag, 'poweroff'); - } - if (isset($_POST['reset'])) { - $success = doPowerAction($api, $serverTag, 'reset'); - } - if (isset($_POST['coldboot'])) { - $success = doPowerAction($api, $serverTag, 'coldboot'); + // Process POST requests; + if (!empty($_POST)) { + if (isset($_POST['poweron'])) { + $success = doPowerAction($api, $serverTag, 'poweron'); + } + if (isset($_POST['poweroff'])) { + $success = doPowerAction($api, $serverTag, 'poweroff'); + } + if (isset($_POST['reset'])) { + $success = doPowerAction($api, $serverTag, 'reset'); + } + if (isset($_POST['coldboot'])) { + $success = doPowerAction($api, $serverTag, 'coldboot'); + } } - } - $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); - - $getBandwidthGraph = $api->get('servers/' . $serverTag . '/bandwidth/graph', [ - 'from' => strtotime($getPeriodStart), - 'height' => 200 - ]); - $getTrafficUsage = $api->get('servers/' . $serverTag . '/bandwidth', [ - 'from' => strtotime($getPeriodStart) - ]); - - // Prepare values before loading it into template vars; - $getTrafficUsage['results']['dateTimeFrom'] = date('d-m-Y', strtotime($getPeriodStart)); - $getTrafficUsage['results']['dateTimeUntil'] = date('d-m-Y', strtotime($getPeriodEnd)); - $getTrafficUsage['results']['usage'] = round($getTrafficUsage['results']['usage'], 2); - $getTrafficUsage['results']['download'] = round($getTrafficUsage['results']['download'], 2); - - // Load and return template with variables; - return [ - 'tabOverviewModuleOutputTemplate' => 'templates/main.tpl', - 'templateVariables' => [ - 'success' => $success ?? false, - 'serverTag' => $serverTag, - 'serverHostname' => $params['domain'], - 'powerStatus' => getPowerStatus($api, $serverTag), - 'ipmiLink' => $ipmiLink, - 'bandwidthGraph' => $getBandwidthGraph['results']['image'], - 'trafficUsage' => $getTrafficUsage['results'] - ], - ]; - - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - $params, - $e->getMessage(), - $e->getTraceAsString() - ); - - return [ - 'tabOverviewModuleOutputTemplate' => 'templates/error.tpl', - 'templateVariables' => ['error' => $e->getMessage()], - ]; + $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); + + $getBandwidthGraph = $api->get('servers/' . $serverTag . '/bandwidth/graph', [ + 'from' => strtotime($getPeriodStart), + 'height' => 200 + ]); + $getTrafficUsage = $api->get('servers/' . $serverTag . '/bandwidth', [ + 'from' => strtotime($getPeriodStart) + ]); + + // Prepare values before loading it into template vars; + $getTrafficUsage['results']['dateTimeFrom'] = date('d-m-Y', strtotime($getPeriodStart)); + $getTrafficUsage['results']['dateTimeUntil'] = date('d-m-Y', strtotime($getPeriodEnd)); + $getTrafficUsage['results']['usage'] = round($getTrafficUsage['results']['usage'], 2); + $getTrafficUsage['results']['download'] = round($getTrafficUsage['results']['download'], 2); + + // Load and return template with variables; + return [ + 'tabOverviewModuleOutputTemplate' => 'templates/main.tpl', + 'templateVariables' => [ + 'success' => $success ?? false, + 'serverTag' => $serverTag, + 'serverHostname' => $params['domain'], + 'powerStatus' => getPowerStatus($api, $serverTag), + 'ipmiLink' => $ipmiLink, + 'bandwidthGraph' => $getBandwidthGraph['results']['image'], + 'trafficUsage' => $getTrafficUsage['results'] + ], + ]; + + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + $params, + $e->getMessage(), + $e->getTraceAsString() + ); + + return [ + 'tabOverviewModuleOutputTemplate' => 'templates/error.tpl', + 'templateVariables' => ['error' => $e->getMessage()], + ]; + } } -} -/* - * Functions to talk to the NovoServe public API - */ - -function getServerTagFromParams(array $params): ServerTag -{ - return new ServerTag($params['username']); -} + /* + * Functions to talk to the NovoServe public API + */ -function getApiClientFromParams(array $params): Client -{ - $apiKey = $params['configoption1']; - $apiSecret = $params['configoption2']; - return new Client($apiKey, $apiSecret); -} + function getServerTagFromParams(array $params): ServerTag + { + return new ServerTag($params['username']); + } -function getWhitelabelFromParams(array $params): string -{ - return is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; -} + function getApiClientFromParams(array $params): Client + { + $apiKey = $params['configoption1']; + $apiSecret = $params['configoption2']; + return new Client($apiKey, $apiSecret); + } -function getPowerStatus(Client $api, ServerTag $serverTag): string -{ - $link = 'servers/' . $serverTag . '/power'; - try { - return $api->get($link)['results'] ?? 'unknown'; - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - ['link' => $link], - $e->getMessage(), - $e->getTraceAsString() - ); - - return 'unknown'; + function getWhitelabelFromParams(array $params): string + { + return is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; } -} + function getPowerStatus(Client $api, ServerTag $serverTag): string + { + $link = 'servers/' . $serverTag . '/power'; + try { + return $api->get($link)['results'] ?? 'unknown'; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + + return 'unknown'; + } -function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string -{ - $link = 'servers/' . $serverTag . '/ipmi-link'; - try { - // Generate an IPMI link; - return $api->post($link, [ - 'remoteIp' => ClientIpHelper::getClientIpAddress(), - 'whitelabel' => $whiteLabel, - ])['results'] ?? ''; - - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - ['link' => $link], - $e->getMessage(), - $e->getTraceAsString() - ); - - return ''; } -} -function doPowerAction(Client $api, ServerTag $serverTag, string $action): string -{ - $actions = [ - 'poweron' => 'Power on', - 'poweroff' => 'Power off', - 'coldboot' => 'Cold boot', - 'reset' => 'Reset', - ]; - if (!isset($actions[$action])) { - return 'Unknown power action'; + function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string + { + $link = 'servers/' . $serverTag . '/ipmi-link'; + try { + // Generate an IPMI link; + return $api->post($link, [ + 'remoteIp' => ClientIpHelper::getClientIpAddress(), + 'whitelabel' => $whiteLabel, + ])['results'] ?? ''; + + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + + return ''; + } } - $link = 'servers/' . $serverTag . '/' . $action; - try { - $api->post($link); - return $actions[$action] . ' command executed.'; - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - ['link' => $link], - $e->getMessage(), - $e->getTraceAsString() - ); - return 'Could not perform ' . $actions[$action] . ' action on server. ' . $e->getMessage(); + function doPowerAction(Client $api, ServerTag $serverTag, string $action): string + { + $actions = [ + 'poweron' => 'Power on', + 'poweroff' => 'Power off', + 'coldboot' => 'Cold boot', + 'reset' => 'Reset', + ]; + if (!isset($actions[$action])) { + return 'Unknown power action'; + } + + $link = 'servers/' . $serverTag . '/' . $action; + try { + $api->post($link); + return $actions[$action] . ' command executed.'; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + return 'Could not perform ' . $actions[$action] . ' action on server. ' . $e->getMessage(); + } } -} From f3444385605eb73fc0bc48ba0f2486a909b0b1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Mon, 4 Nov 2024 12:16:25 +0100 Subject: [PATCH 10/13] some styling --- modules/servers/novoserve/novoserve.php | 64 ++++++++++++------------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 2bcbcea..187615f 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -88,45 +88,43 @@ function novoserve_AdminServicesTabFields(array $params): array $ipmiLink = getIpmiLink($api, $serverTag, getWhitelabelFromParams($params)); $powerStatus = getPowerStatus($api, $serverTag); - if ($ipmiLink) { - $ipmiLinkButton = 'IPMI'; - } else { - $ipmiLinkButton = 'IPMI not available'; - } + $ipmiText = 'IPMI' . ($ipmiLink ? '' : ' not available'); + $disabled = $ipmiLink ? '' : ' disabled="disabled"'; return [ 'NovoServe Module' => <<<"EOS" - ${ipmiLinkButton} +${ipmiText} - Power status: ${powerStatus} +Power status: ${powerStatus} - + EOS ]; } From 3e07d66609578bdf84430c79e6cf5240a46db50f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Mon, 4 Nov 2024 16:33:22 +0100 Subject: [PATCH 11/13] just return 'success' as anything else is interpreted as error by whmcs --- modules/servers/novoserve/novoserve.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 187615f..958bb10 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -360,7 +360,7 @@ function doPowerAction(Client $api, ServerTag $serverTag, string $action): strin $link = 'servers/' . $serverTag . '/' . $action; try { $api->post($link); - return $actions[$action] . ' command executed.'; + return 'success'; } catch (Exception $e) { logModuleCall( 'novoserve', From 39b09425756ab92de21f5583a3e98e682f320afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Mon, 4 Nov 2024 16:50:11 +0100 Subject: [PATCH 12/13] isolate bandwidth graphs, add error ignore --- modules/servers/novoserve/novoserve.php | 429 +++++++++++++----------- 1 file changed, 225 insertions(+), 204 deletions(-) diff --git a/modules/servers/novoserve/novoserve.php b/modules/servers/novoserve/novoserve.php index 958bb10..2e57ed7 100644 --- a/modules/servers/novoserve/novoserve.php +++ b/modules/servers/novoserve/novoserve.php @@ -143,232 +143,253 @@ function novoserve_AdminCustomButtonArray(array $params): array ]; } - function novoserve_poweron(array $params): string - { - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweron'); - } +function novoserve_poweron(array $params): string +{ + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweron'); +} - function novoserve_reset(array $params): string - { - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'reset'); - } +function novoserve_reset(array $params): string +{ + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'reset'); +} - function novoserve_poweroff(array $params): string - { - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweroff'); - } +function novoserve_poweroff(array $params): string +{ + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'poweroff'); +} - function novoserve_coldboot(array $params): string - { - return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'coldboot'); - } +function novoserve_coldboot(array $params): string +{ + return doPowerAction(getApiClientFromParams($params), getServerTagFromParams($params), 'coldboot'); +} - /** - * Client area output logic handling. - * This function is used to define module specific client area output. It should - * return an array consisting of a template file and optional additional - * template variables to make available to that template. - * The template file you return can be one of two types: - * * tabOverviewModuleOutputTemplate - The output of the template provided here - * will be displayed as part of the default product/service client area - * product overview page. - * * tabOverviewReplacementTemplate - Alternatively using this option allows you - * to entirely take control of the product/service overview page within the - * client area. - * Whichever option you choose, extra template variables are defined in the same - * way. This demonstrates the use of the full replacement. - * Please Note: Using tabOverviewReplacementTemplate means you should display - * the standard information such as pricing and billing details in your custom - * template or they will not be visible to the end user. - * - * @param array $params common module parameters - * - * @return array - * @see https://developers.whmcs.com/provisioning-modules/module-parameters/ - */ - function novoserve_ClientArea(array $params): array - { - try { - // Stop if service is not active; - $serviceStatus = $params['status']; - if ($serviceStatus !== 'Active') { - throw new Exception('Service is not active.'); - } +/** + * Client area output logic handling. + * This function is used to define module specific client area output. It should + * return an array consisting of a template file and optional additional + * template variables to make available to that template. + * The template file you return can be one of two types: + * * tabOverviewModuleOutputTemplate - The output of the template provided here + * will be displayed as part of the default product/service client area + * product overview page. + * * tabOverviewReplacementTemplate - Alternatively using this option allows you + * to entirely take control of the product/service overview page within the + * client area. + * Whichever option you choose, extra template variables are defined in the same + * way. This demonstrates the use of the full replacement. + * Please Note: Using tabOverviewReplacementTemplate means you should display + * the standard information such as pricing and billing details in your custom + * template or they will not be visible to the end user. + * + * @param array $params common module parameters + * + * @return array + * @see https://developers.whmcs.com/provisioning-modules/module-parameters/ + */ +function novoserve_ClientArea(array $params): array +{ + try { + // Stop if service is not active; + $serviceStatus = $params['status']; + if ($serviceStatus !== 'Active') { + throw new Exception('Service is not active.'); + } - $getPeriodStart = ''; - $getPeriodEnd = ''; - // Some over-engineered code to get the actual current traffic period; - if ($params['model']->billingcycle !== 'Free Account') { - $nextDueDateTime = new DateTime($params['model']->nextinvoicedate); - $nextDueDateDay = $nextDueDateTime->format('d'); - $nextDueDateTime = new DateTime(date('Y-m-') . $nextDueDateDay); // Create DateTime object, it will automatically bump the date if the day is not in this month; - $getPeriodEndDateTime = $nextDueDateTime; - $getPeriodStart = $getPeriodEndDateTime->modify('-1 month')->format('d-m-Y'); - - if (date('d') < $nextDueDateDay) { - $getPeriodEnd = $nextDueDateTime->format('d-m-Y'); - } else { - $getPeriodEnd = $nextDueDateTime->modify('+1 month')->format('d-m-Y'); - } - } + // Get all service details; + $serverTag = getServerTagFromParams($params); + $whiteLabel = getWhitelabelFromParams($params); - // Get all service details; - $whiteLabel = getWhitelabelFromParams($params); - $serverTag = getServerTagFromParams($params); - - // Create API object; - $api = getApiClientFromParams($params); - - // Process POST requests; - if (!empty($_POST)) { - if (isset($_POST['poweron'])) { - $success = doPowerAction($api, $serverTag, 'poweron'); - } - if (isset($_POST['poweroff'])) { - $success = doPowerAction($api, $serverTag, 'poweroff'); - } - if (isset($_POST['reset'])) { - $success = doPowerAction($api, $serverTag, 'reset'); - } - if (isset($_POST['coldboot'])) { - $success = doPowerAction($api, $serverTag, 'coldboot'); - } - } + // Create API object; + $api = getApiClientFromParams($params); - $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); - - $getBandwidthGraph = $api->get('servers/' . $serverTag . '/bandwidth/graph', [ - 'from' => strtotime($getPeriodStart), - 'height' => 200 - ]); - $getTrafficUsage = $api->get('servers/' . $serverTag . '/bandwidth', [ - 'from' => strtotime($getPeriodStart) - ]); - - // Prepare values before loading it into template vars; - $getTrafficUsage['results']['dateTimeFrom'] = date('d-m-Y', strtotime($getPeriodStart)); - $getTrafficUsage['results']['dateTimeUntil'] = date('d-m-Y', strtotime($getPeriodEnd)); - $getTrafficUsage['results']['usage'] = round($getTrafficUsage['results']['usage'], 2); - $getTrafficUsage['results']['download'] = round($getTrafficUsage['results']['download'], 2); - - // Load and return template with variables; - return [ - 'tabOverviewModuleOutputTemplate' => 'templates/main.tpl', - 'templateVariables' => [ - 'success' => $success ?? false, - 'serverTag' => $serverTag, - 'serverHostname' => $params['domain'], - 'powerStatus' => getPowerStatus($api, $serverTag), - 'ipmiLink' => $ipmiLink, - 'bandwidthGraph' => $getBandwidthGraph['results']['image'], - 'trafficUsage' => $getTrafficUsage['results'] - ], - ]; - - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - $params, - $e->getMessage(), - $e->getTraceAsString() - ); - - return [ - 'tabOverviewModuleOutputTemplate' => 'templates/error.tpl', - 'templateVariables' => ['error' => $e->getMessage()], - ]; + // Process POST requests; + if (!empty($_POST)) { + if (isset($_POST['poweron'])) { + $success = doPowerAction($api, $serverTag, 'poweron'); + } + if (isset($_POST['poweroff'])) { + $success = doPowerAction($api, $serverTag, 'poweroff'); + } + if (isset($_POST['reset'])) { + $success = doPowerAction($api, $serverTag, 'reset'); + } + if (isset($_POST['coldboot'])) { + $success = doPowerAction($api, $serverTag, 'coldboot'); + } } + + $ipmiLink = getIpmiLink($api, $serverTag, $whiteLabel); + + $bandwidthAndTraffic = getBandwidthAndTraffic($api, $serverTag, $params); + + // Load and return template with variables; + return [ + 'tabOverviewModuleOutputTemplate' => 'templates/main.tpl', + 'templateVariables' => [ + 'success' => $success ?? false, + 'serverTag' => $serverTag, + 'serverHostname' => $params['domain'], + 'powerStatus' => getPowerStatus($api, $serverTag), + 'ipmiLink' => $ipmiLink, + 'bandwidthGraph' => $bandwidthAndTraffic['bandwidthGraph'], + 'trafficUsage' => $bandwidthAndTraffic['trafficUsage'], + ], + ]; + + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + $params, + $e->getMessage(), + $e->getTraceAsString() + ); + + return [ + 'tabOverviewModuleOutputTemplate' => 'templates/error.tpl', + 'templateVariables' => ['error' => $e->getMessage()], + ]; } +} - /* - * Functions to talk to the NovoServe public API - */ +/* + * Functions to talk to the NovoServe public API + */ - function getServerTagFromParams(array $params): ServerTag - { - return new ServerTag($params['username']); - } +function getServerTagFromParams(array $params): ServerTag +{ + return new ServerTag($params['username']); +} + +function getApiClientFromParams(array $params): Client +{ + $apiKey = $params['configoption1']; + $apiSecret = $params['configoption2']; + return new Client($apiKey, $apiSecret); +} - function getApiClientFromParams(array $params): Client - { - $apiKey = $params['configoption1']; - $apiSecret = $params['configoption2']; - return new Client($apiKey, $apiSecret); +function getWhitelabelFromParams(array $params): string +{ + return is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; +} + +function getPowerStatus(Client $api, ServerTag $serverTag): string +{ + $link = 'servers/' . $serverTag . '/power'; + try { + return $api->get($link)['results'] ?? 'unknown'; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + + return 'unknown'; } - function getWhitelabelFromParams(array $params): string - { - return is_string($params['configoption3']) ? $params['configoption3'] : 'yes'; +} + +function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string +{ + $link = 'servers/' . $serverTag . '/ipmi-link'; + try { + // Generate an IPMI link; + return $api->post($link, [ + 'remoteIp' => ClientIpHelper::getClientIpAddress(), + 'whitelabel' => $whiteLabel, + ])['results'] ?? ''; + + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + + return ''; } +} - function getPowerStatus(Client $api, ServerTag $serverTag): string - { - $link = 'servers/' . $serverTag . '/power'; - try { - return $api->get($link)['results'] ?? 'unknown'; - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - ['link' => $link], - $e->getMessage(), - $e->getTraceAsString() - ); - - return 'unknown'; - } +function doPowerAction(Client $api, ServerTag $serverTag, string $action): string +{ + $actions = [ + 'poweron' => 'Power on', + 'poweroff' => 'Power off', + 'coldboot' => 'Cold boot', + 'reset' => 'Reset', + ]; + if (!isset($actions[$action])) { + return 'Unknown power action'; + } + $link = 'servers/' . $serverTag . '/' . $action; + try { + $api->post($link); + return 'success'; + } catch (Exception $e) { + logModuleCall( + 'novoserve', + __FUNCTION__, + ['link' => $link], + $e->getMessage(), + $e->getTraceAsString() + ); + return 'Could not perform ' . $actions[$action] . ' action on server. ' . $e->getMessage(); } +} - function getIpmiLink(Client $api, ServerTag $serverTag, string $whiteLabel): string - { - $link = 'servers/' . $serverTag . '/ipmi-link'; - try { - // Generate an IPMI link; - return $api->post($link, [ - 'remoteIp' => ClientIpHelper::getClientIpAddress(), - 'whitelabel' => $whiteLabel, - ])['results'] ?? ''; - - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - ['link' => $link], - $e->getMessage(), - $e->getTraceAsString() - ); - - return ''; +/** + * @return array{bandwidthGraph: string, trafficUsage: array{dateTimeFrom: string, dateTimeUntil: string, usage: float, download: float}} + */ +function getBandwidthAndTraffic(Client $api, ServerTag $serverTag, array $params): array +{ + $getPeriodStart = ''; + $getPeriodEnd = ''; + // Some over-engineered code to get the actual current traffic period; + if ($params['model']->billingcycle !== 'Free Account') { + $nextDueDateTime = new DateTime($params['model']->nextinvoicedate); + $nextDueDateDay = $nextDueDateTime->format('d'); + $nextDueDateTime = new DateTime(date('Y-m-') . $nextDueDateDay); // Create DateTime object, it will automatically bump the date if the day is not in this month; + $getPeriodEndDateTime = $nextDueDateTime; + $getPeriodStart = $getPeriodEndDateTime->modify('-1 month')->format('d-m-Y'); + + if (date('d') < $nextDueDateDay) { + $getPeriodEnd = $nextDueDateTime->format('d-m-Y'); + } else { + $getPeriodEnd = $nextDueDateTime->modify('+1 month')->format('d-m-Y'); } } - function doPowerAction(Client $api, ServerTag $serverTag, string $action): string - { - $actions = [ - 'poweron' => 'Power on', - 'poweroff' => 'Power off', - 'coldboot' => 'Cold boot', - 'reset' => 'Reset', - ]; - if (!isset($actions[$action])) { - return 'Unknown power action'; - } + try { + $getBandwidthGraph = $api->get('servers/' . $serverTag . '/bandwidth/graph', [ + 'from' => strtotime($getPeriodStart), + 'height' => 200 + ]); + } catch (Exception $e) { + $getBandwidthGraph = null; + } - $link = 'servers/' . $serverTag . '/' . $action; - try { - $api->post($link); - return 'success'; - } catch (Exception $e) { - logModuleCall( - 'novoserve', - __FUNCTION__, - ['link' => $link], - $e->getMessage(), - $e->getTraceAsString() - ); - return 'Could not perform ' . $actions[$action] . ' action on server. ' . $e->getMessage(); - } + try { + $getTrafficUsage = $api->get('servers/' . $serverTag . '/bandwidth', [ + 'from' => strtotime($getPeriodStart) + ]); + // Prepare values before loading it into template vars; + $getTrafficUsage['results']['dateTimeFrom'] = date('d-m-Y', strtotime($getPeriodStart)); + $getTrafficUsage['results']['dateTimeUntil'] = date('d-m-Y', strtotime($getPeriodEnd)); + $getTrafficUsage['results']['usage'] = round($getTrafficUsage['results']['usage'], 2); + $getTrafficUsage['results']['download'] = round($getTrafficUsage['results']['download'], 2); + } catch (Exception $e) { + $getTrafficUsage = null; } + + return [ + 'bandwidthGraph' => $getBandwidthGraph ? $getBandwidthGraph['results']['image'] : '', + 'trafficUsage' => $getTrafficUsage ? $getTrafficUsage['results'] : [], + ]; +} From 177e3a000f3f4638ad23babdef63c035106d05b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ewald=20B=C3=B6rger?= Date: Tue, 5 Nov 2024 09:31:13 +0100 Subject: [PATCH 13/13] Update readme --- README.md | 23 ++++++++++++------- screenshot-admin-area.png | Bin 0 -> 30145 bytes screenshot.png => screenshot-client-area.png | Bin 3 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 screenshot-admin-area.png rename screenshot.png => screenshot-client-area.png (100%) diff --git a/README.md b/README.md index b87cd46..a02a69b 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,22 @@ This provisioning module allows you as a reseller to offer your customers full s With just one click they are able to login to the IPMI of the server (e.g. HPE iLO). This feature is also available as an administrator. Features: -- Autologin IPMI for the clientarea; -- Autologin IPMI for the admin side; -- Whitelabel console URL generation; -- Server power management; -- Bandwidth usage graph; -- Traffic usage for current period. +- Autologin IPMI for the client area +- Autologin IPMI for the admin side +- Whitelabel console URL generation +- Server power management for the client area +- Server power management for the admin area +- Bandwidth usage graph +- Traffic usage for current period ### Screenshot -[![](screenshot.png)]() + +#### Client Area +[![](screenshot-client-area.png)]() + +#### Admin Area +[![](screenshot-admin-area.png)]() + ### Requirements - WHMCS 7.x or 8.x; @@ -32,4 +39,4 @@ Features: Note: Ensure that you added the IP address of your WHMCS instance to the API ACL in our portal. ### License -MIT License \ No newline at end of file +MIT License diff --git a/screenshot-admin-area.png b/screenshot-admin-area.png new file mode 100644 index 0000000000000000000000000000000000000000..a306f22c2ad32db7ba20874be06b76bb68979081 GIT binary patch literal 30145 zcmdqJbx@Vx-!>{6P(nJTyCg(ZknRpCK{`c1x_cACrZyd-gi1>*og$5NOKnQJyU*hH zmGASM=e%d;%)B%2A7^GevvJ?|y4U)wy4Ll%t~>OFvJ4gm8OEJEcd+DSCDrcSfgtYO zK_W!E3;xIIY2b%DcWCa&Ns4K>8*HTBbJviX44#b+3NnzmXZcwJPZckob1*aT2~y0A zSiY=`XR(@O{sGfq3@6&>fCILAH*q{gLJ0lU+=4z-yWqM z*YUJJR3tK{MdD?!zrMQE@^m@cc*Xi61A20HR%0AB!H7?T^v_3&1|fyy_RiRZ+*BM2 zp(3yn3_+2F{^vtef&QY&^uwcdH&P^ws-OM)%?zTDe?F?X8PNSBdtC^jeI=!iYsBAM z^iuAiPGyBeMMj0)LH&4q4?X(Me?Cwi{0ykTLT-tYE3J`S6PJDs!$g^RoMb3|Xi`#= ze$h|)zmEhDZXyPS>b}|4PND;s1_lHy{^|LEPeHMv=3{Is_fO5BC{rQeNj`0{LZPBf zu}}j1+b7*Y=ew!H0DaSXI~fv&d%-93)_-eVECz*Kk?pwWI}w2D9G{f9{QU`$hr)tL znkWNONf`fz>Ly@28lZM6lpZ)Myy`CmjiM){{zlSX4E#Vs@#NVA zKlrlFx65)HV*e^eQKTr{nK~T`5QTQyw+3Oi5pJ>q*M3#q71g50od|cgovvYYr^Ug% ztpwB>Z)bl$4hp^V3DCCPV}~RNF=nl#KcUvI}7bm z3UG?&dYL1MALNkSR_9C?Oz9A(`^zMThK3k?cGR~GrG?<`Ecc~%&;1NxMQ}c`eVZSG z2V;jAWQZbhAlUy6c9GN_$kle^wOW(D_4v4s3Y{p@apu5l!+*L^&>!>?f%JPHXG;)* zBr4%&X@7%aKr4#0tRx0eR)#{h7Li;2{B0jJ67(PSjyDyq-sKeW%sJk=70vA_@avXOMVyC<9)U1dK z)5Qn3V{Z?%hEpV{bPNg2J)D>$)BAMKaj^sMbS{LNj>x{Q$8vA6)A7%*2OJwx;lv5s z4d==imzRgnvB;mrCvxbn<#NH5K0GTtSQ}B;nyzJ5&sL0?thB>4VCOMvx`U2}*1wFf zkO;v}K)#0!OHWT9&Q+DDWHzXCKO-tyXp4*k_jblHt1$SS9SqjEShMQad>20M|0MDF z^)J#N4<{Ws^lJ609nakOz@&AC&>B?Pe$nsQcUHNy7l`75&(|(B8T=xHBbUhjzQK2?E5RIuZ?M=<RndHRW z5Z~27#%hoKY{cP8Uz$*y>*jAuhmc`12o)(Qi$w7K81`3X9T5+BzW)+eqg_KMU>X)r zd+zl5^k~wqyoJ|lSQgVI@GcgLC8^D1#Rxu}-{BH8se8QC^sqWdIW?9+E_P+#ZmQ~2 z-vd7T4DXY<5dPm_0$IF&W|Okmo2rX>0){uI$Nry$wn%b?!Av>xo|LCL2h2(-W)PfD zTxyvx;nyJCj+bXH(=Qe8zHqa*FH!#C4+WD{4}+LJ*W;5)vZUl;VplAqf?5-$U-&oF z+m_>kVH;xP78x$y)Oa$FIZbOXH9&JqQ9nbo$J4Yrw)Jid$`yK6iLB-1bf!8iEPd iI*$d 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 KT!pMf*#7`c6}FTB literal 0 HcmV?d00001 diff --git a/screenshot.png b/screenshot-client-area.png similarity index 100% rename from screenshot.png rename to screenshot-client-area.png