diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5132f62..2b4670e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,9 @@ jobs: name: Build and push to GHCR needs: verify runs-on: ubuntu-latest + # A normal build takes ~4 minutes. Fail loudly on a stall instead of + # hanging for GitHub's 6-hour default with `:latest` silently stale. + timeout-minutes: 25 permissions: contents: read packages: write @@ -113,8 +116,16 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + # Separate cache scopes for branch and tag builds. A release pushes + # `main` and the `vX.Y.Z` tag seconds apart, so both builds run at + # once (different refs, different concurrency groups); writing the + # same gha cache scope concurrently stalled the `:latest` build for + # 12+ minutes on v1.8.0. Tag builds still READ main's cache, so they + # stay fast, but each writes only its own scope. + cache-from: | + type=gha,scope=main + type=gha,scope=${{ github.ref_type == 'tag' && 'release' || 'main' }} + cache-to: type=gha,mode=max,scope=${{ github.ref_type == 'tag' && 'release' || 'main' }} # NOTE: the GitHub Release itself is created by release-please.yml (with a # changelog grouped by feat/fix/deps), not here — it creates the git tag as a diff --git a/API_CONTRACT.md b/API_CONTRACT.md index a8115dc..057eb9e 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -93,15 +93,49 @@ All request/response bodies are JSON unless noted otherwise. - Auth: cookie. - Path param: `name` — container name. - Body: none. -- Response: `200 { "streamId": "string" }` — starts a pull + recreate - operation for that container; use the returned `streamId` to subscribe to - progress via the SSE endpoint below. +- Response: `200 { "streamId": "string", "job": {...} }` — queues a pull + + recreate for that container on the server (see `GET /api/jobs`); subscribe to + progress via the SSE endpoint below. The stream replays the whole log on every + (re)connect and is kept for 15 minutes after the job finishes. - Errors: `404` if no such container; `409` if an update is already in progress for that container. - Note: after `up -d`, the container is health-checked; if it doesn't come up healthy the result is reported as `success:false` with an actionable message (and a rollback point is recorded). +### `POST /api/update-all` + +- Auth: cookie. Body: `{ "names": [string] }` (container names, ≤ 500). +- Queues an update for each, as one batch, run on the server (one at a time + within a compose project, up to 3 at once overall) — nothing needs to stay + connected. When the last finishes, one summary notification is sent (if + notifications and `notifyBatchSummary` are on, and the batch had ≥ 2). +- Response: `200 { "batchId", "queued": [name], "skipped": [{ "name", "reason" }] }`; + `400 invalid_payload`. + +### `GET /api/jobs` + +- Auth: cookie. The server's update queue: queued/running jobs plus those + finished in the last 15 minutes, and the latest batch. +- Response: `200 { "jobs": [{ "id", "name", "kind": "update"|"revert"|"switch", "project", "batchId", "status": "queued"|"running"|"done", "waitingOn": "stack"|"slot"|null, "queuedAt", "startedAt", "finishedAt", "success", "message", "tag"? }], "batch": { "id", "total", "done", "succeeded": [name], "failed": [{ "name", "message" }], "startedAt", "finishedAt" }|null }`. +- `GET /api/events` emits `{"type":"jobs-changed"}` whenever this changes. + +### `PUT /api/containers/:name/auto-revert` + +- Auth: cookie. Body: `{ "enabled": boolean }`. When on, an update or tag switch + of this container that comes up unhealthy is reverted automatically in the + same job. Container items report it as `autoRevert`. + +### `GET /api/containers/:name/logs` + +- Auth: cookie. Query: `tail` (default 200, max 1000). The container's own + recent stdout/stderr. Response: `200 { "text", "state", "restartCount" }`. + +### `GET /api/history/entry/:id/log` + +- Auth: cookie. The saved live log of one update (history rows carry + `has_log`). Response: `200 { "log" }`; `404 not_found`. + ### `POST /api/update/:name/revert` - Auth: cookie. @@ -128,7 +162,7 @@ All request/response bodies are JSON unless noted otherwise. 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). +- Response: `200 { "streamId": "string", "job": {...} }` (stream as for updates). - Errors: `400 invalid_tag`; `409 tag_not_offered`; `409 update_in_progress`; `404 not_found`. @@ -399,6 +433,11 @@ Field notes: 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`. +- `downloadSize` — `{ "bytes": number, "exact": boolean }` for a pending (or + skipped) update: the compressed size of the new image's layers the running + image doesn't have; `exact: false` means the running image's manifest + couldn't be read, so it's the whole image (an upper bound). `null` if unknown. +- `autoRevert` — per-container auto-revert (see `PUT .../auto-revert`). - `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 bb0eb62..9146dea 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,17 @@ 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 + to run them (one at a time within each stack, up to 3 stacks at once). Updates run + **on the server**: leaving the page, locking your phone or closing the app doesn't + stop them, and any page (or another device) shows progress — the header reads + "Updating · 2/5 done", and cards pick their live log back up when you return. + Each update shows its **download size** (only the layers you don't already have). 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** + offers a one-click **Revert** to the previous image if it doesn't. Tick + **Auto-revert if unhealthy** on a card to have that happen automatically for that + container. **Container logs** shows the container's own recent output (why it + crash-loops, say). **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 @@ -91,12 +98,13 @@ If the paths don't match you'll get `compose file not found` and broken bind mou 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). +- **History tab** — a log of past updates; expand an entry for **Show update log** + (the pull/recreate output, kept with the entry). **Clear history** wipes it. - **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 + Gotify, or a generic webhook — with a "send test" button, optional alerts when + an update fails, and a one-message summary when an Update all finishes), 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 diff --git a/client/src/App.jsx b/client/src/App.jsx index 31c4ee4..bda99cd 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -7,6 +7,7 @@ import Dashboard from './Dashboard.jsx'; import Header from './components/Header.jsx'; import BottomNav from './components/BottomNav.jsx'; import SelfUpdateBanner from './components/SelfUpdateBanner.jsx'; +import { LiveProvider } from './hooks/useLive.jsx'; import HistoryPage from './pages/HistoryPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx'; @@ -85,6 +86,7 @@ export default function App() { } return ( +
@@ -101,5 +103,6 @@ export default function App() {
+
); } diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index 290f3ec..961e629 100644 --- a/client/src/Dashboard.jsx +++ b/client/src/Dashboard.jsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getContainers, checkNow, getSettings, updateSettings, getStatus, API_BASE } from './api.js'; +import { getContainers, checkNow, getSettings, updateSettings, getStatus, updateAll } from './api.js'; +import { useLive, useLiveEvent } from './hooks/useLive.jsx'; import UpdateCard from './components/UpdateCard.jsx'; import UpdateAllButton from './components/UpdateAllButton.jsx'; import StackGroup from './components/StackGroup.jsx'; @@ -59,7 +60,15 @@ export default function Dashboard({ onPendingCountChange }) { const [filter, setFilter] = useState('updates'); const [search, setSearch] = useState(''); const [lastCheckedAt, setLastCheckedAt] = useState(null); - const [batchSummary, setBatchSummary] = useState(null); + const [batchError, setBatchError] = useState(''); + const [dismissedBatch, setDismissedBatch] = useState(() => { + try { + return localStorage.getItem('dockpull.dismissedBatch') || ''; + } catch { + return ''; + } + }); + const { jobsByName, batch, refreshJobs } = useLive(); // Re-render every minute so "Last checked Xm ago" doesn't go stale while // the page sits open. const [, setClockTick] = useState(0); @@ -69,18 +78,34 @@ export default function Dashboard({ onPendingCountChange }) { return () => clearInterval(id); }, []); - // name -> run() function, populated by each UpdateCard so "Update all" - // can drive the same start+SSE flow the per-card button uses. - const runnersRef = useRef(new Map()); - + // Coalesced reload: while a load is in flight, further requests (several + // updates finishing, live events, a card settling) collapse into ONE + // follow-up load instead of each re-inspecting every container. + const loadState = useRef({ inFlight: null, again: false }); const load = useCallback(async () => { - setError(''); + const st = loadState.current; + if (st.inFlight) { + st.again = true; + return st.inFlight; + } + st.inFlight = (async () => { + do { + st.again = false; + setError(''); + try { + const data = await getContainers(); + setContainers(Array.isArray(data) ? data : []); + } catch (err) { + setError(err.message || 'Failed to load containers'); + } + } while (st.again); + })(); try { - const data = await getContainers(); - setContainers(Array.isArray(data) ? data : []); - } catch (err) { - setError(err.message || 'Failed to load containers'); + await st.inFlight; + } finally { + st.inFlight = null; } + return undefined; }, []); // Actively ask the server to re-check registries, then refresh the list. @@ -151,50 +176,48 @@ export default function Dashboard({ onPendingCountChange }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Live updates: refresh automatically when the server signals a change. - useEffect(() => { - let es; - let debounce; - try { - // API_BASE (not a literal '/api') so live refresh works under a subpath. - es = new EventSource(`${API_BASE}/events`); - es.onmessage = (e) => { - let payload; - try { - payload = JSON.parse(e.data); - } catch { - return; - } - if (payload && payload.type === 'containers-changed') { - clearTimeout(debounce); - debounce = setTimeout(() => load(), 400); - } - }; - } catch { - // EventSource unavailable — manual Check still works. - } - return () => { - clearTimeout(debounce); - if (es) es.close(); - }; + // Live updates: refresh when the server signals a change, and after coming + // back to the app (events may have been missed while it was hidden). + const debounceRef = useRef(null); + const scheduleLoad = useCallback(() => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => load(), 400); }, [load]); + useEffect(() => () => clearTimeout(debounceRef.current), []); + useLiveEvent('containers-changed', scheduleLoad); + useLiveEvent('reconnected', scheduleLoad); const handleSettled = useCallback(() => { - load(); - }, [load]); + scheduleLoad(); + }, [scheduleLoad]); - const registerRunner = useCallback((name, runFn) => { - if (runFn) { - runnersRef.current.set(name, runFn); - } else { - runnersRef.current.delete(name); - } - }, []); + // "Update all" runs on the server: send the list, then just watch. + const startUpdateAll = useCallback( + async (names) => { + setBatchError(''); + try { + const r = await updateAll(names); + if (r?.skipped?.length) { + setBatchError( + `Skipped ${r.skipped.map((x) => `${x.name} (${x.reason})`).join(', ')}.` + ); + } + } catch (err) { + setBatchError(err.message || 'Failed to start Update all'); + } finally { + refreshJobs(); + } + }, + [refreshJobs] + ); - const runUpdateFor = useCallback((name) => { - const runFn = runnersRef.current.get(name); - if (!runFn) return Promise.resolve(); - return runFn(); + const dismissBatch = useCallback((id) => { + setDismissedBatch(id); + try { + localStorage.setItem('dockpull.dismissedBatch', id); + } catch { + // ignore + } }, []); const setFilterPersisted = useCallback((value) => { @@ -204,11 +227,6 @@ export default function Dashboard({ onPendingCountChange }) { }); }, []); - const handleBatchDone = useCallback((outcomes) => { - const failed = outcomes.filter((o) => !o.success); - setBatchSummary({ ok: outcomes.length - failed.length, failed }); - }, []); - // Pinned go to their own bottom section; everything else is the main list. const visible = containers; const pinnedItems = useMemo( @@ -230,7 +248,10 @@ export default function Dashboard({ onPendingCountChange }) { // Apply the filter chip + search needle, then group by stack (compose // project); groups with updates come first. const groups = useMemo(() => { - let items = filter === 'updates' ? mainItems.filter(hasUpdate) : mainItems; + // In "Updates only", keep cards with a queued/running or recently + // finished job on screen, so a finished update shows its result instead of + // vanishing the moment it no longer has an update. + let items = filter === 'updates' ? mainItems.filter((c) => hasUpdate(c) || jobsByName.has(c.name)) : mainItems; const needle = search.trim().toLowerCase(); if (needle) { items = items.filter( @@ -260,7 +281,7 @@ export default function Dashboard({ onPendingCountChange }) { return a.project.localeCompare(b.project); }); return out; - }, [mainItems, filter, search]); + }, [mainItems, filter, search, jobsByName]); const mainCount = useMemo(() => groups.reduce((n, g) => n + g.items.length, 0), [groups]); @@ -289,9 +310,9 @@ export default function Dashboard({ onPendingCountChange }) { @@ -332,25 +353,27 @@ export default function Dashboard({ onPendingCountChange }) { {checkMsg &&

{checkMsg}

} - {batchSummary && ( + {batchError &&

{batchError}

} + + {batch && batch.finishedAt && batch.total > 0 && dismissedBatch !== batch.id && (
0 ? 'banner-warn' : 'banner-ok'} batch-banner`} + className={`banner ${batch.failed.length > 0 ? 'banner-warn' : 'banner-ok'} batch-banner`} role="status" > - Update all finished: {batchSummary.ok} updated - {batchSummary.failed.length > 0 && ( + Update all finished: {batch.succeeded.length} updated + {batch.failed.length > 0 && ( <> - , {batchSummary.failed.length} failed —{' '} - {batchSummary.failed.map((f) => f.name).join(', ')}. See those cards for details. + , {batch.failed.length} failed — {batch.failed.map((f) => f.name).join(', ')}. See + those cards for details. )} - {batchSummary.failed.length === 0 && '. 🎉'} + {batch.failed.length === 0 && '. 🎉'}
@@ -458,7 +481,7 @@ export default function Dashboard({ onPendingCountChange }) { container={container} onSettled={handleSettled} onPinChange={load} - registerRunner={registerRunner} + job={jobsByName.get(container.name) || null} /> ))} diff --git a/client/src/api.js b/client/src/api.js index 40f9651..5357975 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -121,6 +121,32 @@ export function startUpdate(name) { return post(`/update/${encodeURIComponent(name)}`); } +// Queue updates for several containers at once; runs entirely on the server. +// Returns { batchId, queued: [names], skipped: [{ name, reason }] }. +export function updateAll(names) { + return post('/update-all', { names }); +} + +// The server's update queue: { jobs: [...], batch: {...}|null }. +export function getJobs() { + return get('/jobs'); +} + +// A container's own recent output: { text, state, restartCount }. +export function getContainerLogs(name, tail = 200) { + return get(`/containers/${encodeURIComponent(name)}/logs?tail=${tail}`); +} + +// Revert automatically when an update of this container comes up unhealthy. +export function setAutoRevert(name, enabled) { + return request('PUT', `/containers/${encodeURIComponent(name)}/auto-revert`, { enabled }); +} + +// The saved live log of one past update: { log }. +export function getHistoryLog(id) { + return get(`/history/entry/${encodeURIComponent(id)}/log`); +} + export function revertUpdate(name) { return post(`/update/${encodeURIComponent(name)}/revert`); } diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index 39c4954..6edf689 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Link, NavLink } from 'react-router-dom'; import { logout } from '../api.js'; import { useTheme } from '../hooks/useTheme.js'; +import { useLive, isActiveJob } from '../hooks/useLive.jsx'; const LogoutIcon = () => ( DockPull {pendingCount > 0 && {pendingCount}} + {progress && ( + +