+
+
+
+
+ {{ 'app.updates.info.description' | tr }}
+
+
+ {{ app.manifest.title }} {{ app.upstreamVersion }}
+ {{ app.manifest.dockerImage }}
+
+
+
+
+
+ {{ 'app.updates.info.appId' | tr }}
+
+
+ {{ app.id }}
+
+
+
+
+
+ {{ 'app.updates.info.packageVersion' | tr }}
+
+
+
+
+
+
+ {{ 'app.updates.info.lastUpdated' | tr }}
+
+
+ {{ app.updateTime | prettyDate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -753,8 +818,7 @@
-
-
+
diff --git a/dashboard/src/views/app.js b/dashboard/src/views/app.js
index b5321137f..ea78f50fb 100644
--- a/dashboard/src/views/app.js
+++ b/dashboard/src/views/app.js
@@ -136,6 +136,43 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
};
+ $scope.info = {
+ notes: {
+ busy: false,
+ editing: false,
+ content: '',
+
+ edit: function () {
+ $scope.info.notes.busy = false;
+ $scope.info.notes.content = $scope.app.notes;
+ $scope.info.notes.editing = true;
+ },
+
+ dismiss: function () {
+ $scope.info.notes.busy = false;
+ $scope.info.notes.content = $scope.app.notes;
+ $scope.info.notes.editing = false;
+ },
+
+ submit: function () {
+ $scope.info.notes.busy = true;
+
+ Client.configureApp($scope.app.id, 'notes', { notes: $scope.info.notes.content }, function (error) {
+ if (error) return console.error('Failed to save notes.', error);
+
+ $scope.info.notes.busy = false;
+ $scope.info.notes.editing = false;
+ });
+ }
+ },
+
+ show: function () {
+ $scope.info.notes.busy = false;
+ $scope.info.notes.content = $scope.app.notes;
+ $scope.info.notes.editing = false;
+ }
+ };
+
$scope.display = {
busy: false,
error: {},
diff --git a/migrations/20240410151221-apps-add-notes.js b/migrations/20240410151221-apps-add-notes.js
new file mode 100644
index 000000000..2d6ad8b37
--- /dev/null
+++ b/migrations/20240410151221-apps-add-notes.js
@@ -0,0 +1,9 @@
+'use strict';
+
+exports.up = async function (db) {
+ await db.runSql('ALTER TABLE apps ADD COLUMN notes TEXT');
+};
+
+exports.down = async function (db) {
+ await db.runSql('ALTER TABLE apps DROP COLUMN notes');
+};
diff --git a/migrations/schema.sql b/migrations/schema.sql
index 266284f2b..ada41acfc 100644
--- a/migrations/schema.sql
+++ b/migrations/schema.sql
@@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS apps(
inboxName VARCHAR(128), // mailbox of this app
inboxDomain VARCHAR(128), // mailbox domain of this app
label VARCHAR(128), // display name
+ notes TEXT, // free form notes for admins
tagsJson VARCHAR(2048), // array of tags
storageVolumeId VARCHAR(128),
storageVolumePrefix VARCHAR(128),
diff --git a/src/apps.js b/src/apps.js
index 92b75b527..c5aa0c902 100644
--- a/src/apps.js
+++ b/src/apps.js
@@ -31,6 +31,7 @@ exports = module.exports = {
setLabel,
setIcon,
setTags,
+ setNotes,
setMemoryLimit,
setCpuQuota,
setMounts,
@@ -184,7 +185,7 @@ const appstore = require('./appstore.js'),
const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.errorJson', 'apps.runState',
'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.accessRestrictionJson', 'apps.memoryLimit', 'apps.cpuQuota',
- 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', 'apps.operatorsJson',
+ 'apps.label', 'apps.notes', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', 'apps.operatorsJson',
'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.crontab',
'apps.creationTime', 'apps.updateTime', 'apps.enableAutomaticUpdate', 'apps.upstreamUri',
'apps.enableMailbox', 'apps.mailboxDisplayName', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableInbox', 'apps.inboxName', 'apps.inboxDomain',
@@ -574,7 +575,7 @@ function removeInternalFields(app) {
'subdomain', 'domain', 'fqdn', 'certificate', 'crontab', 'upstreamUri',
'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuQuota', 'operators',
'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags',
- 'label', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate',
+ 'label', 'notes', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate',
'storageVolumeId', 'storageVolumePrefix', 'mounts', 'enableTurn', 'enableRedis',
'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain');
@@ -587,7 +588,7 @@ function removeRestrictedFields(app) {
const result = _.pick(app,
'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction',
'secondaryDomains', 'redirectDomains', 'aliasDomains', 'sso', 'subdomain', 'domain', 'fqdn', 'certificate',
- 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup', 'upstreamUri');
+ 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'notes', 'enableBackup', 'upstreamUri');
removeCertificateKeys(result);
return result;
@@ -1527,6 +1528,15 @@ async function setTags(app, tags, auditSource) {
await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, tags });
}
+async function setNotes(app, notes, auditSource) {
+ assert.strictEqual(typeof app, 'object');
+ assert.strictEqual(typeof notes, 'string');
+ assert.strictEqual(typeof auditSource, 'object');
+
+ await update(app.id, { notes });
+ await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: app.id, app, notes });
+}
+
async function setIcon(app, icon, auditSource) {
assert.strictEqual(typeof app, 'object');
assert(icon === null || typeof icon === 'string');
diff --git a/src/routes/apps.js b/src/routes/apps.js
index 21fae5417..2d8b4686f 100644
--- a/src/routes/apps.js
+++ b/src/routes/apps.js
@@ -23,6 +23,7 @@ exports = module.exports = {
setCrontab,
setLabel,
setTags,
+ setNotes,
setIcon,
setTurn,
setRedis,
@@ -257,6 +258,18 @@ async function setTags(req, res, next) {
next(new HttpSuccess(200, {}));
}
+async function setNotes(req, res, next) {
+ assert.strictEqual(typeof req.body, 'object');
+ assert.strictEqual(typeof req.app, 'object');
+
+ if (typeof req.body.notes !== 'string') return next(new HttpError(400, 'notes must be a string'));
+
+ const [error] = await safe(apps.setNotes(req.app, req.body.notes, AuditSource.fromRequest(req)));
+ if (error) return next(BoxError.toHttpError(error));
+
+ next(new HttpSuccess(200, {}));
+}
+
async function setIcon(req, res, next) {
assert.strictEqual(typeof req.body, 'object');
assert.strictEqual(typeof req.app, 'object');
diff --git a/src/server.js b/src/server.js
index 55eeea528..dc1cb2a85 100644
--- a/src/server.js
+++ b/src/server.js
@@ -243,6 +243,7 @@ async function initializeExpressSync() {
router.post('/api/v1/apps/:id/configure/label', json, token, routes.apps.load, authorizeOperator, routes.apps.setLabel);
router.post('/api/v1/apps/:id/configure/tags', json, token, routes.apps.load, authorizeOperator, routes.apps.setTags);
router.post('/api/v1/apps/:id/configure/icon', json, token, routes.apps.load, authorizeOperator, routes.apps.setIcon);
+ router.post('/api/v1/apps/:id/configure/notes', json, token, routes.apps.load, authorizeOperator, routes.apps.setNotes);
router.post('/api/v1/apps/:id/configure/memory_limit', json, token, routes.apps.load, authorizeOperator, routes.apps.setMemoryLimit);
router.post('/api/v1/apps/:id/configure/cpu_quota', json, token, routes.apps.load, authorizeOperator, routes.apps.setCpuQuota);
router.post('/api/v1/apps/:id/configure/automatic_backup', json, token, routes.apps.load, authorizeOperator, routes.apps.setAutomaticBackup);