Repository navigation
feat(os): add the Operating system page - #1218
karlitschek wants to merge 15 commits into
Conversation
a8d1010 to
3620c9c
Compare
b9ecd0b to
59776d4
Compare
3620c9c to
dde9895
Compare
59776d4 to
2e3cd68
Compare
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 "&". 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>
dde9895 to
3f78919
Compare
2e3cd68 to
c05dbf0
Compare
AndyScherzinger
left a comment
There was a problem hiding this comment.
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/refreshandPUT /os/settingscarry noNoAdminRequired,PublicPageorNoCSRFRequired, so they are full-admin only with CSRF required.- Saving the settings requires password confirmation, and
Thresholds::fromInputvalidates 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-htmlorinnerHTML. Package names, origins, advisories and diagnostics are rendered as text. - Copied commands use static upgrade commands,
ServiceCommands::quoteand 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"`) |
There was a problem hiding this comment.
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
versionhappens 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); |
There was a problem hiding this comment.
Mutual exclusion and isCollecting() (line 277) rely on ILockingProvider. Core returns NoopLockingProvider when filelocking.enabled is false, which causes two problems:
RefreshOsStatusandUpdateOsStatuscan 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 |
There was a problem hiding this comment.
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.
| @settings="settingsOpen = true" /> | ||
|
|
||
| <NcNoteCard v-if="stale" type="warning" class="os-view__notice"> | ||
| <p>{{ stale.title }}. {{ stale.detail }}</p> |
There was a problem hiding this comment.
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' |
There was a problem hiding this comment.
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.


Part 5 of 5, stacked on #1217:
AI disclosure: this change was written with Claude Code (claude-opus-5-5) and reviewed by the author; every commit carries an
Assisted-bytrailer.🤖 Generated with Claude Code