diff --git a/src/apps.js b/src/apps.js index da4d78f50..a3bcf4816 100644 --- a/src/apps.js +++ b/src/apps.js @@ -1208,6 +1208,7 @@ async function onTaskFinished(error, appId, installationState, taskId, auditSour const toManifest = success ? app.manifest : task.args[1].updateConfig.manifest; await eventlog.add(eventlog.ACTION_APP_UPDATE_FINISH, auditSource, { app, toManifest, fromManifest, success, errorMessage }); + await notifications.unpin(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, { context: app.id }); break; } case exports.ISTATE_PENDING_BACKUP: { @@ -2786,7 +2787,8 @@ async function autoupdateApps(updateInfo, auditSource) { // updateInfo is { appI if (!canAutoupdateApp(app, updateInfo[appId])) { debug(`app ${app.fqdn} requires manual update`); - notifications.alert(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, `${app.manifest.title} at ${app.fqdn} requires manual update to version ${updateInfo[appId].manifest.version}`, `Changelog:\n${updateInfo[appId].manifest.changelog}\n`, { persist: false }); + notifications.pin(notifications.TYPE_MANUAL_APP_UPDATE_NEEDED, `${app.manifest.title} at ${app.fqdn} requires manual update to version ${updateInfo[appId].manifest.version}`, + `Changelog:\n${updateInfo[appId].manifest.changelog}\n`, { context: app.id }); continue; } diff --git a/src/mail.js b/src/mail.js index a26f89383..552519c9f 100644 --- a/src/mail.js +++ b/src/mail.js @@ -1210,8 +1210,8 @@ async function resolveList(listName, listDomain) { async function checkStatus() { const result = await checkConfiguration(); if (result.status) { - await notifications.clearAlert(notifications.TYPE_MAIL_STATUS, 'Email is not configured properly'); + await notifications.unpin(notifications.TYPE_MAIL_STATUS, {}); } else { - await notifications.alert(notifications.TYPE_MAIL_STATUS, 'Email is not configured properly', result.message, { persist: true }); + await notifications.pin(notifications.TYPE_MAIL_STATUS, 'Email is not configured properly', result.message, {}); } } diff --git a/src/notifications.js b/src/notifications.js index b13f7ac98..a9a4d8a23 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -14,18 +14,21 @@ exports = module.exports = { TYPE_CLOUDRON_UPDATE_FAILED: 'cloudronUpdateFailed', TYPE_CERTIFICATE_RENEWAL_FAILED: 'certificateRenewalFailed', TYPE_BACKUP_CONFIG: 'backupConfig', - TYPE_DISK_SPACE: 'diskSpace', - TYPE_MAIL_STATUS: 'mailStatus', - TYPE_REBOOT: 'reboot', - TYPE_BOX_UPDATE: 'boxUpdate', - TYPE_UPDATE_UBUNTU: 'ubuntuUpdate', - TYPE_MANUAL_APP_UPDATE_NEEDED: 'manualAppUpdate', TYPE_APP_OOM: 'appOutOfMemory', TYPE_APP_UPDATED: 'appUpdated', TYPE_BACKUP_FAILED: 'backupFailed', - alert, - clearAlert, + // these are singleton types allowed in pin() and unpin() + TYPE_DISK_SPACE: 'diskSpace', + TYPE_MAIL_STATUS: 'mailStatus', + TYPE_REBOOT: 'reboot', + TYPE_UPDATE_UBUNTU: 'ubuntuUpdate', + TYPE_BOX_UPDATE: 'boxUpdate', + TYPE_MANUAL_APP_UPDATE_NEEDED: 'manualAppUpdate-', + + // these work off singleton types + pin, + unpin, // exported for testing _add: add @@ -38,6 +41,7 @@ const assert = require('assert'), constants = require('./constants.js'), dashboard = require('./dashboard.js'), database = require('./database.js'), + debug = require('debug')('box:notifications'), eventlog = require('./eventlog.js'), mailer = require('./mailer.js'), users = require('./users.js'); @@ -73,10 +77,10 @@ async function get(id) { return postProcess(result[0]); } -async function getByTitle(title) { - assert.strictEqual(typeof title, 'string'); +async function getByType(type) { + assert.strictEqual(typeof type, 'string'); - const results = await database.query(`SELECT ${NOTIFICATION_FIELDS} from notifications WHERE title = ? ORDER BY creationTime LIMIT 1`, [ title ]); + const results = await database.query(`SELECT ${NOTIFICATION_FIELDS} from notifications WHERE type = ? ORDER BY creationTime LIMIT 1`, [ type ]); if (results.length === 0) return null; return postProcess(results[0]); @@ -235,36 +239,38 @@ async function backupFailed(eventId, taskId, errorMessage) { } } -// type must be one of TYPE_* -async function alert(type, title, message, options) { - assert.strictEqual(typeof type, 'string'); +async function pin(type, title, message, options) { + assert.strictEqual(typeof type, 'string'); // TYPE_ assert.strictEqual(typeof title, 'string'); assert.strictEqual(typeof message, 'string'); assert.strictEqual(typeof options, 'object'); - const result = await getByTitle(title); + // these are singletons. only one notification of such a type can be there + if (type !== exports.TYPE_DISK_SPACE && type !== exports.TYPE_MAIL_STATUS && type !== exports.TYPE_REBOOT && type !== exports.TYPE_UPDATE_UBUNTU && + type !== exports.TYPE_BOX_UPDATE && type !== exports.TYPE_MANUAL_APP_UPDATE_NEEDED) { + debug(`pin: notification of ${type} cannot be pinned`); + return null; + } + const isUpdateType = type === exports.TYPE_BOX_UPDATE || type === exports.TYPE_MANUAL_APP_UPDATE_NEEDED; + if (options.context) type = `${type}-${options.context}`; // create a unique type for this context + + const result = await getByType(type); if (!result) return await add(type, title, message, { eventId: null }); - if (!options.persist) return result.id; - await update(result, { - id: result.id, - eventId: null, - type: type, - title, - message, - acknowledged: false, - creationTime: new Date() - }); + // do not reset the ack state if user has already seen the update notification + const acknowledged = (isUpdateType && result.message === message) ? result.acknowledged : false; + + await update(result, { id: result.id, title, message, acknowledged, creationTime: new Date() }); return result.id; } -// id is unused but nice to search code -async function clearAlert(type) { - assert.strictEqual(typeof type, 'string'); +async function unpin(type, options) { + assert.strictEqual(typeof type, 'string'); // TYPE_ + assert.strictEqual(typeof options, 'object'); - await database.query('DELETE FROM notifications WHERE type = ?', [ type ]); - return null; + if (options.context) type = `${type}-${options.context}`; // create a unique type for this context + await database.query('UPDATE notifications SET acknowledged=1 WHERE type = ?', [ type ]); } async function onEvent(id, action, source, data) { diff --git a/src/system.js b/src/system.js index b06280e06..15d4d2bc1 100644 --- a/src/system.js +++ b/src/system.js @@ -198,9 +198,9 @@ async function checkDiskSpace() { if (markdownMessage) { const finalMessage = `One or more file systems are running out of space. Please increase the disk size at the earliest.\n\n${markdownMessage}`; - await notifications.alert(notifications.TYPE_DISK_SPACE, 'Server is running out of disk space', finalMessage, { persist: true }); + await notifications.pin(notifications.TYPE_DISK_SPACE, 'Server is running out of disk space', finalMessage, {}); } else { - await notifications.clearAlert(notifications.TYPE_DISK_SPACE, 'Server is running out of disk space'); + await notifications.unpin(notifications.TYPE_DISK_SPACE); } } @@ -275,7 +275,7 @@ async function updateDiskUsage(progressCallback) { } async function reboot() { - await notifications.clearAlert(notifications.TYPE_REBOOT, 'Reboot Required'); + await notifications.unpin(notifications.TYPE_REBOOT, {}); const [error] = await safe(shell.promises.sudo([ REBOOT_CMD ], {})); if (error) debug('reboot: could not reboot. %o', error); @@ -349,9 +349,9 @@ async function getBlockDevices() { async function checkRebootRequired() { const { rebootRequired } = await getInfo(); if (rebootRequired) { - await notifications.alert(notifications.TYPE_REBOOT, 'Reboot Required', 'To finish ubuntu security updates, a reboot is necessary.', { persist: true }); + await notifications.pin(notifications.TYPE_REBOOT, 'Reboot Required', 'To finish ubuntu security updates, a reboot is necessary.', {}); } else { - await notifications.clearAlert(notifications.TYPE_REBOOT, 'Reboot Required'); + await notifications.unpin(notifications.TYPE_REBOOT, {}); } } @@ -365,7 +365,7 @@ async function checkUbuntuVersion() { const isBionic = fs.readFileSync('/etc/lsb-release', 'utf-8').includes('18.04'); if (!isBionic) return; - await notifications.alert(notifications.TYPE_UPDATE_UBUNTU, 'Ubuntu upgrade required', 'Ubuntu 18.04 has reached end of life and will not receive security and maintenance updates. Please follow https://docs.cloudron.io/guides/upgrade-ubuntu-20/ to upgrade to Ubuntu 20 at the earliest.', { persist: true }); + await notifications.pin(notifications.TYPE_UPDATE_UBUNTU, 'Ubuntu upgrade required', 'Ubuntu 18.04 has reached end of life and will not receive security and maintenance updates. Please follow https://docs.cloudron.io/guides/upgrade-ubuntu-20/ to upgrade to Ubuntu 20 at the earliest.', {}); } async function runSystemChecks() { diff --git a/src/test/notifications-test.js b/src/test/notifications-test.js index c6ff3198e..3fb2a9172 100644 --- a/src/test/notifications-test.js +++ b/src/test/notifications-test.js @@ -85,45 +85,76 @@ describe('Notifications', function () { expect(error.reason).to.be(BoxError.NOT_FOUND); }); - let alertId; - it('can add alert', async function () { - alertId = await notifications.alert(notifications.TYPE_BOX_UPDATE, 'Cloudron xx is available', 'Awesome changelog', { persist: true }); + let pinId; + describe('pin with context', function () { + it('can add pin', async function () { + pinId = await notifications.pin(notifications.TYPE_BOX_UPDATE, 'Cloudron xx is available', 'Awesome changelog', { context: 'xx' }); - const result = await notifications.get(alertId); - expect(result.title).to.be('Cloudron xx is available'); - expect(result.message).to.be('Awesome changelog'); - expect(result.acknowledged).to.be(false); + const result = await notifications.get(pinId); + expect(result.title).to.be('Cloudron xx is available'); + expect(result.message).to.be('Awesome changelog'); + expect(result.acknowledged).to.be(false); + }); + + it('updating pin with same message does nothing', async function () { + await notifications.update({ id: pinId }, { acknowledged: true }); // ack the alert + + const id = await notifications.pin(notifications.TYPE_BOX_UPDATE, 'Cloudron xx is available', 'Awesome changelog', { context: 'xx' }); + expect(id).to.be(pinId); + + const result = await notifications.get(pinId); + expect(result.title).to.be('Cloudron xx is available'); + expect(result.message).to.be('Awesome changelog'); + expect(result.acknowledged).to.be(true); // notification does not resurface + }); + + it('updating pin with new message resurfaces', async function () { + await notifications.update({ id: pinId }, { acknowledged: true }); // ack the alert + + const id = await notifications.pin(notifications.TYPE_BOX_UPDATE, 'Cloudron xy is available', 'Awesome new changelog', { context: 'xx' }); + expect(id).to.be(pinId); + + const result = await notifications.get(pinId); + expect(result.title).to.be('Cloudron xy is available'); + expect(result.message).to.be('Awesome new changelog'); + expect(result.acknowledged).to.be(false); // notification resurfaces + }); + + it('can unpin', async function () { + await notifications.unpin(notifications.TYPE_BOX_UPDATE, { context: 'xx' }); + + const result = await notifications.get(pinId); + expect(result.acknowledged).to.be(true); + }); }); - it('can update the alert (persist)', async function () { - await notifications.update({ id: alertId }, { acknowledged: true }); // ack the alert + describe('pin without context', function () { + it('can add pin', async function () { + pinId = await notifications.pin(notifications.TYPE_REBOOT, 'Reboot required', 'Do it now', {}); - const id = await notifications.alert(notifications.TYPE_BOX_UPDATE, 'Cloudron xx is available', 'Awesome new changelog', { persist: true }); - expect(id).to.be(alertId); + const result = await notifications.get(pinId); + expect(result.title).to.be('Reboot required'); + expect(result.message).to.be('Do it now'); + expect(result.acknowledged).to.be(false); + }); - const result = await notifications.get(alertId); - expect(result.title).to.be('Cloudron xx is available'); - expect(result.message).to.be('Awesome new changelog'); - expect(result.acknowledged).to.be(false); // notification resurfaces - }); + it('can update the alert', async function () { + await notifications.update({ id: pinId }, { acknowledged: true }); // ack the alert - it('can update the alert (non-persist)', async function () { - await notifications.update({ id: alertId }, { acknowledged: true }); // ack the alert + const id = await notifications.pin(notifications.TYPE_REBOOT, 'Reboot required', 'Do it now', {}); + expect(id).to.be(pinId); - const id = await notifications.alert(notifications.TYPE_BOX_UPDATE, 'Cloudron xx is available', 'Awesome new changelog', { persist: false }); - expect(id).to.be(alertId); + const result = await notifications.get(pinId); + expect(result.title).to.be('Reboot required'); + expect(result.message).to.be('Do it now'); + expect(result.acknowledged).to.be(false); // resurfaces + }); - const result = await notifications.get(alertId); - expect(result.title).to.be('Cloudron xx is available'); - expect(result.message).to.be('Awesome new changelog'); - expect(result.acknowledged).to.be(true); // notification does not resurface - }); + it('can unpin', async function () { + await notifications.unpin(notifications.TYPE_REBOOT, {}); - it('can clear the alert', async function () { - const id = await notifications.clearAlert(notifications.TYPE_BOX_UPDATE); - expect(id).to.be(null); - - const result = await notifications.get(alertId); - expect(result).to.be(null); + const result = await notifications.get(pinId); + expect(result.acknowledged).to.be(true); + }); }); }); diff --git a/src/updatechecker.js b/src/updatechecker.js index 7bc04d044..c91cafb93 100644 --- a/src/updatechecker.js +++ b/src/updatechecker.js @@ -86,7 +86,7 @@ async function checkBoxUpdates(options) { const changelog = updateInfo.changelog.map((m) => `* ${m}\n`).join(''); const message = `Changelog:\n${changelog}\n\nGo to the Settings view to update.\n\n`; - await notifications.alert(notifications.TYPE_BOX_UPDATE, `Cloudron v${updateInfo.version} is available`, message, { persist: false }); + await notifications.pin(notifications.TYPE_BOX_UPDATE, `Cloudron v${updateInfo.version} is available`, message, { context: updateInfo.version }); state.box = updateInfo; setUpdateInfo(state); diff --git a/src/updater.js b/src/updater.js index abef21011..697cf6a27 100644 --- a/src/updater.js +++ b/src/updater.js @@ -25,6 +25,7 @@ const apps = require('./apps.js'), eventlog = require('./eventlog.js'), fs = require('fs'), locks = require('./locks.js'), + notifications = require('./notifications.js'), os = require('os'), path = require('path'), paths = require('./paths.js'), @@ -236,6 +237,7 @@ async function notifyUpdate() { await eventlog.add(eventlog.ACTION_INSTALL_FINISH, AuditSource.CRON, { version: constants.VERSION }); } else { await eventlog.add(eventlog.ACTION_UPDATE_FINISH, AuditSource.CRON, { errorMessage: '', oldVersion: version || 'dev', newVersion: constants.VERSION }); + await notifications.unpin(notifications.TYPE_BOX_UPDATE, { context: constants.VERSION }); const [error] = await safe(tasks.setCompletedByType(tasks.TASK_UPDATE, { error: null })); if (error && error.reason !== BoxError.NOT_FOUND) throw error; // when hotfixing, task may not exist }