feat(update): 主机每日记录组件版本比对,面板更新页展示可用更新与应用命令
This commit is contained in:
28 files changed
+994
-10
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" <<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() {
|
||||
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
|
||||
@@ -4216,6 +4258,8 @@ main() {
|
||||
install_db_backup_timer
|
||||
# After the backup timer: its first bundle is part of the first copy.
|
||||
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.
|
||||
install_watchdog_timer
|
||||
mark_bootstrap_done
|
||||
|
||||
@@ -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"
|
||||
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")"
|
||||
[ -n "$wblock" ] || { echo "FAIL: no install_watchdog_timer found in $BS"; exit 1; }
|
||||
[ "$(printf '%s\n' "$wblock" | wc -l)" -lt 60 ] \
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 --<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:
|
||||
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]
|
||||
|
||||
@@ -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 --<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]
|
||||
|
||||
The installer takes the major the distribution ships (13 on EL9) and never moves it. To
|
||||
|
||||
+4
-2
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -1011,6 +1011,31 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||
});
|
||||
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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "无匹配的提交或暂未加载",
|
||||
|
||||
@@ -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", () => {
|
||||
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 });
|
||||
|
||||
@@ -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<DBBackupStatus>("GET", "/platform/db-backup"),
|
||||
getUpdateReport: () => request<UpdateReport>("GET", "/updates/report"),
|
||||
|
||||
// ---- User admin (admin-tier, spec §7 user admin) ----
|
||||
|
||||
|
||||
@@ -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 --<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. */
|
||||
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;
|
||||
|
||||
@@ -50,4 +50,6 @@ export type WireParity = [
|
||||
Holds<Parity<T.PasskeyCredential, S["PasskeyCredential"]>>,
|
||||
Holds<Parity<T.UpdateWindow, S["UpdateWindow"]>>,
|
||||
Holds<Parity<T.DBBackupStatus, S["DBBackupStatus"]>>,
|
||||
Holds<Parity<T.UpdateReport, S["UpdateReport"]>>,
|
||||
Holds<Parity<T.UpdateComponent, S["UpdateComponent"]>>,
|
||||
];
|
||||
@@ -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 --<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) ----
|
||||
|
||||
export interface UserView {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
function CopyCommand({ command }: { command: string }) {
|
||||
export function CopyCommand({ command }: { command: string }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [copied, setCopied] = useState(false);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) */}
|
||||
<DBBackupCard />
|
||||
|
||||
{/* Installed vs newest upstream versions (read-only, host timer) */}
|
||||
<UpdateReportCard />
|
||||
|
||||
{/* Stats Cards Row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<StatCard
|
||||
|
||||
Reference in new issue
Block a user