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
4 changes: 2 additions & 2 deletions css/main-BIfJTXIB.chunk.css → css/main-BHskGVgT.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-BIfJTXIB.chunk.css';
@import './main-BHskGVgT.chunk.css';
28 changes: 14 additions & 14 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.

6 changes: 3 additions & 3 deletions lib/Database/RuleSet/Mysql.php
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,8 @@ private function build(): array {
severity: Rule::SEVERITY_NOTICE,
formula: 'Sort_rows / Uptime',
test: 'value * 60 >= 1',
issue: 'Many rows are being sorted server-side.',
recommendation: 'Make sure ORDER BY columns are indexed so sorts use indexes instead of merge passes.',
issue: 'The server sorts many rows.',
recommendation: 'No action needed on its own. If "Sort merge-pass rate" also fails, follow its recommendation.',
justification: 'Sorted rows average: %s per hour.',
justificationFormula: 'round(value * 3600, 1)',
requires: ['Sort_rows', 'Uptime'],
Expand All @@ -493,7 +493,7 @@ private function build(): array {
id: 'Join_without_index_rate',
name: 'Join-without-index rate',
category: 'Performance',
severity: Rule::SEVERITY_WARNING,
severity: Rule::SEVERITY_NOTICE,
formula: '(Select_range_check + Select_scan + Select_full_join) / Uptime',
test: 'value * 60 * 60 > 1',
issue: 'Many SELECTs are doing full table scans or joins without indexes.',
Expand Down
53 changes: 35 additions & 18 deletions lib/SetupChecks/DatabaseChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,23 @@ public function run(): SetupResult {
);
}

if ($summary['failing'] === 0) {
$actionable = $summary['alerts'] + $summary['warnings'];

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

return SetupResult::success(
$this->l10n->t('All database checks pass.'),
$this->l10n->n(
'No database problems found. %n tuning hint is available. {link}',
'No database problems found. %n tuning hints are available. {link}',
$summary['notices'],
),
null,
$this->linkParameter(),
);
}

Expand All @@ -83,20 +97,30 @@ public function run(): SetupResult {
$this->l10n->n(
'%n database check is failing. {link}',
'%n database checks are failing. {link}',
$summary['failing'],
$actionable,
),
null,
[
'link' => [
'type' => 'highlight',
'id' => 'serverinfo-database-checks',
'name' => $this->l10n->t('Show the findings…'),
'link' => $this->databasePageUrl(),
],
],
$this->linkParameter(),
);
}

/**
* @return array<string, array<string, string>>
*/
private function linkParameter(): array {
return [
'link' => [
'type' => 'highlight',
'id' => 'serverinfo-database-checks',
'name' => $this->l10n->t('Show the findings…'),
'link' => $this->urlGenerator->linkToRoute(
'settings.AdminSettings.index',
['section' => Application::APP_ID],
) . '#database',
],
];
}

