From 7f160e2feb0b5dad789605683971d4c325acb0b8 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 21:50:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E4=B8=BB=E6=9C=BA=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=AE=B0=E5=BD=95=E7=BB=84=E4=BB=B6=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=AF=94=E5=AF=B9=EF=BC=8C=E9=9D=A2=E6=9D=BF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=A1=B5=E5=B1=95=E7=A4=BA=E5=8F=AF=E7=94=A8=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=B8=8E=E5=BA=94=E7=94=A8=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/update.go | 85 +++++++ cmd/felis/update_test.go | 52 +++++ deploy/bootstrap.sh | 44 ++++ deploy/bootstrap_test.sh | 28 +++ deploy/e2e_check.sh | 2 +- deploy/uninstall.sh | 4 +- deploy/uninstall_test.sh | 7 + docs/deferred-seams.md | 5 +- docs/openapi.yaml | 71 ++++++ docs/operations.md | 14 ++ internal/api/api.go | 6 +- internal/api/handlers_update_report_test.go | 83 +++++++ internal/api/handlers_updates.go | 40 ++++ internal/api/openapi_parity_test.go | 3 + internal/updater/doc.go | 4 +- internal/updates/status.go | 45 ++++ panel/dev/mockApi.ts | 25 +++ panel/src/i18n/resources/en-US/admin.json | 26 ++- panel/src/i18n/resources/zh-CN/admin.json | 25 ++- panel/src/lib/api.test.ts | 13 ++ panel/src/lib/api.ts | 2 + panel/src/lib/openapi.gen.ts | 76 +++++++ panel/src/lib/types.parity.ts | 2 + panel/src/lib/types.ts | 32 +++ panel/src/pages/admin/DBBackupCard.tsx | 2 +- .../src/pages/admin/UpdateReportCard.test.tsx | 94 ++++++++ panel/src/pages/admin/UpdateReportCard.tsx | 210 ++++++++++++++++++ panel/src/pages/admin/UpdatesPage.tsx | 4 + 28 files changed, 994 insertions(+), 10 deletions(-) create mode 100644 internal/api/handlers_update_report_test.go create mode 100644 internal/updates/status.go create mode 100644 panel/src/pages/admin/UpdateReportCard.test.tsx create mode 100644 panel/src/pages/admin/UpdateReportCard.tsx diff --git a/cmd/felis/update.go b/cmd/felis/update.go index 3c2c76c..b85f95f 100644 --- a/cmd/felis/update.go +++ b/cmd/felis/update.go @@ -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") 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") + 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 { return 2 } @@ -209,9 +210,93 @@ func cmdUpdate(args []string, stdout, stderr io.Writer) int { } 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 } +// 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 // every tracked component; with selectors it shows only the components those // selectors name, so `felis update --velocity` is a focused answer rather than the diff --git a/cmd/felis/update_test.go b/cmd/felis/update_test.go index cf7f9b4..40a0029 100644 --- a/cmd/felis/update_test.go +++ b/cmd/felis/update_test.go @@ -1,9 +1,11 @@ package main import ( + "encoding/json" "errors" "strings" "testing" + "time" "felis.lolicon.best/internal/updater" "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) } } + +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) + } +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index a8caae7..6694538 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -346,6 +346,8 @@ DB_BACKUP_SERVICE="/etc/systemd/system/felis-db-backup.service" DB_BACKUP_TIMER="/etc/systemd/system/felis-db-backup.timer" WATCHDOG_SERVICE="/etc/systemd/system/felis-watchdog.service" 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" OFFSITE_ENV="${STATE_DIR}/offsite.env" 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 # 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. +# 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" < "$UPDATE_CHECK_TIMER" <&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")" [ -n "$wblock" ] || { echo "FAIL: no install_watchdog_timer found in $BS"; exit 1; } [ "$(printf '%s\n' "$wblock" | wc -l)" -lt 60 ] \ diff --git a/deploy/e2e_check.sh b/deploy/e2e_check.sh index 80fd8bf..16594e9 100644 --- a/deploy/e2e_check.sh +++ b/deploy/e2e_check.sh @@ -46,7 +46,7 @@ for unit in k3s postgresql felis-velocity; do done # A release may predate a timer; what this commit installs has them all. 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" done fi diff --git a/deploy/uninstall.sh b/deploy/uninstall.sh index a3cf9a3..98cd541 100644 --- a/deploy/uninstall.sh +++ b/deploy/uninstall.sh @@ -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 # service that is already gone. FELIS_UNITS=( - felis-db-backup.timer felis-watchdog.timer felis-offsite.timer felis-build-tools.timer - felis-db-backup.service felis-watchdog.service felis-offsite.service felis-build-tools.service + 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-update-check.service felis-velocity.service felis-nano.service cloudflared-felis.service felis-postgres-firewall.service ) diff --git a/deploy/uninstall_test.sh b/deploy/uninstall_test.sh index a90a20e..3abd2c6 100644 --- a/deploy/uninstall_test.sh +++ b/deploy/uninstall_test.sh @@ -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)" 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 echo "ALL PASS" else diff --git a/docs/deferred-seams.md b/docs/deferred-seams.md index 4e2677b..4866e46 100644 --- a/docs/deferred-seams.md +++ b/docs/deferred-seams.md @@ -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, 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 nil applier is not silent — `Run` records `errNoApplier` against every planned apply, so a mis-scheduled apply is loud rather than lost. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 55cc643..9db31c3 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -383,6 +383,51 @@ components: format: int64 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 --` 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: type: object description: > @@ -3308,6 +3353,32 @@ paths: '403': $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: get: tags: [admin-servers] diff --git a/docs/operations.md b/docs/operations.md index 4a5af99..6b9b7fb 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 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 --` 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] The installer takes the major the distribution ships (13 on EL9) and never moves it. To diff --git a/internal/api/api.go b/internal/api/api.go index 3ef090d..6371181 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -663,10 +663,12 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext}, // Auto-update maintenance window (spec §B; decision core internal/updates). // 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 - // only — the runner/executors that consume the window are still INTEGRATION-ONLY. + // it requires the admin Zero-Trust path, not a mere session. Advisory: `felis + // 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: "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 // last recorded it. Admin-tier: it names the host backup directory. {Method: "GET", Pattern: "/api/v1/platform/db-backup", Admin: true, h: a.handleGetDBBackup}, diff --git a/internal/api/handlers_update_report_test.go b/internal/api/handlers_update_report_test.go new file mode 100644 index 0000000..b2fc739 --- /dev/null +++ b/internal/api/handlers_update_report_test.go @@ -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) + } +} diff --git a/internal/api/handlers_updates.go b/internal/api/handlers_updates.go index 7991844..e6dc121 100644 --- a/internal/api/handlers_updates.go +++ b/internal/api/handlers_updates.go @@ -5,6 +5,8 @@ import ( "errors" "net/http" "time" + + "felis.lolicon.best/internal/updates" ) // 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", "") 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) +} diff --git a/internal/api/openapi_parity_test.go b/internal/api/openapi_parity_test.go index 135c5d4..441b345 100644 --- a/internal/api/openapi_parity_test.go +++ b/internal/api/openapi_parity_test.go @@ -8,6 +8,7 @@ import ( "testing" "felis.lolicon.best/internal/build" + "felis.lolicon.best/internal/updates" "sigs.k8s.io/yaml" ) @@ -51,6 +52,8 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) { "PasskeyCredential": passkeyCredentialView{}, "UpdateWindow": updateWindow{}, "DBBackupStatus": dbBackupView{}, + "UpdateReport": updateReportView{}, + "UpdateComponent": updates.ComponentStatus{}, } for name, v := range pairs { s, ok := doc.Components.Schemas[name] diff --git a/internal/updater/doc.go b/internal/updater/doc.go index df4db98..bb2658e 100644 --- a/internal/updater/doc.go +++ b/internal/updater/doc.go @@ -51,5 +51,7 @@ // 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 // 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 diff --git a/internal/updates/status.go b/internal/updates/status.go new file mode 100644 index 0000000..ff02642 --- /dev/null +++ b/internal/updates/status.go @@ -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 --` +// 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"` +} diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 4d75c3f..efaaa3d 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -1011,6 +1011,31 @@ async function handleSession(ctx: SessionContext): Promise { }); 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": if (!isAdmin(ctx.account.role)) { sendError(ctx.res, 403, "forbidden", "admin account required"); diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 32f9324..0364653 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -90,7 +90,7 @@ "reviewed_at": "Reviewed At", "reject_reason": "Rejection Reason", "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_current_unset": "No maintenance window set. `felis update` will say so and leave the timing to you.", "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_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.", + "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_placeholder": "Select a user submission...", "build_import_submission_none": "No matching submissions found or not loaded", diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index d8bb13f..966e522 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -90,7 +90,7 @@ "reviewed_at": "审核时间", "reject_reason": "驳回理由", "updates_title": "维护与备份", - "updates_subtitle": "查看控制面数据库备份是否新鲜,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。", + "updates_subtitle": "查看控制面数据库备份是否新鲜、哪些组件有新版本,并设置全局维护窗口。Felis 从不自行应用更新:宿主机上的 `felis update` 会显示这个窗口,在窗口外应用前给出警告。", "updates_window_advisory": "维护窗口是提示性的。只有有人执行 `felis update` 打印的应用命令时才会更新;该命令会读取这个窗口,在窗口外运行时给出警告。", "updates_current_unset": "当前未设置维护窗口。`felis update` 会提示这一点,何时应用由你决定。", "updates_start_label": "开始时间", @@ -132,6 +132,29 @@ "dbbackup_fix_hint": "此时主机出故障,账号、服务器归属和存档索引都无法恢复。在主机上立即备份一次,再查看定时任务日志找出它没有运行的原因:", "dbbackup_copy": "复制命令", "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_placeholder": "选择一个用户提交...", "build_import_submission_none": "无匹配的提交或暂未加载", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 7e58b61..7bba7cf 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -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).mock.calls[0]; + expect(String(url)).toBe("/updates/report"); + expect((opts as RequestInit).method).toBe("GET"); + }); + }); + describe("backup now and server jobs wire shapes", () => { 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 }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index b76d9a5..863f91b 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -33,6 +33,7 @@ import type { SubmissionPage, UpdateWindow, DBBackupStatus, + UpdateReport, } from "./types"; import { loadConfig } from "./config"; 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). getDBBackup: () => request("GET", "/platform/db-backup"), + getUpdateReport: () => request("GET", "/updates/report"), // ---- User admin (admin-tier, spec §7 user admin) ---- diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 2544b75..4286cae 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1027,6 +1027,26 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -2193,6 +2213,40 @@ export interface components { */ 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 --` 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. */ PasskeyCredential: { /** @description Opaque passkey row id (used to unbind it). */ @@ -5156,6 +5210,28 @@ export interface operations { 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: { parameters: { query?: never; diff --git a/panel/src/lib/types.parity.ts b/panel/src/lib/types.parity.ts index 792ef7d..386b48a 100644 --- a/panel/src/lib/types.parity.ts +++ b/panel/src/lib/types.parity.ts @@ -50,4 +50,6 @@ export type WireParity = [ Holds>, Holds>, Holds>, + Holds>, + Holds>, ]; diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 0b557b1..7631444 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -422,6 +422,38 @@ export interface DBBackupStatus { 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 --` 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) ---- export interface UserView { diff --git a/panel/src/pages/admin/DBBackupCard.tsx b/panel/src/pages/admin/DBBackupCard.tsx index 9d18f83..d9e26c9 100644 --- a/panel/src/pages/admin/DBBackupCard.tsx +++ b/panel/src/pages/admin/DBBackupCard.tsx @@ -24,7 +24,7 @@ const LABEL_KEY: Record = { 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 [copied, setCopied] = useState(false); async function copy() { diff --git a/panel/src/pages/admin/UpdateReportCard.test.tsx b/panel/src/pages/admin/UpdateReportCard.test.tsx new file mode 100644 index 0000000..910cdf5 --- /dev/null +++ b/panel/src/pages/admin/UpdateReportCard.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/panel/src/pages/admin/UpdateReportCard.tsx b/panel/src/pages/admin/UpdateReportCard.tsx new file mode 100644 index 0000000..b03b65d --- /dev/null +++ b/panel/src/pages/admin/UpdateReportCard.tsx @@ -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 = { + 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 ( + + + {t("versions_status_never")} + + ); + } + if (state === "stale") { + return ( + + + {t("versions_status_stale")} + + ); + } + if (state !== "ok") return null; + return available.length > 0 ? ( + + + {t("versions_status_available", { count: available.length })} + + ) : ( + + + {t("versions_status_current")} + + ); + })(); + + return ( + + +
+
0 + ? "bg-amber-500/10 text-amber-500" + : "bg-primary/10 text-primary", + )} + > + +
+
+ + {t("versions_title")} + {badge} + +

