diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 6dc859e..a8115dc 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -119,6 +119,19 @@ All request/response bodies are JSON unless noted otherwise. image (checks, updates and pins keep working, and the newer version is offered again). +### `POST /api/update/:name/switch-tag` + +- Auth: cookie. Body: `{ "tag": "string" }` — must be a newer tag the last check + offered for this container's image (`newerTag` / `newerMajorTag`). +- Compose-managed: rewrites that service's `image:` line (the last `-f` file that + sets it; `.dockpull.bak` kept), then updates as `POST /api/update/:name`. If the + pull/up fails before the container changed, the file is restored. Standalone: + pulls the new tag and recreates the container on it. The rollback point records + the compose edit, so a revert puts the old tag back in the file. +- Response: `200 { "streamId": "string" }` (stream as for updates). +- Errors: `400 invalid_tag`; `409 tag_not_offered`; `409 update_in_progress`; + `404 not_found`. + ### `GET /api/update/:name/stream` - Auth: cookie. @@ -253,6 +266,36 @@ separate section, but can still be updated by hand. - Un-skips the pending update for `ref` (URL-encoded). Same responses as `POST /api/skip`. +### `POST /api/skip-tag` + +- Auth: cookie. Body: `{ "ref": "string", "tag": "string"|null }`. +- Hides one offered newer tag until an even newer one appears (`tag: null` clears). +- Response: `200 { "ok": true }`; `404 no_pending_update`. + +### `POST /api/auth/logout-all` + +- Auth: cookie. Invalidates every session, then sets a fresh cookie for the + caller so only this device stays signed in. Response: `200 { "ok": true }`. + +### `GET /api/self-update` + +- Auth: cookie. Whether a newer DockPull release exists (from GitHub releases, + cached 30 min). Read-only — DockPull never updates itself. +- Response: `200 { "current": string, "available": boolean, "latest"?: string, "releaseUrl"?: string, "releases"?: [{ "tag", "url", "publishedAt", "body" }] }`. + +### `GET /api/backup` + +- Auth: cookie. Downloads `{ "format": "dockpull-backup", "version": 1, "appVersion", "exportedAt", "settings", "pinned": [ref], "history": [row] }` + (up to 5000 history rows). Contains the notification URL. + +### `POST /api/restore` + +- Auth: cookie. Body: a backup as above (up to 5 MB). Settings are validated + all-or-nothing; invalid pins/history rows are skipped; history is only imported + into an empty history. +- Response: `200 { "ok": true, "settings", "pinned", "history", "skipped", "historySkippedBecauseNotEmpty" }`; + `400 invalid_backup`. + ### `GET /api/settings` - Auth: cookie. @@ -351,6 +394,11 @@ Field notes: the running and available versions mention breaking changes (best-effort, GitHub-sourced images only; scanned when the update event is recorded). `false` otherwise, including when no update is available. +- `newerTag` — a newer version TAG in the same major version (e.g. running + `postgres:16.3`, registry has `16.4`), or `null`. `newerMajorTag` — the newest + tag of a higher major version, only when the `tagUpdates` setting is `major`. + Both respect a skipped tag (`POST /api/skip-tag`) and are `null` when + `tagUpdates` is `off`. - `skipped` — `true` when an update exists but the user skipped that exact build (`POST /api/skip`); `updateAvailable` is then `false`, while `availableDigest`/`availableVersion` still describe the skipped build. diff --git a/README.md b/README.md index eaa2de7..bb0eb62 100644 --- a/README.md +++ b/README.md @@ -75,17 +75,33 @@ If the paths don't match you'll get `compose file not found` and broken bind mou - **Updates tab** — containers grouped by stack, update-available ones on top. Defaults to showing only what needs updating; flip to **All** to see everything. Tap **Update** to pull + recreate that service (watch live logs), or **Update all** - to run them (one at a time within each stack). After an update DockPull verifies the container actually - comes up healthy (catching crash-loops), and offers a one-click **Revert** to the - previous image if it doesn't. **Pin Version** holds a container at its current version; - **Skip** dismisses just the update on offer, and the card returns when a newer - build is published. An update marked **(rebuilt)** has the same version number - but a new image — the publisher re-pushed the tag, usually for base-image or - security patches. + to run them (one at a time within each stack). After an update DockPull verifies the + container actually comes up healthy — waiting as long as the container's own + healthcheck needs, and catching apps that crash a few seconds after starting — and + offers a one-click **Revert** to the previous image if it doesn't. **Pin Version** + holds a container at its current version; **Skip** dismisses just the update on + offer, and the card returns when a newer build is published. An update marked + **(rebuilt)** has the same version number but a new image — the publisher re-pushed + the tag, usually for base-image or security patches. +- **Newer version tags** — for containers on a version tag (`postgres:16.3`, + `app:1.2.0`, linuxserver's `4.0.14-ls283`), DockPull also lists the registry's + tags and offers the newest one in the same major version (and, if you enable it, + the next major). **Switch** edits that service's `image:` line in your compose file + (only that line — comments and formatting stay; a `.dockpull.bak` copy is kept), + then updates it with the usual health check. If the pull fails the file is put back, + and **Revert** restores the old tag in the file too. Images set via a variable + (`${TAG}`), an anchor, or a digest are left alone. "Update all" never switches tags. - **History tab** — a log of past updates. **Clear history** wipes it (with a confirm). -- **Settings tab** — theme, default view, auto-check on open, the **daily background - scan** + **notifications** (Discord, ntfy, Gotify, or a generic webhook — with a - "send test" button), and pinned-version management. +- **Settings tab** — theme, default view, auto-check on open, newer-tag policy, the + **background scan** (daily at a time, or every N hours; a scan missed while the + server was off runs shortly after it starts), **notifications** (Discord, ntfy, + Gotify, or a generic webhook — with a "send test" button, and optional alerts when + an update fails), pinned versions, **prune**, **sign out everywhere**, and + **backup / restore** (settings, pins and history as a JSON file — it contains your + notification URL, so keep it private). +- **DockPull updates** — when a new DockPull release is out, a banner shows what + changed and the command to update. DockPull never updates its own container + (replacing the container it runs in would cut the update off half-way). - **Install as an app (PWA)** — use your browser's "Add to Home Screen" / "Install" to get a standalone, full-screen icon. diff --git a/client/src/App.jsx b/client/src/App.jsx index 4f873ba..31c4ee4 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -6,6 +6,7 @@ import AuthPage from './AuthPage.jsx'; import Dashboard from './Dashboard.jsx'; import Header from './components/Header.jsx'; import BottomNav from './components/BottomNav.jsx'; +import SelfUpdateBanner from './components/SelfUpdateBanner.jsx'; import HistoryPage from './pages/HistoryPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx'; @@ -87,6 +88,7 @@ export default function App() {
+ } /> } /> diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index aad7b54..290f3ec 100644 --- a/client/src/Dashboard.jsx +++ b/client/src/Dashboard.jsx @@ -19,7 +19,14 @@ function timeAgo(ts) { return `${Math.round(h / 24)}d ago`; } +// Anything actionable: a newer build of the same tag, or a newer version tag. function hasUpdate(c) { + return (c.updateAvailable || Boolean(c.newerTag || c.newerMajorTag)) && !c.pinned; +} + +// What "Update all" applies: same-tag updates only. Switching to a newer tag +// (especially a new major) is a deliberate, per-container choice. +function hasDigestUpdate(c) { return c.updateAvailable && !c.pinned; } @@ -211,13 +218,14 @@ export default function Dashboard({ onPendingCountChange }) { const mainItems = useMemo(() => visible.filter((c) => !c.pinned), [visible]); const pendingTargets = useMemo( - () => mainItems.filter(hasUpdate).map((c) => ({ name: c.name, project: c.project })), + () => mainItems.filter(hasDigestUpdate).map((c) => ({ name: c.name, project: c.project })), [mainItems] ); + const pendingCount = useMemo(() => mainItems.filter(hasUpdate).length, [mainItems]); useEffect(() => { - if (onPendingCountChange) onPendingCountChange(pendingTargets.length); - }, [pendingTargets, onPendingCountChange]); + if (onPendingCountChange) onPendingCountChange(pendingCount); + }, [pendingCount, onPendingCountChange]); // Apply the filter chip + search needle, then group by stack (compose // project); groups with updates come first. @@ -268,8 +276,8 @@ export default function Dashboard({ onPendingCountChange }) {

