feat(update): 主机每日记录组件版本比对,面板更新页展示可用更新与应用命令

This commit is contained in:
Lemon-miaow committed 2026-09-25 21:50:49 +08:00
1 parent 24373823cc
commit 7f160e2feb
28 files changed
+994 -10

No files matched your search

+85
View File
@@ -165,6 +165,7 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
force := fs.Bool("force", false, "print the apply command for a selected component even when it is already up to date") force := fs.Bool("force", false, "print the apply command for a selected component even when it is already up to date")
velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar to read the current version from") velocityJar := fs.String("velocity-jar", updater.DefaultVelocityJarPath, "path to the installed Velocity jar to read the current version from")
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml, read for the maintenance window the panel stores") cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml, read for the maintenance window the panel stores")
record := fs.Bool("record", false, "also store this check for the panel's Updates page (felis-update-check.timer runs it daily)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
@@ -209,9 +210,93 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int {
} }
fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force)) fmt.Fprint(stdout, renderApplyGuidance(res, selected, *force))
} }
if *record {
// A fresh context: the discovery pass may have spent most of updateTimeout.
rctx, rcancel := context.WithTimeout(context.Background(), updateWindowTimeout)
defer rcancel()
if err := recordUpdateStatus(rctx, *cfgPath, buildStatusReport(res, src.Notes(), resolvedVersion(), now)); err != nil {
fmt.Fprintf(stderr, "felis update: record the check for the panel: %v\n", err)
return 1
}
fmt.Fprint(stdout, "Recorded this check for the panel's Updates page.\n")
}
return 0 return 0
} }
// buildStatusReport turns one run into the record the panel shows: every planned
// component in plan order, then each component whose installed version could not
// be read, by name. A component the feed could not answer for is StateUnknown with
// the reason, never StateCurrent: the panel must not call a component current when
// nobody could check.
func buildStatusReport(res updater.Result, notes map[string]string, felis string, now time.Time) updates.StatusReport {
selectorOf := map[string]string{}
for _, t := range updateTargets {
if t.component != "" && selectorOf[t.component] == "" {
selectorOf[t.component] = t.selector
}
}
rep := updates.StatusReport{CheckedAt: now.UTC(), Felis: felis, Components: []updates.ComponentStatus{}}
for _, a := range res.RunResult.Plan {
cs := updates.ComponentStatus{
Name: a.Component,
Current: a.Current.String(),
Selector: selectorOf[a.Component],
Note: notes[a.Component],
}
switch {
case a.Kind == updates.ActionPinned:
cs.State = updates.StatePinned
case a.Kind == updates.ActionNotify || a.Kind == updates.ActionApply:
cs.State = updates.StateAvailable
cs.Latest = a.Latest.String()
case a.LatestKnown:
cs.State = updates.StateCurrent
default:
cs.State = updates.StateUnknown
if err := res.RunResult.SourceErrors[a.Component]; err != nil {
cs.Error = err.Error()
}
}
rep.Components = append(rep.Components, cs)
}
names := make([]string, 0, len(res.GatherErrors))
for name := range res.GatherErrors {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
rep.Components = append(rep.Components, updates.ComponentStatus{
Name: name,
State: updates.StateUnreadable,
Selector: selectorOf[name],
Note: notes[name],
Error: res.GatherErrors[name].Error(),
})
}
return rep
}
// recordUpdateStatus upserts rep into platform_settings[updates.StatusKey], the
// row the API serves to the panel's Updates page.
func recordUpdateStatus(ctx context.Context, cfgPath string, rep updates.StatusReport) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
v, err := json.Marshal(rep)
if err != nil {
return err
}
conn, err := pgx.Connect(ctx, cfg.Database.URL)
if err != nil {
return err
}
defer conn.Close(context.Background())
_, err = conn.Exec(ctx, `INSERT INTO platform_settings (key, value) VALUES ($1, $2::jsonb)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, updates.StatusKey, string(v))
return err
}
// renderUpdateReport renders the component status table. With no selectors it shows // renderUpdateReport renders the component status table. With no selectors it shows
// every tracked component; with selectors it shows only the components those // every tracked component; with selectors it shows only the components those
// selectors name, so `felis update --velocity` is a focused answer rather than the // selectors name, so `felis update --velocity` is a focused answer rather than the
+52
View File
@@ -1,9 +1,11 @@
package main package main
import ( import (
"encoding/json"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time"
"felis.lolicon.best/internal/updater" "felis.lolicon.best/internal/updater"
"felis.lolicon.best/internal/updates" "felis.lolicon.best/internal/updates"
@@ -279,3 +281,53 @@ func TestInstallerRefNamesATag(t *testing.T) {
t.Errorf("no felis-api row: installerRef = %q, want main", got) t.Errorf("no felis-api row: installerRef = %q, want main", got)
} }
} }
func mustVersion(t *testing.T, s string) updates.Version {
t.Helper()
v, err := updates.Parse(s)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return v
}
// The record the panel shows keeps every component with a state that cannot be
// mistaken: a feed failure is "unknown" with its reason, an unreadable install is
// listed after the plan, and each row carries the selector that prints its apply.
func TestBuildStatusReport(t *testing.T) {
res := planResult([]updates.Action{
{Component: "felis-api", Current: mustVersion(t, "v0.4.0"), Latest: mustVersion(t, "v0.5.0"), LatestKnown: true, Kind: updates.ActionNotify},
{Component: "velocity", Current: mustVersion(t, "3.4.0"), Latest: mustVersion(t, "3.4.0"), LatestKnown: true, Kind: updates.ActionNone},
{Component: "k3s", Current: mustVersion(t, "v1.36.2+k3s1"), Kind: updates.ActionNone},
{Component: "cloudflared", Current: mustVersion(t, "2026.6.1"), Latest: mustVersion(t, "2026.9.0"), LatestKnown: true, Kind: updates.ActionApply},
{Component: "mc-lobby", Current: mustVersion(t, "1.21.4"), Kind: updates.ActionPinned},
})
res.RunResult.SourceErrors["k3s"] = errors.New("github: HTTP 403")
res.GatherErrors["postgresql"] = errors.New("psql: not found")
res.GatherErrors["jre"] = errors.New("release file missing")
notes := map[string]string{"postgresql": "PostgreSQL 13 is past its end of life", "velocity": "pinned minor 3.4"}
now := time.Date(2026, 9, 25, 3, 4, 5, 0, time.FixedZone("CST", 8*3600))
b, err := json.Marshal(buildStatusReport(res, notes, "v0.4.0", now))
if err != nil {
t.Fatal(err)
}
want := `{"checked_at":"2026-09-24T19:04:05Z","felis":"v0.4.0","components":[` +
`{"name":"felis-api","current":"v0.4.0","latest":"v0.5.0","state":"available","selector":"panel"},` +
`{"name":"velocity","current":"3.4.0","state":"current","selector":"velocity","note":"pinned minor 3.4"},` +
`{"name":"k3s","current":"v1.36.2+k3s1","state":"unknown","selector":"k3s","error":"github: HTTP 403"},` +
`{"name":"cloudflared","current":"2026.6.1","latest":"2026.9.0","state":"available","selector":"cloudflared"},` +
`{"name":"mc-lobby","current":"1.21.4","state":"pinned"},` +
`{"name":"jre","state":"unreadable","selector":"jre","error":"release file missing"},` +
`{"name":"postgresql","state":"unreadable","selector":"postgres","note":"PostgreSQL 13 is past its end of life","error":"psql: not found"}]}`
if string(b) != want {
t.Errorf("status report =\n%s\nwant\n%s", b, want)
}
// Nothing tracked still records an empty list, so the panel can tell "checked,
// nothing to show" from a report that never arrived.
b, _ = json.Marshal(buildStatusReport(planResult(nil), nil, "v0.4.0", now))
if !strings.Contains(string(b), `"components":[]`) {
t.Errorf("an empty check = %s, want an empty components list", b)
}
}
+44
View File
@@ -346,6 +346,8 @@ DB_BACKUP_SERVICE="/etc/systemd/system/felis-db-backup.service"
DB_BACKUP_TIMER="/etc/systemd/system/felis-db-backup.timer" DB_BACKUP_TIMER="/etc/systemd/system/felis-db-backup.timer"
WATCHDOG_SERVICE="/etc/systemd/system/felis-watchdog.service" WATCHDOG_SERVICE="/etc/systemd/system/felis-watchdog.service"
WATCHDOG_TIMER="/etc/systemd/system/felis-watchdog.timer" WATCHDOG_TIMER="/etc/systemd/system/felis-watchdog.timer"
UPDATE_CHECK_SERVICE="/etc/systemd/system/felis-update-check.service"
UPDATE_CHECK_TIMER="/etc/systemd/system/felis-update-check.timer"
WATCHDOG_STATE="/var/lib/felis/watchdog/state.json" WATCHDOG_STATE="/var/lib/felis/watchdog/state.json"
OFFSITE_ENV="${STATE_DIR}/offsite.env" OFFSITE_ENV="${STATE_DIR}/offsite.env"
OFFSITE_SERVICE="/etc/systemd/system/felis-offsite.service" OFFSITE_SERVICE="/etc/systemd/system/felis-offsite.service"
@@ -3262,6 +3264,46 @@ summary_offsite() {
# memory, and mails the owners (their verified addresses, over the [smtp] relay) what # memory, and mails the owners (their verified addresses, over the [smtp] relay) what
# has stayed wrong long enough to matter. It runs on the host so a k3s that is down is # has stayed wrong long enough to matter. It runs on the host so a k3s that is down is
# still reported. The first run happens now, so a broken unit shows up in this install. # still reported. The first run happens now, so a broken unit shows up in this install.
# The daily version check. Felis applies no update on its own; `felis update --record`
# compares what this host runs with the newest upstream releases and stores the result,
# which the panel's Updates page shows with the command that applies each update. It runs
# on the host because that is where the installed versions are readable. The first check
# runs in the background: it waits on the release feeds, and nothing in the install
# depends on it.
install_update_check_timer() {
cat > "$UPDATE_CHECK_SERVICE" <<EOF
[Unit]
Description=Felis component version check (felis update --record)
After=network-online.target postgresql.service k3s.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=${HOST_BIN} update --record -config ${STATE_DIR}/felis.host.toml
TimeoutStartSec=5min
Nice=10
PrivateTmp=yes
NoNewPrivileges=yes
ProtectSystem=full
EOF
cat > "$UPDATE_CHECK_TIMER" <<EOF
[Unit]
Description=Daily Felis component version check
[Timer]
OnCalendar=*-*-* 05:30:00
RandomizedDelaySec=30min
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now felis-update-check.timer
systemctl start --no-block felis-update-check.service
ok "version check: daily; the panel's Updates page shows what has a newer release (journalctl -u felis-update-check)"
}
install_watchdog_timer() { install_watchdog_timer() {
local disks="/,/var/lib/rancher/k3s,/var/lib/postgresql,/var/lib/felis" path local disks="/,/var/lib/rancher/k3s,/var/lib/postgresql,/var/lib/felis" path
for path in "$FELIS_WORLDS_HOST_PATH" "$FELIS_ARCHIVE_LOCAL_PATH" "$FELIS_DB_BACKUP_DIR"; do for path in "$FELIS_WORLDS_HOST_PATH" "$FELIS_ARCHIVE_LOCAL_PATH" "$FELIS_DB_BACKUP_DIR"; do
@@ -4216,6 +4258,8 @@ main() {
install_db_backup_timer install_db_backup_timer
# After the backup timer: its first bundle is part of the first copy. # After the backup timer: its first bundle is part of the first copy.
install_offsite_timer install_offsite_timer
# After install_velocity: the check reads the installed proxy jar's version.
install_update_check_timer
# Last: its first run should see the platform as this install leaves it. # Last: its first run should see the platform as this install leaves it.
install_watchdog_timer install_watchdog_timer
mark_bootstrap_done mark_bootstrap_done
+28
View File
@@ -1530,6 +1530,34 @@ expect "a failed first backup shows its log" "JOURNAL: pg_dump: connection refus
expect "a failed first backup is a loud warning" "WARN: the first database backup failed" "$out" expect "a failed first backup is a loud warning" "WARN: the first database backup failed" "$out"
rm -rf "$tdir" rm -rf "$tdir"
ublock="$(awk '/^install_update_check_timer\(\) \{/,/^}/' "$BS")"
[ -n "$ublock" ] || { echo "FAIL: no install_update_check_timer found in $BS"; exit 1; }
[ "$(printf '%s\n' "$ublock" | wc -l)" -lt 60 ] \
|| { echo "FAIL: the extracted block is not install_update_check_timer -- did its closing brace move?"; exit 1; }
udir="$(mktemp -d)"
out="$(UPDATE_CHECK_SERVICE="$udir/felis-update-check.service" UPDATE_CHECK_TIMER="$udir/felis-update-check.timer" \
HOST_BIN=/usr/local/bin/felis STATE_DIR=/etc/felis bash -c '
ok() { printf "OK: %s\n" "$*"; }; warn() { printf "WARN: %s\n" "$*"; }
systemctl() { printf "SYSTEMCTL: %s\n" "$*"; }
'"$ublock"'
install_update_check_timer' 2>&1)"
unit="$(cat "$udir/felis-update-check.service")"
timer="$(cat "$udir/felis-update-check.timer")"
expect "the version check records its result for the panel" \
"ExecStart=/usr/local/bin/felis update --record -config /etc/felis/felis.host.toml" "$unit"
expect "the version check is a oneshot" "Type=oneshot" "$unit"
expect "the version check runs daily" "OnCalendar=*-*-* 05:30:00" "$timer"
expect "a missed check catches up at boot" "Persistent=true" "$timer"
expect "the version check timer is enabled" "SYSTEMCTL: enable --now felis-update-check.timer" "$out"
expect "the first check runs without holding up the install" "SYSTEMCTL: start --no-block felis-update-check.service" "$out"
expect "the install says where the result shows" "OK: version check: daily; the panel's Updates page" "$out"
rm -rf "$udir"
order="$(awk '/^main\(\) \{/,/^}/' "$BS" | grep -nE '^[[:space:]]*(install_velocity|install_update_check_timer)$' | tr '\n' ' ')"
case "$order" in
*install_velocity*install_update_check_timer*) echo "PASS the version check is installed after the proxy it reads" ;;
*) echo "FAIL the version check must be installed after install_velocity: $order"; fails=$((fails + 1)) ;;
esac
wblock="$(awk '/^install_watchdog_timer\(\) \{/,/^}/' "$BS")" wblock="$(awk '/^install_watchdog_timer\(\) \{/,/^}/' "$BS")"
[ -n "$wblock" ] || { echo "FAIL: no install_watchdog_timer found in $BS"; exit 1; } [ -n "$wblock" ] || { echo "FAIL: no install_watchdog_timer found in $BS"; exit 1; }
[ "$(printf '%s\n' "$wblock" | wc -l)" -lt 60 ] \ [ "$(printf '%s\n' "$wblock" | wc -l)" -lt 60 ] \
+1 -1
View File
@@ -46,7 +46,7 @@ for unit in k3s postgresql felis-velocity; do
done done
# A release may predate a timer; what this commit installs has them all. # A release may predate a timer; what this commit installs has them all.
if [ "$phase" != release ]; then if [ "$phase" != release ]; then
for timer in felis-db-backup.timer felis-watchdog.timer; do for timer in felis-db-backup.timer felis-watchdog.timer felis-update-check.timer; do
check "${timer} is scheduled" systemctl is-enabled --quiet "$timer" check "${timer} is scheduled" systemctl is-enabled --quiet "$timer"
done done
fi fi
+2 -2
View File
@@ -50,8 +50,8 @@ FELIS_CRD="minecraftservers.felis.lolicon.best"
# Every unit the installer and `felis setup` write. Timers first, so none fires into a # Every unit the installer and `felis setup` write. Timers first, so none fires into a
# service that is already gone. # service that is already gone.
FELIS_UNITS=( FELIS_UNITS=(
felis-db-backup.timer felis-watchdog.timer felis-offsite.timer felis-build-tools.timer felis-db-backup.timer felis-watchdog.timer felis-offsite.timer felis-build-tools.timer felis-update-check.timer
felis-db-backup.service felis-watchdog.service felis-offsite.service felis-build-tools.service felis-db-backup.service felis-watchdog.service felis-offsite.service felis-build-tools.service felis-update-check.service
felis-velocity.service felis-nano.service cloudflared-felis.service felis-velocity.service felis-nano.service cloudflared-felis.service
felis-postgres-firewall.service felis-postgres-firewall.service
) )
+7
View File
@@ -206,6 +206,13 @@ expect "a purge wants the word purge" "not confirmed" "$out"
out="$(CONFIRM_TTY="$root/no-tty/x" FELIS_UNINSTALL_SOURCED=1 bash -c '. "$0"; confirm; echo WENT ON' "$US" 2>&1)" out="$(CONFIRM_TTY="$root/no-tty/x" FELIS_UNINSTALL_SOURCED=1 bash -c '. "$0"; confirm; echo WENT ON' "$US" 2>&1)"
expect "with no terminal it asks for --yes" "no terminal to confirm on; re-run with --yes" "$out" expect "with no terminal it asks for --yes" "no terminal to confirm on; re-run with --yes" "$out"
# Every unit the installer writes is one the uninstaller removes: a timer left behind
# keeps firing a felis binary that is gone.
units="$(awk '/^FELIS_UNITS=\(/ { f = 1; next } f && /^\)/ { f = 0 } f' "$US")"
for u in $(sed -n 's|^[A-Z_]*="/etc/systemd/system/\([^"]*\)"$|\1|p' "$(dirname "$US")/bootstrap.sh"); do
expect "the uninstaller removes $u" " $u" " $(printf '%s' "$units" | tr '\n' ' ')"
done
if [ "$fails" -eq 0 ]; then if [ "$fails" -eq 0 ]; then
echo "ALL PASS" echo "ALL PASS"
else else
+4 -1
View File
@@ -28,7 +28,10 @@ A grep across `*.md` and `*.go` returns both sets; only the Go ones are seams.
- `internal/updates/seams.go:32` — `Notifier`. `internal/mail` sends OTP over SMTP, - `internal/updates/seams.go:32` — `Notifier`. `internal/mail` sends OTP over SMTP,
but nothing adapts it to this interface and no in-game channel exists. `felis but nothing adapts it to this interface and no in-game channel exists. `felis
update` passes nil deliberately: a human typing the command is the notification. update` passes nil deliberately. The notification is the panel instead:
`felis-update-check.timer` runs `felis update --record` daily on the host, which
stores the report under `platform_settings.update_report`, and **Admin → Updates →
Component versions** shows it with the command that applies each update.
- `internal/updates/seams.go:43` — `Applier`. Nothing applies an update anywhere. A - `internal/updates/seams.go:43` — `Applier`. Nothing applies an update anywhere. A
nil applier is not silent — `Run` records `errNoApplier` against every planned nil applier is not silent — `Run` records `errNoApplier` against every planned
apply, so a mis-scheduled apply is loud rather than lost. apply, so a mis-scheduled apply is loud rather than lost.
+71
View File
@@ -383,6 +383,51 @@ components:
format: int64 format: int64
description: The freshness limit (26h), shared with `felis db check` and FelisDBBackupStale. description: The freshness limit (26h), shared with `felis db check` and FelisDBBackupStale.
UpdateReport:
type: object
description: >
The newest version check the host recorded (internal/api/handlers_updates.go
updateReportView; the record is internal/updates StatusReport, written by
`felis update --record`, which felis-update-check.timer runs daily).
required: [report, stale, max_age_seconds]
properties:
report:
type: object
nullable: true
description: >
Null until the first check has been recorded (internal/updates
StatusReport).
required: [checked_at, felis, components]
properties:
checked_at: { type: string, format: date-time }
felis: { type: string, description: Version of the felis binary that ran the check. }
components:
type: array
items: { $ref: '#/components/schemas/UpdateComponent' }
stale:
type: boolean
description: True when there is no record or it is older than max_age_seconds.
max_age_seconds:
type: integer
format: int64
description: The freshness limit (26h).
UpdateComponent:
type: object
description: >
One component's line. available has a newer stable release (latest);
unknown means the release feed could not be read and unreadable that the
installed version could not, both with error; pinned never changes by policy.
required: [name, state]
properties:
name: { type: string }
current: { type: string, description: Installed version; omitted when unreadable. }
latest: { type: string, description: The newer stable release; present only when state is available. }
state: { type: string, enum: [current, available, unknown, unreadable, pinned] }
selector: { type: string, description: 'The `felis update --<selector>` flag that prints how to apply it; omitted when none.' }
note: { type: string, description: What the release lookup learned beyond the version; omitted when none. }
error: { type: string, description: Why a version is missing; omitted otherwise. }
PasskeyCredential: PasskeyCredential:
type: object type: object
description: > description: >
@@ -3308,6 +3353,32 @@ paths:
'403': '403':
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
/api/v1/updates/report:
get:
tags: [admin-updates]
operationId: getUpdateReport
summary: The newest recorded version check of every tracked component (admin).
description: >-
What `felis update --record` last stored in platform_settings; the
installer's felis-update-check.timer runs it daily on the host, where the
installed versions are readable. report is null before the first check;
stale is true then, and whenever the check is older than max_age_seconds.
Read-only: Felis applies no update on its own.
x-felis-face: [external]
x-felis-tier: admin
security: [{ sessionCookie: [] }]
responses:
'200':
description: The newest recorded check and whether it is stale.
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateReport'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/v1/fleet: /api/v1/fleet:
get: get:
tags: [admin-servers] tags: [admin-servers]
+14
View File
@@ -247,6 +247,20 @@ against their newest releases; `--k3s`, `--cloudflared`, `--jre` and `--postgres
it to one. PostgreSQL is compared within its major, since a minor release is a package it to one. PostgreSQL is compared within its major, since a minor release is a package
update, and a major past its end of life gets a note naming the current one. update, and a major past its end of life gets a note naming the current one.
The installer also sets up `felis-update-check.timer`, which runs `felis update --record`
once a day around 05:30 (and at boot after a missed run). `--record` stores the result
in `platform_settings`, and the panel's **Admin → Updates → Component versions** card
shows it: each component's installed and newest version, and for the ones with a newer
release the `sudo felis update --<component>` line that prints how to apply it. Felis
applies nothing on its own; the installer re-run above is the apply path. The card turns
red when the newest record is older than 26 hours, meaning the timer stopped:
```sh
systemctl list-timers felis-update-check.timer
journalctl -u felis-update-check -n 50 --no-pager
sudo felis update --record # record a fresh check now
```
### PostgreSQL major versions [CODE-ONLY] ### PostgreSQL major versions [CODE-ONLY]
The installer takes the major the distribution ships (13 on EL9) and never moves it. To The installer takes the major the distribution ships (13 on EL9) and never moves it. To
+4 -2
View File
@@ -663,10 +663,12 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext}, {Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
// Auto-update maintenance window (spec §B; decision core internal/updates). // Auto-update maintenance window (spec §B; decision core internal/updates).
// Admin-tier: it governs whether Felis may apply an update to itself, so setting // Admin-tier: it governs whether Felis may apply an update to itself, so setting
// it requires the admin Zero-Trust path, not a mere session. API+persistence // it requires the admin Zero-Trust path, not a mere session. Advisory: `felis
// only — the runner/executors that consume the window are still INTEGRATION-ONLY. // update` on the host reads it and warns before an apply outside it.
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow}, {Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow}, {Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},
// The newest version check felis-update-check.timer recorded on the host.
{Method: "GET", Pattern: "/api/v1/updates/report", Admin: true, h: a.handleGetUpdateReport},
// Control-plane database backup freshness, as the host's felis-db-backup.timer // Control-plane database backup freshness, as the host's felis-db-backup.timer
// last recorded it. Admin-tier: it names the host backup directory. // last recorded it. Admin-tier: it names the host backup directory.
{Method: "GET", Pattern: "/api/v1/platform/db-backup", Admin: true, h: a.handleGetDBBackup}, {Method: "GET", Pattern: "/api/v1/platform/db-backup", Admin: true, h: a.handleGetDBBackup},
@@ -0,0 +1,83 @@
package api
import (
"errors"
"net/http"
"testing"
"time"
"felis.lolicon.best/internal/updates"
)
// The Updates page's version card reads the report and one flag, stale: set when
// no check was ever recorded and when the daily timer stopped, clear for today's.
func TestUpdateReportNeverRecorded(t *testing.T) {
api, _ := seedUpdatesAPI(t)
w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil)
if w.Code != http.StatusOK || w.Body.String() != `{"report":null,"stale":true,"max_age_seconds":93600}`+"\n" {
t.Fatalf("never checked = %d %s", w.Code, w.Body.String())
}
}
func TestUpdateReportPassesTheRecordThrough(t *testing.T) {
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
api, repo := seedUpdatesAPI(t)
api.Now = func() time.Time { return now }
repo.settings[updates.StatusKey] = []byte(`{"checked_at":"2026-09-25T03:00:00Z","felis":"v0.4.0","components":[` +
`{"name":"felis-api","current":"v0.4.0","latest":"v0.5.0","state":"available","selector":"panel"},` +
`{"name":"k3s","current":"v1.36.2+k3s1","state":"unknown","selector":"k3s","error":"github: HTTP 403"}]}`)
w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil)
want := `{"report":{"checked_at":"2026-09-25T03:00:00Z","felis":"v0.4.0","components":[` +
`{"name":"felis-api","current":"v0.4.0","latest":"v0.5.0","state":"available","selector":"panel"},` +
`{"name":"k3s","current":"v1.36.2+k3s1","state":"unknown","selector":"k3s","error":"github: HTTP 403"}]},` +
`"stale":false,"max_age_seconds":93600}` + "\n"
if w.Code != http.StatusOK || w.Body.String() != want {
t.Fatalf("report = %d\n%s\nwant\n%s", w.Code, w.Body.String(), want)
}
}
func TestUpdateReportFreshness(t *testing.T) {
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
checkedAt string
stale bool
}{
{"checked last night", "2026-09-25T03:00:00Z", false},
{"yesterday's, timer slightly late", "2026-09-24T11:00:00Z", false},
{"timer missed a day", "2026-09-24T09:00:00Z", true},
{"no timestamp", "0001-01-01T00:00:00Z", true},
} {
t.Run(tc.name, func(t *testing.T) {
api, repo := seedUpdatesAPI(t)
api.Now = func() time.Time { return now }
repo.settings[updates.StatusKey] = []byte(`{"checked_at":"` + tc.checkedAt + `","felis":"v0.4.0","components":null}`)
w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil)
want := `{"report":{"checked_at":"` + tc.checkedAt + `","felis":"v0.4.0","components":[]},"stale":false,"max_age_seconds":93600}` + "\n"
if tc.stale {
want = `{"report":{"checked_at":"` + tc.checkedAt + `","felis":"v0.4.0","components":[]},"stale":true,"max_age_seconds":93600}` + "\n"
}
if w.Code != http.StatusOK || w.Body.String() != want {
t.Fatalf("report = %d %s, want %s", w.Code, w.Body.String(), want)
}
})
}
}
func TestUpdateReportStoreOutageIsAnError(t *testing.T) {
api, repo := seedUpdatesAPI(t)
repo.failGetSetting = errors.New("connection reset")
if w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil); w.Code != http.StatusInternalServerError {
t.Fatalf("a failed settings read answered %d", w.Code)
}
}
func TestUpdateReportAdminOnly(t *testing.T) {
repo := newFakeRepo()
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
if w := do(api.ExternalHandler(), "GET", "/api/v1/updates/report", "", nil); w.Code != http.StatusForbidden {
t.Fatalf("player read = %d, want 403", w.Code)
}
}
+40
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"net/http" "net/http"
"time" "time"
"felis.lolicon.best/internal/updates"
) )
// SysAdmin-set maintenance window for the auto-update subsystem (task #38; the // SysAdmin-set maintenance window for the auto-update subsystem (task #38; the
@@ -115,3 +117,41 @@ func (a *API) handleSetUpdateWindow(w http.ResponseWriter, r *http.Request) {
a.audit(r, "updates.window_set", "") a.audit(r, "updates.window_set", "")
writeJSON(w, http.StatusOK, body) writeJSON(w, http.StatusOK, body)
} }
// updateReportView is the wire shape of the newest version check. Report is null
// until felis-update-check.timer (or `felis update --record`) has run once; Stale
// is true for a missing report too, so the panel has one flag for "the versions
// shown are not today's".
type updateReportView struct {
Report *updates.StatusReport `json:"report"`
Stale bool `json:"stale"`
MaxAgeSeconds int64 `json:"max_age_seconds"`
}
// handleGetUpdateReport returns the newest recorded version check (admin-tier).
// The check runs on the host, where the installed versions are readable, and
// records itself in platform_settings[updates.StatusKey]. Only a missing key
// reads as "never checked"; any other store error is a 500.
func (a *API) handleGetUpdateReport(w http.ResponseWriter, r *http.Request) {
view := updateReportView{Stale: true, MaxAgeSeconds: int64(updates.StatusStaleAfter.Seconds())}
raw, err := a.Repo.GetSetting(r.Context(), updates.StatusKey)
switch {
case errors.Is(err, ErrNotFound):
writeJSON(w, http.StatusOK, view)
return
case err != nil:
writeError(w, r, err)
return
}
var rep updates.StatusReport
if err := json.Unmarshal(raw, &rep); err != nil {
writeError(w, r, err)
return
}
if rep.Components == nil {
rep.Components = []updates.ComponentStatus{}
}
view.Report = &rep
view.Stale = a.now().Sub(rep.CheckedAt) > updates.StatusStaleAfter
writeJSON(w, http.StatusOK, view)
}
+3
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
"felis.lolicon.best/internal/updates"
"sigs.k8s.io/yaml" "sigs.k8s.io/yaml"
) )
@@ -51,6 +52,8 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
"PasskeyCredential": passkeyCredentialView{}, "PasskeyCredential": passkeyCredentialView{},
"UpdateWindow": updateWindow{}, "UpdateWindow": updateWindow{},
"DBBackupStatus": dbBackupView{}, "DBBackupStatus": dbBackupView{},
"UpdateReport": updateReportView{},
"UpdateComponent": updates.ComponentStatus{},
} }
for name, v := range pairs { for name, v := range pairs {
s, ok := doc.Components.Schemas[name] s, ok := doc.Components.Schemas[name]
+3 -1
View File
@@ -51,5 +51,7 @@
// Notifier (SMTP + in-game) and Applier (control-plane image bump, cloudflared swap) // Notifier (SMTP + in-game) and Applier (control-plane image bump, cloudflared swap)
// — the CLI passes nil for both on purpose, so it reports and never applies — the // — the CLI passes nil for both on purpose, so it reports and never applies — the
// in-cluster CronJob entry point, and the runtime append of the live Pinned // in-cluster CronJob entry point, and the runtime append of the live Pinned
// Minecraft fleet. // Minecraft fleet. The scheduled check runs on the host instead:
// felis-update-check.timer runs `felis update --record`, which stores the report
// under updates.StatusKey for the panel's Updates page.
package updater package updater
+45
View File
@@ -0,0 +1,45 @@
package updates
import "time"
// StatusKey is the platform_settings key `felis update --record` writes the
// newest version check to (felis-update-check.timer runs it daily on the host,
// where the versions are readable); internal/api serves it to the panel. Felis
// applies nothing on its own, so this record is how a SysAdmin learns an update
// exists without opening a shell on the node.
const StatusKey = "update_report"
// StatusStaleAfter is how old the newest check may get before the panel says the
// daily timer stopped: a day plus the timer's randomized delay and a margin.
const StatusStaleAfter = 26 * time.Hour
// Component states in a StatusReport.
const (
StateCurrent = "current" // the newest stable release is installed
StateAvailable = "available" // a newer stable release exists
StateUnknown = "unknown" // the release feed could not be read
StateUnreadable = "unreadable" // the installed version could not be read
StatePinned = "pinned" // never proposed a change by policy
)
// StatusReport is the value stored under StatusKey.
type StatusReport struct {
CheckedAt time.Time `json:"checked_at"`
// Felis is the version of the felis binary that ran the check.
Felis string `json:"felis"`
Components []ComponentStatus `json:"components"`
}
// ComponentStatus is one component's line of a StatusReport. Latest is empty
// unless State is StateAvailable; Error names why a version is missing (State
// StateUnknown or StateUnreadable); Selector is the `felis update --<selector>`
// flag that prints how to apply it, empty for a component with none.
type ComponentStatus struct {
Name string `json:"name"`
Current string `json:"current,omitempty"`
Latest string `json:"latest,omitempty"`
State string `json:"state"`
Selector string `json:"selector,omitempty"`
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
}
+25
View File
@@ -1011,6 +1011,31 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
}); });
return true; return true;
} }
case "GET updates/report": {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
// Last night's timer run: one update waiting, one feed unreachable, one
// install unreadable, so the card shows every state it has.
sendJSON(ctx.res, 200, {
report: {
checked_at: new Date(Date.now() - 7 * 3600 * 1000).toISOString(),
felis: "v0.4.0",
components: [
{ name: "felis-api", current: "v0.4.0", latest: "v0.5.1", state: "available", selector: "panel" },
{ name: "velocity", current: "3.4.0-SNAPSHOT+b528", state: "current", selector: "velocity" },
{ name: "k3s", current: "v1.36.2+k3s1", state: "current", selector: "k3s" },
{ name: "cloudflared", current: "2026.6.1", latest: "2026.9.0", state: "available", selector: "cloudflared" },
{ name: "jre", current: "21.0.8+9", state: "unknown", selector: "jre", error: "adoptium: GET https://api.adoptium.net/v3/assets/latest/21/hotspot: context deadline exceeded" },
{ name: "postgresql", current: "13.22", state: "current", selector: "postgres", note: "PostgreSQL 13 reached its end of life on 2025-11-13; plan a major upgrade (docs/operations.md §4)" },
],
},
stale: false,
max_age_seconds: 26 * 3600,
});
return true;
}
case "GET updates/window": case "GET updates/window":
if (!isAdmin(ctx.account.role)) { if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required"); sendError(ctx.res, 403, "forbidden", "admin account required");
+25 -1
View File
@@ -90,7 +90,7 @@
"reviewed_at": "Reviewed At", "reviewed_at": "Reviewed At",
"reject_reason": "Rejection Reason", "reject_reason": "Rejection Reason",
"updates_title": "Maintenance & Backups", "updates_title": "Maintenance & Backups",
"updates_subtitle": "Check that the control-plane database backup is fresh, and set the platform-wide maintenance window. Felis never applies an update on its own: `felis update` on the host shows this window and warns before you apply outside it.", "updates_subtitle": "Check that the control-plane database backup is fresh, see which components have a newer release, and set the platform-wide maintenance window. Felis never applies an update on its own: `felis update` on the host shows this window and warns before you apply outside it.",
"updates_window_advisory": "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.", "updates_window_advisory": "The window is advisory. Updates happen only when someone runs the apply commands `felis update` prints; it reads this window and warns when run outside it.",
"updates_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.", "updates_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.",
"updates_start_label": "Start Time", "updates_start_label": "Start Time",
@@ -132,6 +132,30 @@
"dbbackup_fix_hint": "If the host failed now, accounts, server ownership and the archive index could not be recovered. Take a backup on the host now, then read the timer's log to find out why it did not run:", "dbbackup_fix_hint": "If the host failed now, accounts, server ownership and the archive index could not be recovered. Take a backup on the host now, then read the timer's log to find out why it did not run:",
"dbbackup_copy": "Copy command", "dbbackup_copy": "Copy command",
"dbbackup_offsite_note": "Backups are kept on this host only and are lost with its disk. Copy the backup directory to another machine regularly; restore and disaster-recovery steps are in the troubleshooting guide, §16.", "dbbackup_offsite_note": "Backups are kept on this host only and are lost with its disk. Copy the backup directory to another machine regularly; restore and disaster-recovery steps are in the troubleshooting guide, §16.",
"versions_title": "Component versions",
"versions_subtitle": "felis-update-check.timer compares what this host runs with the newest upstream releases every day. Felis applies nothing on its own.",
"versions_status_available_one": "{{count}} update available",
"versions_status_available_other": "{{count}} updates available",
"versions_status_current": "Up to date",
"versions_status_stale": "Check overdue",
"versions_status_never": "Never checked",
"versions_refresh": "Refresh",
"versions_loading": "Reading the latest version check…",
"versions_checked": "Checked {{when}} by felis {{felis}}",
"versions_none": "The check tracked no components.",
"versions_col_component": "Component",
"versions_col_installed": "Installed",
"versions_col_latest": "Newer release",
"versions_col_status": "Status",
"versions_state_available": "Update available",
"versions_state_current": "Up to date",
"versions_state_unknown": "Feed unreachable",
"versions_state_unreadable": "Version unreadable",
"versions_state_pinned": "Pinned",
"versions_apply_hint": "To apply, run this on the host, inside the maintenance window below if you set one. It prints the exact command for each component and warns when run outside the window:",
"versions_never_title": "No version check has been recorded yet",
"versions_stale_title": "The newest version check is more than {{hours}} hours old",
"versions_fix_hint": "The versions below may be out of date. Run a check on the host now, then read the timer's log to find out why it did not run:",
"build_import_submission_label": "Import parameters from submission", "build_import_submission_label": "Import parameters from submission",
"build_import_submission_placeholder": "Select a user submission...", "build_import_submission_placeholder": "Select a user submission...",
"build_import_submission_none": "No matching submissions found or not loaded", "build_import_submission_none": "No matching submissions found or not loaded",
+24 -1
View File
@@ -90,7 +90,7 @@
"reviewed_at": "审核时间", "reviewed_at": "审核时间",
"reject_reason": "驳回理由", "reject_reason": "驳回理由",
"updates_title": "维护与备份", "updates_title": "维护与备份",
"updates_subtitle": "查看控制面数据库备份是否新鲜,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。", "updates_subtitle": "查看控制面数据库备份是否新鲜、哪些组件有新版本,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。",
"updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。", "updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。",
"updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。", "updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。",
"updates_start_label": "开始时间", "updates_start_label": "开始时间",
@@ -132,6 +132,29 @@
"dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即备份一次,再查看定时任务日志找出它没有运行的原因:", "dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即备份一次,再查看定时任务日志找出它没有运行的原因:",
"dbbackup_copy": "复制命令", "dbbackup_copy": "复制命令",
"dbbackup_offsite_note": "备份只保存在这台主机上,硬盘损坏或主机丢失时会一起丢失。请定期把备份目录复制到另一台机器;恢复与灾备步骤见故障排查文档 §16。", "dbbackup_offsite_note": "备份只保存在这台主机上,硬盘损坏或主机丢失时会一起丢失。请定期把备份目录复制到另一台机器;恢复与灾备步骤见故障排查文档 §16。",
"versions_title": "组件版本",
"versions_subtitle": "felis-update-check.timer 每天把本机运行的版本与上游最新发行版比对。Felis 不会自行应用任何更新。",
"versions_status_available_other": "{{count}} 个可用更新",
"versions_status_current": "已是最新",
"versions_status_stale": "检查已逾期",
"versions_status_never": "从未检查",
"versions_refresh": "刷新",
"versions_loading": "正在读取最近一次版本检查…",
"versions_checked": "{{when}}由 felis {{felis}} 检查",
"versions_none": "本次检查没有跟踪任何组件。",
"versions_col_component": "组件",
"versions_col_installed": "已安装",
"versions_col_latest": "更新版本",
"versions_col_status": "状态",
"versions_state_available": "有可用更新",
"versions_state_current": "已是最新",
"versions_state_unknown": "无法访问发行源",
"versions_state_unreadable": "读不到版本",
"versions_state_pinned": "已固定",
"versions_apply_hint": "要应用更新,请在主机上运行下面的命令;如果设置了维护窗口,请在窗口内运行。它会列出每个组件的确切命令,并在窗口外运行时发出警告:",
"versions_never_title": "还没有记录过版本检查",
"versions_stale_title": "最近一次版本检查已超过 {{hours}} 小时",
"versions_fix_hint": "下面的版本可能已过时。请先在主机上立即检查一次,再查看定时器日志找出它没有运行的原因:",
"build_import_submission_label": "从已有的审核提交导入参数", "build_import_submission_label": "从已有的审核提交导入参数",
"build_import_submission_placeholder": "选择一个用户提交...", "build_import_submission_placeholder": "选择一个用户提交...",
"build_import_submission_none": "无匹配的提交或暂未加载", "build_import_submission_none": "无匹配的提交或暂未加载",
+13
View File
@@ -699,6 +699,19 @@ describe("image whitelist and builds wire shapes", () => {
}); });
}); });
describe("component version check", () => {
it("getUpdateReport GETs /updates/report and keeps a null report", async () => {
const report = { report: null, stale: true, max_age_seconds: 93600 };
const fetchSpy = fakeFetch(report);
vi.stubGlobal("fetch", fetchSpy);
const res = await api.getUpdateReport();
expect(res).toEqual({ report: null, stale: true, max_age_seconds: 93600 });
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/updates/report");
expect((opts as RequestInit).method).toBe("GET");
});
});
describe("backup now and server jobs wire shapes", () => { describe("backup now and server jobs wire shapes", () => {
it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => { it("backupNow POSTs to /servers/{name}/backup with no body and parses the 202", async () => {
const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 }); const fetchSpy = fakeFetch({ name: "survival", status: "backing_up" }, { status: 202 });
+2
View File
@@ -33,6 +33,7 @@ import type {
SubmissionPage, SubmissionPage,
UpdateWindow, UpdateWindow,
DBBackupStatus, DBBackupStatus,
UpdateReport,
} from "./types"; } from "./types";
import { loadConfig } from "./config"; import { loadConfig } from "./config";
import i18next from "i18next"; import i18next from "i18next";
@@ -755,6 +756,7 @@ export const api = rejectingSync({
// Freshness of the host's control-plane database backup (felis-db-backup.timer). // Freshness of the host's control-plane database backup (felis-db-backup.timer).
getDBBackup: () => request<DBBackupStatus>("GET", "/platform/db-backup"), getDBBackup: () => request<DBBackupStatus>("GET", "/platform/db-backup"),
getUpdateReport: () => request<UpdateReport>("GET", "/updates/report"),
// ---- User admin (admin-tier, spec §7 user admin) ---- // ---- User admin (admin-tier, spec §7 user admin) ----
+76
View File
@@ -1027,6 +1027,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/updates/report": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* The newest recorded version check of every tracked component (admin).
* @description What `felis update --record` last stored in platform_settings; the installer's felis-update-check.timer runs it daily on the host, where the installed versions are readable. report is null before the first check; stale is true then, and whenever the check is older than max_age_seconds. Read-only: Felis applies no update on its own.
*/
get: operations["getUpdateReport"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/fleet": { "/api/v1/fleet": {
parameters: { parameters: {
query?: never; query?: never;
@@ -2193,6 +2213,40 @@ export interface components {
*/ */
max_age_seconds: number; max_age_seconds: number;
}; };
/** @description The newest version check the host recorded (internal/api/handlers_updates.go updateReportView; the record is internal/updates StatusReport, written by `felis update --record`, which felis-update-check.timer runs daily). */
UpdateReport: {
/** @description Null until the first check has been recorded (internal/updates StatusReport). */
report: {
/** Format: date-time */
checked_at: string;
/** @description Version of the felis binary that ran the check. */
felis: string;
components: components["schemas"]["UpdateComponent"][];
} | null;
/** @description True when there is no record or it is older than max_age_seconds. */
stale: boolean;
/**
* Format: int64
* @description The freshness limit (26h).
*/
max_age_seconds: number;
};
/** @description One component's line. available has a newer stable release (latest); unknown means the release feed could not be read and unreadable that the installed version could not, both with error; pinned never changes by policy. */
UpdateComponent: {
name: string;
/** @description Installed version; omitted when unreadable. */
current?: string;
/** @description The newer stable release; present only when state is available. */
latest?: string;
/** @enum {string} */
state: "current" | "available" | "unknown" | "unreadable" | "pinned";
/** @description The `felis update --<selector>` flag that prints how to apply it; omitted when none. */
selector?: string;
/** @description What the release lookup learned beyond the version; omitted when none. */
note?: string;
/** @description Why a version is missing; omitted otherwise. */
error?: string;
};
/** @description Display projection of one bound passkey (internal/api/handlers_passkey.go passkeyCredentialView). Carries no secret — the public key is never returned. */ /** @description Display projection of one bound passkey (internal/api/handlers_passkey.go passkeyCredentialView). Carries no secret — the public key is never returned. */
PasskeyCredential: { PasskeyCredential: {
/** @description Opaque passkey row id (used to unbind it). */ /** @description Opaque passkey row id (used to unbind it). */
@@ -5156,6 +5210,28 @@ export interface operations {
403: components["responses"]["Forbidden"]; 403: components["responses"]["Forbidden"];
}; };
}; };
getUpdateReport: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The newest recorded check and whether it is stale. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UpdateReport"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
};
};
fleet: { fleet: {
parameters: { parameters: {
query?: never; query?: never;
+2
View File
@@ -50,4 +50,6 @@ export type WireParity = [
Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>, Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>,
Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>, Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>,
Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>, Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>,
Holds<Parity<T.UpdateReport, S["UpdateReport"]>>,
Holds<Parity<T.UpdateComponent, S["UpdateComponent"]>>,
]; ];
+32
View File
@@ -422,6 +422,38 @@ export interface DBBackupStatus {
max_age_seconds: number; max_age_seconds: number;
} }
// ---- Component version check (internal/api/handlers_updates.go updateReportView) ----
export type UpdateComponentState = "current" | "available" | "unknown" | "unreadable" | "pinned";
export interface UpdateComponent {
name: string;
/** Installed version; absent when unreadable. */
current?: string;
/** The newer stable release; present only when state is available. */
latest?: string;
state: UpdateComponentState;
/** The `felis update --<selector>` flag that prints how to apply it. */
selector?: string;
note?: string;
/** Why a version is missing (unknown / unreadable). */
error?: string;
}
export interface UpdateStatusReport {
checked_at: string;
felis: string;
components: UpdateComponent[];
}
export interface UpdateReport {
/** Null until the host's felis-update-check.timer has recorded a check. */
report: UpdateStatusReport | null;
/** True when there is no record or it is older than max_age_seconds. */
stale: boolean;
max_age_seconds: number;
}
// ---- User admin types (internal/api/repo.go UserView, UserDetail, QuotaView, SessionView) ---- // ---- User admin types (internal/api/repo.go UserView, UserDetail, QuotaView, SessionView) ----
export interface UserView { export interface UserView {
+1 -1
View File
@@ -24,7 +24,7 @@ const LABEL_KEY: Record<DBBackupLabel, string> = {
const FIX_COMMANDS = ["sudo felis db backup", "journalctl -u felis-db-backup -n 50 --no-pager"]; const FIX_COMMANDS = ["sudo felis db backup", "journalctl -u felis-db-backup -n 50 --no-pager"];
function CopyCommand({ command }: { command: string }) { export function CopyCommand({ command }: { command: string }) {
const { t } = useTranslation("admin"); const { t } = useTranslation("admin");
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
async function copy() { async function copy() {
@@ -0,0 +1,94 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import i18next from "i18next";
import { UpdateReportCard } from "./UpdateReportCard";
import type { UpdateReport } from "@/lib/types";
const calls = vi.hoisted(() => ({ getUpdateReport: vi.fn() }));
vi.mock("@/lib/config", () => ({ loadConfig: () => Promise.resolve({}) }));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return { ...actual, api: { ...actual.api, ...calls } };
});
const CHECKED = new Date(Date.now() - 7 * 3600 * 1000).toISOString();
const WITH_UPDATES: UpdateReport = {
report: {
checked_at: CHECKED,
felis: "v0.4.0",
components: [
{ name: "felis-api", current: "v0.4.0", latest: "v0.5.1", state: "available", selector: "panel" },
{ name: "k3s", current: "v1.36.2+k3s1", state: "current", selector: "k3s" },
{ name: "cloudflared", current: "2026.6.1", latest: "2026.9.0", state: "available", selector: "cloudflared" },
{ name: "jre", current: "21.0.8+9", state: "unknown", selector: "jre", error: "adoptium: context deadline exceeded" },
{ name: "postgresql", state: "unreadable", selector: "postgres", error: "psql: not found", note: "PostgreSQL 13 is past its end of life" },
],
},
stale: false,
max_age_seconds: 93600,
};
beforeEach(() => {
calls.getUpdateReport.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
return i18next.changeLanguage("en-US");
});
function rowText(name: string): string {
const row = screen.getByText(name).closest("li");
if (!row) throw new Error(`no row for ${name}`);
return row.textContent ?? "";
}
describe("UpdateReportCard", () => {
it("lists each component's state and hands over the host command for the ones with updates", async () => {
calls.getUpdateReport.mockResolvedValue(WITH_UPDATES);
render(<UpdateReportCard />);
expect(await screen.findByText("2 updates available")).toBeTruthy();
expect(screen.getByText("Checked 7 hours ago by felis v0.4.0")).toBeTruthy();
expect(rowText("felis-api")).toBe("felis-apiInstalled: v0.4.0Newer release: v0.5.1Update available");
expect(rowText("k3s")).toBe("k3sInstalled: v1.36.2+k3s1Newer release: —Up to date");
expect(rowText("jre")).toBe("jreInstalled: 21.0.8+9Newer release: —Feed unreachableadoptium: context deadline exceeded");
expect(rowText("postgresql")).toBe(
"postgresqlInstalled: —Newer release: —Version unreadablepsql: not foundPostgreSQL 13 is past its end of life",
);
expect(screen.getByTitle("sudo felis update --panel --cloudflared").textContent).toBe("sudo felis update --panel --cloudflared");
expect(screen.queryByText("sudo felis update --record")).toBeNull();
});
it("says up to date and offers no apply command when nothing is newer", async () => {
calls.getUpdateReport.mockResolvedValue({
...WITH_UPDATES,
report: { ...WITH_UPDATES.report!, components: [{ name: "k3s", current: "v1.36.2+k3s1", state: "current", selector: "k3s" }] },
});
render(<UpdateReportCard />);
expect(await screen.findByText("k3s")).toBeTruthy();
const title = screen.getByText("Component versions").closest("div")!;
expect(within(title).getByText("Up to date")).toBeTruthy();
expect(document.body.textContent).not.toContain("sudo felis update");
});
it("tells the admin how to run the check when the timer never recorded one", async () => {
calls.getUpdateReport.mockResolvedValue({ report: null, stale: true, max_age_seconds: 93600 });
render(<UpdateReportCard />);
expect(await screen.findByText("Never checked")).toBeTruthy();
expect(screen.getByText("No version check has been recorded yet")).toBeTruthy();
expect(screen.getByTitle("sudo felis update --record")).toBeTruthy();
expect(screen.getByTitle("journalctl -u felis-update-check -n 50 --no-pager")).toBeTruthy();
});
it("flags an overdue check and keeps its versions visible", async () => {
calls.getUpdateReport.mockResolvedValue({ ...WITH_UPDATES, stale: true });
render(<UpdateReportCard />);
expect(await screen.findByText("Check overdue")).toBeTruthy();
expect(screen.getByText("The newest version check is more than 26 hours old")).toBeTruthy();
expect(rowText("felis-api")).toContain("v0.5.1");
// A stale list is not a basis to apply from: the apply hint waits for a fresh check.
expect(screen.queryByTitle("sudo felis update --panel --cloudflared")).toBeNull();
expect(screen.getByTitle("sudo felis update --record")).toBeTruthy();
});
});
+210
View File
@@ -0,0 +1,210 @@
import { AlertTriangle, ArrowUpCircle, CheckCircle2, Loader2, PackageCheck, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MessageLine } from "@/components/MessageLine";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
import { formatAbsolute, formatRelative } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { UpdateComponent, UpdateComponentState } from "@/lib/types";
import { CopyCommand } from "./DBBackupCard";
// The installed versions are only readable on the host, so the check runs there
// (felis-update-check.timer → `felis update --record`) and this card reads what
// it recorded. Felis applies nothing on its own: an available update comes with
// the host command that prints how to apply it.
const STATE_STYLE: Record<UpdateComponentState, string> = {
available: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25",
current: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25",
unknown: "bg-zinc-500/10 text-muted-foreground border-zinc-500/25",
unreadable: "bg-rose-500/10 text-rose-500 border-rose-500/25",
pinned: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/25",
};
const FIX_COMMANDS = ["sudo felis update --record", "journalctl -u felis-update-check -n 50 --no-pager"];
const GRID = "sm:grid sm:grid-cols-[minmax(0,9rem)_minmax(0,1fr)_minmax(0,1fr)_7.5rem] sm:items-center sm:gap-3";
export function UpdateReportCard() {
const { t, i18n } = useTranslation("admin");
const locale = i18n.language;
const { data, error, loading, reload } = useAsync(() => api.getUpdateReport(), []);
const report = data?.report ?? null;
const maxAgeHours = data ? Math.round(data.max_age_seconds / 3600) : 26;
const available = report?.components.filter((c) => c.state === "available") ?? [];
const selectors = [...new Set(available.map((c) => c.selector).filter((s): s is string => !!s))];
const state: "loading" | "error" | "never" | "stale" | "ok" = !data
? error
? "error"
: "loading"
: !report
? "never"
: data.stale
? "stale"
: "ok";
const badge = (() => {
if (state === "never") {
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t("versions_status_never")}
</Badge>
);
}
if (state === "stale") {
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{t("versions_status_stale")}
</Badge>
);
}
if (state !== "ok") return null;
return available.length > 0 ? (
<Badge className="gap-1 border-transparent bg-amber-500/15 text-amber-600 dark:text-amber-400">
<ArrowUpCircle className="h-3 w-3" />
{t("versions_status_available", { count: available.length })}
</Badge>
) : (
<Badge className="gap-1 border-transparent bg-emerald-500/15 text-emerald-500">
<CheckCircle2 className="h-3 w-3" />
{t("versions_status_current")}
</Badge>
);
})();
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<div className="flex min-w-0 items-center gap-3">
<div
className={cn(
"hidden rounded-md p-2 sm:block",
state === "stale" || state === "never"
? "bg-destructive/10 text-destructive"
: available.length > 0
? "bg-amber-500/10 text-amber-500"
: "bg-primary/10 text-primary",
)}
>
<PackageCheck className="h-5 w-5" />
</div>
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2 text-base font-semibold">
{t("versions_title")}
{badge}
</CardTitle>
<p className="mt-0.5 text-xs text-muted-foreground">{t("versions_subtitle")}</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={reload}
disabled={loading}
aria-label={t("versions_refresh")}
title={t("versions_refresh")}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</CardHeader>
<CardContent className="space-y-4 text-sm">
{state === "loading" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("versions_loading")}
</div>
)}
{state === "error" && <MessageLine kind="error" message={humanizeError(error)} />}
{report && (
<>
<p className="text-xs text-muted-foreground">
<span title={formatAbsolute(report.checked_at, locale)} className={cn(state === "stale" && "text-destructive")}>
{t("versions_checked", { when: formatRelative(report.checked_at, Date.now(), locale), felis: report.felis })}
</span>
</p>
{report.components.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("versions_none")}</p>
) : (
<div className="rounded-md border border-border">
<div className={cn("hidden border-b px-3 py-2 text-[11px] font-semibold text-muted-foreground", GRID)}>
<div>{t("versions_col_component")}</div>
<div>{t("versions_col_installed")}</div>
<div>{t("versions_col_latest")}</div>
<div>{t("versions_col_status")}</div>
</div>
<ul className="divide-y divide-border">
{report.components.map((c) => (
<ComponentRow key={c.name} c={c} />
))}
</ul>
</div>
)}
</>
)}
{state === "ok" && available.length > 0 && (
<div className="space-y-2 rounded-lg border border-amber-500/25 bg-amber-500/5 p-4">
<p className="text-xs leading-relaxed text-foreground">{t("versions_apply_hint")}</p>
<CopyCommand command={`sudo felis update ${selectors.map((s) => `--${s}`).join(" ")}`.trimEnd()} />
</div>
)}
{(state === "stale" || state === "never") && (
<div className="space-y-3 rounded-lg border border-destructive/25 bg-destructive/5 p-4">
<div className="flex items-start gap-2 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="font-semibold">
{state === "never" ? t("versions_never_title") : t("versions_stale_title", { hours: maxAgeHours })}
</p>
<p className="text-xs leading-relaxed text-destructive/90">{t("versions_fix_hint")}</p>
</div>
</div>
<div className="space-y-2">
{FIX_COMMANDS.map((cmd) => (
<CopyCommand key={cmd} command={cmd} />
))}
</div>
</div>
)}
</CardContent>
</Card>
);
}
function ComponentRow({ c }: { c: UpdateComponent }) {
const { t } = useTranslation("admin");
return (
<li className="px-3 py-2.5 text-xs">
<div className={cn("flex flex-wrap items-center gap-x-3 gap-y-1", GRID)}>
<div className="w-full font-semibold text-foreground sm:w-auto">{c.name}</div>
<div className="min-w-0 truncate font-mono text-muted-foreground" title={c.current}>
<span className="sm:hidden">{t("versions_col_installed")}: </span>
{c.current ?? "—"}
</div>
<div className="min-w-0 truncate font-mono" title={c.latest}>
<span className="text-muted-foreground sm:hidden">{t("versions_col_latest")}: </span>
{c.latest ? <span className="text-amber-600 dark:text-amber-400">{c.latest}</span> : <span className="text-muted-foreground">—</span>}
</div>
<div>
<span className={cn("inline-block rounded border px-1.5 py-px text-[10px] font-semibold", STATE_STYLE[c.state])}>
{t(`versions_state_${c.state}`)}
</span>
</div>
</div>
{c.error && <p className="mt-1 break-words text-[11px] leading-relaxed text-rose-500/90">{c.error}</p>}
{c.note && <p className="mt-1 break-words text-[11px] leading-relaxed text-muted-foreground">{c.note}</p>}
</li>
);
}
+4
View File
@@ -14,6 +14,7 @@ import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { formatAbsolute } from "@/lib/format"; import { formatAbsolute } from "@/lib/format";
import { DBBackupCard } from "./DBBackupCard"; import { DBBackupCard } from "./DBBackupCard";
import { UpdateReportCard } from "./UpdateReportCard";
function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string { function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string {
if (!dateOrStr) return ""; if (!dateOrStr) return "";
@@ -170,6 +171,9 @@ export function UpdatesPage() {
{/* Control-plane database backup freshness (read-only, host timer) */} {/* Control-plane database backup freshness (read-only, host timer) */}
<DBBackupCard /> <DBBackupCard />
{/* Installed vs newest upstream versions (read-only, host timer) */}
<UpdateReportCard />
{/* Stats Cards Row */} {/* Stats Cards Row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<StatCard <StatCard