Skip to content

feat(os): add the Operating system page - #1218

Open
karlitschek wants to merge 15 commits into
feat/os-status-alertsfrom
feat/os-status-page
Open

karlitschek wants to merge 15 commits into
feat/os-status-alertsfrom
feat/os-status-page

Conversation

@karlitschek

@karlitschek karlitschek commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Part 5 of 5, stacked on #1217:

  1. feat(os): collect the operating system status #1214 – collect the operating system status
  2. feat(os): add an optional root helper #1215 – optional root helper
  3. feat(os): support dnf, zypper, pacman and apk #1216 – dnf, zypper, pacman and apk
  4. feat(os): report operating system concerns where admins look #1217 – setup checks, notifications, monitoring API, dashboard widget
  5. feat(os): add the Operating system page #1218 – the Operating system page ← this PR

AI disclosure: this change was written with Claude Code (claude-opus-5-5) and reviewed by the author; every commit carries an Assisted-by trailer.

🤖 Generated with Claude Code

@nickvergessen

Copy link
Copy Markdown
Member
grafik

Small color issue somewhere

@nickvergessen

Copy link
Copy Markdown
Member

Also success color seems to use the wrong variable:
grafik

karlitschek and others added 15 commits October 23, 2026 10:46
A fourth page in the Monitoring settings: the verdict at a glance, pending
updates with the packages Nextcloud runs on marked, reboot and restart
findings with the commands to copy, release support, maintenance, system
health and root helper setup. Alert thresholds and notifications are set in
a dialog. The Status page links to it when something needs attention.

The filter pills of the Database page are now a shared component. Frontend
tests run with Vitest.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
"Check now" ran the whole collection inside the POST request. The
package manager timeouts allow minutes, so behind a proxy timeout the
browser saw an error while PHP kept running, and several admins, tabs
or the cron job could run dnf or zypper in parallel.

The request now only queues a RefreshOsStatus job and answers 202. The
page shows that a check is running and polls GET /os, at a growing
interval and for at most 15 minutes, until collectedAt changes. Every
request has a 30 second timeout. A page that loads while a check is
queued or running follows it as well.

collectAndStore() takes an exclusive lock through ILockingProvider, so
the hourly job, the queued job and occ never collect at the same time.
POST /os/refresh answers 409 while the lock is held, and occ --refresh
says so instead of pointing to the log.

The queued job runs only from system cron, like UpdateOsStatus: under
AJAX or webcron it would run inside a web request and hold a web server
worker for minutes. Without system cron the endpoint answers 409 and the
page disables the button and points to occ serverinfo:os-status
--refresh instead.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The setup dialog copied the helper from the live app directory and said
that this protects against a compromised web server. It does not: the
web server can write to that directory, so it can replace the script
before root copies it, and the "helper outdated" prompt asks for that
copy after every update.

The dialog now tells admins to install the script from a freshly
downloaded and extracted release of the app or of Nextcloud, and its
command copies bin/serverinfo-os-report relative to that directory.
The payload no longer carries the path inside the app directory, which
nothing uses any more.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The page built "sudo systemctl restart <unit>" itself and always offered
"sudo systemctl reboot". On Alpine, where services run under OpenRC, both
were wrong, and unit names were not shell-quoted, so an escaped name such
as "foo\x2dbar" lost its backslash when pasted.

The server now sends the commands with the status, like the upgrade
commands: systemctl when systemd runs (/run/systemd/system), rc-service
and reboot under OpenRC (/run/openrc), and none when the init system
cannot be told. Unit names are quoted for a POSIX shell. Without a
command the page names the service or asks for a reboot in words.

The health section no longer says "systemd is not available" on systems
that do not use systemd, and offers timedatectl only with systemd.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The updates table started on the security filter whenever the package
manager classifies updates, so a system with no security update but 40
others showed an empty table under the tiles. The filter was also chosen
only once and kept after a new check, even when the new list had nothing
under it.

The table now starts on "All" when there is no security update, chooses
again when a check brings a new status, and says so in a row of its own
when a filter matches nothing. The spec for this case now checks the
rows instead of only the hint below the table.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
updateSettings() declared "bool $notifications" and "array $thresholds"
without defaults, so a request that left one out failed with a
TypeError and a 500. Both are now optional in the signature and checked
in the method, which answers 400 with the name of the missing field.