Containers

- {pendingTargets.length > 0 ? ( - {pendingTargets.length} + {pendingCount > 0 ? ( + {pendingCount} ) : ( !loading && 0 )} diff --git a/client/src/api.js b/client/src/api.js index f3b6a55..40f9651 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -95,6 +95,11 @@ export function logout() { return post('/auth/logout'); } +// Sign out every other session (this one gets a fresh cookie). +export function logoutAll() { + return post('/auth/logout-all'); +} + // --- Containers / updates --- export function getContainers() { @@ -174,6 +179,27 @@ export function unskipUpdate(ref) { return del(`/skip/${encodeURIComponent(ref)}`); } +// Move a container to a newer version tag the last check found. +export function switchTag(name, tag) { + return post(`/update/${encodeURIComponent(name)}/switch-tag`, { tag }); +} + +// Hide an offered newer tag until an even newer one appears (tag null clears). +export function skipTag(ref, tag) { + return post('/skip-tag', { ref, tag }); +} + +// --- Backup / restore --- (download is a plain link to `${API_BASE}/backup`) + +export function restoreBackup(backup) { + return post('/restore', backup); +} + +// Is a newer DockPull out? { current, available, latest?, releaseUrl?, releases? } +export function getSelfUpdate() { + return get('/self-update'); +} + // --- Settings --- export function getSettings() { diff --git a/client/src/components/SelfUpdateBanner.jsx b/client/src/components/SelfUpdateBanner.jsx new file mode 100644 index 0000000..0973a7e --- /dev/null +++ b/client/src/components/SelfUpdateBanner.jsx @@ -0,0 +1,117 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { getSelfUpdate } from '../api.js'; + +const DISMISS_KEY = 'dockpull.selfUpdate.dismissed'; +const UPDATE_COMMAND = 'docker compose pull dockpull && docker compose up -d dockpull'; + +/** + * "A newer DockPull is available" banner. Read-only on purpose: DockPull never + * updates its own container (that would recreate the process doing the + * update), so this explains what changed and how to update by hand. Dismissed + * per version; any failure to check just shows nothing. + */ +export default function SelfUpdateBanner() { + const [info, setInfo] = useState(null); + const [open, setOpen] = useState(null); // null | 'notes' | 'how' + const [copied, setCopied] = useState(false); + const [dismissed, setDismissed] = useState(() => { + try { + return localStorage.getItem(DISMISS_KEY) || ''; + } catch { + return ''; + } + }); + + useEffect(() => { + let cancelled = false; + getSelfUpdate() + .then((d) => { + if (!cancelled && d?.available) setInfo(d); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const dismiss = useCallback(() => { + if (!info) return; + try { + localStorage.setItem(DISMISS_KEY, info.latest); + } catch { + // private mode etc. — just hide for this page view + } + setDismissed(info.latest); + }, [info]); + + const copy = useCallback(async () => { + try { + await navigator.clipboard.writeText(UPDATE_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard blocked (http, permissions) — the command is visible to copy by hand + } + }, []); + + if (!info || dismissed === info.latest) return null; + + return ( +
+
+ + DockPull {info.latest} is available — you're on {info.current}. + + + + + + +
+ + {open === 'how' && ( +
+

+ DockPull doesn't update itself (replacing the container it runs in would cut the update + off half-way). On your server, in the folder with DockPull's compose file, run: +

+
+ {UPDATE_COMMAND} + +
+

+ Using Dockge? Open DockPull's stack and press Update. Built from source? + Run git pull && docker compose up -d --build. Your settings and history + are kept. +

+
+ )} + + {open === 'notes' && ( +
+ {info.releases.map((r) => ( +
+
+ + {r.tag} + + {r.publishedAt && ( + {new Date(r.publishedAt).toLocaleDateString()} + )} +
+ {r.body &&
{r.body}
} +
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index d65da43..6ba83a9 100644 --- a/client/src/components/UpdateCard.jsx +++ b/client/src/components/UpdateCard.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { pin, unpin, skipUpdate, unskipUpdate, getChangelog } from '../api.js'; +import { pin, unpin, skipUpdate, unskipUpdate, skipTag, getChangelog } from '../api.js'; import { useUpdateRunner } from '../hooks/useUpdateRunner.js'; import StatusMessage from './StatusMessage.jsx'; import StreamLog from './StreamLog.jsx'; @@ -147,7 +147,7 @@ function ChangelogContent({ data }) { * - registerRunner(name, runFn) — handle for "Update all" */ export default function UpdateCard({ container, onSettled, onPinChange, registerRunner }) { - const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, breakingRisk, skipped, pinned, sourceUrl, canRevert, rollbackVersion, checkError, state } = + const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, breakingRisk, skipped, newerTag, newerMajorTag, pinned, sourceUrl, canRevert, rollbackVersion, checkError, state, composeFile } = container; const [pinBusy, setPinBusy] = useState(false); @@ -159,7 +159,8 @@ export default function UpdateCard({ container, onSettled, onPinChange, register const [clData, setClData] = useState(null); const [clError, setClError] = useState(''); - const { run, revert, busy, startError, status, lines } = useUpdateRunner(name, onSettled); + const { run, revert, switchTo, busy, startError, status, lines } = useUpdateRunner(name, onSettled); + const [confirmTag, setConfirmTag] = useState(null); useEffect(() => { if (registerRunner) registerRunner(name, run); @@ -215,6 +216,22 @@ export default function UpdateCard({ container, onSettled, onPinChange, register } }, [skipped, image, onPinChange]); + const dismissTag = useCallback( + async (tag) => { + setPinBusy(true); + setActionError(''); + try { + await skipTag(image, tag); + if (onPinChange) onPinChange(); + } catch (err) { + setActionError(err.message || 'Failed to skip version'); + } finally { + setPinBusy(false); + } + }, + [image, onPinChange] + ); + const toggleChangelog = useCallback(async () => { const next = !clOpen; setClOpen(next); @@ -241,7 +258,7 @@ export default function UpdateCard({ container, onSettled, onPinChange, register const isRebuild = showUpdateAvailable && sameVersion(runningLabel, availableVersion); return ( -
+
@@ -313,6 +330,32 @@ export default function UpdateCard({ container, onSettled, onPinChange, register

)} + {!pinned && (newerTag || newerMajorTag) && ( +
+ {[newerTag, newerMajorTag].filter(Boolean).map((tag) => ( +
+ + {tag === newerMajorTag ? 'New major version' : 'Newer version'}{' '} + {tag} + + + + + +
+ ))} +
+ )} + {checkError && (

⚠ Couldn't check for updates (e.g. private registry or rate limit). @@ -385,6 +428,28 @@ export default function UpdateCard({ container, onSettled, onPinChange, register /> )} + {confirmTag && ( + { + const tag = confirmTag; + setConfirmTag(null); + if (!busy) switchTo(tag); + }} + onCancel={() => setConfirmTag(null)} + /> + )} + {clOpen && (

{breakingRisk && clData?.type === 'github' && ( diff --git a/client/src/hooks/useUpdateRunner.js b/client/src/hooks/useUpdateRunner.js index 593ffd2..1087362 100644 --- a/client/src/hooks/useUpdateRunner.js +++ b/client/src/hooks/useUpdateRunner.js @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { startUpdate, revertUpdate } from '../api.js'; +import { startUpdate, revertUpdate, switchTag } from '../api.js'; import { useSSE } from './useSSE.js'; /** @@ -82,6 +82,10 @@ export function useUpdateRunner(name, onSettled) { const run = useCallback(() => start(startUpdate, 'Update started…'), [start]); const revert = useCallback(() => start(revertUpdate, 'Reverting…'), [start]); + const switchTo = useCallback( + (tag) => start((n) => switchTag(n, tag), `Switching to ${tag}…`), + [start] + ); useEffect(() => { if (!result) return; @@ -105,5 +109,5 @@ export function useUpdateRunner(name, onSettled) { const busy = starting || streamActive; - return { run, revert, busy, starting, startError, status, lines }; + return { run, revert, switchTo, busy, starting, startError, status, lines }; } diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx index 73bd237..0f37b2f 100644 --- a/client/src/pages/SettingsPage.jsx +++ b/client/src/pages/SettingsPage.jsx @@ -9,6 +9,9 @@ import { getStatus, getDanglingImages, pruneImages, + logoutAll, + restoreBackup, + API_BASE, } from '../api.js'; import ConfirmDialog from '../components/ConfirmDialog.jsx'; import { useTheme } from '../hooks/useTheme.js'; @@ -26,6 +29,19 @@ function formatBytes(n) { return `${value.toFixed(1)} ${units[i]}`; } +// "in 3h", "in 25m", "at 09:00 tomorrow"-ish relative time for a future epoch ms. +function formatWhen(ts) { + const ms = ts - Date.now(); + if (!Number.isFinite(ms)) return ''; + if (ms < 60_000) return 'in under a minute'; + const m = Math.round(ms / 60_000); + if (m < 60) return `in ${m}m`; + const h = Math.floor(m / 60); + const rest = m % 60; + if (h < 24) return `in ${h}h${rest ? ` ${rest}m` : ''}`; + return `on ${new Date(ts).toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' })}`; +} + // Rough relative age for an image's creation time (Docker's `created` is Unix // seconds). Helps explain why some dangling layers show "Untracked source" — // e.g. an old layer from a container's earlier update, before its rollback @@ -87,10 +103,12 @@ export default function SettingsPage({ onPruneComplete } = {}) { const [pruning, setPruning] = useState(false); const [pruneStatus, setPruneStatus] = useState(''); const [pruneSummaryLoading, setPruneSummaryLoading] = useState(false); - // The dangling images still selected for pruning. Starts as the full fetched - // list; the user can drop rows (which then just reappear next time, since - // they're never removed). Drives both the table and what gets pruned. - const [pruneSelection, setPruneSelection] = useState([]); + // Every prunable image from the preview, and the IDs the user has left out. + // Revert points (images named after a container) start excluded: pruning + // one removes that container's undo button, so it's opt-in. + const [pruneCandidates, setPruneCandidates] = useState([]); + const [pruneExcluded, setPruneExcluded] = useState(() => new Set()); + const pruneSelection = pruneCandidates.filter((img) => !pruneExcluded.has(img.id)); const [health, setHealth] = useState(null); // null = unknown, true/false once checked const [status, setStatus] = useState(null); // { version, serverLocalTime, timeZone } @@ -130,12 +148,73 @@ export default function SettingsPage({ onPruneComplete } = {}) { .catch(() => setHealth(false)); }, []); - useEffect(() => { + const refreshStatus = useCallback(() => { getStatus() .then((s) => setStatus(s || null)) .catch(() => {}); }, []); + useEffect(() => { + refreshStatus(); + }, [refreshStatus]); + + // --- Account & data --- + const [accountBusy, setAccountBusy] = useState(false); + const [accountStatus, setAccountStatus] = useState(''); + const [confirmLogoutAll, setConfirmLogoutAll] = useState(false); + const [pendingRestore, setPendingRestore] = useState(null); + + const handleLogoutAll = useCallback(async () => { + setConfirmLogoutAll(false); + setAccountBusy(true); + setAccountStatus(''); + try { + await logoutAll(); + setAccountStatus('Signed out everywhere else. This device stays signed in.'); + } catch (err) { + setAccountStatus(err.message || 'Failed to sign out other sessions'); + } finally { + setAccountBusy(false); + } + }, []); + + const handleRestoreFile = useCallback(async (e) => { + const file = e.target.files?.[0]; + e.target.value = ''; // allow picking the same file again + if (!file) return; + setAccountStatus(''); + try { + const data = JSON.parse(await file.text()); + if (data?.format !== 'dockpull-backup') throw new Error("That file isn't a DockPull backup."); + setPendingRestore(data); + } catch (err) { + setAccountStatus(err instanceof SyntaxError ? "That file isn't valid JSON." : err.message); + } + }, []); + + const handleRestoreConfirm = useCallback(async () => { + const backup = pendingRestore; + setPendingRestore(null); + setAccountBusy(true); + try { + const r = await restoreBackup(backup); + setAccountStatus( + `Restored ${r.settings} settings, ${r.pinned} pinned, ${r.history} history entries` + + (r.historySkippedBecauseNotEmpty ? ' (history kept as is — it already had entries)' : '') + + (r.skipped ? `; skipped ${r.skipped} invalid entr${r.skipped === 1 ? 'y' : 'ies'}` : '') + + '.' + ); + const fresh = await getSettings(); + setSettings(fresh); + setWebhookDraft(fresh?.discordWebhookUrl || ''); + refreshStatus(); + } catch (err) { + setAccountStatus(err.message || 'Restore failed'); + } finally { + setAccountBusy(false); + } + }, [pendingRestore, refreshStatus]); + const saveSetting = useCallback(async (patch) => { setSettings((prev) => ({ ...prev, ...patch })); // optimistic setSettingsError(''); @@ -174,7 +253,9 @@ export default function SettingsPage({ onPruneComplete } = {}) { setPruneStatus('Nothing to prune — no dangling layers found.'); return; } - setPruneSelection(summary.images || []); + const images = summary.images || []; + setPruneCandidates(images); + setPruneExcluded(new Set(images.filter((img) => img.fromContainer).map((img) => img.id))); setConfirmPrune(true); } catch (err) { setPruneStatus(err.message || 'Failed to check for dangling layers'); @@ -185,8 +266,13 @@ export default function SettingsPage({ onPruneComplete } = {}) { // Drop a layer from this prune. It isn't removed, so it reappears the next // time the dialog is opened (which re-fetches the current dangling set). - const excludePruneImage = useCallback((id) => { - setPruneSelection((sel) => sel.filter((img) => img.id !== id)); + const togglePruneImage = useCallback((id) => { + setPruneExcluded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); }, []); const handlePrune = useCallback(async () => { @@ -212,7 +298,7 @@ export default function SettingsPage({ onPruneComplete } = {}) { setPruneStatus(err.message || 'Prune failed'); } finally { setPruning(false); - setPruneSelection([]); + setPruneCandidates([]); } }, [onPruneComplete, pruneSelection]); @@ -310,15 +396,39 @@ export default function SettingsPage({ onPruneComplete } = {}) { {settings?.autoCheckOnOpen ? 'On' : 'Off'}
+
+
+ Newer version tags + + For containers on a version tag (e.g. postgres:16.3), offer newer + versions like 16.4 — and optionally the next major version. + +
+ +

Background checks & notifications

- Daily scan + Background scan - Run a scan once a day even when the app is closed. + Check for updates on a schedule, even when the app is closed. A scan missed while + the server was off runs shortly after it starts. + {settings?.backgroundCheckEnabled && status?.nextScanAt ? ( + <> Next scan {formatWhen(status.nextScanAt)}. + ) : null}
- Daily scan time - - When the daily scan runs, on the server's clock - {status?.timeZone ? ( - <> - {' '} - — currently {status.serverLocalTime} {status.timeZone}. If that's off, set the - container's TZ (e.g. TZ=Europe/London). - - ) : ( - '.' - )} - + Schedule + Once a day at a set time, or every few hours.
- saveSetting({ scheduledCheckTime: e.target.value }).catch(() => {})} +
+ {settings?.scheduleMode === 'interval' ? ( +
+
+ Scan every + Hours between scans (1–168). +
+ +
+ ) : ( +
+
+ Daily scan time + + When the daily scan runs, on the server's clock + {status?.timeZone ? ( + <> + {' '} + — currently {status.serverLocalTime} {status.timeZone}. If that's off, set the + container's TZ (e.g. TZ=Europe/London). + + ) : ( + '.' + )} + +
+ saveSetting({ scheduledCheckTime: e.target.value }).then(refreshStatus).catch(() => {})} + disabled={!settings || !settings?.backgroundCheckEnabled} + /> +
+ )}
Notify via @@ -403,7 +555,7 @@ export default function SettingsPage({ onPruneComplete } = {}) {
Send notifications - Notify on the daily scan when updates are found. + Notify after a background scan when updates are found.
+
+
+ Notify on failures + + Also send a message when an update or revert fails, or comes up unhealthy. + +
+ +