From 1983f78fba9134fd717fdf57749f4aca9dc8bad3 Mon Sep 17 00:00:00 2001 From: Romein van Buren Date: Wed, 22 Jun 2022 17:22:19 +0200 Subject: [PATCH] Initial commit Signed-off-by: Romein van Buren --- .gitignore | 6 + entities/webservice.js | 457 +++++++++++++++++++ entities/webserviceheartbeat.js | 71 +++ entities/webserviceoutage.js | 424 +++++++++++++++++ gui/components/formautotestfield.svelte | 268 +++++++++++ gui/components/formoutagetablefield.svelte | 152 +++++++ gui/modules/webservicemonitor.svelte | 176 ++++++++ gui/modules/webserviceoutages.svelte | 276 ++++++++++++ gui/modules/webservices.svelte | 276 ++++++++++++ gui/widgets/webservicestatus.svelte | 129 ++++++ index.js | 501 +++++++++++++++++++++ lib/operators.js | 25 + lib/realvalues.js | 21 + lib/testendpoints.js | 66 +++ lib/testservice.js | 138 ++++++ lib/testservices.js | 19 + lib/utils.js | 11 + 17 files changed, 3016 insertions(+) create mode 100644 .gitignore create mode 100644 entities/webservice.js create mode 100644 entities/webserviceheartbeat.js create mode 100644 entities/webserviceoutage.js create mode 100644 gui/components/formautotestfield.svelte create mode 100644 gui/components/formoutagetablefield.svelte create mode 100644 gui/modules/webservicemonitor.svelte create mode 100644 gui/modules/webserviceoutages.svelte create mode 100644 gui/modules/webservices.svelte create mode 100644 gui/widgets/webservicestatus.svelte create mode 100644 index.js create mode 100644 lib/operators.js create mode 100644 lib/realvalues.js create mode 100644 lib/testendpoints.js create mode 100644 lib/testservice.js create mode 100644 lib/testservices.js create mode 100644 lib/utils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..508a790 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +node_modules +/cache +config.custom.js +config.temp.js +package-lock.json diff --git a/entities/webservice.js b/entities/webservice.js new file mode 100644 index 0000000..5fb8dcf --- /dev/null +++ b/entities/webservice.js @@ -0,0 +1,457 @@ +'use strict'; + +const { makeId } = require('core/makeid'); + +const states = { + concept: 'concept', + online: 'online', + offline: 'offline', +}; + +module.exports = { + format: 5, + author: 'Romein van Buren', + vendor: 'Smart Yellow', + purpose: 'Store web services for use on a status page', + store: 'webservices', + + forms: ({ settings }) => ({ + default: { + pages: [ + { label: 'meta', + sections: [ + 'id', + 'state', + 'name', + 'tags', + 'channels', + ], + }, + { label: 'description', + sections: [ + 'summary', + 'visual', + 'body', + ], + }, + { label: 'auto testing', + sections: [ + 'autotestEnabled', + 'lastChecked', + 'autotest', + ], + }, + { label: 'statistics', + sections: [ + 'outageStats', + 'outageTable', + ], + }, + ], + + sections: { + id: { + label: 'id', + fields: [ + { key: 'id', + editor: 'string', + validator: '', + visible: false, + readonly: true, + }, + ], + }, + + state: { + label: 'status', + fields: [ + { key: 'state', + editor: 'select', + options: states, + translate: true, + }, + ], + }, + + name: { + label: 'name', + fields: [ + { key: 'name', + editor: 'string', + placeholder: 'service name', + localized: true, + }, + ], + }, + + tags: { + label: 'tags', + fields: [ + { key: 'tags', + editor: 'multiselect', + placeholder: 'enter tags...', + visible: !!(settings.serviceTags && Object.keys(settings.serviceTags).length), + options: settings.serviceTags, + }, + ], + }, + + channels: { + label: 'channels', + fields: [ + { key: 'channels', + editor: 'multiselect', + placeholder: 'enter channels...', + visible: !!(settings.channels && Object.keys(settings.channels).length), + options: settings.channels, + }, + ], + }, + + summary: { + label: 'summary', + fields: [ + { key: 'summary', + editor: 'text', + localized: true, + }, + ], + }, + + body: { + label: 'body', + fields: [ + { key: 'body', + editor: 'text', + type: 'string', + localized: true, + markup: true, + }, + ], + }, + + visual: { + label: 'visual', + fields: [ + { key: 'visual', + editor: 'file', + accept: [ 'image/*' ], + }, + ], + }, + + lastChecked: { + label: 'status last checked on', + fields: [ + { key: 'lastChecked', + editor: 'date', + readonly: true, + format: 'datetime', + }, + ], + }, + + outageStats: { + label: 'number of outages', + fields: [ + { key: 'outages', + editor: 'string', + visible: ({ newEntity }) => !newEntity, + readonly: true, + placeholder: 'all outages', + default: 0, + }, + + { key: 'outagesOpen', + editor: 'string', + visible: ({ newEntity }) => !newEntity, + readonly: true, + label: 'open', + placeholder: '0', + }, + + { key: 'outagesResolved', + editor: 'string', + visible: ({ newEntity }) => !newEntity, + readonly: true, + label: 'closed', + placeholder: '0', + }, + ], + }, + + outageTable: { + label: 'outage list', + fields: [ + { key: 'id', + editor: 'smartyellow/outagetable', + }, + ], + }, + + autotestEnabled: { + label: 'autotesting enabled?', + fields: [ + { key: 'autotestEnabled', + editor: 'checkbox', + }, + ], + }, + + autotest: { + label: 'endpoint requirements', + fields: [ + { key: 'autotest', + editor: 'smartyellow/autotest', + }, + ], + }, + }, + }, + }), + + schema: ({ settings }) => ({ + id: { + type: 'string', + required: ({ newEntity }) => newEntity, + lowercase: true, + trim: true, + filter: { + title: 'id', + match: '^[a-zA-Z0-9]{6}$', + order: 999, + }, + default: () => makeId(6), + validate: async ({ newValues, oldValues, newEntity, storage }) => { + if (newEntity) { + const r = storage ? await storage.store('webdesq/blog').get(newValues.id) : null; + return (r == null ? true : 'id already exists'); + } + else { + // ID cannot be changed if record was already created + return (newValues.id == oldValues.id ? true : 'id cannot be changed'); + } + }, + }, + + name: { + type: 'stringset', + trim: true, + required: [ true, 'Name is missing!' ], + filter: { + title: 'title', + match: '^[a-zA-Z0-9]*', + localized: true, + }, + format: { + label: 'name', + type: 'text', + sortable: 'string', + align: 'left', + minWidth: 150, + enabled: true, + priority: 1, + }, + }, + + state: { + type: 'string', + required: true, + default: 'concept', + filter: { + title: 'state', + options: states, + }, + format: { + label: 'state', + type: 'state', + align: 'left', + sortable: 'text', + sorted: 'down', + minWidth: 90, + enabled: true, + options: { + concept: { + name: 'concept', + class: 'l2', + }, + online: { + name: 'online', + class: 'l4', + }, + offline: { + name: 'offline', + class: 'l1', + }, + }, + }, + }, + + tags: { + type: 'array', + of: 'string', + default: [], + filter: { + title: 'tags', + match: '^[a-z]', + options: async ({ storage }) => (await storage.store('smartyellow/webservice').find({ 'log.deleted': { $exists: false } }, { keys: [ 'id', 'tags' ] }).sort({ name: 1 }).toArray()) + .reduce((acc, curr) => { + if (curr.tags) { + for (let i = 0; i < curr.tags.length; i++) { + acc[curr.tags[i]] = curr.tags[i]; + } + } + return acc; + }, {}), + }, + format: { + type: 'text', + label: 'tags', + sortable: 'text', + enabled: true, + minWidth: 100, + }, + }, + + channels: { + type: 'array', + of: 'string', + default: () => { + const keys = settings.channels ? Object.keys(settings.channels) : []; + if (keys.length == 1) { + return [ keys[0] ]; + } + else { + return []; + } + }, + filter: { + title: 'channel', + match: settings.channels && Object.keys(settings.channels).length ? '^[' + Object.keys(settings.channels).join('|') + ']' : null, + }, + validate: async ({ newValues }) => (newValues.channels.every(key => !!settings.channels[key]) ? true : 'One or more invalid channels'), + format: { + label: 'channels', + type: 'text', + sortable: 'text', + minWidth: 80, + enabled: true, + }, + }, + + summary: { + type: 'stringset', + default: '', + }, + + body: { + type: 'stringset', + default: '', + filter: { + title: 'message contains', + match: '[a-z0-9A-Z]*', + localized: true, + }, + }, + + visual: { + type: 'array', + of: [ 'string' ], + default: [], + skip: true, + onDataValid: async ({ newValues, storage, user }) => { + newValues.visual = newValues.visual || []; + for (let i = 0; i < newValues.visual.length; i++) { + if (newValues.visual[i].data) { + if (storage) { + // If storage is available, insert the new file into storage and collect id + const result = await storage({ user }).bucket('webdesq/media').insert({ + id: makeId(6), + filename: newValues.visual[i].name, + metadata: { + contentType: newValues.visual[i].type, + }, + }, newValues.visual[i].data) + .catch(error => { + if (error.code !== 'DUPLICATE_FILE') { + throw error; + } + newValues.visual[i] = error.file.id; + }); + if (result) { + newValues.visual[i] = result.id; + } + } + else { + // If no storage is available, remove slot by setting it to null + newValues.visual[i] = null; + } + } + // remove empty slots in photo array + newValues.visual = newValues.visual.filter(i => i != null); + newValues.visual = [ ...new Set(newValues.visual) ]; + } + }, + }, + + outages: { + type: 'computed', + default: 0, + format: { + label: 'outages', + type: 'number', + align: 'left', + sortable: 'number', + minWidth: 100, + enabled: true, + }, + generator: async ({ values, storage, user }) => { + const outages = await storage({ user }) + .store('smartyellow/webserviceoutage') + .find({ services: values.id }) + .toArray(); + return outages.length || 0; + }, + }, + + outagesOpen: { + type: 'computed', + default: 0, + format: { + label: 'open outages', + type: 'number', + align: 'left', + sortable: 'number', + minWidth: 100, + enabled: true, + }, + generator: async ({ values, storage, user }) => { + const outages = await storage({ user }) + .store('smartyellow/webserviceoutage') + .find({ services: values.id, resolved: false }) + .toArray(); + return outages.length || 0; + }, + }, + + outagesResolved: { + type: 'computed', + default: 0, + format: { + label: 'resolved outages', + type: 'number', + align: 'left', + sortable: 'number', + minWidth: 100, + enabled: true, + }, + generator: async ({ values, storage, user }) => { + const outages = await storage({ user }) + .store('smartyellow/webserviceoutage') + .find({ services: values.id, resolved: true }) + .toArray(); + return outages.length || 0; + }, + }, + }), +}; diff --git a/entities/webserviceheartbeat.js b/entities/webserviceheartbeat.js new file mode 100644 index 0000000..3430cde --- /dev/null +++ b/entities/webserviceheartbeat.js @@ -0,0 +1,71 @@ +'use strict'; + +const { makeId } = require('core/makeid'); + +module.exports = { + format: 5, + author: 'Romein van Buren', + vendor: 'Smart Yellow', + purpose: 'Store the heartbeat of web services', + store: 'webserviceheartbeat', + + schema: () => ({ + id: { + type: 'string', + required: ({ newEntity }) => newEntity, + lowercase: true, + trim: true, + filter: { + title: 'id', + match: '^[a-zA-Z0-9]{6}$', + order: 999, + }, + default: () => makeId(6), + validate: async ({ newValues, oldValues, newEntity, storage }) => { + if (newEntity) { + const r = storage ? await storage.store('webdesq/blog').get(newValues.id) : null; + return (r == null ? true : 'id already exists'); + } + else { + // ID cannot be changed if record was already created + return (newValues.id == oldValues.id ? true : 'id cannot be changed'); + } + }, + }, + + down: { + type: 'boolean', + default: true, + required: [ true, 'is the service up or down?' ], + format: { + type: 'state', + options: { + true: { + name: 'yes', + class: 'l1', + }, + false: { + name: 'no', + class: 'l5', + }, + }, + }, + }, + + webservice: { + type: 'string', + default: '', + required: [ true, 'webservice id is missing' ], + validate: async ({ storage, newValues }) => { + const r = storage ? await storage.store('smartyellow/webservice').get(newValues.webservice) : null; + return r == null ? 'service id does not exist' : true; + }, + }, + + date: { + type: 'date', + default: '', + required: [ true, 'date is missing' ], + }, + }), +}; diff --git a/entities/webserviceoutage.js b/entities/webserviceoutage.js new file mode 100644 index 0000000..8be6d7f --- /dev/null +++ b/entities/webserviceoutage.js @@ -0,0 +1,424 @@ +'use strict'; + +const { makeId } = require('core/makeid'); + +const states = { + concept: 'concept', + online: 'online', + offline: 'offline', +}; + +const severity = { + major: 'major outage', + minor: 'minor outage', + scheduled: 'scheduled maintenance', + none: 'no impact on end user', +}; + +module.exports = { + format: 5, + author: 'Romein van Buren', + vendor: 'Smart Yellow', + purpose: 'Keep track of web service outage', + store: 'webserviceoutage', + + forms: ({ settings }) => ({ + default: { + pages: [ + { label: 'meta', + sections: [ + 'id', + 'name', + 'state', + 'severity', + 'resolved', + 'services', + 'tags', + ], + }, + { label: 'description', + sections: [ + 'summary', + 'visual', + 'body', + ], + }, + { label: 'updates', + sections: [ 'updates' ], + }, + { label: 'internal notes', + sections: [ 'notes' ], + }, + ], + + sections: { + id: { + label: 'id', + fields: [ + { key: 'id', + editor: 'string', + validator: '', + visible: false, + readonly: true, + }, + ], + }, + + state: { + label: 'status', + fields: [ + { key: 'state', + editor: 'select', + options: states, + translate: true, + }, + ], + }, + + severity: { + label: 'severity', + fields: [ + { key: 'severity', + editor: 'select', + options: severity, + translate: true, + }, + ], + }, + + resolved: { + label: 'resolved?', + fields: [ + { key: 'resolved', + editor: 'checkbox', + }, + ], + }, + + services: { + label: 'services', + fields: [ + { key: 'services', + editor: 'multiselect', + placeholder: 'Add one or more web services this outage message belongs to.', + options: async ({ storage, user }) => await storage({ user }) + .store('smartyellow/webservice') + .find({ 'log.deleted': { $exists: false } }) + .toObject('id', 'name'), + }, + ], + }, + + name: { + label: 'name', + fields: [ + { key: 'name', + editor: 'string', + placeholder: 'a really brief description of the outage', + localized: true, + }, + ], + }, + + tags: { + label: 'tags', + fields: [ + { key: 'tags', + editor: 'multiselect', + placeholder: 'enter tags...', + visible: !!(settings.outageTags && Object.keys(settings.outageTags).length), + options: settings.outageTags, + }, + ], + }, + + summary: { + label: 'summary', + fields: [ + { key: 'summary', + editor: 'text', + localized: true, + }, + ], + }, + + body: { + label: 'body', + fields: [ + { key: 'body', + editor: 'text', + type: 'string', + localized: true, + markup: true, + }, + ], + }, + + visual: { + label: 'visual', + fields: [ + { key: 'visual', + editor: 'file', + accept: [ 'image/*' ], + }, + ], + }, + + updates: { + label: 'updates', + fields: [ + { key: 'updates', + editor: 'notes', + userId: true, + placeholder: 'updates', + }, + ], + }, + + notes: { + label: 'notes', + fields: [ + { key: 'notes', + editor: 'notes', + userId: true, + //localized: true, + placeholder: 'notes', + }, + ], + }, + }, + }, + }), + + schema: () => ({ + id: { + type: 'string', + required: ({ newEntity }) => newEntity, + lowercase: true, + trim: true, + filter: { + title: 'id', + match: '^[a-zA-Z0-9]{6}$', + order: 999, + }, + default: () => makeId(6), + validate: async ({ newValues, oldValues, newEntity, storage }) => { + if (newEntity) { + const r = storage ? await storage.store('webdesq/blog').get(newValues.id) : null; + return (r == null ? true : 'id already exists'); + } + else { + // ID cannot be changed if record was already created + return (newValues.id == oldValues.id ? true : 'id cannot be changed'); + } + }, + }, + + name: { + type: 'stringset', + trim: true, + required: [ true, 'Name is missing!' ], + filter: { + title: 'title', + match: '^[a-zA-Z0-9]*', + localized: true, + }, + format: { + label: 'name', + type: 'text', + sortable: 'string', + align: 'left', + minWidth: 150, + enabled: true, + priority: 1, + }, + }, + + state: { + type: 'string', + required: true, + default: 'concept', + filter: { + title: 'state', + options: states, + }, + format: { + label: 'state', + type: 'state', + align: 'left', + sortable: 'text', + sorted: 'down', + minWidth: 90, + enabled: true, + options: { + concept: { + name: 'concept', + class: 'l2', + }, + online: { + name: 'online', + class: 'l4', + }, + offline: { + name: 'offline', + class: 'l1', + }, + }, + }, + }, + + severity: { + type: 'string', + required: true, + default: '', + filter: { + title: 'severity', + options: severity, + }, + format: { + label: 'severity', + type: 'state', + align: 'left', + sortable: 'text', + minWidth: 90, + enabled: true, + options: { + major: { + name: 'major', + class: 'l1', + }, + minor: { + name: 'minor', + class: 'l2', + }, + scheduled: { + name: 'scheduled', + class: 'l3', + }, + none: { + name: 'no impact', + class: 'l4', + }, + }, + }, + }, + + resolved: { + type: 'boolean', + default: true, + format: { + type: 'checkbox', + sortable: 'boolean', + align: 'center', + label: 'resolved?', + minWidth: 90, + enabled: true, + + //type: 'state', + //options: { + // true: { + // name: 'yes', + // class: 'l5', + // }, + // false: { + // name: 'no', + // class: 'l1', + // }, + //}, + }, + }, + + tags: { + type: 'array', + of: 'string', + default: [], + filter: { + title: 'tags', + match: '^[a-z]', + options: async ({ storage }) => (await storage.store('smartyellow/webserviceoutage').find({ 'log.deleted': { $exists: false } }, { keys: [ 'id', 'tags' ] }).sort({ name: 1 }).toArray()) + .reduce((acc, curr) => { + if (curr.tags) { + for (let i = 0; i < curr.tags.length; i++) { + acc[curr.tags[i]] = curr.tags[i]; + } + } + return acc; + }, {}), + }, + format: { + type: 'text', + label: 'tags', + sortable: 'text', + enabled: true, + minWidth: 100, + }, + }, + + summary: { + type: 'stringset', + default: '', + }, + + body: { + type: 'stringset', + default: '', + filter: { + title: 'message contains', + match: '[a-z0-9A-Z]*', + localized: true, + }, + }, + + visual: { + type: 'array', + of: [ 'string' ], + default: [], + skip: true, + onDataValid: async ({ newValues, storage, user }) => { + newValues.visual = newValues.visual || []; + for (let i = 0; i < newValues.visual.length; i++) { + if (newValues.visual[i].data) { + if (storage) { + // If storage is available, insert the new file into storage and collect id + const result = await storage({ user }).bucket('webdesq/media').insert({ + id: makeId(6), + filename: newValues.visual[i].name, + metadata: { + contentType: newValues.visual[i].type, + }, + }, newValues.visual[i].data) + .catch(error => { + if (error.code !== 'DUPLICATE_FILE') { + throw error; + } + newValues.visual[i] = error.file.id; + }); + if (result) { + newValues.visual[i] = result.id; + } + } + else { + // If no storage is available, remove slot by setting it to null + newValues.visual[i] = null; + } + } + // remove empty slots in photo array + newValues.visual = newValues.visual.filter(i => i != null); + newValues.visual = [ ...new Set(newValues.visual) ]; + } + }, + }, + + notes: { + type: 'array', + of: {}, + default: [], + }, + + updates: { + type: 'array', + of: {}, + default: [], + }, + }), +}; diff --git a/gui/components/formautotestfield.svelte b/gui/components/formautotestfield.svelte new file mode 100644 index 0000000..2263166 --- /dev/null +++ b/gui/components/formautotestfield.svelte @@ -0,0 +1,268 @@ + + +{#if specs.label} + + {translate(specs.label, language)} + +{/if} + +{#each value as endpoint, iEndpoint (endpoint)} +
+
+ +
+ + +
+
+ + {#if endpoint.headers?.length > 0} + {translate('Headers', language)} + + + + + + + + + + {#each endpoint.headers as header, iHeader (header)} + + + + + + {/each} + +
{translate('Header name', language)}{translate('Value', language)}
+ + +
+ + +
+
+ {/if} + +
+ +
+ + {#if endpoint.requirements?.length > 0} + {translate('Requirements', language)} + + + + + + + + + + + + {#each endpoint.requirements as req, iReq (req)} + + + + + + + + + + {/each} + +
{translate('Real value', language)}{translate('Truth', language)}{translate('Operator', language)}{translate('Value', language)}
+ + + + + + +
+ + +
+
+ {/if} + +
+ +
+
+{/each} + +
+ +
+ + diff --git a/gui/components/formoutagetablefield.svelte b/gui/components/formoutagetablefield.svelte new file mode 100644 index 0000000..fb216f5 --- /dev/null +++ b/gui/components/formoutagetablefield.svelte @@ -0,0 +1,152 @@ + + +{#if specs.label} + + {translate(specs.label, language)} + +{/if} + +
+ + + + + + + + + + + + {#each outages as outage} + {@const name = outage.name[language] || outage.name.en} + + + + + + + {/each} + +
{translate('id', language)}{translate('name', language)}{translate('severity', language)}
{outage.id}{name} + {#if severity[outage.severity]} + + {severity[outage.severity]?.name} + + {:else} + + {translate('unclassified', language)} + + {/if} + + +
+
+ + diff --git a/gui/modules/webservicemonitor.svelte b/gui/modules/webservicemonitor.svelte new file mode 100644 index 0000000..860a6c4 --- /dev/null +++ b/gui/modules/webservicemonitor.svelte @@ -0,0 +1,176 @@ + + +{#if error} +
+ {translate('Failed to fetch fresh data', language)} +
+{/if} + +{#if mounted} + + + + + {#if webservices.length} +
+ {#each webservices as service} + {@const name = service.name[language] || service.name.en} +
+
{name}
+ +
+ {@html translate('Status last checked on: ', [ `${new Date(service.lastChecked).toLocaleString()}`, language ])} +
+ +
+ {#if service.heartbeat[service.heartbeat.length - 1]?.down == true} + DOWN + {:else} + UP + {/if} + + openWebService(name, service.id)}> + open + +
+
+ {/each} +
+ {:else} + No webservices + {/if} + +
+
+ +{:else} +

Loading...

+{/if} + + diff --git a/gui/modules/webserviceoutages.svelte b/gui/modules/webserviceoutages.svelte new file mode 100644 index 0000000..57a4d9e --- /dev/null +++ b/gui/modules/webserviceoutages.svelte @@ -0,0 +1,276 @@ + + + +{#if mounted} + + {#if item} + + + {#if !readonly} + + {/if} + {#if log && log.created && settings && settings.previewUrl} + +
  • Page preview
  • +
  • Website preview
  • +
    + {/if} + {#if form && form.languages} + + {#each form.languages as l} +
  • (l.enabled = !l.enabled)}> + {l.name} +
  • + {/each} +
    + {/if} + {#if log && log.created} + + {/if} + {#if log && log.created && user.can(pluginName + '/deleteServices')} + + {/if} + {#if user.is('sysadmin')} + + {/if} +
    + + + +
    + + {#if preview} + + + + {/if} + {#if history} + {#if selectedVersions} + + + + {/if} + + + + {/if} + {#if debug} + + + + {/if} + + + {:else} + + + shareQuery(e, api)} + bind:this={multifilter} + /> + {#if user.can(pluginName + '/createServices')} + openItem(e, true)} title="{translate('new', language)}" hint="{translate('Create new outage', language)}" /> + {/if} + + + + {/if} + +{/if} + + diff --git a/gui/modules/webservices.svelte b/gui/modules/webservices.svelte new file mode 100644 index 0000000..7f3fd7b --- /dev/null +++ b/gui/modules/webservices.svelte @@ -0,0 +1,276 @@ + + + +{#if mounted} + + {#if item} + + + {#if !readonly} + + {/if} + {#if log && log.created && settings && settings.previewUrl} + +
  • Page preview
  • +
  • Website preview
  • +
    + {/if} + {#if form && form.languages} + + {#each form.languages as l} +
  • (l.enabled = !l.enabled)}> + {l.name} +
  • + {/each} +
    + {/if} + {#if log && log.created} + + {/if} + {#if log && log.created && user.can(pluginName + '/deleteServices')} + + {/if} + {#if user.is('sysadmin')} + + {/if} +
    + + + + + + {#if preview} + + + + {/if} + {#if history} + {#if selectedVersions} + + + + {/if} + + + + {/if} + {#if debug} + + + + {/if} + + + {:else} + + + shareQuery(e, api)} + bind:this={multifilter} + /> + {#if user.can(pluginName + '/createServices')} + openItem(e, true)} title="{translate('new', language)}" hint="{translate('Create new web service', language)}" /> + {/if} + + + + {/if} + +{/if} + + diff --git a/gui/widgets/webservicestatus.svelte b/gui/widgets/webservicestatus.svelte new file mode 100644 index 0000000..d0ec2fa --- /dev/null +++ b/gui/widgets/webservicestatus.svelte @@ -0,0 +1,129 @@ + + +{#await promise} + {translate('Loading...', language)} +{:then data} + {@const servicesDown = data?.filter(d => d.heartbeat[d.heartbeat.length - 1]?.down == true)} + {@const servicesUp = data?.filter(d => d.heartbeat[d.heartbeat.length - 1]?.down != true)} + + {#if servicesDown?.length} +
    +
    + {@html icons.warning} +
    +
    + {translate('there are services down', language)} +
    + {#each servicesDown as service} +
    + {service.name[language] || service.name.en} +
    + {/each} +
    +
    +
    + {/if} + + {#if servicesUp?.length} +
    + {#if servicesDown.length} +
    + {translate('services up', language)} +
    + + {#each servicesUp as service} +
    + {service.name[language] || service.name.en} +
    + {/each} + {:else} +
    + {@html icons.check} +
    +
    + {translate('all services are up', language)} +
    + + {#each servicesUp as service} +
    + {service.name[language] || service.name.en} +
    + {/each} +
    +
    + {/if} +
    + {/if} +{:catch} +
    + {translate('Encountered an error while fetching web service data.', language)} +
    +{/await} + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..7ccaeb4 --- /dev/null +++ b/index.js @@ -0,0 +1,501 @@ +'use strict'; + +const { makeId } = require('core/makeid'); +const { testService } = require('./lib/testservice'); +const { testServices } = require('./lib/testservices'); + +const guiCluster = 'web service status'; +const icons = { + server: '', + outage: '', + checks: '', +}; + +module.exports = { + + // Friendly name + name: 'Status', + + // Brief description of this plugin + purpose: 'Keep track of the status of your web services automatically and get notified when a service is down.', + + // Version of this plugin + version: '1.0.0', + + // Name of the plugin author + author: 'Romein van Buren', + + // Name of vendor of this plugin + vendor: 'Smart Yellow', + + // Array of plugins this plugin depends on + requires: [ 'webdesq/storage' ], + + // Features this plugin offers + features: { + seeServices: { + description: 'See all web services', + }, + createServices: { + description: 'Create new web services', + requires: [ 'seeServices' ], + }, + editServices: { + description: 'Edit web services', + requires: [ 'seeServices' ], + }, + deleteServices: { + description: 'Delete web services', + requires: [ 'editServices' ], + }, + + seeOutages: { + description: 'See all outages', + requires: [ 'seeServices' ], + }, + createOutages: { + description: 'Create new outages', + requires: [ 'seeServices', 'seeOutages' ], + }, + editOutages: { + description: 'Edit outages', + requires: [ 'seeServices', 'seeOutages' ], + }, + deleteOutages: { + description: 'Delete outages', + requires: [ 'seeServices', 'editOutages' ], + }, + + seeMonitor: { + description: 'See the monitor', + }, + }, + + icon: icons.server, + + entities: { + webservice: 'webservice.js', + webserviceoutage: 'webserviceoutage.js', + webserviceheartbeat: 'webserviceheartbeat.js', + }, + + settings: { + autotestInterval: { + type: 'number', + label: 'autotest interval', + description: 'Autotest interval in minutes.', + default: 10, + }, + emailSender: { + type: 'string', + label: 'notification sender', + description: 'Sender of notifications about service statuses. Format: Name ', + default: '', + }, + emailRecipient: { + type: 'array', + label: 'notification recipients', + description: 'Recipients of notifications about service statuses. Format: Name ', + default: [], + }, + serviceTags: { + type: 'keys', + label: 'service tags', + description: 'Tags that can be assigned to web services to categorise them.', + default: {}, + }, + outageTags: { + type: 'keys', + label: 'outage tags', + description: 'Tags that can be assigned to outage messages to categorise them.', + default: {}, + }, + draftOutageEntries: { + type: 'boolean', + label: 'draft outage entries', + description: 'Automatically draft outage entry when a service is down.', + default: true, + }, + }, + + init: ({ server, settings }) => { + settings.autotestInterval = Number(settings.autotestInterval); + if (Number.isNaN(settings.autotestInterval)) { + server.warn('status: settings.autotestInterval is not a number. Using default value 10.'); + settings.autotestInterval = 10; + } + + return true; + }, + + gui: { + components: [ + 'formautotestfield.svelte', + 'formoutagetablefield.svelte', + ], + + modules: () => [ + { path: 'webservices.svelte', + requires: [ 'seeServices', 'editServices' ], + menu: { + cluster: guiCluster, + icon: icons.server, + title: 'web services', + }, + }, + + { path: 'webserviceoutages.svelte', + requires: [ 'seeServices', 'editServices' ], + menu: { + cluster: guiCluster, + icon: icons.outage, + title: 'outages', + }, + }, + + { path: 'webservicemonitor.svelte', + requires: [ 'seeMonitor' ], + menu: { + cluster: guiCluster, + icon: icons.checks, + title: 'monitor', + }, + }, + ], + + widgets: () => [ + { path: 'webservicestatus.svelte', + title: 'Web service status', + purpose: 'Monitor web service status', + defaults: { + title: 'Web service status', + }, + }, + ], + }, + + jobs: ({ server, settings }) => [ + { id: 'autotest', + purpose: 'Check whether services are up and send a notification if not.', + mandatory: false, + runAtBoot: true, + active: true, + interval: Number(settings.autotestInterval) * 60 * 1000, + action: () => testServices({ server, settings, makeId }), + }, + ], + + hooks: ({ server, settings }) => [ + { id: 'autotestOnSave', + order: 500, + event: 'saveEntity', + entity: [ 'smartyellow/webservice' ], + purpose: 'Check whether services are up and send a notification if not.', + handler: ({ item }) => testService({ + service: item, + server, + settings, + makeId, + }), + }, + ], + + routes: ({ server }) => [ + + // Get all services + { route: '/webservices', + method: 'get', + requires: 'smartyellow/status/seeServices', + handler: async (req, res, user) => { + const services = server.storage({ user }).store('smartyellow/webservice').find(); + const result = await (req.headers['format'] == 'object' ? services.toObject() : services.toArray()); + + if (req.headers['format'] == 'object') { + for (const service of Object.keys(result)) { + result[service].heartbeat = await server + .storage + .store('smartyellow/webserviceheartbeat') + .find({ service: service.id }) + .toArray(); + } + } + else { + for (const [ i, service ] of result.entries()) { + result[i].heartbeat = await server + .storage + .store('smartyellow/webserviceheartbeat') + .find({ webservice: service.id }) + .toArray(); + } + } + + res.json(result); + }, + }, + + // Get details for specific service + { route: '/webservices/:id', + method: 'get', + requires: 'smartyellow/status/seeServices', + handler: async (req, res, user) => { + const doc = await server.storage({ user }).store('smartyellow/webservice').get(req.params[0]); + const result = await server.validateEntity({ + entity: 'smartyellow/webservice', + id: req.params[0], + data: doc, + validateOnly: true, + user: user, + isNew: false, + }); + res.json(result); + }, + }, + + { route: '/webservices/search', + method: 'post', + requires: 'smartyellow/status/seeServices', + handler: async (req, res, user) => { + const filters = await server.getFilters({ + entity: 'smartyellow/webservice', + user: user, + }); + const q = server.storage({ user }).prepareQuery(filters, req.body.query, req.body.languages || false); + const result = await server.storage({ user }).store('smartyellow/webservice').find(q).sort({ 'log.created.on': -1 }).toArray(); + res.json(result); + }, + }, + + // Get filters for services + { route: '/webservices/filters', + method: 'get', + requires: 'smartyellow/status/seeServices', + handler: async (req, res, user) => { + res.json(await server.getFilters({ + entity: 'smartyellow/webservice', + user: user, + })); + }, + }, + + // Get formats for services + { route: '/webservices/formats', + method: 'get', + requires: 'smartyellow/status/seeServices', + handler: async (req, res, user) => { + const formats = await server.getFormats({ + entity: 'smartyellow/webservice', + user: user, + }); + res.json(formats); + }, + }, + + // Create new service + { route: '/webservices', + method: 'post', + requires: 'smartyellow/status/createServices', + handler: async (req, res, user) => { + + // Validate the posted data + const result = await server.validateEntity({ + entity: 'smartyellow/webservice', + data: req.body, + storeIfValid: true, + validateOnly: req.headers['init'], + form: req.headers['form'] || 'default', + isNew: true, + user: user, + }); + + if (!result.errors) { + // broadcast message to all clients to notify users have been changed + server.publish('cms', 'smartyellow/status/reload'); + } + + return res.json(result); + }, + }, + + // Update existing service + { route: '/webservices/:id', + method: 'put', + requires: 'smartyellow/status/editServices', + handler: async (req, res, user) => { + + // Validate the posted data + const result = await server.validateEntity({ + entity: 'smartyellow/webservice', + id: req.params[0], + data: req.body, + storeIfValid: true, + validateOnly: req.headers['init'], + form: req.headers['form'] || 'default', + isNew: false, + user: user, + }); + + if (!result.errors) { + // broadcast message to all clients to notify users have been changed + server.publish('cms', 'smartyellow/status/reload'); + } + + return res.json(result); + }, + }, + + // Delete specific service + { route: '/webservices/:id', + method: 'delete', + requires: 'smartyellow/status/deleteServices', + handler: async (req, res, user) => { + const result = await server.storage({ user }).store('smartyellow/webservice').delete({ id: req.params[0] }); + + if (!result.errors) { + // broadcast message to all clients to notify users have been changed + server.publish('cms', 'smartyellow/status/reload'); + } + + res.json(result); + }, + }, + + // Get all outages + { route: '/outages', + method: 'get', + requires: 'smartyellow/status/seeOutages', + handler: async (req, res, user) => { + const outages = server.storage({ user }).store('smartyellow/webserviceoutage').find(); + const result = await (req.headers['format'] == 'object' ? outages.toObject() : outages.toArray()); + res.json(result); + }, + }, + + // Get details for specific outage + { route: '/outages/:id', + method: 'get', + requires: 'smartyellow/status/seeOutages', + handler: async (req, res, user) => { + const doc = await server.storage({ user }).store('smartyellow/webserviceoutage').get(req.params[0]); + const result = await server.validateEntity({ + entity: 'smartyellow/webserviceoutage', + id: req.params[0], + data: doc, + validateOnly: true, + user: user, + isNew: false, + }); + res.json(result); + }, + }, + + { route: '/outages/search', + method: 'post', + requires: 'smartyellow/status/seeOutages', + handler: async (req, res, user) => { + const filters = await server.getFilters({ + entity: 'smartyellow/webserviceoutage', + user: user, + }); + const q = server.storage({ user }).prepareQuery(filters, req.body.query, req.body.languages || false); + const result = await server.storage({ user }).store('smartyellow/webserviceoutage').find(q).sort({ 'log.created.on': -1 }).toArray(); + res.json(result); + }, + }, + + // Get filters for outages + { route: '/outages/filters', + method: 'get', + requires: 'smartyellow/status/seeOutages', + handler: async (req, res, user) => { + res.json(await server.getFilters({ + entity: 'smartyellow/webserviceoutage', + user: user, + })); + }, + }, + + // Get formats for outages + { route: '/outages/formats', + method: 'get', + requires: 'smartyellow/status/seeOutages', + handler: async (req, res, user) => { + const formats = await server.getFormats({ + entity: 'smartyellow/webserviceoutage', + user: user, + }); + res.json(formats); + }, + }, + + // Create new service + { route: '/outages', + method: 'post', + requires: 'smartyellow/status/createOutages', + handler: async (req, res, user) => { + + // Validate the posted data + const result = await server.validateEntity({ + entity: 'smartyellow/webserviceoutage', + data: req.body, + storeIfValid: true, + validateOnly: req.headers['init'], + form: req.headers['form'] || 'default', + isNew: true, + user: user, + }); + + if (!result.errors) { + // broadcast message to all clients to notify users have been changed + server.publish('cms', 'smartyellow/status/reload'); + } + + return res.json(result); + }, + }, + + // Update existing service + { route: '/outages/:id', + method: 'put', + requires: 'smartyellow/status/editOutages', + handler: async (req, res, user) => { + + // Validate the posted data + const result = await server.validateEntity({ + entity: 'smartyellow/webserviceoutage', + id: req.params[0], + data: req.body, + storeIfValid: true, + validateOnly: req.headers['init'], + form: req.headers['form'] || 'default', + isNew: false, + user: user, + }); + + if (!result.errors) { + // broadcast message to all clients to notify users have been changed + server.publish('cms', 'smartyellow/status/reload'); + } + + return res.json(result); + }, + }, + + // Delete specific service + { route: '/outages/:id', + method: 'delete', + requires: 'smartyellow/status/deleteOutages', + handler: async (req, res, user) => { + const result = await server.storage({ user }).store('smartyellow/webserviceoutage').delete({ id: req.params[0] }); + + if (!result.errors) { + // broadcast message to all clients to notify users have been changed + server.publish('cms', 'smartyellow/status/reload'); + } + + res.json(result); + }, + }, + + ], + +}; diff --git a/lib/operators.js b/lib/operators.js new file mode 100644 index 0000000..74cf2e1 --- /dev/null +++ b/lib/operators.js @@ -0,0 +1,25 @@ +'use strict'; + +const operatorNames = { + equal: 'equal to', + start: 'starting with', + end: 'ending in', + contain: 'containing', + greater: 'greater than', + less: 'less than', + greatereq: 'greater than or equal to', + lesseq: 'less than or equal to', +}; + +const operators = { + equal: (a, b) => String(a) === String(b), + start: (a, b) => String(a).startsWith(b), + end: (a, b) => String(a).startsWith(b), + contain: (a, b) => String(a).includes(b), + greater: (a, b) => Number(a) > Number(b), + less: (a, b) => Number(a) < Number(b), + greatereq: (a, b) => Number(a) >= Number(b), + lesseq: (a, b) => Number(a) <= Number(b), +}; + +module.exports = { operatorNames, operators }; diff --git a/lib/realvalues.js b/lib/realvalues.js new file mode 100644 index 0000000..4afe283 --- /dev/null +++ b/lib/realvalues.js @@ -0,0 +1,21 @@ +'use strict'; + +const realValueNames = { + httpstatus: 'HTTP status code', + body: 'Response body', + bodylength: 'Response body length', + ok: 'Response OK?', + redir: 'Redirected?', + restype: 'Response type', +}; + +const realValues = { + httpstatus: ({ res }) => res.status, + body: ({ body }) => body, + bodylength: ({ body }) => body.length, + ok: ({ res }) => res.ok, + redir: ({ res }) => res.redirected, + restype: ({ res }) => res.type, +}; + +module.exports = { realValueNames, realValues }; diff --git a/lib/testendpoints.js b/lib/testendpoints.js new file mode 100644 index 0000000..bbcca17 --- /dev/null +++ b/lib/testendpoints.js @@ -0,0 +1,66 @@ +'use strict'; + +const fetch = require('node-fetch'); +const { operators } = require('./operators'); +const { realValues } = require('./realvalues'); + +async function testEndpoints(endpoints) { + const output = { + serviceUp: true, + success: true, + error: false, + requirement: undefined, + realValue: undefined, + }; + + for (const endpoint of endpoints) { + try { + const headers = endpoint.headers.reduce((obj, item) => { + obj[item.name] = item.value; + return obj; + }, {}); + + const res = await fetch(endpoint.uri, { headers }); + const body = await res.text(); + + endpoint.requirements.forEach(requirement => { + if (output.success === false || output.serviceUp === false) { + return; + } + + if (!Object.keys(operators).includes(requirement.operator)) { + output.success = false; + output.error = 'unknown operator: ' + requirement.operator; + } + + if (!Object.keys(realValues).includes(requirement.type)) { + output.success = false; + output.error = 'unknown type: ' + requirement.type; + } + + const realValue = realValues[requirement.type]({ res, body }); + let result = operators[requirement.operator](realValue, requirement.string); + + if (!requirement.truth) { + result = !result; + } + + if (!result) { + output.success = true; + output.serviceUp = false; + output.requirement = requirement; + output.realValue = realValue; + } + }); + } + catch (err) { + output.success = false; + output.error = err; + console.error(err); + } + } + + return output; +} + +module.exports = { testEndpoints }; diff --git a/lib/testservice.js b/lib/testservice.js new file mode 100644 index 0000000..0d28560 --- /dev/null +++ b/lib/testservice.js @@ -0,0 +1,138 @@ +'use strict'; + +const { testEndpoints } = require('./testendpoints'); +const { roundDate } = require('./utils'); + +async function testService({ service, server, settings, makeId }) { + if (!service.autotestEnabled) { + return; + } + + try { + // Autotest the service + const result = await testEndpoints(service.autotest); + const name = service.name.en; + + // Insert check date + await server.storage.store('smartyellow/webservice').update( + { id: service.id }, + { $set: { lastChecked: new Date() } } + ); + + // Get all heartbeats plus the last one + const heartbeat = await server + .storage + .store('smartyellow/webserviceheartbeat') + .find({ webservice: service.id }) + .toArray(); + const lastBeat = heartbeat[heartbeat.length - 1]; + + // Get date + const date = roundDate(new Date()); + + // Error + if (result.error) { + server.error('Error while checking status: ' + name); + server.error(result); + } + + // Service down + else if (!result.serviceUp) { + server.warn('Service down: ' + name); + server.warn(result); + + // Don't perform automatic actions if already done + if ((lastBeat && lastBeat.down == false) || !lastBeat) { + // Insert heartbeat if last one is not valid anymore + try { + await server.storage.store('smartyellow/webserviceheartbeat').insert({ + id: makeId(6), + down: true, + webservice: service.id, + testResult: result, + date: date, + }); + } + catch (err) { + server.error(err); + server.error('could not save web service heartbeat'); + } + + // Send e-mail notification + if (server.sendEmail && settings.emailSender && settings.emailRecipient) { + try { + await server.sendEmail({ + sender: settings.emailSender, + to: settings.emailRecipient, + subject: `[outage] ${name} is down`, + body: `

    Dear recipient,

    +

    This is to inform you about web service outage. + The service ${name} does not meet the + requirements for being considered as 'working'.

    +

    Please always check this before taking action.

    `, + }); + } + catch (err) { + server.error(err); + server.error('could not send endpoint status notification e-mail'); + } + } + + // Draft outage entry + if (settings.draftOutageEntries) { + try { + await server + .storage + .store('smartyellow/webserviceoutage') + .insert({ + id: makeId(6), + name: { + en: `[automatic] Outage for ${name}`, + }, + state: 'concept', + resolved: false, + services: [ service.id ], + tags: [ 'automatically created' ], + notes: [ { + date: new Date(), + userId: 'system', + text: `Automatically created outage. Reason: ${JSON.stringify(result, null, 2)}`, + } ], + }); + } + catch (err) { + server.error(err); + server.error('could not automatically draft outage entry'); + } + } + } + } + + // Service up + else { + server.info('Service up: ' + name); + + // Insert heartbeat if last one is not valid anymore + if ((lastBeat && lastBeat.down == true) || !lastBeat) { + try { + await server.storage.store('smartyellow/webserviceheartbeat').insert({ + id: makeId(6), + down: false, + webservice: service.id, + date: date, + testResult: result, + }); + } + catch (err) { + server.error(err); + server.error('could not save web service heartbeat'); + } + } + } + } + catch (err) { + server.error(err); + } +} + +module.exports = { testService }; diff --git a/lib/testservices.js b/lib/testservices.js new file mode 100644 index 0000000..924cac0 --- /dev/null +++ b/lib/testservices.js @@ -0,0 +1,19 @@ +'use strict'; + +const { testService } = require('./testservice'); + +async function testServices({ server, settings, makeId }) { + const services = await server + .storage + .store('smartyellow/webservice') + .find({ autotestEnabled: true }) + .toArray(); + + services.forEach(async service => { + if (service.autotestEnabled) { + testService({ service, server, settings, makeId }); + } + }); +} + +module.exports = { testServices }; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..daa76ae --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,11 @@ +'use strict'; + +// Round minutes up to 10 and remove seconds +// e.g. 15:34:51 -> 15:30:00 +function roundDate(d) { + d.setMinutes(Math.round(d.getMinutes() / 10) * 10); + d.setSeconds(0, 0); + return d; +} + +module.exports = { roundDate };