The dialog had its own copy of the defaults, the 3650-day limit and the
warning/error pairs from Thresholds. The payload now carries them, and
the dialog validates and restores defaults from it. "Restore defaults"
also turns notifications back on, which is their default.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The Operating system page brought Vitest specs and "npm test", but no
workflow ran them, so they never ran on a pull request. This adds the
organization's node-test workflow template unchanged; it runs
"npm run test", which is "vitest run".

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The page wrote its own concern titles, reboot reasons, support sentences
and the names of the extended support programmes, which ConcernTexts
already writes for the setup checks, notifications and the widget. The
two copies had already drifted apart ("components" against "libraries").

OsController now adds each concern's title and detail, rendered by
ConcernTexts in the admin's language, to the payload. The page shows
them in the summary chips, the reboot item, release support, failed
services and the clock check. The JavaScript copies and their
translation strings are gone; Verdict::toArray(), which only the old
payload used, is gone too.

The page keeps its own words where ConcernTexts has none: for states
that could not be determined, for the specifics of each reboot reason
(the new kernel version, the updated packages) and for the restart
items, which describe the PHP process and the helper's list rather than
the restart concern.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Small things on the Operating system page:

- Os::getOSName() read /etc/os-release itself. It now reads it through
  IHost, like the collector, with the same /usr/lib/os-release fallback
  and the same open_basedir handling.
- The unsupported-system note said "This server runs {os}" with only
  the kernel release, e.g. "This server runs 14.1-RELEASE" on FreeBSD.
  The payload now carries the system's name for that case.
- FilterPills used a <nav> landmark for a group of toggle buttons; it is
  now a labelled role="group". The Database page uses the same component
  and is unchanged otherwise.
- t() and n() escape placeholder values as HTML and sanitize the result
  by default, but Vue's text interpolation escapes again, so an "&" in a
  path or tool name showed as "&amp;". The page's translations with
  placeholders now pass { escape: false, sanitize: false }; escape alone
  is not enough in @nextcloud/l10n 3.4, whose DOMPurify pass still
  rewrites text that looks like markup.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The page put "Checked 3 weeks ago" in small print under a green
verdict when cron had stopped, and only said that "Check now" needs
system cron, not that everything shown may be out of date.

A freshness warning now shows the server's sentence for it in a note
under the verdict, with the occ command when background jobs do not
run from system cron; that note replaces the separate "Check now needs
system cron" note, so the page does not say it twice. The root helper
section also names the new clock-skew state: a report dated in the
future, which is not used.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The helper's documented cron line passes --random-delay so that
servers sharing it do not all reach the package mirrors at the same
minute; the setup dialog still showed the line without it.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The alert settings decide whether anything about the operating system
is ever reported: with notifications off and every threshold raised to
the maximum, pending security updates, reboots and an unsupported
release all show as fine. A hijacked admin session could do that
silently with one PUT to /os/settings.

updateSettings now carries #[PasswordConfirmationRequired], as the
database connection settings of this app already do. The settings
dialog asks for the password with confirmPassword() from
@nextcloud/password-confirmation before it saves, so an admin whose
last confirmation has expired gets the prompt instead of a failed
save. Closing the prompt leaves the dialog open with the form as it
was and saves nothing.

The package is new to the app; its own dependencies are all already
in the tree. Vitest inlines it like @nextcloud/vue because it imports
CSS that Node cannot load.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
The setup dialog told admins to run sudo install on
bin/serverinfo-os-report relative to wherever they stood. Run from
/var/www, that is the live install, which the web server user owns and
can swap between extracting and installing; root then runs the swapped
script from cron.

The install command now extracts only the helper from the Nextcloud
release archive into a new directory from mktemp -d, which only the
admin can enter, installs it from there and removes the directory. It
is the procedure the README describes.

Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Assisted-by: ClaudeCode:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>

@AndyScherzinger AndyScherzinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this for correctness, security and performance, with the whole stack deployed on the nextcloud-docker-dev instance. Inline comments carry the details; this is the summary.

Security

The server side is tight.

  • GET /os, POST /os/refresh and PUT /os/settings carry no NoAdminRequired, PublicPage or NoCSRFRequired, so they are full-admin only with CSRF required.
  • Saving the settings requires password confirmation, and Thresholds::fromInput validates types, range and ordering.
  • The refresh only queues RefreshOsStatus, so no command runs in a web request. It is limited to 6 per 10 minutes and refused without system cron.
  • No new file uses v-html or innerHTML. Package names, origins, advisories and diagnostics are rendered as text.
  • Copied commands use static upgrade commands, ServiceCommands::quote and sanitised helper paths.

