Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions css/main-C-zeP7Uf.chunk.css → css/main-184HzJm-.chunk.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion css/serverinfo-main.css
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/* extracted by css-entry-points-plugin */
@import './main-C-zeP7Uf.chunk.css';
@import './main-184HzJm-.chunk.css';
30 changes: 15 additions & 15 deletions js/serverinfo-main.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/serverinfo-main.mjs.map

Large diffs are not rendered by default.

22 changes: 3 additions & 19 deletions lib/Controller/DatabaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public function __construct(
*/
public function check(): JSONResponse {
// PHP holds the session lock for the whole request, and this one takes
// seconds while the Status page keeps polling every two. Without letting
// seconds while the page keeps polling its live values. Without letting
// go first, those polls queue up behind it and can exhaust the web
// server's workers. Nothing below writes to the session.
$this->session->close();
Expand Down Expand Up @@ -124,31 +124,15 @@ public function live(): JSONResponse {
// page's own polling queue up behind it. Nothing below writes to it.
$this->session->close();

$started = microtime(true);
try {
$this->probe->ping();
$latencyMs = round((microtime(true) - $started) * 1000.0, 2);
} catch (\Throwable) {
$latencyMs = null;
}

try {
$metrics = $this->liveMetrics->collect();
return new JSONResponse($this->liveMetrics->collect());
} catch (\Throwable $e) {
$this->logger->debug('serverinfo: live database metrics failed: {msg}', [
'msg' => $e->getMessage(), 'app' => Application::APP_ID,
]);

return new JSONResponse(['available' => false, 'latencyMs' => $latencyMs]);
return new JSONResponse(['available' => false, 'latencyMs' => null]);
}

return new JSONResponse([
'available' => true,
'latencyMs' => $latencyMs,
'connections' => $metrics['connections'],
'throughput' => $metrics['throughput'],
'threadsRunning' => $metrics['threadsRunning'],
]);
}

/**
Expand Down
26 changes: 0 additions & 26 deletions lib/Database/DatabaseProbe.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,32 +87,6 @@ public function detectVersion(string $flavour): string {
}
}

/**
* Run a cheap, read-only query so the caller can time the round-trip.
*
* Used by the live latency chart on the dashboard. We deliberately
* use the same query the user requested ("SHOW TABLES") on
* MySQL/MariaDB and the closest catalog-read equivalent on Postgres
* — it exercises parsing, planner lookup, and result transit so the
* timing is meaningful rather than a pure round-trip ping.
*
* Throws on failure; the caller decides how to surface that.
*/
public function ping(): void {
$conn = $this->openConnection();
try {
$flavour = $this->flavourOf($conn);
$sql = $flavour === Snapshot::FLAVOUR_PGSQL
? "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')"
: 'SHOW TABLES';
// Materialise the rowset so the timing reflects the full
// query, not just sending the SQL.
$this->all($conn, $sql);
} finally {
$this->closeIfOverride($conn);
}
}

/**
* Total on-disk size of the probed database in bytes, or null when
* it cannot be determined. Same approach as the serverinfo app
Expand Down
103 changes: 71 additions & 32 deletions lib/Database/LiveMetrics.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
namespace OCA\ServerInfo\Database;

use Doctrine\DBAL\Connection;
use OCA\ServerInfo\AppInfo\Application;
use OCP\IDBConnection;
use Psr\Log\LoggerInterface;

/**
* Read-only queries behind the live boxes on the database page: connection
* saturation, cache hit ratio and throughput. Nothing here mutates anything.
* Read-only queries behind the live boxes on the database page: round-trip
* latency, connection saturation and throughput. Nothing here mutates
* anything.
*
* Ported from Nextcloud's DB Doctor app. Every query goes through
* DatabaseProbe::withConnection(), so a configured override connection is
Expand All @@ -23,45 +26,80 @@
final class LiveMetrics {
public function __construct(
private DatabaseProbe $probe,
private LoggerInterface $logger,
) {
}

/**
* A cheap snapshot of the headline live metrics. Returns cumulative
* counters (throughput) that the client turns into a per-second rate.
* A cheap snapshot of the live metrics, taken over one connection whose
* flavour is detected once. Returns cumulative counters (throughput) that
* the client turns into a per-second rate.
*
* The page polls this every few seconds, so it opens at most one override
* connection and keeps the number of queries to a handful.
*
* @return array{
* connections: array{used:int, max:int},
* cacheHitRatio: float|null,
* throughput: array{counter: float, label: string},
* threadsRunning: int,
* available: bool,
* latencyMs: float|null,
* connections?: array{used:int, max:int},
* throughput?: array{counter: float, label: string},
* threadsRunning?: int,
* }
*/
public function collect(): array {
return $this->probe->withConnection(function (Connection|IDBConnection $conn, string $flavour): array {
if ($flavour === Snapshot::FLAVOUR_PGSQL) {
return $this->liveMetricsPostgres($conn);
if ($flavour === Snapshot::FLAVOUR_UNSUPPORTED) {
return ['available' => false, 'latencyMs' => null];
}

$latencyMs = $this->latencyMs($conn);

try {
$metrics = $flavour === Snapshot::FLAVOUR_PGSQL
? $this->liveMetricsPostgres($conn)
: $this->liveMetricsMysql($conn);
} catch (\Throwable $e) {
$this->logger->debug('serverinfo: live database metrics failed: {msg}', [
'msg' => $e->getMessage(), 'app' => Application::APP_ID,
]);

return ['available' => false, 'latencyMs' => $latencyMs];
}
return $this->liveMetricsMysql($conn);

return ['available' => true, 'latencyMs' => $latencyMs] + $metrics;
});
}

/**
* Round trip of the cheapest query there is, on the connection that is
* already open, so it measures the database and the network to it rather
* than connection setup or catalogue size.
*/
private function latencyMs(Connection|IDBConnection $conn): ?float {
$started = hrtime(true);
try {
$this->probe->scalar($conn, 'SELECT 1');
} catch (\Throwable) {
return null;
}

return round((hrtime(true) - $started) / 1_000_000, 2);
}

/**
* @return array{connections: array{used:int, max:int}, throughput: array{counter: float, label: string}, threadsRunning: int}
*/
private function liveMetricsMysql(Connection|IDBConnection $conn): array {
$status = $this->statusMap($conn,
'SHOW GLOBAL STATUS WHERE Variable_name IN '
. "('Threads_connected','Threads_running','Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads','Questions')");
. "('Threads_connected','Threads_running','Questions')");
$maxConn = $this->variableInt($conn, 'max_connections');

$readReq = (float)($status['Innodb_buffer_pool_read_requests'] ?? 0);
$reads = (float)($status['Innodb_buffer_pool_reads'] ?? 0);
$hit = $readReq > 0 ? 1.0 - ($reads / $readReq) : null;

return [
'connections' => [
'used' => (int)($status['Threads_connected'] ?? 0),
'max' => $maxConn,
],
'cacheHitRatio' => $hit,
'throughput' => [
'counter' => (float)($status['Questions'] ?? 0),
'label' => 'Queries/s',
Expand All @@ -70,28 +108,29 @@ private function liveMetricsMysql(Connection|IDBConnection $conn): array {
];
}

/**
* @return array{connections: array{used:int, max:int}, throughput: array{counter: float, label: string}, threadsRunning: int}
*/
private function liveMetricsPostgres(Connection|IDBConnection $conn): array {
$used = (int)$this->probe->scalar($conn, 'SELECT count(*) FROM pg_stat_activity');
$max = (int)$this->probe->scalar($conn, "SELECT setting FROM pg_settings WHERE name = 'max_connections'");
$active = (int)$this->probe->scalar($conn, "SELECT count(*) FROM pg_stat_activity WHERE state = 'active'");

$row = $this->probe->row($conn, "
SELECT COALESCE(SUM(blks_hit), 0) AS hit,
COALESCE(SUM(blks_read), 0) AS rd,
COALESCE(SUM(xact_commit + xact_rollback), 0) AS xacts
$activity = $this->probe->row($conn, "
SELECT count(*) AS used,
count(*) FILTER (WHERE state = 'active') AS active,
current_setting('max_connections') AS max_connections
FROM pg_stat_activity");
$xacts = $this->probe->scalar($conn, "
SELECT COALESCE(SUM(xact_commit + xact_rollback), 0)
FROM pg_stat_database WHERE datname NOT LIKE 'template%'");
$hitB = (float)($row['hit'] ?? 0);
$readB = (float)($row['rd'] ?? 0);
$total = $hitB + $readB;

return [
'connections' => ['used' => $used, 'max' => $max],
'cacheHitRatio' => $total > 0 ? $hitB / $total : null,
'connections' => [
'used' => (int)($activity['used'] ?? 0),
'max' => (int)($activity['max_connections'] ?? 0),
],
'throughput' => [
'counter' => (float)($row['xacts'] ?? 0),
'counter' => (float)($xacts ?? 0),
'label' => 'Transactions/s',
],
'threadsRunning' => $active,
'threadsRunning' => (int)($activity['active'] ?? 0),
];
}

Expand Down
41 changes: 40 additions & 1 deletion lib/LiveData.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,28 @@

namespace OCA\ServerInfo;

use OCA\ServerInfo\Resources\ThermalZone;
use OCP\ICache;
use OCP\ICacheFactory;

class LiveData {
/**
* Seconds a temperature reading is reused. Reading the sensors can wake
* sleeping drives or send them NVMe or ATA commands, which the page's poll
* must not do every few seconds; temperatures change slowly anyway.
*/
public const THERMAL_TTL = 15;
private const THERMAL_KEY = 'thermalzones';

private ICache $cache;

public function __construct(
private Os $os,
private UptimeFormatter $uptimeFormatter,
ICacheFactory $cacheFactory,
) {
// Local, not distributed: the sensors belong to the host answering.
$this->cache = $cacheFactory->createLocal('serverinfo-live');
}

public function getData(): array {
Expand All @@ -31,7 +48,29 @@ public function getData(): array {
],
'servertime' => $this->os->getTime(),
'uptime' => $this->uptimeFormatter->format($this->os->getUptime()),
'thermalzones' => $this->os->getThermalZones(),
'thermalzones' => $this->thermalZones(),
];
}

/**
* The sensor readings, read at most once per THERMAL_TTL. Without a local
* memory cache configured they are read on every call.
*
* @return list<array{zone: string, type: string, temp: float}>
*/
private function thermalZones(): array {
$cached = $this->cache->get(self::THERMAL_KEY);
if (is_array($cached)) {
/** @var list<array{zone: string, type: string, temp: float}> */
return $cached;
}

$zones = array_values(array_map(
static fn (ThermalZone $zone): array => $zone->jsonSerialize(),
$this->os->getThermalZones(),
));
$this->cache->set(self::THERMAL_KEY, $zones, self::THERMAL_TTL);

return $zones;
}
}
2 changes: 1 addition & 1 deletion lib/Os.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ public function sampleUptime(): int {

/**
* Sampling the uptime costs a process fork, and this is reached from a
* two-second poll, so the boot time it implies is remembered and only
* five-second poll, so the boot time it implies is remembered and only
* re-measured once a minute. In between the answer is still exact — it is
* wall-clock arithmetic on a constant — except for up to a minute after a
* reboot.
Expand Down
Loading
Loading