/**
* Runs the checks and records the outcome, so the next page load is cheap.
*
Expand Down Expand Up @@ -125,11 +149,4 @@ private function runNow(): ?array {
return null;
}
}

private function databasePageUrl(): string {
return $this->urlGenerator->linkToRoute(
'settings.AdminSettings.index',
['section' => Application::APP_ID],
) . '#database';
}
}
15 changes: 12 additions & 3 deletions src/components/DatabaseRuleCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<article class="rule-card" :class="`rule-card--${rule.status}`">
<article class="rule-card" :class="`rule-card--${outcome}`">
<header class="rule-card__header">
<span class="rule-card__dot" :class="`rule-card__dot--${rule.severity}`" />
<div class="rule-card__title-block">
Expand All @@ -16,7 +16,7 @@
<code>{{ rule.id }}</code>
</span>
</div>
<span class="rule-card__chip" :class="`rule-card__chip--${rule.status}`">{{ statusLabel }}</span>
<span class="rule-card__chip" :class="`rule-card__chip--${outcome}`">{{ statusLabel }}</span>
</header>

<p v-if="rule.status === 'fail' && rule.issue" class="rule-card__issue">
Expand Down Expand Up @@ -93,19 +93,24 @@ import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import CodeJson from 'vue-material-design-icons/CodeJson.vue'
import { ruleOutcome } from '../utils.ts'

const props = defineProps<{ rule: DatabaseRule }>()

defineEmits<{ snippet: [rule: DatabaseRule] }>()

const hasDetails = computed(() => props.rule.value !== null || props.rule.apply !== null)

const outcome = computed(() => ruleOutcome(props.rule))

const statusLabel = computed(() => {
switch (props.rule.status) {
switch (outcome.value) {
// TRANSLATORS: Status of a database check that found nothing wrong
case 'ok': return t('serverinfo', 'Passing')
// TRANSLATORS: Status of a database check that could not run, e.g. the value was unavailable
case 'skipped': return t('serverinfo', 'Skipped')
// TRANSLATORS: Status of a database check that found an optional tuning opportunity, not a problem
case 'hint': return t('serverinfo', 'Hint')
// TRANSLATORS: Status of a database check that found a problem
default: return t('serverinfo', 'Failing')
}
Expand Down Expand Up @@ -147,6 +152,10 @@ const formattedValue = computed(() => {
border-inline-start: 4px solid var(--color-error);
}

.rule-card--hint {
border-inline-start: 4px solid var(--color-border-maxcontrast);
}

.rule-card--skipped {
opacity: 0.7;
}
Expand Down
11 changes: 11 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,19 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { DatabaseRule } from './types.ts'

import { t } from '@nextcloud/l10n'

/**
* A failed notice-level check is a tuning hint rather than a failure.
*
* @param rule the database check
*/
export function ruleOutcome(rule: DatabaseRule): DatabaseRule['status'] | 'hint' {
return rule.status === 'fail' && rule.severity === 'notice' ? 'hint' : rule.status
}

/**
* Formats a size in megabytes to a human-readable string.
*
Expand Down
7 changes: 4 additions & 3 deletions src/views/DatabaseView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import DatabaseSettingsDialog from '../components/DatabaseSettingsDialog.vue'
import DatabaseSnippetDialog from '../components/DatabaseSnippetDialog.vue'
import SectionHeading from '../components/SectionHeading.vue'
import SectionSkeleton from '../components/SectionSkeleton.vue'
import { ruleOutcome } from '../utils.ts'

const props = defineProps<{ active: boolean }>()

Expand All @@ -114,8 +115,8 @@ const statusFilter = ref<'all' | 'fail' | 'ok' | 'skipped'>('fail')
const rules = computed<DatabaseRule[]>(() => (check.value?.supported ? check.value.results : []))

const filters = computed(() => [
// TRANSLATORS: Filter showing only the database checks that found a problem
{ id: 'fail' as const, label: t('serverinfo', 'Failing'), count: rules.value.filter((r) => r.status === 'fail').length },
// TRANSLATORS: Filter showing the database checks that found a problem or a tuning hint
{ id: 'fail' as const, label: t('serverinfo', 'Findings'), count: rules.value.filter((r) => r.status === 'fail').length },
// TRANSLATORS: Filter showing only the database checks that found nothing wrong
{ id: 'ok' as const, label: t('serverinfo', 'Passing'), count: rules.value.filter((r) => r.status === 'ok').length },
// TRANSLATORS: Filter showing only the database checks that could not run
Expand All @@ -142,7 +143,7 @@ const groups = computed(() => {
return [...byCategory.entries()].map(([category, categoryRules]) => ({
category,
rules: categoryRules,
failCount: categoryRules.filter((rule) => rule.status === 'fail').length,
failCount: categoryRules.filter((rule) => ruleOutcome(rule) === 'fail').length,
}))
})

Expand Down
16 changes: 16 additions & 0 deletions tests/lib/Database/AdvisorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use OCA\ServerInfo\Database\Advisor;
use OCA\ServerInfo\Database\ExpressionEvaluator;
use OCA\ServerInfo\Database\Rule;
use OCA\ServerInfo\Database\RuleResult;
use OCA\ServerInfo\Database\RuleSet\Mysql;
use OCA\ServerInfo\Database\Snapshot;
Expand Down Expand Up @@ -103,4 +104,19 @@ public function testReplicaLagPassesOnRunningReplica(): void {

$this->assertSame(RuleResult::STATUS_OK, $result->status);
}

/**
* Select_scan alone exceeds one per hour on any active Nextcloud, so the
* rule is a hint rather than a warning.
*/
public function testJoinWithoutIndexRateIsANotice(): void {
$result = $this->resultFor('Join_without_index_rate', [
'Select_range_check' => 0,
'Select_scan' => 50000,
'Select_full_join' => 0,
]);

$this->assertSame(RuleResult::STATUS_FAIL, $result->status);
$this->assertSame(Rule::SEVERITY_NOTICE, $result->rule->severity);
}
}
99 changes: 99 additions & 0 deletions tests/lib/SetupChecks/DatabaseChecksTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

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