{t("versions_subtitle")}

+
+
+ +
+ + + {state === "loading" && ( +
+ + {t("versions_loading")} +
+ )} + + {state === "error" && } + + {report && ( + <> +

+ + {t("versions_checked", { when: formatRelative(report.checked_at, Date.now(), locale), felis: report.felis })} + +

+ {report.components.length === 0 ? ( +

{t("versions_none")}

+ ) : ( +
+
+
{t("versions_col_component")}
+
{t("versions_col_installed")}
+
{t("versions_col_latest")}
+
{t("versions_col_status")}
+
+
    + {report.components.map((c) => ( + + ))} +
+
+ )} + + )} + + {state === "ok" && available.length > 0 && ( +
+

{t("versions_apply_hint")}

+ `--${s}`).join(" ")}`.trimEnd()} /> +
+ )} + + {(state === "stale" || state === "never") && ( +
+
+ +
+

+ {state === "never" ? t("versions_never_title") : t("versions_stale_title", { hours: maxAgeHours })} +

+

{t("versions_fix_hint")}

+
+
+
+ {FIX_COMMANDS.map((cmd) => ( + + ))} +
+
+ )} +
+
+ ); +} + +function ComponentRow({ c }: { c: UpdateComponent }) { + const { t } = useTranslation("admin"); + return ( +
  • +
    +
    {c.name}
    +
    + {t("versions_col_installed")}: + {c.current ?? "—"} +
    +
    + {t("versions_col_latest")}: + {c.latest ? {c.latest} : —} +
    +
    + + {t(`versions_state_${c.state}`)} + +
    +
    + {c.error &&

    {c.error}

    } + {c.note &&

    {c.note}

    } +
  • + ); +} diff --git a/panel/src/pages/admin/UpdatesPage.tsx b/panel/src/pages/admin/UpdatesPage.tsx index db29bca..f479cb0 100644 --- a/panel/src/pages/admin/UpdatesPage.tsx +++ b/panel/src/pages/admin/UpdatesPage.tsx @@ -14,6 +14,7 @@ import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { formatAbsolute } from "@/lib/format"; import { DBBackupCard } from "./DBBackupCard"; +import { UpdateReportCard } from "./UpdateReportCard"; function toLocalDatetimeString(dateOrStr: Date | string | null | undefined): string { if (!dateOrStr) return ""; @@ -170,6 +171,9 @@ export function UpdatesPage() { {/* Control-plane database backup freshness (read-only, host timer) */} + {/* Installed vs newest upstream versions (read-only, host timer) */} + + {/* Stats Cards Row */}