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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,55 @@ as far as open_basedir lets it check, one written before the last reboot or
on another host, one older than 26 hours, and one dated more than five
minutes in the future (a clock problem).

## History

A background job samples the server every five minutes and keeps the values
in the database (table `serverinfo_samples`), so that load, memory and disk
usage can be looked at after the fact. It records:

- the load average over 1, 5 and 15 minutes
- memory and swap, used and total
- free and total space of the file system holding the data directory; space
reserved for root counts as used, because Nextcloud cannot use it
- active users in the last 5 minutes and the last 24 hours
- the number of files, as last counted by the storage statistics job (see
below), so it moves in steps of that job's interval
- the database size, measured at most once an hour

PHP-FPM and OPcache are not part of the history. Background jobs run in a PHP
process of their own (the command line PHP under system cron), which has its
own OPcache and is not part of the web server's FPM pool, so the figures would
describe the wrong process.

On the Status page, the Load and Memory charts can switch from the live
values to the last 24 hours, 7 days, 30 days or year, and **Disk usage over
time** shows the data directory's file system. A day is shown in five-minute
steps, 7 and 30 days in hours and a year in days; longer ranges also draw the
highest load and memory use.

Five-minute samples are kept for 48 hours. From them the job also computes
hourly rows, kept for 30 days, and daily rows (UTC days), kept for a year.
These hold the averages, and for the load, memory used and active users also
the highest sample. The current hour and day are brought up to date on every
run. The retention can be changed:

```
occ config:app:set serverinfo history_retention_raw_hours --type=integer --value=48
occ config:app:set serverinfo history_retention_hourly_days --type=integer --value=30
occ config:app:set serverinfo history_retention_daily_days --type=integer --value=365
```

The five-minute samples are kept for at least 25 hours, which the daily rows
are computed from. With the defaults the table holds about 1,700 rows per
server.

The job is due every four minutes (`job_interval_history`, in seconds) and
stores one sample per five-minute slot, so a cron run every five minutes never
skips a slot. With AJAX or webcron, samples are only taken when a request runs
the background jobs, and the history has gaps. Each sample names the host that
took it; with several web servers sharing the database, that is whichever one
ran the job.

## API