namespace OCA\ServerInfo\Tests\SetupChecks;

use OCA\ServerInfo\Database\Advisor;
use OCA\ServerInfo\Database\CheckSummary;
use OCA\ServerInfo\Database\DatabaseProbe;
use OCA\ServerInfo\Database\RuleSet\Mysql;
use OCA\ServerInfo\Database\RuleSet\Postgres;
use OCA\ServerInfo\SetupChecks\DatabaseChecks;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\SetupCheck\SetupResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;

class DatabaseChecksTest extends \Test\TestCase {
private CheckSummary&MockObject $summary;
private DatabaseProbe&MockObject $probe;
private DatabaseChecks $check;

protected function setUp(): void {
parent::setUp();

$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnArgument(0);
$l10n->method('n')->willReturnCallback(
static fn (string $singular, string $plural, int $count): string => str_replace('%n', (string)$count, $count === 1 ? $singular : $plural),
);

$this->summary = $this->createMock(CheckSummary::class);
$this->probe = $this->createMock(DatabaseProbe::class);

$this->check = new DatabaseChecks(
$l10n,
$this->createMock(IURLGenerator::class),
$this->summary,
$this->probe,
$this->createMock(Advisor::class),
new Mysql(),
new Postgres(),
);
}

public static function summaryProvider(): array {
return [
'all pass' => [0, 0, 0, SetupResult::SUCCESS, 'All database checks pass.', false],
'one notice' => [0, 0, 1, SetupResult::SUCCESS, 'No database problems found. 1 tuning hint is available. {link}', true],
'notices only' => [0, 0, 2, SetupResult::SUCCESS, 'No database problems found. 2 tuning hints are available. {link}', true],
'one warning' => [0, 1, 4, SetupResult::WARNING, '1 database check is failing. {link}', true],
'alerts and warnings' => [1, 1, 4, SetupResult::WARNING, '2 database checks are failing. {link}', true],
];
}

#[DataProvider('summaryProvider')]
public function testRun(int $alerts, int $warnings, int $notices, string $severity, string $description, bool $hasLink): void {
$this->summary->method('latest')->willReturn([
'ranAt' => time(),
'supported' => true,
'failing' => $alerts + $warnings + $notices,
'alerts' => $alerts,
'warnings' => $warnings,
'notices' => $notices,
]);

$result = $this->check->run();

$this->assertSame($severity, $result->getSeverity());
$this->assertSame($description, $result->getDescription());
$this->assertSame($hasLink, isset($result->getDescriptionParameters()['link']));
}

public function testRunUnsupported(): void {
$this->summary->method('latest')->willReturn([
'ranAt' => time(),
'supported' => false,
'failing' => 0,
'alerts' => 0,
'warnings' => 0,
'notices' => 0,
]);

$this->assertSame(SetupResult::SUCCESS, $this->check->run()->getSeverity());
}

public function testRunProbeFails(): void {
$this->summary->method('latest')->willReturn(null);
$this->probe->method('snapshot')->willThrowException(new \RuntimeException());

$this->assertSame(SetupResult::INFO, $this->check->run()->getSeverity());
}
}
Loading