feat: address review on self-hosted updates: the updater no longer re-locks its mutex when a job finishes (every job used to deadlock at completion and freeze the status API), the backend caches the updater view so the member version pill, the health checks and the admin poll share one read and an absent updater is reported as not running rather than broken, the bare-metal upgrade builds unprivileged and hands off to a root-owned fixed-path installer that refuses symlinks so sudoers allows one command instead of install/cp/rm/chown/chmod/systemctl/ln, the installer fails when the backend does not come back, the seed image gets the version build args, the dashboard gates the update action on manage_settings and stops polling a backend that answers 404, and revived timestamps are typed as Date

This commit is contained in:
Matthew Meszaros
2026-09-03 05:04:30 -07:00
parent b2ea1f1961
commit bb35de0455
16 changed files with 242 additions and 103 deletions
+5 -1
View File
@@ -125,7 +125,11 @@ export function UpdateDialog({ open, onOpenChange }: Props) {
const checkout = updater?.checkout;
const job = updater?.job ?? updater?.last_job;
const canApply =
canManage && updater?.status === "ok" && !!state?.update_available && phase === "idle";
canManage &&
updater?.status === "ok" &&
!!state?.update_available &&
!checkout?.dirty &&
phase === "idle";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
+7 -3
View File
@@ -25,6 +25,10 @@ file is what `POST /api/v1/workers/enroll` returns for an enrollment token.
`warmbly-updater.service` is the exception to the unprivileged rule: it runs as
the user who owns the checkout (`deploy` in the unit; change it) and drives
`scripts/upgrade-bare-metal.sh`, which uses sudo for the install and restart
steps. `updater.env` holds only `UPDATER_TOKEN` (the backend's
`INTERNAL_API_TOKEN`). See [Updates](https://docs.warmbly.com/development/updates/).
`scripts/upgrade-bare-metal.sh`, which builds unprivileged and then runs
`warmbly-install-release.sh` (installed root-owned at
`/usr/local/sbin/warmbly-install-release`) through sudo. That installer takes no
arguments, touches only fixed paths and refuses symlinks, and is the single
command to allow in sudoers. `updater.env` holds only `UPDATER_TOKEN` (the
backend's `INTERNAL_API_TOKEN`). See
[Updates](https://docs.warmbly.com/development/updates/).
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
#
# The privileged half of a bare-metal upgrade: install the artifacts that
# scripts/upgrade-bare-metal.sh built under /opt/warmbly/src/out and friends,
# then restart the units, backend first. It takes no arguments and touches only
# fixed paths, so it is the one command the checkout's owner may run through
# sudo without a password. Install it root-owned and not writable by that user:
#
# sudo install -o root -g root -m 0755 deploy/systemd/warmbly-install-release.sh /usr/local/sbin/warmbly-install-release
# deploy ALL=(root) NOPASSWD: /usr/local/sbin/warmbly-install-release # visudo
#
# A symlink under the build output is refused: a caller who owns the checkout
# must not be able to make root read or install a file from somewhere else.
set -euo pipefail
SRC=/opt/warmbly/src
PREFIX=/opt/warmbly
HEALTH=http://127.0.0.1:8080/health
[[ "$(id -u)" -eq 0 ]] || { echo "run through sudo" >&2; exit 1; }
[[ $# -eq 0 ]] || { echo "takes no arguments" >&2; exit 2; }
log() { printf '==> %s\n' "$*"; }
regular() { [[ -f "$1" && ! -L "$1" ]]; }
plain_dir() { [[ -d "$1" && ! -L "$1" ]]; }
has_unit() { systemctl list-unit-files "warmbly-$1.service" --no-legend 2>/dev/null | grep -q .; }
log "installing binaries"
for bin in backend forms consumer worker migrate warmblyctl updater; do
f="$SRC/out/$bin"
if regular "$f"; then
install -o root -g root -m 0755 "$f" "$PREFIX/bin/$bin"
fi
done
ln -sf "$PREFIX/bin/warmblyctl" /usr/local/bin/warmblyctl
if has_unit tracking && regular "$SRC/tracking/target/release/tracking"; then
install -o root -g root -m 0755 "$SRC/tracking/target/release/tracking" "$PREFIX/bin/tracking"
fi
if has_unit realtime && plain_dir "$SRC/realtime/_build/prod/rel/realtime"; then
log "installing realtime"
rm -rf "$PREFIX/realtime"
cp -r --no-dereference "$SRC/realtime/_build/prod/rel/realtime" "$PREFIX/realtime"
chown -R warmbly:warmbly "$PREFIX/realtime"
fi
# The runtime config.js is written by hand on a bare-metal install and a
# rebuilt dist/ would drop it, so it is kept across the copy.
for app in web admin; do
if plain_dir "$SRC/$app/dist" && plain_dir "$PREFIX/$app"; then
log "installing $app"
cfg="$(mktemp)"
[[ -f "$PREFIX/$app/config.js" ]] && cp "$PREFIX/$app/config.js" "$cfg"
rm -rf "$PREFIX/$app"
cp -r --no-dereference "$SRC/$app/dist" "$PREFIX/$app"
[[ -s "$cfg" ]] && cp "$cfg" "$PREFIX/$app/config.js"
rm -f "$cfg"
chown -R root:root "$PREFIX/$app"
chmod -R a+rX "$PREFIX/$app"
fi
done
if plain_dir "$SRC/forms/dist" && plain_dir "$PREFIX/forms"; then
log "installing forms"
rm -rf "$PREFIX/forms/dist"
cp -r --no-dereference "$SRC/forms/dist" "$PREFIX/forms/dist"
chown -R root:root "$PREFIX/forms/dist"
chmod -R a+rX "$PREFIX/forms/dist"
fi
log "restarting backend"
systemctl restart warmbly-backend
healthy=0
for _ in $(seq 1 60); do
if curl -fsS "$HEALTH" >/dev/null 2>&1; then healthy=1; break; fi
sleep 2
done
if [[ "$healthy" -ne 1 ]]; then
log "the backend did not answer at $HEALTH within two minutes; the other services were not restarted"
exit 1
fi
rest=()
for svc in forms consumer tracking realtime worker; do
has_unit "$svc" && rest+=("warmbly-$svc")
done
if [[ ${#rest[@]} -gt 0 ]]; then
log "restarting ${rest[*]}"
systemctl restart "${rest[@]}"
fi
log "installed"
+3 -2
View File
@@ -7,8 +7,9 @@ Wants=network-online.target
[Service]
Type=simple
# Runs as the user who owns the checkout, so git writes stay theirs. The
# upgrade script escalates for the install and restart steps through sudo;
# grant that user the sudoers line from the docs.
# upgrade script builds unprivileged and then runs the fixed-path installer
# (/usr/local/sbin/warmbly-install-release, root-owned) through sudo; that
# installer is the only command to allow in sudoers. See the docs.
User=deploy
Group=deploy
EnvironmentFile=/etc/warmbly/updater.env
+8 -1
View File
@@ -346,7 +346,10 @@ services:
WORKER_IMAGE: ${WORKER_IMAGE:-}
# Update indicator: polls GitHub Releases and shows a newer version in the
# admin panel's top bar. Applying it goes through the updater service
# below; unset UPDATER_URL to keep the panel report-only.
# below. The address is always set because a profile cannot be tested
# here; with the profile off the host does not resolve and the backend
# reports the updater as not running (report-only), never as broken.
# UPDATER_URL=none in .env turns the button off explicitly.
UPDATE_CHECK_ENABLED: ${UPDATE_CHECK_ENABLED:-true}
UPDATE_CHECK_INTERVAL: ${UPDATE_CHECK_INTERVAL:-30m}
UPDATE_CHANNEL: ${UPDATE_CHANNEL:-stable}
@@ -591,6 +594,10 @@ services:
build:
context: .
dockerfile: deploy/docker/backend.Dockerfile
args:
VERSION: ${WARMBLY_BUILD_VERSION:-}
COMMIT: ${WARMBLY_BUILD_COMMIT:-}
BUILT_AT: ${WARMBLY_BUILD_TIME:-}
entrypoint: ["/app/seed"]
environment:
<<: *selfhost-env
+2 -1
View File
@@ -562,9 +562,10 @@ Because the worker is not in a container, the admin panel's SSH-driven day-two a
## Upgrading
`scripts/upgrade-bare-metal.sh` is the build steps above in order: build every artifact this host runs, install it, restart the backend first (migrations are forward-only and apply on its boot), then the rest, keeping each frontend's `config.js` across the copy. Run it as the user who owns the checkout:
`scripts/upgrade-bare-metal.sh` is the build steps above in order: build every artifact this host runs as the checkout's owner, then run the root-owned installer `warmbly-install-release` through sudo, which installs them, restarts the backend first (migrations are forward-only and apply on its boot), then the rest, keeping each frontend's `config.js` across the copy. Install that one script root-owned once, then run the upgrade as the user who owns the checkout:
```bash
sudo install -o root -g root -m 0755 /opt/warmbly/src/deploy/systemd/warmbly-install-release.sh /usr/local/sbin/warmbly-install-release
cd /opt/warmbly/src && scripts/upgrade-bare-metal.sh --pull # git pull --ff-only, build, install, restart
```
+7 -4
View File
@@ -84,24 +84,27 @@ To update by hand instead: `make upgrade` (a fast-forward pull, then `make up`).
### Without Docker
On a [bare-metal install](/development/bare-metal/) the updater is a systemd unit that runs `scripts/upgrade-bare-metal.sh` after pulling. The script is the documented upgrade procedure as a script: build every artifact this host runs, install it, restart the backend first, then the rest, keeping each frontend's `config.js` across the copy.
On a [bare-metal install](/development/bare-metal/) the updater is a systemd unit that runs `scripts/upgrade-bare-metal.sh` after pulling. The script builds every artifact this host runs as the checkout's owner, then hands off to one privileged installer, `warmbly-install-release`, which copies the artifacts into `/opt/warmbly`, keeps each frontend's `config.js`, restarts the backend first and the rest once it answers.
```bash
cd /opt/warmbly/src
go build -o out/updater ./cmd/updater && sudo install -m 0755 out/updater /opt/warmbly/bin/
sudo install -o root -g root -m 0755 deploy/systemd/warmbly-install-release.sh /usr/local/sbin/warmbly-install-release
sudo install -m 0644 deploy/systemd/warmbly-updater.service /etc/systemd/system/
printf 'UPDATER_TOKEN=%s\n' "$(grep ^INTERNAL_API_TOKEN= /etc/warmbly/warmbly.env | cut -d= -f2-)" | sudo tee /etc/warmbly/updater.env >/dev/null
sudo chmod 0600 /etc/warmbly/updater.env
sudo systemctl daemon-reload && sudo systemctl enable --now warmbly-updater
```
The unit runs as the user who owns the checkout (`deploy` as shipped; edit it), and the script uses `sudo` for the install and restart steps, so that user needs a sudoers line for exactly those commands:
The unit runs as the user who owns the checkout (`deploy` as shipped; edit it). The only root step is the installer, so that user needs exactly one sudoers line, and nothing broader:
```
deploy ALL=(root) NOPASSWD: /usr/bin/install, /bin/cp, /bin/rm, /bin/chown, /bin/chmod, /bin/systemctl, /bin/ln
deploy ALL=(root) NOPASSWD: /usr/local/sbin/warmbly-install-release
```
Then point the backend at it in `warmbly.env` and restart it:
The installer is root-owned and not writable by that user, takes no arguments, reads only from fixed paths under the checkout and refuses symlinks there, so owning the checkout does not become owning the host. The binaries it installs run as the unprivileged `warmbly` user either way.
Then point the backend at the updater in `warmbly.env` and restart it:
```bash
UPDATER_URL=http://127.0.0.1:8095
+2 -2
View File
@@ -36,7 +36,7 @@ func checkUpdateAvailable(ctx context.Context, d Deps, _ Input) *Finding {
if st.Updater.Status == "ok" {
msg += "Open the version pill in the top bar and choose Update and restart; the stack rebuilds, restarts and resumes on its own."
} else {
msg += "Pull the repository and restart the stack, or enable the updater to do it from this panel."
msg += "Run make upgrade on the host, or enable the updater to do it from this panel."
}
return result(CategoryUpdates, SeverityInfo, "An update is available", msg, docsUpdates)
}
@@ -48,7 +48,7 @@ func checkUpdaterUnreachable(ctx context.Context, d Deps, _ Input) *Finding {
return nil
}
st := d.Updates.State(ctx, false)
if !st.Updater.Configured || st.Updater.Status == "ok" {
if st.Updater.Status != "unreachable" {
return nil
}
return result(CategoryUpdates, SeverityWarning, "The updater is not answering",
+56 -9
View File
@@ -94,8 +94,22 @@ type Service struct {
latest *Latest
checkedAt time.Time
checkErr string
// The updater view is cached briefly so the member-facing version pill,
// the health checks and the admin poll share one read instead of each
// dialling the updater, and a stalled updater cannot slow every caller.
viewMu sync.Mutex
view UpdaterView
viewUntil time.Time
}
// viewTTL is how long a good updater read is served from cache; viewFailTTL
// how long a failed one is, so an absent updater is dialled rarely.
const (
viewTTL = 2 * time.Second
viewFailTTL = 30 * time.Second
)
func New(cfg Config) *Service {
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 15 * time.Second}
@@ -141,14 +155,38 @@ func (s *Service) Start(ctx context.Context) {
func (s *Service) Check(ctx context.Context) State {
s.checkGitHub(ctx)
view := s.updaterStatus(ctx, http.MethodPost, "/check")
s.storeView(view)
return s.compose(view, false)
}
// State returns the cached release check plus a live read of the updater.
// withLog keeps job logs; the top-bar poll drops them to stay small.
// State returns the cached release check plus the updater's state, read live
// at most every viewTTL. withLog keeps job logs; the top-bar poll drops them.
func (s *Service) State(ctx context.Context, withLog bool) State {
return s.compose(s.cachedView(ctx), !withLog)
}
func (s *Service) cachedView(ctx context.Context) UpdaterView {
s.viewMu.Lock()
if time.Now().Before(s.viewUntil) {
v := s.view
s.viewMu.Unlock()
return v
}
s.viewMu.Unlock()
view := s.updaterStatus(ctx, http.MethodGet, "/status")
return s.compose(view, !withLog)
s.storeView(view)
return view
}
func (s *Service) storeView(view UpdaterView) {
ttl := viewTTL
if view.Status == "unreachable" || (view.Status == "off" && view.Configured) {
ttl = viewFailTTL
}
s.viewMu.Lock()
s.view = view
s.viewUntil = time.Now().Add(ttl)
s.viewMu.Unlock()
}
// Apply asks the updater to move to target: "latest" picks the tracked branch
@@ -161,8 +199,12 @@ func (s *Service) Apply(ctx context.Context, target string) (*updater.Job, error
switch strings.TrimSpace(target) {
case "", "latest", "branch":
view := s.updaterStatus(ctx, http.MethodGet, "/status")
s.storeView(view)
if view.Status != "ok" {
return nil, fmt.Errorf("updater is %s: %s", view.Status, view.Error)
if view.Error == "" {
return nil, ErrUpdaterNotConfigured
}
return nil, errors.New(view.Error)
}
if view.Checkout != nil && view.Checkout.Detached {
s.mu.Lock()
@@ -278,10 +320,19 @@ func (s *Service) updaterStatus(ctx context.Context, method, path string) Update
return UpdaterView{Status: "off"}
}
view := UpdaterView{Configured: true}
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
cctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
resp, err := s.call(cctx, method, path, nil)
if err != nil {
// Under compose the backend always gets UPDATER_URL=http://updater:8095,
// profile or not. A host that does not resolve is the profile being
// off, which is report-only by choice, not a broken updater.
var dns *net.DNSError
if errors.As(err, &dns) {
view.Status = "off"
view.Error = "the updater is not running; enable the updater compose profile (make up does) to update from here"
return view
}
view.Status = "unreachable"
view.Error = describeDialError(err)
return view
@@ -322,10 +373,6 @@ func (s *Service) call(ctx context.Context, method, path string, body io.Reader)
// describeDialError turns the usual "not running" failures into the sentence
// the panel shows, instead of a raw dial string.
func describeDialError(err error) string {
var dns *net.DNSError
if errors.As(err, &dns) {
return "the updater host does not resolve; it is not running (enable the updater compose profile) or UPDATER_URL is wrong"
}
if strings.Contains(err.Error(), "connection refused") {
return "the updater is not accepting connections; it is not running or UPDATER_URL points at the wrong port"
}
+10 -4
View File
@@ -191,10 +191,10 @@ func (r *Runner) execute(ctx context.Context, job *Job, req UpdateRequest) {
if err != nil {
job.Status = JobFailed
job.Error = err.Error()
r.logf(job, "update failed: %v", err)
r.appendLocked(job, fmt.Sprintf("update failed: %v", err))
} else {
job.Status = JobSucceeded
r.logf(job, "update finished")
r.appendLocked(job, "update finished")
}
r.lastJob = job
r.job = nil
@@ -356,13 +356,19 @@ func (r *Runner) step(job *Job, name string) {
}
func (r *Runner) logf(job *Job, format string, args ...any) {
line := time.Now().UTC().Format("15:04:05") + " " + fmt.Sprintf(format, args...)
r.mu.Lock()
r.appendLocked(job, fmt.Sprintf(format, args...))
r.mu.Unlock()
}
// appendLocked records one log line. The caller holds r.mu; the mutex is not
// reentrant, so the completion path in execute must use this, never logf.
func (r *Runner) appendLocked(job *Job, msg string) {
line := time.Now().UTC().Format("15:04:05") + " " + msg
job.Log = append(job.Log, line)
if len(job.Log) > maxLogLines {
job.Log = job.Log[len(job.Log)-maxLogLines:]
}
r.mu.Unlock()
log.Printf("updater: %s", line)
}
+21 -60
View File
@@ -1,26 +1,22 @@
#!/usr/bin/env bash
#
# Rebuild and restart a Docker-free Warmbly install after the checkout moved.
# This is what the updater runs in UPDATER_MODE=command (deploy/systemd/
# warmbly-updater.service), and what you run by hand after `git pull`. It is
# the "Upgrading" section of docs/content/docs/development/bare-metal.mdx as a
# script: build every artifact that exists on this host, install it, restart
# the backend first (migrations apply on its boot), then the rest.
# Rebuild a Docker-free Warmbly install after the checkout moved, then hand the
# artifacts to the privileged installer. This is what the updater runs in
# UPDATER_MODE=command (deploy/systemd/warmbly-updater.service), and what you
# run by hand after `git pull`. It is the "Upgrading" section of
# docs/content/docs/development/bare-metal.mdx as a script.
#
# scripts/upgrade-bare-metal.sh # build + install + restart
# scripts/upgrade-bare-metal.sh # build, then install + restart
# scripts/upgrade-bare-metal.sh --pull # git pull --ff-only first
#
# Run it as the user who owns the checkout. Install and restart steps use sudo;
# give that user this sudoers line (visudo) so the updater can run unattended:
#
# deploy ALL=(root) NOPASSWD: /usr/bin/install, /bin/cp, /bin/rm, /bin/chown, /bin/chmod, /bin/systemctl, /bin/ln
#
# Run it as the user who owns the checkout. Everything here runs unprivileged;
# the only root step is the fixed-path installer, which is the single command
# that user may run through sudo (see deploy/systemd/warmbly-install-release.sh).
set -euo pipefail
SRC="${WARMBLY_SRC:-/opt/warmbly/src}"
PREFIX="${WARMBLY_PREFIX:-/opt/warmbly}"
SUDO="sudo"
if [[ "$(id -u)" -eq 0 ]]; then SUDO=""; fi
INSTALLER="${WARMBLY_INSTALLER:-/usr/local/sbin/warmbly-install-release}"
log() { printf '==> %s\n' "$*"; }
@@ -29,6 +25,12 @@ if [[ "${1:-}" == "--pull" ]]; then
git -C "$SRC" pull --ff-only
fi
[[ -x "$INSTALLER" ]] || {
echo "$INSTALLER is missing. Install it root-owned first:" >&2
echo " sudo install -o root -g root -m 0755 $SRC/deploy/systemd/warmbly-install-release.sh $INSTALLER" >&2
exit 1
}
cd "$SRC"
VERSION="$(git describe --tags --always --dirty 2>/dev/null || echo dev)"
COMMIT="$(git rev-parse HEAD 2>/dev/null || true)"
@@ -65,52 +67,11 @@ if command -v pnpm >/dev/null 2>&1; then
done
fi
log "installing binaries"
$SUDO install -m 0755 out/backend out/forms out/consumer out/worker out/migrate out/warmblyctl out/updater "$PREFIX/bin/"
$SUDO ln -sf "$PREFIX/bin/warmblyctl" /usr/local/bin/warmblyctl
if [[ -f tracking/target/release/tracking ]] && has_unit tracking; then
$SUDO install -m 0755 tracking/target/release/tracking "$PREFIX/bin/tracking"
fi
if [[ -d realtime/_build/prod/rel/realtime ]] && has_unit realtime; then
$SUDO rm -rf "$PREFIX/realtime"
$SUDO cp -r realtime/_build/prod/rel/realtime "$PREFIX/realtime"
$SUDO chown -R warmbly:warmbly "$PREFIX/realtime"
fi
# Frontends: the runtime config.js is written by hand on a bare-metal install
# and a rebuilt dist/ would drop it, so keep it across the copy.
for app in web admin; do
if [[ -d "$app/dist" && -d "$PREFIX/$app" ]]; then
log "installing $app"
cfg="$(mktemp)"
[[ -f "$PREFIX/$app/config.js" ]] && cp "$PREFIX/$app/config.js" "$cfg"
$SUDO rm -rf "$PREFIX/$app"
$SUDO cp -r "$app/dist" "$PREFIX/$app"
[[ -s "$cfg" ]] && $SUDO cp "$cfg" "$PREFIX/$app/config.js"
rm -f "$cfg"
$SUDO chmod -R a+rX "$PREFIX/$app"
fi
done
if [[ -d forms/dist && -d "$PREFIX/forms" ]]; then
log "installing forms"
$SUDO rm -rf "$PREFIX/forms/dist"
$SUDO cp -r forms/dist "$PREFIX/forms/dist"
fi
log "restarting backend"
$SUDO systemctl restart warmbly-backend
for i in $(seq 1 60); do
if curl -fsS http://127.0.0.1:8080/health >/dev/null 2>&1; then break; fi
sleep 2
done
rest=()
for svc in forms consumer tracking realtime worker; do
has_unit "$svc" && rest+=("warmbly-$svc")
done
if [[ ${#rest[@]} -gt 0 ]]; then
log "restarting ${rest[*]}"
$SUDO systemctl restart "${rest[@]}"
log "installing and restarting (sudo $INSTALLER)"
if [[ "$(id -u)" -eq 0 ]]; then
"$INSTALLER"
else
sudo -n "$INSTALLER"
fi
log "done: $VERSION"
+3 -3
View File
@@ -892,8 +892,8 @@ function PrimaryButton({
);
}
function relative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
function relative(at: Date | string): string {
const diff = Date.now() - new Date(at).getTime();
const min = Math.round(diff / 60_000);
if (min < 1) return "just now";
if (min < 60) return `${min} min ago`;
@@ -901,5 +901,5 @@ function relative(iso: string): string {
if (h < 24) return `${h} hour${h === 1 ? "" : "s"} ago`;
const d = Math.round(h / 24);
if (d < 30) return `${d} day${d === 1 ? "" : "s"} ago`;
return new Date(iso).toLocaleDateString();
return new Date(at).toLocaleDateString();
}
+11 -2
View File
@@ -18,14 +18,23 @@ import { clearUpdateStarted, readUpdateStarted } from "@/lib/updateSession";
import { cn } from "@/lib/utils";
import UpdateDialog from "./UpdateDialog";
// Platform admin permission bits, mirroring internal/models/admin_permission.go.
const ADMIN_VIEW_ANALYTICS = 1 << 11;
const ADMIN_MANAGE_SETTINGS = 1 << 14;
export function VersionPill() {
const versionQ = useInstanceVersion();
const { data: user } = useUser();
const isAdmin = user?.is_admin === true;
// Mirrors the backend gates: view_analytics reads the update state,
// manage_settings is what check and apply require. An admin without the
// latter sees the same read-only badge as a member.
const perms = user?.admin_permissions ?? 0;
const canView = (perms & ADMIN_VIEW_ANALYTICS) === ADMIN_VIEW_ANALYTICS;
const isAdmin = (perms & ADMIN_MANAGE_SETTINGS) === ADMIN_MANAGE_SETTINGS;
const v = versionQ.data;
const selfHosted = !!v?.self_hosted;
const adminQ = useInstanceUpdate(isAdmin && selfHosted);
const adminQ = useInstanceUpdate(canView && selfHosted);
const admin = adminQ.data;
const qc = useQueryClient();
const [open, setOpen] = React.useState(false);
@@ -1,16 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import getInstanceVersion from "../../client/auth/getInstanceVersion";
import type { AppError } from "../../client/normalizeError";
// Feeds the version pill in the header. The backend re-checks releases on its
// own interval, so a five minute poll here is enough for the pill to turn
// amber while the tab is open. A backend without the endpoint (an older
// self-host) fails once and the pill stays hidden.
// self-host) answers 404: the pill stays hidden and the poll stops. Any other
// failure keeps polling, because the backend restarting for an update is the
// one moment the pill must come back on its own.
export default function useInstanceVersion() {
return useQuery({
queryKey: ["auth", "instance"],
queryFn: getInstanceVersion,
staleTime: 5 * 60_000,
refetchInterval: 5 * 60_000,
refetchInterval: (q) => ((q.state.error as AppError | null)?.status === 404 ? false : 5 * 60_000),
retry: false,
});
}
@@ -1,7 +1,8 @@
/**
* The platform admin's view of updates, served by GET /admin/instance/update.
* Mirrors internal/app/updates.State. Only a platform admin can read it; the
* member-facing summary is InstanceVersion.
* member-facing summary is InstanceVersion. Timestamps are Date objects:
* Request revives ISO strings on every response.
*/
export interface InstanceCheckout {
branch: string;
@@ -11,7 +12,7 @@ export interface InstanceCheckout {
remote_commit: string;
behind: number;
dirty: boolean;
fetched_at: string;
fetched_at: Date;
fetch_error?: string;
}
@@ -22,8 +23,8 @@ export interface UpdateJob {
status: UpdateJobStatus;
target: string;
step: string;
started_at: string;
finished_at?: string;
started_at: Date;
finished_at?: Date;
error?: string;
from_commit: string;
to_commit?: string;
@@ -43,11 +44,11 @@ export interface InstanceUpdater {
}
export default interface InstanceUpdate {
running: { version: string; commit?: string; built_at?: string };
latest?: { tag: string; name?: string; html_url?: string; published_at?: string; channel: string };
running: { version: string; commit?: string; built_at?: Date };
latest?: { tag: string; name?: string; html_url?: string; published_at?: Date; channel: string };
update_available: boolean;
reason?: "release" | "commits";
checked_at?: string;
checked_at?: Date;
check_error?: string;
enabled: boolean;
interval: string;
@@ -2,6 +2,7 @@
* The running Warmbly on a self-hosted instance and whether a newer one
* exists, served by GET /auth/instance. A hosted deployment answers
* `self_hosted: false` and nothing else, so the dashboard shows no pill there.
* Timestamps are Date objects: Request revives ISO strings on every response.
*/
export default interface InstanceVersion {
self_hosted: boolean;
@@ -11,7 +12,7 @@ export default interface InstanceVersion {
latest?: {
tag: string;
html_url?: string;
published_at?: string;
published_at?: Date;
};
checked_at?: string;
checked_at?: Date;
}