Skip to content

Commit 4191c49

Browse files
committed
feat(updates): in-app update notifications via GitHub releases
Adds an opt-out periodic check for new Docker Dash releases on GitHub. A subtle pulsing badge appears next to the sidebar version when an update is available; click opens a modal with the release notes (rendered from the GitHub Release body) and an admin-only upgrade command. - src/services/update-check.js: fetches latest release, semver-compares, caches in settings table. 12h cron + 60s post-boot one-shot, both leader-gated for HA. Configurable owner/repo via env. - src/routes/update-check.js: GET status (any auth user), POST refresh + setting (admin only), audited. - public/js/update-notifier.js: fetches at app start, badge + modal, inline minimal markdown renderer (no deps). - public/js/pages/settings.js: General tab gets toggle + check-now. - 24 new tests (931 total passing). i18n EN + RO. Privacy: 1 outbound HTTPS call per instance per 12h, can be disabled in System Settings → General for air-gapped deployments. Release: v7.3.0
1 parent e47324e commit 4191c49

16 files changed

Lines changed: 766 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,62 @@
22

33
All notable changes to Docker Dash are documented here.
44

5+
## [7.3.0] - 2026-04-25 — "Update Notifications"
6+
7+
Periodic, opt-out check for new Docker Dash releases on GitHub. Solves the "user cloned the repo a week ago and has no idea v7.3.0 shipped" gap. Designed to be **quiet**: a tiny pulsing ↑ badge next to the sidebar version, click-to-open modal with the full release notes (rendered from the GitHub Release `body`), and a one-click "show upgrade command" for admins.
8+
9+
### Added — Backend
10+
11+
- **`src/services/update-check.js`** (~165 LOC) — polls `https://api.github.com/repos/<owner>/<repo>/releases/latest`, semver-compares against `src/version.js`, caches the result in the `settings` table. Configurable owner/repo via `DD_UPDATE_CHECK_OWNER` / `DD_UPDATE_CHECK_REPO` env vars (defaults: `bogdanpricop/docker-dash`). 5s timeout, no redirects, custom User-Agent. Network failures are caught + logged + leave the existing cache untouched.
12+
13+
- **`src/routes/update-check.js`** mounted at `/api/system/update-check` (before `/api/system` so it bypasses that router's host-extraction middleware):
14+
- `GET /` — read current status. Returns `{ current, latest, hasUpdate, releaseNotes, releaseUrl, publishedAt, lastChecked, enabled }`. Auth required, no role gate (sidebar badge needs to work for operators + viewers).
15+
- `POST /refresh` — admin-only force refresh. Service has a 60s anti-abuse throttle.
16+
- `POST /setting` — admin-only enable/disable toggle. Audited.
17+
18+
- **Background job** in `src/jobs/index.js` — cron `17 */12 * * *` (every 12h) + a one-shot 60s post-boot run so the badge can light up on first login without waiting half a day. Both gated on `cluster.isLeader()` so HA replicas don't N× the GitHub call. Service short-circuits when disabled (cheap settings.get on each tick).
19+
20+
### Added — Frontend
21+
22+
- **`public/js/update-notifier.js`** (~190 LOC) — self-contained module. Fetches status at app start, renders a 14×14 pulsing ↑ badge inside `#sidebar-version` when an update is available. Click → modal with:
23+
- Header: `current → latest` with publish date + last-checked timestamp.
24+
- Release notes: minimal markdown→HTML renderer (no external deps) handling headings, **bold**, *italic*, `inline code`, fenced code blocks, lists, links. All inputs HTML-escaped first.
25+
- Admin-only `<details>`: copy-pasteable `git pull && APP_VERSION=X.Y.Z docker compose up -d --build app` with a "back up /data first" warning.
26+
- Footer: link to the release page on GitHub + Close.
27+
28+
- **System Settings → General** — new card with the toggle, last-checked status, and a "Check now" button (forwards to `POST /refresh`).
29+
30+
- **CSS** in `public/css/app.css``.update-badge` with subtle pulse animation. Hidden when sidebar is collapsed (inherits from existing `.sidebar.collapsed .sidebar-version` rule).
31+
32+
- **i18n** — new `updates:` block in EN + RO with 18 keys covering badge tooltip, modal labels, settings card, status messages. Other 9 languages fall back to EN via `_fallback`.
33+
34+
### Privacy + air-gap
35+
36+
- One outbound HTTPS call every 12h, total — not per user. Configurable poll interval is fixed at 12h (no operator footgun).
37+
- `User-Agent: docker-dash/<version>` — no IP/hostname/install ID leaked beyond what the TCP connection inherently exposes.
38+
- **Disable in System Settings → General** for fully air-gapped deployments. When disabled: zero outbound calls, badge never appears, modal still opens manually if cached data exists (so operators can still read the last release notes they fetched before air-gapping).
39+
- Audit log: `update_check_enabled` / `update_check_disabled` on toggle.
40+
41+
### Tests
42+
43+
- **`src/__tests__/update-check.test.js`** — 24 tests covering semver parse/compare, enable/disable round-trip, getStatus state machine (empty cache, equal version, newer version, disabled bypass, corrupt JSON), refresh HTTP behavior (success path, disabled short-circuit, network error preserves cache, non-200, force bypass, missing body field).
44+
45+
- **Suite: 907 → 931 passing / 60 suites.** Lint clean, npm audit clean.
46+
47+
### Files touched
48+
49+
- `src/services/update-check.js` (new)
50+
- `src/routes/update-check.js` (new)
51+
- `src/server.js` — mount route before `/api/system`
52+
- `src/jobs/index.js` — 12h cron + one-shot post-boot refresh (leader-gated)
53+
- `public/js/update-notifier.js` (new)
54+
- `public/index.html` — script tag for `update-notifier.js`
55+
- `public/js/app.js``UpdateNotifier.init()` after auth
56+
- `public/js/pages/settings.js` — General tab gets the toggle card
57+
- `public/css/app.css` — badge styling + pulse animation
58+
- `public/js/i18n/en.js` + `ro.js``updates:` block
59+
- `src/__tests__/update-check.test.js` (new)
60+
561
## [7.2.1] - 2026-04-23 — Bug fixes
662

763
### Fixed

docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ services:
44
context: .
55
dockerfile: Dockerfile
66
args:
7-
APP_VERSION: "${APP_VERSION:-7.2.1}"
8-
image: docker-dash:${APP_VERSION:-7.2.1}
7+
APP_VERSION: "${APP_VERSION:-7.3.0}"
8+
image: docker-dash:${APP_VERSION:-7.3.0}
99
container_name: docker-dash
1010
restart: unless-stopped
1111
env_file:
@@ -54,7 +54,7 @@ services:
5454
dd-egress-filter:
5555
build:
5656
context: ./docker/egress-filter
57-
image: docker-dash-egress-filter:${APP_VERSION:-7.2.1}
57+
image: docker-dash-egress-filter:${APP_VERSION:-7.3.0}
5858
container_name: dd-egress-filter
5959
restart: unless-stopped
6060
# Uses the default bridge so target containers on the default bridge can

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "docker-dash",
3-
"version": "7.2.1",
3+
"version": "7.3.0",
44
"description": "Full-featured Docker management dashboard",
55
"main": "src/server.js",
66
"scripts": {

public/css/app.css

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,21 @@ textarea { resize: vertical; min-height: 80px; }
205205
.sidebar-logo { font-size: 26px; flex-shrink: 0; filter: drop-shadow(0 0 8px rgba(56,139,253,0.3)); }
206206
.sidebar-title-group { display: flex; flex-direction: column; flex: 1; min-width: 0; }
207207
.sidebar-title { font-weight: 700; font-size: 16px; color: var(--text-white); white-space: nowrap; }
208-
.sidebar-version { font-size: 10px; color: var(--text-dim); font-family: var(--mono); letter-spacing: 0.5px; margin-top: -2px; }
208+
.sidebar-version { font-size: 10px; color: var(--text-dim); font-family: var(--mono); letter-spacing: 0.5px; margin-top: -2px; display: inline-flex; align-items: center; gap: 5px; }
209+
.sidebar-version .update-badge {
210+
display: inline-flex; align-items: center; justify-content: center;
211+
width: 14px; height: 14px; border-radius: 50%;
212+
background: var(--accent); color: var(--text-white);
213+
font-size: 8px; cursor: pointer;
214+
box-shadow: 0 0 6px rgba(56,139,253,0.5);
215+
animation: update-badge-pulse 2.5s ease-in-out infinite;
216+
transition: transform var(--transition);
217+
}
218+
.sidebar-version .update-badge:hover { transform: scale(1.15); }
219+
@keyframes update-badge-pulse {
220+
0%, 100% { box-shadow: 0 0 6px rgba(56,139,253,0.5); }
221+
50% { box-shadow: 0 0 10px rgba(56,139,253,0.85); }
222+
}
209223
.sidebar-toggle {
210224
background: none; border: none; color: var(--text-dim); cursor: pointer;
211225
padding: 6px; font-size: 14px; border-radius: var(--radius-xs);

public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@
323323
<script src="/js/components/context-menu.js?v=__VERSION__"></script>
324324
<script src="/js/components/taskbar.js?v=__VERSION__"></script>
325325
<script src="/js/components/remediate-wizard.js?v=__VERSION__"></script>
326+
<script src="/js/update-notifier.js?v=__VERSION__"></script>
326327
<script src="/js/pages/dashboard.js?v=__VERSION__"></script>
327328
<script src="/js/pages/containers.js?v=__VERSION__"></script>
328329
<script src="/js/pages/images.js?v=__VERSION__"></script>

public/js/app.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,13 @@ const App = {
379379
versionEl.addEventListener('click', () => this.navigate('/whatsnew'));
380380
}
381381

382+
// v7.3.0: check GitHub for newer version (cached server-side, ~1 call/12h
383+
// total across all users). Adds an unobtrusive ↑ badge next to the
384+
// sidebar version when an update is available.
385+
if (window.UpdateNotifier) {
386+
window.UpdateNotifier.init().catch(() => { /* silent */ });
387+
}
388+
382389
// Restore host context
383390
Api.restoreHost();
384391

public/js/i18n/en.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ i18n.register('en', 'EN', 'English', {
7777
title: 'Notifications', markAllRead: 'Mark all read',
7878
empty: 'No notifications', failedToLoad: 'Failed to load',
7979
},
80+
updates: {
81+
badgeTooltip: 'New version available: {{version}} — click for details',
82+
modalTitle: 'Update available',
83+
publishedOn: 'Published {{date}}',
84+
lastChecked: 'Last checked',
85+
never: 'never',
86+
noNotes: '_(No release notes provided.)_',
87+
openOnGithub: 'View on GitHub',
88+
showUpgradeCmd: 'Show upgrade command',
89+
upgradeCmdHint: 'Run on the host where Docker Dash is deployed:',
90+
upgradeCmdWarn: 'Read the release notes above before upgrading. Always back up /data first.',
91+
settingTitle: 'Update notifications',
92+
settingDesc: 'Periodically check GitHub for new Docker Dash releases (one HTTPS call every 12 hours). Disable for fully air-gapped deployments.',
93+
settingEnabled: 'Check for updates',
94+
checkNow: 'Check now',
95+
checking: 'Checking...',
96+
checkFailed: 'Check failed',
97+
upToDate: "You're on the latest version",
98+
refreshed: 'Update check refreshed',
99+
},
80100
time: {
81101
justNow: 'just now', minutesAgo: '{{n}}m ago', hoursAgo: '{{n}}h ago', daysAgo: '{{n}}d ago',
82102
days: 'd', hours: 'h', minutes: 'm', seconds: 's',

public/js/i18n/ro.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ i18n.register('ro', 'RO', 'Română', {
5353
title: 'Notificari', markAllRead: 'Marcheaza toate citite',
5454
empty: 'Fara notificari', failedToLoad: 'Eroare la incarcare',
5555
},
56+
updates: {
57+
badgeTooltip: 'Versiune noua disponibila: {{version}} — click pentru detalii',
58+
modalTitle: 'Actualizare disponibila',
59+
publishedOn: 'Publicat {{date}}',
60+
lastChecked: 'Ultima verificare',
61+
never: 'niciodata',
62+
noNotes: '_(Nu sunt note de release.)_',
63+
openOnGithub: 'Deschide pe GitHub',
64+
showUpgradeCmd: 'Arata comanda de upgrade',
65+
upgradeCmdHint: 'Ruleaza pe host-ul unde e instalat Docker Dash:',
66+
upgradeCmdWarn: 'Citeste notele de release inainte de upgrade. Fa intotdeauna backup la /data.',
67+
settingTitle: 'Notificari de actualizare',
68+
settingDesc: 'Verifica periodic GitHub pentru versiuni noi de Docker Dash (un singur HTTPS la 12 ore). Dezactiveaza pentru deploy-uri complet izolate.',
69+
settingEnabled: 'Verifica actualizari',
70+
checkNow: 'Verifica acum',
71+
checking: 'Se verifica...',
72+
checkFailed: 'Verificare esuata',
73+
upToDate: 'Esti pe ultima versiune',
74+
refreshed: 'Verificare actualizare reusita',
75+
},
5676
time: {
5777
justNow: 'acum', minutesAgo: '{{n}}m in urma', hoursAgo: '{{n}}h in urma', daysAgo: '{{n}}z in urma',
5878
days: 'z', hours: 'h', minutes: 'm', seconds: 's',

public/js/pages/settings.js

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,13 +1560,81 @@ const SettingsPage = {
15601560
</table>
15611561
</div>
15621562
</div>
1563+
1564+
<div class="card" style="max-width:600px;margin-top:16px">
1565+
<div class="card-header"><h3><i class="fas fa-arrow-up" style="color:var(--accent);margin-right:8px"></i>${i18n.t('updates.settingTitle')}</h3></div>
1566+
<div class="card-body">
1567+
<p class="text-muted text-sm" style="margin-bottom:14px">${i18n.t('updates.settingDesc')}</p>
1568+
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;margin-bottom:14px">
1569+
<input type="checkbox" id="upd-enabled">
1570+
<span>${i18n.t('updates.settingEnabled')}</span>
1571+
</label>
1572+
<div id="upd-status-row" style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;font-size:12px;color:var(--text-dim)">
1573+
<span id="upd-status-text">…</span>
1574+
<button class="btn btn-sm" id="upd-check-now"><i class="fas fa-sync"></i> ${i18n.t('updates.checkNow')}</button>
1575+
</div>
1576+
</div>
1577+
</div>
15631578
`;
15641579

15651580
// Fetch version dynamically
15661581
Api.get('/health').then(h => {
15671582
const vEl = el.querySelector('#settings-version');
15681583
if (vEl && h.version) vEl.innerHTML = `v${h.version} <i class="fas fa-bullhorn" style="font-size:10px"></i>`;
15691584
}).catch(() => {});
1585+
1586+
// Update-check section (v7.3.0)
1587+
const renderUpdStatus = (status) => {
1588+
const text = el.querySelector('#upd-status-text');
1589+
if (!text || !status) return;
1590+
const last = status.lastChecked
1591+
? new Date(status.lastChecked).toLocaleString()
1592+
: i18n.t('updates.never');
1593+
if (status.hasUpdate) {
1594+
text.innerHTML = `<a href="#" id="upd-open-modal" style="color:var(--accent)"><i class="fas fa-arrow-up"></i> ${i18n.t('updates.modalTitle')}: ${Utils.escapeHtml(status.latest)}</a> · ${i18n.t('updates.lastChecked')}: ${Utils.escapeHtml(last)}`;
1595+
text.querySelector('#upd-open-modal')?.addEventListener('click', (e) => {
1596+
e.preventDefault();
1597+
window.UpdateNotifier?.openModal();
1598+
});
1599+
} else if (status.enabled) {
1600+
text.innerHTML = `<i class="fas fa-check" style="color:var(--green)"></i> ${i18n.t('updates.upToDate')} · ${i18n.t('updates.lastChecked')}: ${Utils.escapeHtml(last)}`;
1601+
} else {
1602+
text.innerHTML = `<i class="fas fa-pause" style="color:var(--text-dim)"></i> ${i18n.t('common.disabled')}`;
1603+
}
1604+
};
1605+
1606+
try {
1607+
const status = await Api.get('/system/update-check');
1608+
el.querySelector('#upd-enabled').checked = !!status.enabled;
1609+
renderUpdStatus(status);
1610+
} catch { /* anonymous-only? unlikely on a settings page */ }
1611+
1612+
el.querySelector('#upd-enabled').addEventListener('change', async (e) => {
1613+
const enabled = e.target.checked;
1614+
try {
1615+
await window.UpdateNotifier.setEnabled(enabled);
1616+
Toast.success(enabled ? i18n.t('common.enabled') : i18n.t('common.disabled'));
1617+
const status = await Api.get('/system/update-check');
1618+
renderUpdStatus(status);
1619+
} catch (err) { Toast.error(err.message); }
1620+
});
1621+
1622+
el.querySelector('#upd-check-now').addEventListener('click', async (e) => {
1623+
const btn = e.currentTarget;
1624+
const original = btn.innerHTML;
1625+
btn.disabled = true;
1626+
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${i18n.t('updates.checking')}`;
1627+
try {
1628+
const result = await window.UpdateNotifier.forceRefresh();
1629+
renderUpdStatus(result.status);
1630+
Toast.success(i18n.t('updates.refreshed'));
1631+
} catch (err) {
1632+
Toast.error(`${i18n.t('updates.checkFailed')}: ${err.message}`);
1633+
} finally {
1634+
btn.disabled = false;
1635+
btn.innerHTML = original;
1636+
}
1637+
});
15701638
},
15711639

15721640
_showHelp() {

public/js/update-notifier.js

8.48 KB
Binary file not shown.

0 commit comments

Comments
 (0)