The one security-relevant item is the helper install command (inline). It is not copy-pasteable as offered, and it skips the checksum step the README asks for.

Correctness

  • Mutual exclusion and the "collecting" state depend on file locking, which can be disabled. When it is on, a killed collection blocks "Check now" for the lock TTL.
  • Polling swallows every error until the 15-minute timeout.
  • Small l10n and timing items.

Covered well: UI states for not yet checked, restricted, unsupported, stale, outdated, refreshing and rate-limited; level icons with hidden text labels, so state is not shown by colour alone; plurals via n(); and license files that match the lock file.

Performance

The settings bundle grows from about 642 KB to 759 KB (+18 %), plus about 19 KB of CSS, all loaded eagerly whichever tab is opened (inline).

One more item without an inline anchor: relative times use the browser clock, while "for N days" uses the server's now from the last fetch. A tab left open behind v-show keeps both frozen. Reloading when the tab becomes active again, if the data is older than a few minutes, would fix it.


// A directory from mktemp -d is only accessible to the admin, so nobody can
// swap the extracted script before root installs it.
const installCommand = computed(() => `d=$(mktemp -d) && tar -xjf nextcloud-<version>.tar.bz2 -C "$d" ${ARCHIVED_HELPER} && sudo install -o root -g root -m 0755 "$d"/${ARCHIVED_HELPER} ${shellQuote(props.helper.installPath)} && rm -r "$d"`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copy button copies tar -xjf nextcloud-<version>.tar.bz2 … with a literal <version>. To a shell, <version and >.tar.bz2 are redirections, not a placeholder.

  • Pasted as-is, the command fails with version: No such file or directory.
  • If a file named version happens to exist, it also creates or truncates ./.tar.bz2.
  • The rm -r "$d" at the end only runs on success, so a failure leaves the temp directory behind.

The server knows its own version, so the exact archive name could be put in. Also, the README asks to check the archive against its published SHA-256 or signature before extracting a file root will run every six hours, but the dialog offers no such step. A sha256sum -c line before the tar would match the README.

*/
public function collectAndStore(): ?OsStatus {
try {
$this->locks->acquireLock(self::LOCK, ILockingProvider::LOCK_EXCLUSIVE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mutual exclusion and isCollecting() (line 277) rely on ILockingProvider. Core returns NoopLockingProvider when filelocking.enabled is false, which causes two problems:

  • RefreshOsStatus and UpdateOsStatus can run the package manager in parallel.
  • The page loses its "collecting" signal and reports a failed check while one is still running.

With locking on, a collection killed mid-run (OOM, SIGKILL) keeps the lock until its TTL (at least 3600 s). For that whole time "Check now" answers 409 and the page shows "Checking…". Suggest a dedicated mutex with an expiry tied to the collector's own time limits, e.g. IMemcache::add or an app config timestamp.

try {
payload = await fetchPayload()
} catch {
continue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every failed GET is swallowed here. A 401 after the session expired, a 403 or a 500 keeps polling silently for 15 minutes, and the page then blames the background jobs. Suggest stopping on 401/403, or after a few consecutive failures, with an error state of its own.

Comment thread src/views/OsView.vue
@settings="settingsOpen = true" />

<NcNoteCard v-if="stale" type="warning" class="os-view__notice">
<p>{{ stale.title }}. {{ stale.detail }}</p>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the . between two separately translated strings is hard-coded. CJK languages expect 。, and an empty detail leaves a dangling period. Rendering title and detail as separate elements, as the other sections already do, avoids both.

import SettingsNavigation from '../components/SettingsNavigation.vue'
import BackgroundJobsView from './BackgroundJobsView.vue'
import DatabaseView from './DatabaseView.vue'
import OsView from './OsView.vue'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (performance): the bundle grows from about 642 KB to 759 KB (+18 %), plus about 19 KB of CSS, including @nextcloud/password-confirmation and its global stylesheet. All of it loads on every visit to the Monitoring settings, whichever tab is shown. defineAsyncComponent(() => import('./OsView.vue')) would load it only when the tab is opened.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants