Skip to content
Merged
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
18 changes: 9 additions & 9 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.

2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\ServerInfo\AppInfo;

use OCA\ServerInfo\Config\ConfigLexicon;
use OCA\ServerInfo\SetupChecks\DatabaseChecks;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
Expand All @@ -28,6 +29,7 @@ public function __construct() {
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerConfigLexicon(ConfigLexicon::class);
$context->registerSetupCheck(DatabaseChecks::class);
}

#[\Override]
Expand Down
8 changes: 8 additions & 0 deletions lib/Config/ConfigLexicon.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
class ConfigLexicon implements ILexicon {
public const CACHED_SLOWEST_JOBS = 'cached_slowest_jobs';
public const JOB_INTERVAL_JOB_STATS = 'job_interval_job_stats';
public const CACHED_DB_CHECK = 'cached_db_check';

#[\Override]
public function getStrictness(): Strictness {
Expand All @@ -45,6 +46,13 @@ public function getAppConfigs(): array {
60 * 60 * 6,
'How often the background job statistics are recomputed, in seconds',
),
new Entry(
self::CACHED_DB_CHECK,
ValueType::ARRAY,
[],
'Outcome of the last database check run, read by the setup check on Settings > Overview',
lazy: true,
),
];
}

Expand Down
14 changes: 12 additions & 2 deletions lib/Controller/DatabaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Doctrine\DBAL\DriverManager;
use OCA\ServerInfo\AppInfo\Application;
use OCA\ServerInfo\Database\Advisor;
use OCA\ServerInfo\Database\CheckSummary;
use OCA\ServerInfo\Database\DatabaseProbe;
use OCA\ServerInfo\Database\LiveMetrics;
use OCA\ServerInfo\Database\RuleSet;
Expand Down Expand Up @@ -47,6 +48,7 @@ public function __construct(
private DatabaseProbe $probe,
private LiveMetrics $liveMetrics,
private Advisor $advisor,
private CheckSummary $summary,
private MysqlRuleSet $mysqlRules,
private PostgresRuleSet $postgresRules,
private LoggerInterface $logger,
Expand Down Expand Up @@ -84,21 +86,29 @@ public function check(): JSONResponse {
}

if (!$snapshot->isSupported()) {
$this->summary->record(false, []);

return new JSONResponse([
'supported' => false,
'reason' => 'flavour',
'flavour' => $snapshot->flavour,
]);
}

$results = $this->advisor->run($this->ruleSetFor($snapshot), $snapshot);
$results = array_map(
static fn ($result) => $result->jsonSerialize(),
$this->advisor->run($this->ruleSetFor($snapshot), $snapshot),
);
// Opening this page is what keeps the Overview's setup check current, so
// acting on a finding here clears the warning there without a wait.
$this->summary->record(true, $results);

return new JSONResponse([
'supported' => true,
'flavour' => $snapshot->flavour,
'version' => $snapshot->version,
'ranAt' => time(),
'results' => array_map(static fn ($result) => $result->jsonSerialize(), $results),
'results' => $results,
]);
}

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

declare(strict_types=1);

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

namespace OCA\ServerInfo\Database;

use OCA\ServerInfo\Config\ConfigLexicon;
use OCP\AppFramework\Services\IAppConfig;

/**
* Remembers how the last run of the database checks went.
*
* The setup check on Settings → Overview needs an answer on every page load,
* and running fifty-odd rules against a live server that often is not
* reasonable. Recording the outcome whenever the checks do run gives that page
* something cheap to read, and opening the database page refreshes it, so
* fixing a finding clears the warning without waiting for anything.
*/
class CheckSummary {
public function __construct(
private IAppConfig $appConfig,
) {
}

/**
* @param list<array<string, mixed>> $results as returned by the advisor
*/
public function record(bool $supported, array $results): void {
$failing = array_filter($results, static fn (array $r): bool => ($r['status'] ?? '') === 'fail');

$this->appConfig->setAppValueArray(ConfigLexicon::CACHED_DB_CHECK, [
'ranAt' => time(),
'supported' => $supported,
'failing' => count($failing),
'alerts' => $this->countSeverity($failing, 'alert'),
'warnings' => $this->countSeverity($failing, 'warning'),
'notices' => $this->countSeverity($failing, 'notice'),
]);
}

/**
* @return array{ranAt: int, supported: bool, failing: int, alerts: int, warnings: int, notices: int}|null
* null when nothing has been recorded yet or the record is older than $maxAge
*/
public function latest(int $maxAge): ?array {
$stored = $this->appConfig->getAppValueArray(ConfigLexicon::CACHED_DB_CHECK);
if ($stored === [] || !isset($stored['ranAt'])) {
return null;
}

if (time() - (int)$stored['ranAt'] > $maxAge) {
return null;
}

return [
'ranAt' => (int)$stored['ranAt'],
'supported' => (bool)($stored['supported'] ?? false),
'failing' => (int)($stored['failing'] ?? 0),
'alerts' => (int)($stored['alerts'] ?? 0),
'warnings' => (int)($stored['warnings'] ?? 0),
'notices' => (int)($stored['notices'] ?? 0),
];
}

/**
* @param array<array<string, mixed>> $failing
*/
private function countSeverity(array $failing, string $severity): int {
return count(array_filter($failing, static fn (array $r): bool => ($r['severity'] ?? '') === $severity));
}
}
135 changes: 135 additions & 0 deletions lib/SetupChecks/DatabaseChecks.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

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

namespace OCA\ServerInfo\SetupChecks;

use OCA\ServerInfo\AppInfo\Application;
use OCA\ServerInfo\Database\Advisor;
use OCA\ServerInfo\Database\CheckSummary;
use OCA\ServerInfo\Database\DatabaseProbe;
use OCA\ServerInfo\Database\RuleSet\Mysql as MysqlRuleSet;
use OCA\ServerInfo\Database\RuleSet\Postgres as PostgresRuleSet;
use OCA\ServerInfo\Database\Snapshot;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\SetupCheck\ISetupCheck;
use OCP\SetupCheck\SetupResult;

/**
* Puts failing database checks in front of an admin on Settings → Overview,
* where they already look for things that are wrong, rather than only on a
* page they have to know to visit.
*/
class DatabaseChecks implements ISetupCheck {
/**
* How long a recorded result is trusted. Opening the database page records
* a fresh one, so in practice this only matters for a server nobody has
* looked at in a day.
*/
private const MAX_AGE = 86400;

public function __construct(
private IL10N $l10n,
private IURLGenerator $urlGenerator,
private CheckSummary $summary,
private DatabaseProbe $probe,
private Advisor $advisor,
private MysqlRuleSet $mysqlRules,
private PostgresRuleSet $postgresRules,
) {
}

#[\Override]
public function getCategory(): string {
return 'database';
}

#[\Override]
public function getName(): string {
return $this->l10n->t('Database configuration');
}

#[\Override]
public function run(): SetupResult {
$summary = $this->summary->latest(self::MAX_AGE) ?? $this->runNow();

if ($summary === null) {
return SetupResult::info(
$this->l10n->t('The database configuration could not be checked.'),
);
}

if (!$summary['supported']) {
return SetupResult::success(
$this->l10n->t('These checks only cover MySQL, MariaDB and PostgreSQL.'),
);
}

if ($summary['failing'] === 0) {
return SetupResult::success(
$this->l10n->t('All database checks pass.'),
);
}

// The Overview only renders a link when it comes through as a rich object
// in the parameters; linkToDoc alone is not shown, so {link} it is.
return SetupResult::warning(
$this->l10n->n(
'%n database check is failing. {link}',
'%n database checks are failing. {link}',
$summary['failing'],
),
null,
[
'link' => [
'type' => 'highlight',
'id' => 'serverinfo-database-checks',
'name' => $this->l10n->t('Show the findings…'),
'link' => $this->databasePageUrl(),
],
],
);
}

/**
* Runs the checks and records the outcome, so the next page load is cheap.
*
* @return array{ranAt: int, supported: bool, failing: int, alerts: int, warnings: int, notices: int}|null
*/
private function runNow(): ?array {
try {
$snapshot = $this->probe->snapshot();
if (!$snapshot->isSupported()) {
$this->summary->record(false, []);

return $this->summary->latest(self::MAX_AGE);
}

$ruleSet = $snapshot->flavour === Snapshot::FLAVOUR_PGSQL ? $this->postgresRules : $this->mysqlRules;
$results = array_map(
static fn ($result) => $result->jsonSerialize(),
$this->advisor->run($ruleSet, $snapshot),
);
$this->summary->record(true, $results);

return $this->summary->latest(self::MAX_AGE);
} catch (\Throwable) {
// A database that cannot be probed is a problem the other setup
// checks will report far better than this one can.
return null;
}
}

private function databasePageUrl(): string {
return $this->urlGenerator->linkToRoute(
'settings.AdminSettings.index',
['section' => Application::APP_ID],
) . '#database';
}
}
27 changes: 25 additions & 2 deletions src/views/SettingsAdmin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<script setup lang="ts">
import type { LiveData, PeriodicData, SettingsView } from '../types.ts'

import { ref } from 'vue'
import { ref, watch } from 'vue'
import SettingsNavigation from '../components/SettingsNavigation.vue'
import BackgroundJobsView from './BackgroundJobsView.vue'
import DatabaseView from './DatabaseView.vue'
Expand All @@ -33,7 +33,30 @@ import { useStaticData } from '../composables/useStaticData.ts'

defineOptions({ name: 'ServerInfo' })

const view = ref<SettingsView>('status')
const VIEWS: SettingsView[] = ['status', 'database', 'background-jobs']

/**
* Reads the page out of the URL fragment so that a link can point straight at
* one — the setup check on Settings > Overview links to #database. Anything we
* do not recognise leaves the default alone rather than showing a blank page.
*/
function viewFromHash(): SettingsView {
const fromHash = window.location.hash.replace(/^#/, '') as SettingsView

return VIEWS.includes(fromHash) ? fromHash : 'status'
}

const view = ref<SettingsView>(viewFromHash())

// replaceState, not a hash assignment: switching pages should not fill the
// browser's back button with entries inside one settings section.
watch(view, (current) => {
window.history.replaceState(null, '', `#${current}`)
})

window.addEventListener('hashchange', () => {
view.value = viewFromHash()
})

const { data: staticData } = useStaticData()
const { data: liveData, tick } = useLiveData<LiveData>('/apps/serverinfo/update')
Expand Down
10 changes: 10 additions & 0 deletions tests/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
</file>
<file src="lib/Database/CheckSummary.php">
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
</file>
<file src="lib/Database/DatabaseProbe.php">
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
Expand Down Expand Up @@ -184,6 +189,11 @@
<code><![CDATA[AdminSettings]]></code>
</UnusedClass>
</file>
<file src="lib/SetupChecks/DatabaseChecks.php">
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
</PossiblyUnusedMethod>
</file>
<file src="lib/ShareStatistics.php">
<PossiblyUnusedMethod>
<code><![CDATA[__construct]]></code>
Expand Down
Loading