Initial commit

Signed-off-by: Romein van Buren <romein@vburen.nl>
This commit is contained in:
Romein van Buren 2022-06-22 17:22:19 +02:00
commit 1983f78fba
Signed by: romein
GPG Key ID: 0EFF8478ADDF6C49
17 changed files with 3016 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
.DS_Store
node_modules
/cache
config.custom.js
config.temp.js
package-lock.json

457
entities/webservice.js Normal file
View File

@ -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;
},
},
}),
};

View File

@ -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' ],
},
}),
};

View File

@ -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: [],
},
}),
};

View File

@ -0,0 +1,268 @@
<script>
import { createEventDispatcher } from 'svelte';
import Toggle from 'components/webdesq/toggle.svelte';
import { operatorNames } from '../../lib/operators';
import { realValueNames } from '../../lib/realvalues';
export let value = [];
export let specs = {};
export let readonly = true;
export let language = 'en';
export let translate = s => s;
const dispatch = createEventDispatcher();
const defaultEndpoint = {
uri: '',
headers: [],
requirements: [],
};
const defaultReq = {
type: 'httpstatus',
truth: 'true',
operator: 'equal',
string: ''
};
const defaultHeader = {
name: '',
value: '',
};
const changeValue = () => dispatch('changeValue', value) && console.log(value);;
const appendEndpoint = () => value = [...value, defaultEndpoint];
function removeEndpoint(i) {
value.splice(i, 1);
value = value;
}
</script>
{#if specs.label}
<span class="label" class:alignright={specs.labelPosition === 'right'}>
{translate(specs.label, language)}
</span>
{/if}
{#each value as endpoint, iEndpoint (endpoint)}
<div class="endpoint">
<div>
<label for="uri-{iEndpoint}">
{translate('Endpoint URI', language)}
</label>
<div class="flex">
<input
id="uri-{iEndpoint}"
type="text"
placeholder="https://"
disabled={readonly}
bind:value={endpoint.uri}
on:focus
on:blur={changeValue}
/>
<button on:click={() => removeEndpoint(iEndpoint)}>
&times;
</button>
</div>
</div>
{#if endpoint.headers?.length > 0}
<strong>{translate('Headers', language)}</strong>
<table>
<thead>
<tr>
<th>{translate('Header name', language)}</th>
<th>{translate('Value', language)}</th>
</tr>
</thead>
<tbody>
{#each endpoint.headers as header, iHeader (header)}
<tr>
<td>
<input
type="text"
placeholder={translate('name...', language)}
disabled={readonly}
bind:value={header.name}
on:focus
on:blur={changeValue}
/>
</td>
<td>
<div class="flex">
<input
type="text"
placeholder={translate('value...', language)}
disabled={readonly}
bind:value={header.value}
on:focus
on:blur={changeValue}
/>
<button on:click={() => {
endpoint.headers.splice(iHeader, 1);
endpoint = endpoint;
}}>
&times;
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div>
<button on:click={() => endpoint.headers = [...endpoint.headers, defaultHeader]}>
{translate('add request header', language)}
</button>
</div>
{#if endpoint.requirements?.length > 0}
<strong>{translate('Requirements', language)}</strong>
<table>
<thead>
<tr>
<th>{translate('Real value', language)}</th>
<th class="center">{translate('Truth', language)}</th>
<th>{translate('Operator', language)}</th>
<th>{translate('Value', language)}</th>
</tr>
</thead>
<tbody>
{#each endpoint.requirements as req, iReq (req)}
<tr>
<td>
<select
bind:value={req.type}
disabled={readonly}
on:focus
on:keyup={changeValue}
on:change={changeValue}
on:blur={changeValue}
>
{#each Object.keys(realValueNames) as valName}
<option value={valName}>{realValueNames[valName]}</option>
{/each}
</select>
</td>
<td class="center">
<Toggle
inline
labels={{
on: translate('is', language),
off: translate('isn\'t', language)
}}
bind:value={req.truth}
on:change={changeValue}
{readonly}
{language}
{translate}
/>
</td>
<td>
<select
bind:value={req.operator}
disabled={readonly}
on:focus
on:keyup={changeValue}
on:change={changeValue}
on:blur={changeValue}
>
{#each Object.keys(operatorNames) as opName}
<option value={opName}>{operatorNames[opName]}</option>
{/each}
</select>
</td>
<td>
<div class="flex">
<input
type="text"
placeholder={translate('value...', language)}
disabled={readonly}
bind:value={req.string}
on:focus
on:blur={changeValue}
/>
<button on:click={() => {
endpoint.requirements.splice(iReq, 1);
endpoint = endpoint;
}}>
&times;
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
<div>
<button on:click={() => endpoint.requirements = [...endpoint.requirements, defaultReq]}>
{translate('add requirement', language)}
</button>
</div>
</div>
{/each}
<div>
<button on:click={appendEndpoint}>
{translate('add endpoint', language)}
</button>
</div>
<style>
div:not(:last-child) {
margin-bottom: 10px;
}
label, strong {
font-weight: 700;
line-height: 1em;
margin-bottom: .7em;
display: block;
}
label:not(:first-child), strong:not(:first-child) {
margin-top: 1em;
padding-top: 1em;
border-top: 1px solid rgba(0, 0, 0, .2);
}
input, select {
padding: 0.4em;
}
.endpoint {
border: 1px solid rgba(226, 226, 226, .76);
background-color: rgba(70, 90, 131, .07);
border-radius: 3px;
padding: .5rem;
}
table {
margin-left: -2px;
width: calc(100% + 2px);
}
thead tr th {
text-align: left;
font-weight: 400;
font-style: italic;
}
.flex {
display: flex;
gap: 3px;
}
.flex input {
flex: 1 0;
}
.flex button {
min-width: 35px;
flex: 0 1;
}
.center {
text-align: center;
}
</style>

View File

@ -0,0 +1,152 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { api } from 'helpers/webdesq/stores.js';
export let value = ''; // contains webservice id
export let specs = {};
export let language = 'en';
export let translate = s => s;
const icons = {
server: '<path d="M0 308.58v150.83a57.73 57.73 0 0 0 10.69 33.38H757.3a57.62 57.62 0 0 0 10.7-33.37V308.58a57.73 57.73 0 0 0-10.69-33.38H10.7A57.76 57.76 0 0 0 0 308.58Zm665.6 81.82a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM640 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 0-25.6ZM588.8 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM537.6 352a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM512 390.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM486.4 352a12.8 12.8 0 1 1-.01 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM435.2 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-300.8-25.6A57.67 57.67 0 0 1 192 384a57.67 57.67 0 0 1-57.6 57.6A57.67 57.67 0 0 1 76.8 384a57.67 57.67 0 0 1 57.6-57.6Zm622.91-76.8A57.76 57.76 0 0 0 768 216.22V65.38A59.05 59.05 0 0 0 709.02 6.4H58.98A59.05 59.05 0 0 0 0 65.38V216.2a57.73 57.73 0 0 0 10.69 33.39H757.3Zm-91.7-102.4a12.8 12.8 0 1 1-.02 25.6 12.8 12.8 0 0 1 .01-25.6ZM640 108.8a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM512 147.2a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1-.01 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6ZM134.4 83.2a57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6 57.67 57.67 0 0 1-57.6-57.6 57.67 57.67 0 0 1 57.6-57.6ZM10.69 518.4A57.76 57.76 0 0 0 0 551.78v150.83c0 32.53 26.46 59 58.98 59H709a59.05 59.05 0 0 0 58.99-59V551.79a57.73 57.73 0 0 0-10.69-33.38Zm123.7 166.4a57.67 57.67 0 0 1-57.59-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm300.8-64a12.8 12.8 0 1 1 .02-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.61 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.61 12.8 12.8 0 0 1 0 25.6Zm0 0"/>',
};
const severity = {
major: {
name: 'major',
class: 'l1',
},
minor: {
name: 'minor',
class: 'l2',
},
scheduled: {
name: 'scheduled',
class: 'l3',
},
none: {
name: 'no impact',
class: 'l4',
},
}
const dispatch = createEventDispatcher();
const service = value; // for clarity
let outages = [];
function openOutage(title, id) {
dispatch('openitem', {
type: 'smartyellow/webserviceoutages',
title: title,
icon: icons.server,
closeable: true,
isNew: false,
data: { id: id },
});
}
onMount(async () => {
outages = (await api.get('/outages')).filter(o => o.services?.includes(service));
});
</script>
{#if specs.label}
<span class="label" class:alignright={specs.labelPosition === 'right'}>
{translate(specs.label, language)}
</span>
{/if}
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>{translate('id', language)}</th>
<th>{translate('name', language)}</th>
<th>{translate('severity', language)}</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
{#each outages as outage}
{@const name = outage.name[language] || outage.name.en}
<tr>
<td>{outage.id}</td>
<td>{name}</td>
<td>
{#if severity[outage.severity]}
<span class="state {severity[outage.severity]?.class}">
{severity[outage.severity]?.name}
</span>
{:else}
<span class="state l0">
{translate('unclassified', language)}
</span>
{/if}
</td>
<td class="center">
<button class="small" on:click={() => openOutage(name, outage.id)}>
{translate('open', language)}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.table-container {
overflow: auto;
}
.table {
table-layout: fixed;
min-width: 100%;
border-spacing: 0;
border-top: 1px solid #ccc;
}
th {
padding: 3px 6px;
border: 1px solid #ccc;
border-width: 0 1px 1px 0;
background: #eee;
font-weight: normal;
user-select: none;
box-sizing: border-box;
text-align: left;
}
th:first-child,
td:first-child {
position: sticky;
top: 0;
left: 0;
z-index: 3;
border-left: 1px solid #ccc;
}
td {
position: relative;
border: 1px solid #ccc;
border-width: 0 1px 1px 0;
padding: 3px 6px;
background: #fff;
text-align: left;
}
tr > td:first-child {
border-left-width: 1px;
}
span.state.l0 {
background-color: #808080;
color: rgba(255, 255, 255, 0.9);
}
button.small {
padding: 3px 10px;
min-width: 20px;
margin: 2px;
}
.center {
text-align: center;
}
</style>

View File

@ -0,0 +1,176 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { translate, api } from 'helpers/webdesq/stores.js';
import PanelManager from 'components/webdesq/panelmanager.svelte';
import Panel from 'components/webdesq/panel.svelte';
export let language = 'en';
const icons = {
wrench: '<path d="M175 631c0-15-13-28-29-28s-29 13-29 28 13 27 29 27 29-12 29-27zm294-180L158 743a61 61 0 0 1-41 16c-16 0-31-6-42-16l-48-46a52 52 0 0 1 0-78l310-292c24 57 72 102 132 124zm289-187c0 15-6 32-11 46a206 206 0 0 1-192 129c-113 0-205-86-205-192S442 55 555 55c33 0 76 9 104 27 5 3 7 7 7 12s-3 9-7 12l-134 72v96l88 46c15-8 121-71 130-71s15 7 15 15zm0 0"/>',
server: '<path d="M0 308.58v150.83a57.73 57.73 0 0 0 10.69 33.38H757.3a57.62 57.62 0 0 0 10.7-33.37V308.58a57.73 57.73 0 0 0-10.69-33.38H10.7A57.76 57.76 0 0 0 0 308.58Zm665.6 81.82a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM640 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 0-25.6ZM588.8 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM537.6 352a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM512 390.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM486.4 352a12.8 12.8 0 1 1-.01 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM435.2 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-300.8-25.6A57.67 57.67 0 0 1 192 384a57.67 57.67 0 0 1-57.6 57.6A57.67 57.67 0 0 1 76.8 384a57.67 57.67 0 0 1 57.6-57.6Zm622.91-76.8A57.76 57.76 0 0 0 768 216.22V65.38A59.05 59.05 0 0 0 709.02 6.4H58.98A59.05 59.05 0 0 0 0 65.38V216.2a57.73 57.73 0 0 0 10.69 33.39H757.3Zm-91.7-102.4a12.8 12.8 0 1 1-.02 25.6 12.8 12.8 0 0 1 .01-25.6ZM640 108.8a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM512 147.2a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1-.01 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6ZM134.4 83.2a57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6 57.67 57.67 0 0 1-57.6-57.6 57.67 57.67 0 0 1 57.6-57.6ZM10.69 518.4A57.76 57.76 0 0 0 0 551.78v150.83c0 32.53 26.46 59 58.98 59H709a59.05 59.05 0 0 0 58.99-59V551.79a57.73 57.73 0 0 0-10.69-33.38Zm123.7 166.4a57.67 57.67 0 0 1-57.59-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm300.8-64a12.8 12.8 0 1 1 .02-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.61 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.61 12.8 12.8 0 0 1 0 25.6Zm0 0"/>',
};
const dispatch = createEventDispatcher();
let mounted = false;
let error = false;
let webservices = [];
async function refresh() {
console.log('refresh');
try {
webservices = await api.get('/webservices');
}
catch (err) {
console.error(err);
error = true;
}
}
function openWebService(title, id) {
dispatch('openitem', {
type: 'smartyellow/webservices',
title: title,
icon: icons.server,
closeable: true,
isNew: false,
data: { id: id },
});
}
onMount(async () => {
await refresh();
const interval = setInterval(async () => await refresh(), 10_000);
mounted = true;
return () => clearInterval(interval);
});
</script>
{#if error}
<div class="error">
{translate('Failed to fetch fresh data', language)}
</div>
{/if}
{#if mounted}
<PanelManager>
<Panel>
{#if webservices.length}
<div class="servicelist">
{#each webservices as service}
{@const name = service.name[language] || service.name.en}
<div class="service">
<div class="title">{name}</div>
<div class="date">
{@html translate('Status last checked on: <m>', [ `<span>${new Date(service.lastChecked).toLocaleString()}</span>`, language ])}
</div>
<div class="tags">
{#if service.heartbeat[service.heartbeat.length - 1]?.down == true}
<span class="tag red">DOWN</span>
{:else}
<span class="tag green">UP</span>
{/if}
<span class="tag light link" on:click={() => openWebService(name, service.id)}>
open
</span>
</div>
</div>
{/each}
</div>
{:else}
No webservices
{/if}
</Panel>
</PanelManager>
{:else}
<h2>Loading...</h2>
{/if}
<style>
div:not(:last-child) {
margin-bottom: 9px;
}
.servicelist {
width: 100%;
overflow-y: scroll;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 3px;
}
.servicelist .service {
background-color: #fff;
margin-bottom: 1px;
padding: 1em;
}
.servicelist .service .title {
font-size: 1.5em;
}
.tag {
background-color: #808080;
color: #fff;
padding: 2px 5px;
border-radius: 5px;
display: inline-block;
}
.tag.green {
background-color: #007000;
}
.tag.red {
background-color: #980000;
}
.tag.light {
background-color: rgba(0, 0, 0, .1);
color: inherit;
cursor: pointer;
}
.tag.link {
text-decoration: underline;
transition: background-color .2s;
}
.tag.link.light:hover {
background-color: rgba(0, 0, 0, .2);
}
.tag.link::after {
content: '';
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5z'/%3E%3Cpath fill-rule='evenodd' d='M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0v-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: contain;
width: 1em;
height: 1em;
display: inline-block;
margin-left: .3em;
}
.date {
color: rgba(0, 0, 0, .5);
}
.date :global(span) {
font-style: italic;
opacity: 1.3;
color: rgba(0, 0, 0, .8);
}
.error {
padding: 10px;
color: #fff;
}
@media (max-width: 500px) {
.servicelist {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -0,0 +1,276 @@
<script>
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { user, users, prefix, translate, api } from 'helpers/webdesq/stores.js';
import shareQuery from 'helpers/webdesq/sharequery.js';
// import regular components
import PanelManager from 'components/webdesq/panelmanager.svelte';
import Panel from 'components/webdesq/panel.svelte';
import Toolbar from 'components/webdesq/toolbar.svelte';
import ToolbarButton from 'components/webdesq/toolbarbutton.svelte';
import SaveOrDiscard from 'components/webdesq/saveordiscard.svelte';
import Multifilter from 'components/webdesq/multifilter.svelte';
import Grid from 'components/webdesq/grid.svelte';
import Form from 'components/webdesq/form.svelte';
import ObjectTree from 'components/webdesq/objecttree.svelte';
import EditHistory from 'components/webdesq/edithistory.svelte';
import CompareVersions from 'components/webdesq/compareversions.svelte';
// props
export let focused = false;
export let language = 'en';
export let icon = false;
export let id = false;
export let modified = false;
export let notifications = false;
// local state
const dispatch = createEventDispatcher();
const confirm = getContext('confirm');
const entity = 'smartyellow/webserviceoutage';
const moduleName = 'smartyellow/webserviceoutages';
const pluginName = 'smartyellow/status';
const readonly = user.cannot(pluginName + '/editOutages');
const icons = {
trash: '<path d="M638 77H501V25c0-14-11-25-25-25H292c-14 0-25 11-25 25v52H130c-14 0-25 11-25 25v77c0 14 11 25 25 25h508c14 0 25-11 25-25v-77c0-14-11-25-25-25zm-187 0H317V50h134zm0 0M140 254l21 490c0 13 11 24 25 24h396c14 0 25-11 25-24l21-490zm168 412a25 25 0 01-50 0V356a25 25 0 0150 0zm101 0a25 25 0 11-50 0V356a25 25 0 0150 0zm101 0a25 25 0 01-50 0V356a25 25 0 0150 0zm0 0"/>',
history: '<path d="M402 238v183l157 92 26-44-128-76V238zm0 0"/><path d="M439 55a329 329 0 00-329 329H0l142 142 3 6 148-148H183a256 256 0 1175 181l-52 52A329 329 0 10438 55zm0 0"/>',
wrench: '<path d="M175 631c0-15-13-28-29-28s-29 13-29 28 13 27 29 27 29-12 29-27zm294-180L158 743a61 61 0 0 1-41 16c-16 0-31-6-42-16l-48-46a52 52 0 0 1 0-78l310-292c24 57 72 102 132 124zm289-187c0 15-6 32-11 46a206 206 0 0 1-192 129c-113 0-205-86-205-192S442 55 555 55c33 0 76 9 104 27 5 3 7 7 7 12s-3 9-7 12l-134 72v96l88 46c15-8 121-71 130-71s15 7 15 15zm0 0"/>',
lexicon: '<path d="M685 313H396c-46 0-83 37-83 83v157l-75 54a22 22 0 000 36l76 54c6 40 41 71 82 71h289c46 0 83-37 83-83V396c0-46-37-83-83-83zm-97 316c-5 0-9-1-11-6l-9-32h-55l-9 32c-1 5-5 6-11 6-8 0-19-5-19-13v-2l47-152c2-7 11-10 20-10s18 3 20 10l46 152 1 2c0 8-12 13-20 13zm0 0"/><path d="M520 567h42l-21-74zm0 0M268 396c0-30 11-58 28-80-25 0-49-8-69-22-19 14-43 22-69 22a13 13 0 010-25c18 0 35-5 49-14-17-19-28-43-31-70h-18a13 13 0 110-25h57v-31a13 13 0 1125 0v31h56a13 13 0 110 25h-17c-3 27-14 51-32 70 15 9 32 14 49 14 7 0 13 5 13 12 23-22 54-35 87-35h59v-53l75-54a22 22 0 000-36l-76-54c-6-40-41-71-82-71H83C37 0 0 37 0 83v289c0 46 37 83 83 83h185zm0 0"/><path d="M227 261c14-14 24-33 26-54h-52c3 21 12 40 26 54zm0 0"/>',
preview: '<path d="m45 631 158-135c-35-49-55-110-55-174 0-167 133-301 295-301 164 0 296 134 296 301 0 166-132 301-296 301-63 0-122-21-170-56L140 728l-9 8a66 66 0 0 1-94-8 69 69 0 0 1 8-97Zm623-309c0-127-101-229-225-229a227 227 0 0 0-224 229c0 126 100 229 224 229s225-104 225-229Zm0 0"/><path d="M281 294c33-30 94-75 162-75 69 0 130 44 163 75 17 15 17 41 0 56-33 30-94 75-163 75-68 0-129-44-162-75a39 39 0 0 1 0-56Zm162 94c37 0 65-30 65-66s-29-66-65-66c-35 0-65 30-65 66s29 66 65 66Zm0 0"/><path d="M409 322c0 19 16 34 34 34 19 0 34-15 34-34s-15-35-34-35c-18 0-34 16-34 35" />',
};
const gridOptions = {
icons,
index: true,
storageKey: moduleName,
multiselect: true,
header: true,
footer: false,
rowselect: true,
};
let mounted = false;
let settings = {};
let item, backup = false;
let isNew = id === true;
let dragdata = false;
let form;
let log;
let filters = {};
let items = [];
let errors = {};
let grid;
let savebar;
let multifilter;
let debug = false;
let history = false;
let selectedVersions = false;
let preview = false;
onMount(async function () {
try {
if (id) {
// get item, form and log for specified id
try {
// get settings, to read previewUrl
settings = await api.get('/outages/settings');
({ item, form, log } = isNew ? await api.post('/outages', {}, { init: true }) : await api.get('/outages/' + id));
// if existing item, set tabtitle to title of item
if (!isNew) {
dispatch('tabchanged', { title: item.name });
}
api.subscribe(pluginName + '/reload', async msg => {
if (msg.id == item.id) {
({ item, form, log } = await api.get('/outages' + id));
}
});
api.subscribe(entity + '/reload', async () => {
({ form, log } = await api.get('/outages/' + id));
});
}
catch (e) {
console.log('could not open item', id);
dispatch('unpin', { id: id, module: moduleName });
dispatch('close');
return;
}
}
else {
filters = await api.get('/outages/filters');
gridOptions.columns = await api.get('/outages/formats');
// subscribe to 'reload' message
api.subscribe(pluginName + '/reload', async () => {
multifilter.submit();
});
}
mounted = true;
}
catch (e) {
console.log('Failed to fetch outages', e);
}
});
function openItem(event, newItem = false) {
dispatch('openitem', {
type: moduleName,
title: newItem ? 'new outage' : '',
icon: icon,
closeable: true,
isNew: newItem,
data: {
id: event.detail.id,
},
});
}
async function saveChanges() {
if (focused) {
// save changes for outage in form
savebar.start();
const result = isNew ?
await api.post('/outages', item) :
await api.put('/outages/' + item.id, item);
savebar.stop(result);
if (!result.errors) {
log = result.log;
// Once saved, we need to use PUT request to update newly created outages
isNew = false;
// Update tab title
dispatch('tabchanged', { title: item.title });
}
}
}
async function submitFilters({ detail }) {
if (grid) {
grid.reset();
}
items = await api.post('/outages/search', detail);
}
async function deleteItem() {
if (item && item.id) {
try {
await confirm({
msg: translate('Are you sure you want to delete this item?', language),
});
dispatch('close');
await api.delete('/outages/' + item.id);
}
catch (e) {
console.log(e);
}
}
}
function previewItem() {
if (preview) {
preview = false;
}
else {
let p = settings.previewUrl;
p = p.replace(':slug', (item.slug[language] ? item.slug[language] : item.slug.nl));
preview = {
url: p,
};
console.log('outgoing preview', preview);
}
}
</script>
{#if mounted}
{#if item}
<Toolbar large="768" fill="true">
{#if !readonly}
<SaveOrDiscard {language} {translate} {notifications} bind:errors bind:modified bind:item bind:backup bind:this={savebar} on:save={saveChanges} />
{/if}
{#if log && log.created && settings && settings.previewUrl}
<ToolbarButton title="{translate('preview', language)}" icon={icons.preview} hint="{translate('preview', language)}" submenu>
<li class:checked="{preview}" on:click="{previewItem}">Page preview</li>
<li on:click="{() => window.open((settings.previewUrl.split('/')[3]))}">Website preview</li>
</ToolbarButton>
{/if}
{#if form && form.languages}
<ToolbarButton icon={icons.lexicon} title="{translate('language', language)}" hint="{translate('Language', language)}" submenu>
{#each form.languages as l}
<li class:checked={l.enabled} on:click={() => (l.enabled = !l.enabled)}>
{l.name}
</li>
{/each}
</ToolbarButton>
{/if}
{#if log && log.created}
<ToolbarButton icon={icons.history} bind:value={history} title="{translate('history', language)}" hint="{translate('History', language)}" />
{/if}
{#if log && log.created && user.can(pluginName + '/deleteServices')}
<ToolbarButton icon={icons.trash} on:click={deleteItem} title="{translate('delete', language)}" hint="{translate('Delete', language)}" />
{/if}
{#if user.is('sysadmin')}
<ToolbarButton icon={icons.wrench} bind:value={debug} title="{translate('debug', language)}" hint="{translate('Show debugger', language)}" />
{/if}
</Toolbar>
<PanelManager>
<Panel>
<Form bind:data={item} prefix={$prefix} {api} {form} {readonly} {modified} {language} {translate} {errors} {debug} {notifications} on:save={saveChanges} />
</Panel>
{#if preview}
<Panel width="50%">
<iframe title="preview" src="{preview.url}"></iframe>
</Panel>
{/if}
{#if history}
{#if selectedVersions}
<Panel width="40%">
<CompareVersions {language} {translate} versions={selectedVersions} {form} />
</Panel>
{/if}
<Panel width="25%">
<EditHistory bind:backup bind:item bind:errors bind:selectedVersions user={$user} {api} {entity} {log} {readonly} {language} {translate} />
</Panel>
{/if}
{#if debug}
<Panel width="30%">
<ObjectTree bind:data={item} />
</Panel>
{/if}
</PanelManager>
{:else}
<Toolbar large="768">
<Multifilter
{moduleName}
{language}
{translate}
{dragdata}
users={Object.keys($users)}
options={filters}
placeholder={translate('Fill in criteria', language)}
on:submit={submitFilters}
on:share={e => shareQuery(e, api)}
bind:this={multifilter}
/>
{#if user.can(pluginName + '/createServices')}
<ToolbarButton {icon} on:click={e => openItem(e, true)} title="{translate('new', language)}" hint="{translate('Create new outage', language)}" />
{/if}
</Toolbar>
<Grid bind:this={grid} {language} {items} {translate} options={gridOptions} bind:dragdata on:select={openItem} />
{/if}
{/if}
<style>
:global(.panel>iframe) {
height: 100%;
border: 0;
}
</style>

View File

@ -0,0 +1,276 @@
<script>
import { createEventDispatcher, onMount, getContext } from 'svelte';
import { user, users, prefix, translate, api } from 'helpers/webdesq/stores.js';
import shareQuery from 'helpers/webdesq/sharequery.js';
// import regular components
import PanelManager from 'components/webdesq/panelmanager.svelte';
import Panel from 'components/webdesq/panel.svelte';
import Toolbar from 'components/webdesq/toolbar.svelte';
import ToolbarButton from 'components/webdesq/toolbarbutton.svelte';
import SaveOrDiscard from 'components/webdesq/saveordiscard.svelte';
import Multifilter from 'components/webdesq/multifilter.svelte';
import Grid from 'components/webdesq/grid.svelte';
import Form from 'components/webdesq/form.svelte';
import ObjectTree from 'components/webdesq/objecttree.svelte';
import EditHistory from 'components/webdesq/edithistory.svelte';
import CompareVersions from 'components/webdesq/compareversions.svelte';
// props
export let focused = false;
export let language = 'en';
export let icon = false;
export let id = false;
export let modified = false;
export let notifications = false;
// local state
const dispatch = createEventDispatcher();
const confirm = getContext('confirm');
const entity = 'smartyellow/webservice';
const moduleName = 'smartyellow/webservices';
const pluginName = 'smartyellow/status';
const readonly = user.cannot(pluginName + '/editServices');
const icons = {
trash: '<path d="M638 77H501V25c0-14-11-25-25-25H292c-14 0-25 11-25 25v52H130c-14 0-25 11-25 25v77c0 14 11 25 25 25h508c14 0 25-11 25-25v-77c0-14-11-25-25-25zm-187 0H317V50h134zm0 0M140 254l21 490c0 13 11 24 25 24h396c14 0 25-11 25-24l21-490zm168 412a25 25 0 01-50 0V356a25 25 0 0150 0zm101 0a25 25 0 11-50 0V356a25 25 0 0150 0zm101 0a25 25 0 01-50 0V356a25 25 0 0150 0zm0 0"/>',
history: '<path d="M402 238v183l157 92 26-44-128-76V238zm0 0"/><path d="M439 55a329 329 0 00-329 329H0l142 142 3 6 148-148H183a256 256 0 1175 181l-52 52A329 329 0 10438 55zm0 0"/>',
wrench: '<path d="M175 631c0-15-13-28-29-28s-29 13-29 28 13 27 29 27 29-12 29-27zm294-180L158 743a61 61 0 0 1-41 16c-16 0-31-6-42-16l-48-46a52 52 0 0 1 0-78l310-292c24 57 72 102 132 124zm289-187c0 15-6 32-11 46a206 206 0 0 1-192 129c-113 0-205-86-205-192S442 55 555 55c33 0 76 9 104 27 5 3 7 7 7 12s-3 9-7 12l-134 72v96l88 46c15-8 121-71 130-71s15 7 15 15zm0 0"/>',
lexicon: '<path d="M685 313H396c-46 0-83 37-83 83v157l-75 54a22 22 0 000 36l76 54c6 40 41 71 82 71h289c46 0 83-37 83-83V396c0-46-37-83-83-83zm-97 316c-5 0-9-1-11-6l-9-32h-55l-9 32c-1 5-5 6-11 6-8 0-19-5-19-13v-2l47-152c2-7 11-10 20-10s18 3 20 10l46 152 1 2c0 8-12 13-20 13zm0 0"/><path d="M520 567h42l-21-74zm0 0M268 396c0-30 11-58 28-80-25 0-49-8-69-22-19 14-43 22-69 22a13 13 0 010-25c18 0 35-5 49-14-17-19-28-43-31-70h-18a13 13 0 110-25h57v-31a13 13 0 1125 0v31h56a13 13 0 110 25h-17c-3 27-14 51-32 70 15 9 32 14 49 14 7 0 13 5 13 12 23-22 54-35 87-35h59v-53l75-54a22 22 0 000-36l-76-54c-6-40-41-71-82-71H83C37 0 0 37 0 83v289c0 46 37 83 83 83h185zm0 0"/><path d="M227 261c14-14 24-33 26-54h-52c3 21 12 40 26 54zm0 0"/>',
preview: '<path d="m45 631 158-135c-35-49-55-110-55-174 0-167 133-301 295-301 164 0 296 134 296 301 0 166-132 301-296 301-63 0-122-21-170-56L140 728l-9 8a66 66 0 0 1-94-8 69 69 0 0 1 8-97Zm623-309c0-127-101-229-225-229a227 227 0 0 0-224 229c0 126 100 229 224 229s225-104 225-229Zm0 0"/><path d="M281 294c33-30 94-75 162-75 69 0 130 44 163 75 17 15 17 41 0 56-33 30-94 75-163 75-68 0-129-44-162-75a39 39 0 0 1 0-56Zm162 94c37 0 65-30 65-66s-29-66-65-66c-35 0-65 30-65 66s29 66 65 66Zm0 0"/><path d="M409 322c0 19 16 34 34 34 19 0 34-15 34-34s-15-35-34-35c-18 0-34 16-34 35" />',
};
const gridOptions = {
icons,
index: true,
storageKey: moduleName,
multiselect: true,
header: true,
footer: false,
rowselect: true,
};
let mounted = false;
let settings = {};
let item, backup = false;
let isNew = id === true;
let dragdata = false;
let form;
let log;
let filters = {};
let items = [];
let errors = {};
let grid;
let savebar;
let multifilter;
let debug = false;
let history = false;
let selectedVersions = false;
let preview = false;
onMount(async function () {
try {
if (id) {
// get item, form and log for specified id
try {
// get settings, to read previewUrl
settings = await api.get('/webservices/settings');
({ item, form, log } = isNew ? await api.post('/webservices', {}, { init: true }) : await api.get('/webservices/' + id));
// if existing item, set tabtitle to title of item
if (!isNew) {
dispatch('tabchanged', { title: item.name });
}
api.subscribe(pluginName + '/reload', async msg => {
if (msg.id == item.id) {
({ item, form, log } = await api.get('/webservices' + id));
}
});
api.subscribe(entity + '/reload', async () => {
({ form, log } = await api.get('/webservices/' + id));
});
}
catch (e) {
console.log('could not open item', id);
dispatch('unpin', { id: id, module: moduleName });
dispatch('close');
return;
}
}
else {
filters = await api.get('/webservices/filters');
gridOptions.columns = await api.get('/webservices/formats');
// subscribe to 'reload' message
api.subscribe(pluginName + '/reload', async () => {
multifilter.submit();
});
}
mounted = true;
}
catch (e) {
console.log('Failed to fetch web services', e);
}
});
function openItem(event, newItem = false) {
dispatch('openitem', {
type: moduleName,
title: newItem ? 'new web service' : '',
icon: icon,
closeable: true,
isNew: newItem,
data: {
id: event.detail.id,
},
});
}
async function saveChanges() {
if (focused) {
// save changes for web service in form
savebar.start();
const result = isNew ?
await api.post('/webservices', item) :
await api.put('/webservices/' + item.id, item);
savebar.stop(result);
if (!result.errors) {
log = result.log;
// Once saved, we need to use PUT request to update newly created web service
isNew = false;
// Update tab title
dispatch('tabchanged', { title: item.name });
}
}
}
async function submitFilters({ detail }) {
if (grid) {
grid.reset();
}
items = await api.post('/webservices/search', detail);
}
async function deleteItem() {
if (item && item.id) {
try {
await confirm({
msg: translate('Are you sure you want to delete this item?', language),
});
dispatch('close');
await api.delete('/webservices/' + item.id);
}
catch (e) {
console.log(e);
}
}
}
function previewItem() {
if (preview) {
preview = false;
}
else {
let p = settings.previewUrl;
p = p.replace(':slug', (item.slug[language] ? item.slug[language] : item.slug.nl));
preview = {
url: p,
};
console.log('outgoing preview', preview);
}
}
</script>
{#if mounted}
{#if item}
<Toolbar large="768" fill="true">
{#if !readonly}
<SaveOrDiscard {language} {translate} {notifications} bind:errors bind:modified bind:item bind:backup bind:this={savebar} on:save={saveChanges} />
{/if}
{#if log && log.created && settings && settings.previewUrl}
<ToolbarButton title="{translate('preview', language)}" icon={icons.preview} hint="{translate('preview', language)}" submenu>
<li class:checked="{preview}" on:click="{previewItem}">Page preview</li>
<li on:click="{() => window.open((settings.previewUrl.split('/')[3]))}">Website preview</li>
</ToolbarButton>
{/if}
{#if form && form.languages}
<ToolbarButton icon={icons.lexicon} title="{translate('language', language)}" hint="{translate('Language', language)}" submenu>
{#each form.languages as l}
<li class:checked={l.enabled} on:click={() => (l.enabled = !l.enabled)}>
{l.name}
</li>
{/each}
</ToolbarButton>
{/if}
{#if log && log.created}
<ToolbarButton icon={icons.history} bind:value={history} title="{translate('history', language)}" hint="{translate('History', language)}" />
{/if}
{#if log && log.created && user.can(pluginName + '/deleteServices')}
<ToolbarButton icon={icons.trash} on:click={deleteItem} title="{translate('delete', language)}" hint="{translate('Delete', language)}" />
{/if}
{#if user.is('sysadmin')}
<ToolbarButton icon={icons.wrench} bind:value={debug} title="{translate('debug', language)}" hint="{translate('Show debugger', language)}" />
{/if}
</Toolbar>
<PanelManager>
<Panel>
<Form bind:data={item} prefix={$prefix} {api} {form} {readonly} {modified} {language} {translate} {errors} {debug} {notifications} on:save={saveChanges} />
</Panel>
{#if preview}
<Panel width="50%">
<iframe title="preview" src="{preview.url}"></iframe>
</Panel>
{/if}
{#if history}
{#if selectedVersions}
<Panel width="40%">
<CompareVersions {language} {translate} versions={selectedVersions} {form} />
</Panel>
{/if}
<Panel width="25%">
<EditHistory bind:backup bind:item bind:errors bind:selectedVersions user={$user} {api} {entity} {log} {readonly} {language} {translate} />
</Panel>
{/if}
{#if debug}
<Panel width="30%">
<ObjectTree bind:data={item} />
</Panel>
{/if}
</PanelManager>
{:else}
<Toolbar large="768">
<Multifilter
{moduleName}
{language}
{translate}
{dragdata}
users={Object.keys($users)}
options={filters}
placeholder={translate('Fill in criteria', language)}
on:submit={submitFilters}
on:share={e => shareQuery(e, api)}
bind:this={multifilter}
/>
{#if user.can(pluginName + '/createServices')}
<ToolbarButton {icon} on:click={e => openItem(e, true)} title="{translate('new', language)}" hint="{translate('Create new web service', language)}" />
{/if}
</Toolbar>
<Grid bind:this={grid} {language} {items} {translate} options={gridOptions} bind:dragdata on:select={openItem} />
{/if}
{/if}
<style>
:global(.panel>iframe) {
height: 100%;
border: 0;
}
</style>

View File

@ -0,0 +1,129 @@
<script>
import { onMount } from 'svelte';
import { translate, api } from 'helpers/webdesq/stores.js';
export let language = 'en';
export const settings = {
title: {
type: 'string',
label: translate('title', language),
},
};
const icons = {
warning: '<path d="M384 0C172.27 0 0 172.25 0 384s172.27 384 384 384 384-172.25 384-384S595.73 0 384 0Zm29.54 605.54a29.55 29.55 0 0 1-59.08 0V576a29.55 29.55 0 0 1 59.08 0Zm0-118.16a29.55 29.55 0 0 1-59.08 0V162.46a29.55 29.55 0 0 1 59.08 0Zm0 0"/>',
check: '<path d="M655.65 112.36c-149.8-149.8-393.5-149.8-543.3 0-149.8 149.76-149.8 393.53 0 543.3C187.23 730.57 285.62 768 384 768c98.38 0 196.73-37.43 271.65-112.34 149.8-149.77 149.8-393.54 0-543.3Zm-56.92 166.22-224.1 224.1a31.93 31.93 0 0 1-22.65 9.39c-8.2 0-16.39-3.14-22.63-9.39L201.29 374.62a31.98 31.98 0 0 1 0-45.26 31.98 31.98 0 0 1 45.27 0l105.42 105.42 201.48-201.47a31.98 31.98 0 0 1 45.27 0 31.98 31.98 0 0 1 0 45.27Zm0 0"/>',
}
let promise;
async function refresh() {
promise = await api.get('/webservices');
}
onMount(() => {
refresh();
const interval = setInterval(() => refresh(), 10_000);
return () => clearInterval(interval);
});
</script>
{#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}
<div class="section outage red">
<div class="hasicon">
<svg viewBox="0 0 768 768">{@html icons.warning}</svg>
<div>
<div class="title">
{translate('there are services down', language)}
</div>
{#each servicesDown as service}
<div class="service">
{service.name[language] || service.name.en}
</div>
{/each}
</div>
</div>
</div>
{/if}
{#if servicesUp?.length}
<div class="section up" class:green={!servicesDown?.length}>
{#if servicesDown.length}
<div class="title">
{translate('services up', language)}
</div>
{#each servicesUp as service}
<div class="service">
{service.name[language] || service.name.en}
</div>
{/each}
{:else}
<div class="hasicon">
<svg viewBox="0 0 768 768">{@html icons.check}</svg>
<div>
<div class="title">
{translate('all services are up', language)}
</div>
{#each servicesUp as service}
<div class="service">
{service.name[language] || service.name.en}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
{:catch}
<div class="error">
{translate('Encountered an error while fetching web service data.', language)}
</div>
{/await}
<style>
.section {
padding: 1em;
}
.section.red {
background-color: #980000;
color: #fff;
}
.section.green {
color: #007000;
}
.hasicon {
display: flex;
gap: 2em;
align-items: center;
}
.hasicon svg {
width: 2em;
height: 2em;
}
.red svg :global(*) {
fill: #fff;
}
.green svg :global(*) {
fill: #007000;
}
.title {
text-transform: uppercase;
opacity: .7;
margin-bottom: .5em;
}
.section.outage .service {
font-size: 1.5em;
}
</style>

501
index.js Normal file
View File

@ -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: '<path d="M0 308.58v150.83a57.73 57.73 0 0 0 10.69 33.38H757.3a57.62 57.62 0 0 0 10.7-33.37V308.58a57.73 57.73 0 0 0-10.69-33.38H10.7A57.76 57.76 0 0 0 0 308.58Zm665.6 81.82a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM640 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 0-25.6ZM588.8 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM537.6 352a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM512 390.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM486.4 352a12.8 12.8 0 1 1-.01 25.61 12.8 12.8 0 0 1 0-25.61Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6ZM435.2 352a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.61Zm-300.8-25.6A57.67 57.67 0 0 1 192 384a57.67 57.67 0 0 1-57.6 57.6A57.67 57.67 0 0 1 76.8 384a57.67 57.67 0 0 1 57.6-57.6Zm622.91-76.8A57.76 57.76 0 0 0 768 216.22V65.38A59.05 59.05 0 0 0 709.02 6.4H58.98A59.05 59.05 0 0 0 0 65.38V216.2a57.73 57.73 0 0 0 10.69 33.39H757.3Zm-91.7-102.4a12.8 12.8 0 1 1-.02 25.6 12.8 12.8 0 0 1 .01-25.6ZM640 108.8a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1-.01 25.6 12.8 12.8 0 0 1 .01-25.6ZM512 147.2a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1-.01 25.61 12.8 12.8 0 0 1 0-25.6Zm-25.6 38.4a12.8 12.8 0 1 1 0 25.6 12.8 12.8 0 0 1 0-25.6Zm-25.6-38.4a12.8 12.8 0 1 1 0 25.61 12.8 12.8 0 0 1 0-25.6ZM134.4 83.2a57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6 57.67 57.67 0 0 1-57.6-57.6 57.67 57.67 0 0 1 57.6-57.6ZM10.69 518.4A57.76 57.76 0 0 0 0 551.78v150.83c0 32.53 26.46 59 58.98 59H709a59.05 59.05 0 0 0 58.99-59V551.79a57.73 57.73 0 0 0-10.69-33.38Zm123.7 166.4a57.67 57.67 0 0 1-57.59-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm300.8-64a12.8 12.8 0 1 1 .02-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.61 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.6 12.8 12.8 0 0 1 0 25.6Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.61 12.8 12.8 0 0 1 0 25.6Zm0 0"/>',
outage: '<path d="M601.6 435.2c-91.75 0-166.4 74.65-166.4 166.4S509.85 768 601.6 768 768 693.35 768 601.6s-74.65-166.4-166.4-166.4Zm0 307.2c-77.63 0-140.8-63.17-140.8-140.8 0-77.63 63.17-140.8 140.8-140.8 77.63 0 140.8 63.17 140.8 140.8 0 77.63-63.17 140.8-140.8 140.8Zm0 0"/><path d="M664.96 538.25c-5-5-13.1-5-18.1 0L601.6 583.5l-45.26-45.25a12.79 12.79 0 1 0-18.1 18.1l45.26 45.25-45.26 45.25a12.79 12.79 0 0 0 9.05 21.85 12.8 12.8 0 0 0 9.05-3.75l45.26-45.25 45.26 45.25a12.77 12.77 0 0 0 18.1 0c5-5 5-13.1 0-18.1L619.7 601.6l45.26-45.25c5-5 5-13.1 0-18.1ZM709.02 0H58.98A59.05 59.05 0 0 0 0 58.98V209.8a57.76 57.76 0 0 0 10.69 33.4H757.3a57.68 57.68 0 0 0 10.7-33.38V58.98A59.05 59.05 0 0 0 709.02 0ZM134.4 192a57.67 57.67 0 0 1-57.6-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm300.8-64a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6ZM640 128a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6ZM396.8 601.6c0-32.15 7.65-62.5 20.93-89.6H10.69A57.76 57.76 0 0 0 0 545.38V696.2c0 32.54 26.46 59 58.98 59h407.6c-42.67-37.57-69.78-92.41-69.78-153.6Zm-262.4 76.8a57.67 57.67 0 0 1-57.6-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6ZM10.69 268.8A57.75 57.75 0 0 0 0 302.18V453a57.75 57.75 0 0 0 10.69 33.4h421.7c36.92-54.04 98.98-89.6 169.2-89.6 65.57 0 123.9 31.1 161.4 79.2a56.8 56.8 0 0 0 5-22.98V302.18a57.73 57.73 0 0 0-10.69-33.38Zm123.7 166.4a57.67 57.67 0 0 1-57.59-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm300.8-64a12.8 12.8 0 1 1 .02-25.61 12.8 12.8 0 0 1-.01 25.6Zm51.2 0a12.8 12.8 0 1 1 .02-25.6 12.8 12.8 0 0 1-.01 25.6Zm51.21 0a12.8 12.8 0 1 1 .01-25.61 12.8 12.8 0 0 1 0 25.6Zm51.2 0a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm51.2 0a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm0 0"/>',
checks: '<path d="M601.6 435.2c-48.18 0-93.66 20.24-124.8 55.53a12.82 12.82 0 0 0 19.2 16.94c26.28-29.78 64.78-46.87 105.6-46.87 77.63 0 140.8 63.17 140.8 140.8 0 77.63-63.17 140.8-140.8 140.8-63.9 0-120.4-43.1-137.49-102.4h60.7v-25.6H422.4v102.4H448v-46.9c26.79 58 86.8 98.1 153.6 98.1 91.75 0 166.4-74.65 166.4-166.4s-74.65-166.4-166.4-166.4Zm0 0"/><path d="M10.69 268.8A57.75 57.75 0 0 0 0 302.18V453a57.75 57.75 0 0 0 10.69 33.4h406.64C455.85 425.02 523.94 384 601.6 384c66.38 0 125.83 29.96 165.76 76.96.37-2.6.64-5.24.64-7.96V302.18a57.73 57.73 0 0 0-10.69-33.38Zm123.7 166.4a57.67 57.67 0 0 1-57.59-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm300.8-64a12.8 12.8 0 1 1 .02-25.61 12.8 12.8 0 0 1-.01 25.6Zm51.2 0a12.8 12.8 0 1 1 .02-25.6 12.8 12.8 0 0 1-.01 25.6Zm51.21 0a12.8 12.8 0 1 1 .01-25.61 12.8 12.8 0 0 1 0 25.6Zm51.2 0a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm51.2 0a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6ZM709.02 0H58.98A59.05 59.05 0 0 0 0 58.98V209.8a57.76 57.76 0 0 0 10.69 33.4H757.3a57.68 57.68 0 0 0 10.7-33.38V58.98A59.05 59.05 0 0 0 709.02 0ZM134.4 192a57.67 57.67 0 0 1-57.6-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm300.8-64a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 .01-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6Zm25.6-38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 .01-25.6 12.8 12.8 0 0 1-.01 25.6ZM640 128a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.61Zm25.6 38.4a12.8 12.8 0 1 1 0-25.61 12.8 12.8 0 0 1 0 25.6ZM403.53 512H10.7A57.76 57.76 0 0 0 0 545.38V696.2c0 32.54 26.46 59 58.98 59h388.64C408.33 715.8 384 661.5 384 601.6c0-31.96 7.1-62.23 19.53-89.6ZM134.4 678.4a57.67 57.67 0 0 1-57.6-57.6 57.67 57.67 0 0 1 57.6-57.6 57.67 57.67 0 0 1 57.6 57.6 57.67 57.67 0 0 1-57.6 57.6Zm0 0"/>',
};
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 <email@example.com>',
default: '',
},
emailRecipient: {
type: 'array',
label: 'notification recipients',
description: 'Recipients of notifications about service statuses. Format: Name <email@example.com>',
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);
},
},
],
};

25
lib/operators.js Normal file
View File

@ -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 };

21
lib/realvalues.js Normal file
View File

@ -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 };

66
lib/testendpoints.js Normal file
View File

@ -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 };

138
lib/testservice.js Normal file
View File

@ -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: `<p>Dear recipient,</p>
<p>This is to inform you about web service outage.
The service <em>${name}</em> does not meet the
requirements for being considered as 'working'.</p>
<p>Please always check this before taking action.</p>`,
});
}
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 };

19
lib/testservices.js Normal file
View File

@ -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 };

11
lib/utils.js Normal file
View File

@ -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 };