Major client-area overhaul, WHMCS 9 + VirtFusion v7 compatibility, and a
hardening pass on every destructive client.php endpoint.
Tested against WHMCS 9.0.3 + VirtFusion v7.0.0 Build 9.
Features
- "On This Page" jump-link group injected into the WHMCS Actions sidebar
via ClientAreaPrimarySidebar; auto-hides links for hidden panels.
- Monthly traffic chart (last 12 months) with rx/tx bars and centered
legend; replaces the dead canvas that read non-existent JSON paths.
- Live Stats panel: CPU, memory, disk I/O from remoteState; 30s refresh
while the panel is visible AND the page has focus.
- Filesystem usage rows in the Resources panel from qemu-guest-agent
fsinfo; pseudo-FS filtered out.
- Server Overview meta chips: data-center location with country flag,
OS template/agent name with kernel on hover, "Created N days ago".
- Hypervisor maintenance banner at the top of the page.
- Mask Sensitive screenshot mode: IPv4 keeps first two octets, IPv6
keeps first two hextets, hostnames keep first char per dot-label.
Inputs masked via text-security: disc; covers Server Name + Hostname
+ IP cells + rDNS panel rows.
- Per-IP copy buttons folded into the Server Overview cells (replaces
the deleted standalone Network panel).
- VNC viewer popup served from a same-origin authenticated route
(client.php?action=vncViewer) — POST + requireSameOrigin, rotates
the wss token on every open, X-Frame-Options DENY, strict CSP.
Bug Fixes
- UsageUpdate cron silently no-op'd: read server.usage.traffic.used
which doesn't exist. Bandwidth now from /servers/{id}/traffic;
disk usage from remoteState.agent.fsinfo.
- WHMCS 9 multi-service order short-circuit: AfterModuleCreate's
AcceptOrder fired after the first service and terminated the batch
loop, orphaning siblings. Defer until every VF service in the order
has a server_id.
- Orphaned services produced six generic 500s; new
requireProvisionedService() helper emits one clean 409 with an
actionable message. Wired into all 17 client.php cases.
- Server Overview Traffic showed "- / Unlimited"; now renders real
bytes and "Unmetered" (limit=0 is per-period uncapped, not feature-off).
- Rename endpoint moved to PUT /servers/{id}/modify/name in VF v7
(was 404'ing); response is HTTP 201 not 200/204.
- Rename was force-lowercasing the input; relaxed validation to
preserve case + freeze the input row mid-flight to prevent
double-submits.
- "Other" OS category icon override removed; uses VirtFusion's icon
instead of a hardcoded SVG.
- Save button squish on the rename row fixed via flex-wrap layout.
Security
- CSRF protection (requirePost + requireSameOrigin) added to every
destructive POST: rebuild, resetPassword, resetServerPassword,
powerAction, rename, selfServiceAddCredit, toggleVnc, vncViewer.
Previously only rdnsUpdate had it.
- Open-redirect defence in Module::fetchLoginTokens — refuses to
return a redirect URL whose host doesn't match the configured VF
panel hostname.
- Per-action rate limiting via new Module::requireRateLimit helper
(Cache-backed): rebuild 60s, resetPassword/resetServerPassword 30s,
powerAction 10s, vncViewer/toggleVnc/selfServiceAddCredit 5s.
- vncViewer route delivers strict Content-Security-Policy
(default-src none, script-src self + VF panel, connect-src wss VF
panel, frame-ancestors none).
- IPv6 examples in placeholder/comments switched to the IANA
documentation prefix 2001:db8::/32 (RFC 3849).
Removed
- Network panel (duplicated Server Overview IP rows).
- VNC enable/disable toggle (VF firewall flag is non-functional;
toggle was misleading).
- Network Speed row in Resources panel (always 0 from VF API).
Internal
- Module::fetchServerData now passes ?remoteState=true.
- ServerResource::process exposes osName/osPretty/osKernel/osDistro/
osIcon/location/locationIcon/hypervisorMaintenance/createdAt/
builtAt/live.* fields.
- Module::toggleVnc corrected to send {vnc:bool} (the actual API
param) instead of {enabled:bool} (silent no-op).
- Module::getVncConsole + toggleVnc return baseUrl alongside the
envelope so the viewer route can build the wss URL.
- Panel margins tightened mb-3 → mb-2 across all 11 panels.
This commit is contained in:
@@ -191,7 +191,21 @@ class Module
|
||||
if ($ctx['request']->getRequestInfo('http_code') == '200') {
|
||||
$data = json_decode($data);
|
||||
if (isset($data->data->authentication->endpoint_complete)) {
|
||||
return $ctx['cp']['base_url'] . $data->data->authentication->endpoint_complete;
|
||||
$ssoUrl = $ctx['cp']['base_url'] . $data->data->authentication->endpoint_complete;
|
||||
// Open-redirect defence: assert the URL we're about to send
|
||||
// the customer to is on the configured VirtFusion host. If
|
||||
// the API response, the cp_base_url config, or someone
|
||||
// tampering with tblservers managed to point us elsewhere,
|
||||
// refuse rather than 302 to an arbitrary destination.
|
||||
$expectedHost = parse_url($ctx['cp']['base_url'], PHP_URL_HOST);
|
||||
$actualHost = parse_url($ssoUrl, PHP_URL_HOST);
|
||||
if (! $expectedHost || strcasecmp((string) $expectedHost, (string) $actualHost) !== 0) {
|
||||
Log::insert(__FUNCTION__ . ':host_mismatch', ['expected' => $expectedHost, 'actual' => $actualHost], 'SSO redirect rejected');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $ssoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,14 +288,46 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId']);
|
||||
// ?remoteState=true asks VirtFusion to introspect libvirt + (when
|
||||
// available) qemu-guest-agent on the guest, returning live CPU/memory
|
||||
// gauges, disk I/O counters, and per-mount filesystem usage under
|
||||
// remoteState.{cpu,memory,disk,agent.fsinfo}. This is heavier than
|
||||
// the bare /servers/{id} call (libvirt round-trip on the hypervisor)
|
||||
// — acceptable on the page-load path at our scale; revisit caching
|
||||
// if hypervisor load becomes a concern.
|
||||
$data = $ctx['request']->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '?remoteState=true');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
if ($ctx['request']->getRequestInfo('http_code') == '200') {
|
||||
return json_decode($data);
|
||||
if ($ctx['request']->getRequestInfo('http_code') != '200') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
$serverObj = json_decode($data);
|
||||
|
||||
// Merge billing-period traffic onto the server object. The
|
||||
// /servers/{id} response exposes only the period WINDOW
|
||||
// (settings.resources.traffic = limit GB; traffic.public.currentPeriod
|
||||
// = start/end/limit) — the actual byte counter for the period lives
|
||||
// on the dedicated /servers/{id}/traffic endpoint at
|
||||
// data.monthly[0].total. We surface it as ->data->trafficUsedBytes
|
||||
// so ServerResource (and any future consumer) has one stable path
|
||||
// to read from. Non-fatal: if the secondary call fails, the field
|
||||
// stays absent and ServerResource falls back to its "-" sentinel.
|
||||
try {
|
||||
$trafficReq = $this->initCurl($ctx['cp']['token']);
|
||||
$trafficResp = $trafficReq->get($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/traffic');
|
||||
if ($trafficReq->getRequestInfo('http_code') == '200') {
|
||||
$trafficJson = json_decode($trafficResp);
|
||||
$current = $trafficJson->data->monthly[0] ?? null;
|
||||
if ($current && isset($current->total) && is_numeric($current->total)) {
|
||||
$serverObj->data->trafficUsedBytes = (int) $current->total;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::insert(__FUNCTION__ . ':traffic', [], $e->getMessage());
|
||||
}
|
||||
|
||||
return $serverObj;
|
||||
} catch (\Exception $e) {
|
||||
Log::insert(__FUNCTION__, [], $e->getMessage());
|
||||
|
||||
@@ -402,12 +448,17 @@ class Module
|
||||
}
|
||||
}
|
||||
|
||||
// VirtFusion v7+ moved this endpoint from PATCH /servers/{id}/name
|
||||
// to PUT /servers/{id}/modify/name (consistent with the rest of
|
||||
// the /modify/* family). The old path returns 404 on v7 panels.
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['name' => $newName]));
|
||||
$data = $ctx['request']->patch($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/name');
|
||||
$data = $ctx['request']->put($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/modify/name');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||||
$success = $httpCode == 200 || $httpCode == 204;
|
||||
// VF v7 returns 201 (Created) on rename — older versions returned
|
||||
// 200/204. Accept all three so we cover the version range.
|
||||
$success = $httpCode == 200 || $httpCode == 201 || $httpCode == 204;
|
||||
|
||||
if ($success && $serverObject !== null && PowerDns\Config::isEnabled()) {
|
||||
// Sync PTRs: only records whose current content equals the old hostname
|
||||
@@ -507,19 +558,29 @@ class Module
|
||||
}
|
||||
|
||||
if (count($catTemplates) <= 1) {
|
||||
// Track the "Other"-category icon from VF so the singleton
|
||||
// bucket below can reuse it instead of falling back to the
|
||||
// generic letter placeholder.
|
||||
if (($osCategory['name'] ?? '') === 'Other' && ! isset($otherIcon)) {
|
||||
$otherIcon = $osCategory['icon'] ?? null;
|
||||
}
|
||||
$otherTemplates = array_merge($otherTemplates, $catTemplates);
|
||||
} else {
|
||||
$catName = $osCategory['name'] ?? 'Unknown';
|
||||
// Use VF's category icon as-is for every category, including
|
||||
// "Other" — the historic override that forced a generic icon
|
||||
// was reverted; whatever the API returns (linux_logo.png in
|
||||
// our setup) is the canonical source.
|
||||
$categories[] = [
|
||||
'name' => $esc($catName),
|
||||
'icon' => ($catName === 'Other') ? null : ($osCategory['icon'] ?? null),
|
||||
'icon' => $osCategory['icon'] ?? null,
|
||||
'templates' => $catTemplates,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($otherTemplates)) {
|
||||
$categories[] = ['name' => 'Other', 'icon' => null, 'templates' => $otherTemplates];
|
||||
$categories[] = ['name' => 'Other', 'icon' => $otherIcon ?? null, 'templates' => $otherTemplates];
|
||||
}
|
||||
|
||||
return $categories;
|
||||
@@ -631,7 +692,19 @@ class Module
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
if ($ctx['request']->getRequestInfo('http_code') == 200) {
|
||||
return json_decode($data, true);
|
||||
$result = json_decode($data, true);
|
||||
if (! is_array($result)) {
|
||||
return false;
|
||||
}
|
||||
// The VirtFusion API returns the noVNC viewer path as
|
||||
// data.vnc.wss.url (e.g. "/vnc/?token=...") — a relative
|
||||
// path. The browser needs the full URL, so we expose the
|
||||
// VF base URL alongside the API payload. Same pattern used
|
||||
// by fetchOsTemplates so the OS gallery can build full
|
||||
// logo URLs.
|
||||
$result['baseUrl'] = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -657,13 +730,25 @@ class Module
|
||||
return false;
|
||||
}
|
||||
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['enabled' => (bool) $enabled]));
|
||||
// Body param is "vnc" — NOT "enabled". The API silently no-ops
|
||||
// an unknown key, which is why earlier toggle clicks appeared to
|
||||
// do nothing. Confirmed against the official endpoint signature.
|
||||
$ctx['request']->addOption(CURLOPT_POSTFIELDS, json_encode(['vnc' => (bool) $enabled]));
|
||||
$data = $ctx['request']->post($ctx['cp']['url'] . '/servers/' . $ctx['serverId'] . '/vnc');
|
||||
Log::insert(__FUNCTION__, $ctx['request']->getRequestInfo(), $data);
|
||||
|
||||
$httpCode = $ctx['request']->getRequestInfo('http_code');
|
||||
if ($httpCode == 200 || $httpCode == 204) {
|
||||
return json_decode($data, true) ?: ['success' => true];
|
||||
$result = json_decode($data, true) ?: ['success' => true];
|
||||
// Mirror getVncConsole() so the JS popup-opener can build the
|
||||
// full wss:// URL from data.vnc.wss.url + baseUrl. Without
|
||||
// this the response only carries the relative path and the
|
||||
// popup goes nowhere.
|
||||
if (is_array($result)) {
|
||||
$result['baseUrl'] = rtrim(str_replace('/api/v1', '', $ctx['cp']['url']), '/');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1049,6 +1134,39 @@ class Module
|
||||
$this->output(['success' => false, 'errors' => 'cross-origin check failed'], true, true, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-(serviceID, action) rate limit. Emits 429 if hit; otherwise stamps
|
||||
* a token in the cache that expires after $windowSec.
|
||||
*
|
||||
* Defence-in-depth against:
|
||||
* - runaway client scripts hammering destructive actions on the
|
||||
* customer's own service (rebuild, power-off, password reset)
|
||||
* - cumulative VirtFusion API load from a misbehaving customer
|
||||
*
|
||||
* Uses the existing Cache class so it inherits Redis (when available)
|
||||
* or atomic filesystem fallback. Keys are namespaced under "rl:" to
|
||||
* avoid collisions with other Cache users.
|
||||
*
|
||||
* @param string $key Action/scope identifier (e.g. "power:1234")
|
||||
* @param int $windowSec Minimum seconds between calls
|
||||
* @return bool|void true if not rate-limited; emits 429 + exits otherwise
|
||||
*/
|
||||
public function requireRateLimit(string $key, int $windowSec)
|
||||
{
|
||||
$cacheKey = 'rl:' . $key;
|
||||
if (Cache::get($cacheKey) !== null) {
|
||||
$this->output(
|
||||
['success' => false, 'errors' => 'Too many requests. Please wait a moment and try again.'],
|
||||
true,
|
||||
true,
|
||||
429,
|
||||
);
|
||||
}
|
||||
Cache::set($cacheKey, 1, $windowSec);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the WHMCS service is in a status where client-initiated writes make sense.
|
||||
*
|
||||
@@ -1089,6 +1207,45 @@ class Module
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the WHMCS service has a corresponding VirtFusion server linked.
|
||||
*
|
||||
* A service can exist in tblhosting (Active, paid for, etc.) without ever
|
||||
* having been successfully provisioned in VirtFusion — typically when the
|
||||
* order was accepted but the CreateAccount call failed or never ran. In
|
||||
* that state, mod_virtfusion_direct has either no row for the service or
|
||||
* a row with NULL server_id.
|
||||
*
|
||||
* Without this guard, every downstream feature method (getTrafficStats,
|
||||
* getServerBackups, etc.) silently returns false because resolveServiceContext
|
||||
* can't build a valid request, and client.php translates that into a generic
|
||||
* "Unable to retrieve X" 500 — which gives the client a broken UI with no
|
||||
* indication of the real problem. With the guard, the client gets a single
|
||||
* clear 409 explaining the state, and our log shows the unprovisioned
|
||||
* lookup attempt instead of N misleading "Unable to..." entries.
|
||||
*
|
||||
* Returns 409 Conflict because the request is well-formed but the server's
|
||||
* current state (no provisioned VF server) prevents fulfilment — the same
|
||||
* semantics WHMCS itself uses when a service is in the wrong status.
|
||||
*
|
||||
* @param int $serviceID WHMCS service ID
|
||||
* @return bool|void true on success; emits 409 JSON and exits otherwise
|
||||
*/
|
||||
public function requireProvisionedService(int $serviceID)
|
||||
{
|
||||
$row = Database::getSystemService($serviceID);
|
||||
if (! $row || empty($row->server_id)) {
|
||||
$this->output(
|
||||
['success' => false, 'errors' => 'Server has not been provisioned yet. Please contact support if this is unexpected.'],
|
||||
true,
|
||||
true,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pre-configured Curl instance with JSON Accept/Content-Type headers
|
||||
* and a Bearer token for authenticating against the VirtFusion API.
|
||||
|
||||
@@ -64,15 +64,23 @@ class ServerResource
|
||||
if ($server['settings']['resources']['traffic'] > 0) {
|
||||
$traffic = $server['settings']['resources']['traffic'] . ' GB';
|
||||
} else {
|
||||
$traffic = 'Unlimited';
|
||||
// limit=0 in VirtFusion means "no cap on this period". We
|
||||
// surface that as "Unmetered" rather than "Unlimited" — limits
|
||||
// exist (the period still rolls over monthly, traffic is still
|
||||
// counted), the customer just isn't billed for overage.
|
||||
$traffic = 'Unmetered';
|
||||
}
|
||||
}
|
||||
|
||||
// trafficUsedBytes is merged onto the response by Module::fetchServerData()
|
||||
// from the dedicated /servers/{id}/traffic endpoint. Reading it directly
|
||||
// (rather than the non-existent server.usage.traffic.used path that we
|
||||
// historically referenced) is what unblocks the "X GB / Unmetered" display
|
||||
// for unmetered plans — there IS usage to show even when there's no cap.
|
||||
$trafficUsed = '-';
|
||||
if (isset($server['usage']['traffic']['used'])) {
|
||||
$trafficUsed = round($server['usage']['traffic']['used'] / 1073741824, 2) . ' GB';
|
||||
} elseif (isset($server['settings']['resources']['traffic']) && $server['settings']['resources']['traffic'] > 0) {
|
||||
$trafficUsed = '0 GB';
|
||||
if (isset($server['trafficUsedBytes']) && is_numeric($server['trafficUsedBytes'])) {
|
||||
$bytes = (int) $server['trafficUsedBytes'];
|
||||
$trafficUsed = ($bytes > 0 ? round($bytes / 1073741824, 2) : 0) . ' GB';
|
||||
}
|
||||
|
||||
$data = [
|
||||
@@ -94,18 +102,55 @@ class ServerResource
|
||||
'ipv6Unformatted' => [],
|
||||
'mac' => '-',
|
||||
],
|
||||
'networkSpeed' => [
|
||||
'inbound' => isset($server['settings']['resources']['networkSpeedInbound']) ? $server['settings']['resources']['networkSpeedInbound'] . ' Mbps' : '-',
|
||||
'outbound' => isset($server['settings']['resources']['networkSpeedOutbound']) ? $server['settings']['resources']['networkSpeedOutbound'] . ' Mbps' : '-',
|
||||
],
|
||||
'vncEnabled' => isset($server['vnc']['enabled']) ? (bool) $server['vnc']['enabled'] : false,
|
||||
'memoryRaw' => isset($server['settings']['resources']['memory']) ? (int) $server['settings']['resources']['memory'] : 0,
|
||||
'cpuRaw' => isset($server['settings']['resources']['cpuCores']) ? (int) $server['settings']['resources']['cpuCores'] : 0,
|
||||
'storageRaw' => isset($server['settings']['resources']['storage']) ? (int) $server['settings']['resources']['storage'] : 0,
|
||||
'trafficRaw' => isset($server['settings']['resources']['traffic']) ? (int) $server['settings']['resources']['traffic'] : 0,
|
||||
'trafficUsedRaw' => isset($server['usage']['traffic']['used']) ? round($server['usage']['traffic']['used'] / 1073741824, 2) : 0,
|
||||
'networkSpeedInboundRaw' => isset($server['settings']['resources']['networkSpeedInbound']) ? (int) $server['settings']['resources']['networkSpeedInbound'] : 0,
|
||||
'networkSpeedOutboundRaw' => isset($server['settings']['resources']['networkSpeedOutbound']) ? (int) $server['settings']['resources']['networkSpeedOutbound'] : 0,
|
||||
'trafficUsedRaw' => isset($server['trafficUsedBytes']) ? round((int) $server['trafficUsedBytes'] / 1073741824, 2) : 0,
|
||||
|
||||
// -- Identity / catalog ---------------------------------------
|
||||
// os.templateName is always present; qemuAgent.os.* only when
|
||||
// qemu-guest-agent is installed and running on the guest. Both
|
||||
// are surfaced; the template chooses which to emphasise.
|
||||
'osName' => $server['os']['templateName'] ?? '-',
|
||||
'osPretty' => $server['qemuAgent']['os']['pretty-name'] ?? null,
|
||||
'osKernel' => $server['qemuAgent']['os']['kernel-release'] ?? null,
|
||||
'osDistro' => $server['qemuAgent']['os']['id'] ?? null,
|
||||
'osIcon' => $server['qemuAgent']['os']['img'] ?? null,
|
||||
|
||||
// -- Data center / hypervisor ---------------------------------
|
||||
'location' => $server['hypervisor']['group']['name'] ?? '-',
|
||||
'locationIcon' => $server['hypervisor']['group']['icon'] ?? null,
|
||||
'hypervisorMaintenance' => (bool) ($server['hypervisor']['maintenance'] ?? false),
|
||||
|
||||
// -- Server lifetime ------------------------------------------
|
||||
'createdAt' => $server['created'] ?? null,
|
||||
'builtAt' => $server['built'] ?? null,
|
||||
|
||||
// -- Live state (requires ?remoteState=true on the upstream call) -
|
||||
// Fields default to null when the live block is absent — happens
|
||||
// when remoteState wasn't requested or the hypervisor couldn't
|
||||
// reach libvirt at fetch time. Templates must isset()-guard each.
|
||||
'live' => [
|
||||
'state' => $server['remoteState']['state'] ?? null,
|
||||
'cpu' => isset($server['remoteState']['cpu']) ? (float) $server['remoteState']['cpu'] : null,
|
||||
// memory.* values are kilobytes (libvirt convention).
|
||||
'memoryActualKB' => isset($server['remoteState']['memory']['actual']) ? (int) $server['remoteState']['memory']['actual'] : null,
|
||||
'memoryUnusedKB' => isset($server['remoteState']['memory']['unused']) ? (int) $server['remoteState']['memory']['unused'] : null,
|
||||
'memoryAvailableKB' => isset($server['remoteState']['memory']['available']) ? (int) $server['remoteState']['memory']['available'] : null,
|
||||
'memoryRssKB' => isset($server['remoteState']['memory']['rss']) ? (int) $server['remoteState']['memory']['rss'] : null,
|
||||
// disk.{drive}.{rd,wr,fl}.{reqs,bytes,times} — surfacing the
|
||||
// primary drive (vda) cumulative byte counters. JS can derive
|
||||
// throughput rates from successive samples.
|
||||
'diskRdBytes' => isset($server['remoteState']['disk']['vda']['rd.bytes']) ? (int) $server['remoteState']['disk']['vda']['rd.bytes'] : null,
|
||||
'diskWrBytes' => isset($server['remoteState']['disk']['vda']['wr.bytes']) ? (int) $server['remoteState']['disk']['vda']['wr.bytes'] : null,
|
||||
// Filesystems: only present when qemu-guest-agent is running
|
||||
// inside the VM. Each entry is normalised to {name, mountpoint,
|
||||
// type, usedBytes, totalBytes}; pseudo-FS (devtmpfs, proc, sys)
|
||||
// are filtered out — only real mounts the customer cares about.
|
||||
'filesystems' => self::extractFilesystems($server['remoteState']['agent']['fsinfo'] ?? null),
|
||||
],
|
||||
];
|
||||
|
||||
if (array_key_exists('network', $server)) {
|
||||
@@ -140,4 +185,67 @@ class ServerResource
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise the qemu-guest-agent fsinfo array into customer-facing rows.
|
||||
*
|
||||
* Only "real" filesystems are returned — pseudo-FS like proc/sysfs/devtmpfs
|
||||
* have no meaning in a usage context. Returned entries are sorted with the
|
||||
* root mount first so the most relevant row leads in the UI.
|
||||
*
|
||||
* @param array|null $fsinfo remoteState.agent.fsinfo from the API
|
||||
* @return array List of {name, mountpoint, type, usedBytes, totalBytes}
|
||||
*/
|
||||
private static function extractFilesystems($fsinfo): array
|
||||
{
|
||||
if (! is_array($fsinfo) || $fsinfo === []) {
|
||||
return [];
|
||||
}
|
||||
// Filesystems we never want to show — they're kernel/runtime, not user storage.
|
||||
$skipTypes = ['proc', 'sysfs', 'devtmpfs', 'devpts', 'tmpfs', 'cgroup', 'cgroup2',
|
||||
'pstore', 'bpf', 'mqueue', 'debugfs', 'tracefs', 'securityfs',
|
||||
'configfs', 'fusectl', 'autofs', 'hugetlbfs', 'rpc_pipefs',
|
||||
'binfmt_misc', 'overlay', 'squashfs', 'ramfs', 'fuse.gvfsd-fuse',
|
||||
'efivarfs', 'selinuxfs'];
|
||||
$rows = [];
|
||||
foreach ($fsinfo as $fs) {
|
||||
if (! is_array($fs)) {
|
||||
continue;
|
||||
}
|
||||
$type = $fs['type'] ?? '';
|
||||
if (in_array($type, $skipTypes, true)) {
|
||||
continue;
|
||||
}
|
||||
$mount = $fs['mountpoint'] ?? '';
|
||||
// Skip /boot* and /run* — useful in monitoring tools but noisy on
|
||||
// a customer-facing dashboard. Customers care about the root and
|
||||
// any data mounts.
|
||||
if ($mount === '/boot' || str_starts_with($mount, '/boot/')) {
|
||||
continue;
|
||||
}
|
||||
if ($mount === '/run' || str_starts_with($mount, '/run/')) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'name' => (string) ($fs['name'] ?? '-'),
|
||||
'mountpoint' => (string) $mount,
|
||||
'type' => (string) $type,
|
||||
'usedBytes' => isset($fs['used-bytes']) ? (int) $fs['used-bytes'] : 0,
|
||||
'totalBytes' => isset($fs['total-bytes']) ? (int) $fs['total-bytes'] : 0,
|
||||
];
|
||||
}
|
||||
// Root mount first; everything else by mountpoint alphabetical.
|
||||
usort($rows, function ($a, $b) {
|
||||
if ($a['mountpoint'] === '/') {
|
||||
return -1;
|
||||
}
|
||||
if ($b['mountpoint'] === '/') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return strcmp($a['mountpoint'], $b['mountpoint']);
|
||||
});
|
||||
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user