The API provides a lot of information about a running Nextcloud
Expand Down
3 changes: 2 additions & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<name>Monitoring</name>
<summary>Monitoring app with useful server information</summary>
<description>Provides useful server information, such as CPU load, RAM usage, disk usage, number of users, etc. Shows pending operating system updates, required reboots and service restarts, and how long the Linux distribution is still supported.</description>
<version>8.0.0-dev.1</version>
<version>8.0.0-dev.2</version>
<licence>AGPL-3.0-or-later</licence>
<author>Bjoern Schiessle</author>
<author>Ivan Sein Santiago</author>
Expand All @@ -25,6 +25,7 @@
<job>OCA\ServerInfo\Jobs\UpdateStorageStats</job>
<job>OCA\ServerInfo\Jobs\UpdateJobStats</job>
<job>OCA\ServerInfo\Jobs\UpdateOsStatus</job>
<job>OCA\ServerInfo\Jobs\CollectHistory</job>
</background-jobs>
<commands>
<command>OCA\ServerInfo\Commands\UpdateStorageStats</command>
Expand Down
1 change: 1 addition & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
['name' => 'os#status', 'url' => '/os', 'verb' => 'GET'],
['name' => 'os#refresh', 'url' => '/os/refresh', 'verb' => 'POST'],
['name' => 'os#updateSettings', 'url' => '/os/settings', 'verb' => 'PUT'],
['name' => 'history#series', 'url' => '/history', 'verb' => 'GET'],
],
'ocs' => [
['name' => 'api#info', 'url' => '/api/v1/info', 'verb' => 'GET'],
Expand Down

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-CFQNrwXO.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.license
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ This file is generated from multiple sources. Included packages:
- version: 7.8.5
- license: ISC
- serverinfo
- version: 8.0.0-dev.1
- version: 8.0.0-dev.2
- license: AGPL-3.0-or-later
- tabbable
- version: 6.5.0
Expand Down
2 changes: 1 addition & 1 deletion js/serverinfo-main.mjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/serverinfo-main.mjs.map.license
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ This file is generated from multiple sources. Included packages:
- version: 7.8.5
- license: ISC
- serverinfo
- version: 8.0.0-dev.1
- version: 8.0.0-dev.2
- license: AGPL-3.0-or-later
- tabbable
- version: 6.5.0
Expand Down
22 changes: 22 additions & 0 deletions lib/Config/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ class ConfigLexicon implements ILexicon {
public const OS_LISTS_ERROR_DAYS = 'os_lists_error_days';
public const OS_ALLOW_NETWORK = 'os_allow_network';

public const JOB_INTERVAL_HISTORY = 'job_interval_history';
public const HISTORY_RETENTION_RAW_HOURS = 'history_retention_raw_hours';
public const HISTORY_RETENTION_HOURLY_DAYS = 'history_retention_hourly_days';
public const HISTORY_RETENTION_DAILY_DAYS = 'history_retention_daily_days';
public const CACHED_HISTORY_DB_SIZE = 'cached_history_db_size';

#[\Override]
public function getStrictness(): Strictness {
// The app predates the lexicon and still writes keys that are not listed here
Expand Down Expand Up @@ -159,6 +165,22 @@ public function getAppConfigs(): array {
'Whether the operating system status may use the network as the web server user: on Arch, checkupdates syncs its own package databases from the pacman mirrors and arch-audit fetches the security advisories from security.archlinux.org',
lazy: true,
),
new Entry(
self::JOB_INTERVAL_HISTORY,
ValueType::INT,
4 * 60,
'How often the history sampler is due, in seconds. It stores one sample per five-minute bucket, so a value a little below five minutes keeps a cron run every five minutes from skipping buckets',
),
new Entry(self::HISTORY_RETENTION_RAW_HOURS, ValueType::INT, 48, 'Hours the five-minute history samples are kept, at least 25', lazy: true),
new Entry(self::HISTORY_RETENTION_HOURLY_DAYS, ValueType::INT, 30, 'Days the hourly history averages are kept', lazy: true),
new Entry(self::HISTORY_RETENTION_DAILY_DAYS, ValueType::INT, 365, 'Days the daily history averages are kept', lazy: true),
new Entry(
self::CACHED_HISTORY_DB_SIZE,
ValueType::ARRAY,
[],
'Database size and when the history sampler last measured it, so it is measured at most once an hour',
lazy: true,
),
];
}

Expand Down
80 changes: 80 additions & 0 deletions lib/Controller/HistoryController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo\Controller;

use OCA\ServerInfo\Db\Sample;
use OCA\ServerInfo\Db\SampleMapper;
use OCA\ServerInfo\History\Range;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IRequest;

/**
* The stored history for the charts. Admin only, like the rest of the app.
*
* @psalm-api
*/
class HistoryController extends Controller {
/** Most points one response carries; the longest range needs 720. */
public const MAX_POINTS = 1000;

public function __construct(
string $appName,
IRequest $request,
private SampleMapper $mapper,
private ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
}

/**
* One series per metric for the range, at the finest resolution kept for
* all of it, as parallel arrays with the bucket start times in `t`. With
* several nodes writing samples, the series is the one of the node that
* wrote the newest.
*
* @param string $range 24h, 7d, 30d or 1y
*/
public function series(string $range = '24h'): JSONResponse {
$selected = Range::tryFrom($range);
if ($selected === null) {
return new JSONResponse(['field' => 'range'], Http::STATUS_BAD_REQUEST);
}

$now = $this->timeFactory->getTime();
$from = $now - $selected->seconds();
$resolution = $selected->resolution();
$node = $this->mapper->latestNode($resolution);
$rows = $node === null ? [] : $this->mapper->findSince($resolution, $from, $node, self::MAX_POINTS);

$series = ['t' => [], 'samples' => []];
foreach ([...Sample::METRICS, ...array_keys(Sample::PEAKS)] as $metric) {
$series[$metric] = [];
}
foreach ($rows as $row) {
$series['t'][] = $row->getBucketStart();
$series['samples'][] = $row->getSamples();
foreach ([...Sample::METRICS, ...array_keys(Sample::PEAKS)] as $metric) {
$series[$metric][] = $row->getMetric($metric);
}
}

return new JSONResponse([
'range' => $selected->value,
'resolution' => $resolution,
'from' => $from,
'now' => $now,
'node' => $node,
'series' => $series,
]);
}
}
98 changes: 98 additions & 0 deletions lib/Db/Sample.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ServerInfo\Db;

use OCP\AppFramework\Db\SnowflakeAwareEntity;
use OCP\DB\Types;

/**
* One point of the history: a five-minute sample, or the hourly or daily
* aggregate of those samples. Memory and swap are in MB, disk space and the
* database size in bytes. A metric that could not be read is null.
*
* @method string getNodeName()
* @method void setNodeName(string $nodeName)
* @method int getResolution()
* @method void setResolution(int $resolution)
* @method int getBucketStart()
* @method void setBucketStart(int $bucketStart)
* @method int getSamples()
* @method void setSamples(int $samples)
*
* @psalm-suppress PossiblyUnusedProperty the properties are reached through Entity's magic accessors
*/
class Sample extends SnowflakeAwareEntity {
public const RESOLUTION_RAW = 300;
public const RESOLUTION_HOURLY = 3600;
public const RESOLUTION_DAILY = 86400;

/** Metrics whose aggregate is the average of the samples. */
public const METRICS = [
'load1', 'load5', 'load15',
'memUsed', 'memTotal', 'swapUsed', 'swapTotal',
'diskFree', 'diskTotal',
'users5min', 'users24h',
'files', 'dbSize',
];

/** Metrics whose aggregate is the highest sample, and the metric they are taken from. */
public const PEAKS = [
'load1Max' => 'load1',
'memUsedMax' => 'memUsed',
'users5minMax' => 'users5min',
];

private const FLOATS = ['load1', 'load5', 'load15', 'load1Max'];

protected ?string $nodeName = null;
protected ?int $resolution = null;
protected ?int $bucketStart = null;
protected ?int $samples = null;
protected ?float $load1 = null;
protected ?float $load5 = null;
protected ?float $load15 = null;
protected ?float $load1Max = null;
protected ?int $memUsed = null;
protected ?int $memUsedMax = null;
protected ?int $memTotal = null;
protected ?int $swapUsed = null;
protected ?int $swapTotal = null;
protected ?int $diskFree = null;
protected ?int $diskTotal = null;
protected ?int $users5min = null;
protected ?int $users5minMax = null;
protected ?int $users24h = null;
protected ?int $files = null;
protected ?int $dbSize = null;

public function __construct() {
$this->addType('id', Types::STRING);
$this->addType('nodeName', Types::STRING);
$this->addType('resolution', Types::INTEGER);
$this->addType('bucketStart', Types::BIGINT);
$this->addType('samples', Types::INTEGER);
foreach ([...self::METRICS, ...array_keys(self::PEAKS)] as $metric) {
$this->addType($metric, in_array($metric, self::FLOATS, true) ? Types::FLOAT : Types::BIGINT);
}
}

public static function isFloat(string $metric): bool {
return in_array($metric, self::FLOATS, true);
}

public function getMetric(string $metric): int|float|null {
/** @var int|float|null */
return $this->getter($metric);
}

public function setMetric(string $metric, int|float|null $value): void {
$this->setter($metric, [$value]);
}
}
Loading
Loading