Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 26 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -87,6 +88,7 @@ export default function App() {
<div className="app-shell">
<Header pendingCount={pendingCount} needsPruning={needsPruning} onLoggedOut={handleLoggedOut} />
<main className="app-main">
<SelfUpdateBanner />
<Routes>
<Route path="/" element={<Dashboard onPendingCountChange={setPendingCount} />} />
<Route path="/history" element={<HistoryPage />} />
Expand Down
18 changes: 13 additions & 5 deletions client/src/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -268,8 +276,8 @@ export default function Dashboard({ onPendingCountChange }) {
<div className="dashboard-header">
<div className="title-row">
<h2>Containers</h2>
{pendingTargets.length > 0 ? (
<span className="badge">{pendingTargets.length}</span>
{pendingCount > 0 ? (
<span className="badge">{pendingCount}</span>
) : (
!loading && <span className="badge badge-muted">0</span>
)}
Expand Down
26 changes: 26 additions & 0 deletions client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions client/src/components/SelfUpdateBanner.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="self-update" role="status">
<div className="self-update-row">
<span className="self-update-text">
<strong>DockPull {info.latest}</strong> is available — you're on {info.current}.
</span>
<span className="self-update-actions">
<button type="button" className="btn-ghost" onClick={() => setOpen(open === 'notes' ? null : 'notes')}>
{open === 'notes' ? 'Hide' : "What's new"}
</button>
<button type="button" className="btn-ghost" onClick={() => setOpen(open === 'how' ? null : 'how')}>
How to update
</button>
<button type="button" className="banner-dismiss" onClick={dismiss} aria-label="Dismiss until the next version">
×
</button>
</span>
</div>

{open === 'how' && (
<div className="self-update-panel">
<p>
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:
</p>
<div className="self-update-cmd">
<code>{UPDATE_COMMAND}</code>
<button type="button" className="btn btn-sm" onClick={copy}>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<p className="self-update-note">
Using Dockge? Open DockPull's stack and press <strong>Update</strong>. Built from source?
Run <code>git pull &amp;&amp; docker compose up -d --build</code>. Your settings and history
are kept.
</p>
</div>
)}

{open === 'notes' && (
<div className="self-update-panel">
{info.releases.map((r) => (
<div className="changelog-release" key={r.tag}>
<div className="changelog-release-head">
<a href={r.url} target="_blank" rel="noopener noreferrer">
{r.tag}
</a>
{r.publishedAt && (
<span className="changelog-date">{new Date(r.publishedAt).toLocaleDateString()}</span>
)}
</div>
{r.body && <pre className="changelog-body">{r.body}</pre>}
</div>
))}
</div>
)}
</div>
);
}
Loading
Loading