Turn the status page into a full status page generator
Components can be put in groups and given public names. Public incidents carry posts written for customers (the internal timeline stays internal), the components they affect, and a page for each incident plus paged history. Scheduled maintenance has its own window, shows affected components as under maintenance, and is announced when scheduled, started, ended or canceled. Branding (title, logo, accent checked for contrast, contact link, footer) and a custom domain that serves only the status page. Visitors follow by RSS, by confirmed email, or by webhook that must answer a test request. Admin UI for all of it, tests, README and design notes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4fd31a182
commit
cf4d5b3ce2
12 files changed
+2403
-147
No files matched your search
+117
-11
@@ -445,14 +445,15 @@ const PAGES = {
|
||||
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
|
||||
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
|
||||
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
|
||||
channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'],
|
||||
statuspage:['Status page', 'What customers see: components, incidents, scheduled maintenance, and who follows along.'],
|
||||
channels: ['Channels','Email addresses and webhooks that receive every alert and incident update.'],
|
||||
users: ['People', 'Everyone who can sign in, and how to reach them.'],
|
||||
account: ['Account', 'Your password, API keys and personal topic.'],
|
||||
};
|
||||
const NAV = [
|
||||
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
|
||||
['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
|
||||
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
|
||||
['Set up', [['statuspage', 'Status page', 'status'], ['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
|
||||
];
|
||||
function route() {
|
||||
const [tab, arg] = (location.hash.replace(/^#\/?/, '') || 'alerts').split('/');
|
||||
@@ -468,7 +469,7 @@ async function render() {
|
||||
side.push(h('div', {class:'navsec'}, sec));
|
||||
for (const [k, l, i] of items) side.push(nav(k, l, i));
|
||||
}
|
||||
side.push(h('div', {class:'navsec'}, 'Public'), h('a', {class:'navitem', href:'/status', target:'_blank', rel:'noopener'}, icon('status'), 'Status page'));
|
||||
side.push(h('div', {class:'navsec'}, 'Public'), h('a', {class:'navitem', href:'/status', target:'_blank', rel:'noopener'}, icon('status'), 'View status page'));
|
||||
side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'),
|
||||
h('button', {class:'navitem', onclick:toggleTheme}, icon('dark'), 'Switch theme'),
|
||||
h('button', {class:'navitem', onclick: act(async () => { await api('POST', V1 + '/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out')));
|
||||
@@ -566,7 +567,8 @@ function raiseAlertDialog() {
|
||||
const KINDS = [['http', 'Website (HTTP)'], ['tcp', 'Port (TCP)'], ['dns', 'DNS lookup'], ['tls', 'Certificate expiry'], ['heartbeat', 'Heartbeat (cron job or service pings us)']];
|
||||
views.monitors = async () => {
|
||||
S.headerAction = ['Add monitor', () => monitorDialog()];
|
||||
const [mons, states] = await Promise.all([api('GET', V1 + '/monitors'), api('GET', V1 + '/monitor-states')]);
|
||||
const [mons, states, groups] = await Promise.all([api('GET', V1 + '/monitors'), api('GET', V1 + '/monitor-states'), api('GET', V1 + '/status-groups')]);
|
||||
S.sgroups = groups;
|
||||
if (!mons.length) return emptyState('monitors', 'No monitors yet', 'Add a check for a website, a port, a certificate or a scheduled job. Failures raise an alert.', h('button', {class:'btn', onclick:() => monitorDialog()}, 'Add monitor'));
|
||||
const statusBadge = (m, st) => !m.enabled ? badge('Paused') : st.status === 'up' ? badge('Up', 'success') : st.status === 'down' ? badge('Down', 'danger') : badge('Waiting for first check');
|
||||
return table(['Name', 'Check', 'Status', 'Last check', 'Uptime', ''], mons.map(m => { const st = states[m.id] || {};
|
||||
@@ -589,6 +591,7 @@ function monitorDialog(m) {
|
||||
warn: h('input', {type:'number', min:1, value:m.warnDays || 14}), grace: h('input', {type:'number', min:0, value:m.graceS || 0}),
|
||||
pol: select([['', 'None, only broadcast'], ...S.policies.map(p => [p.id, p.name])], m.policyId || ''),
|
||||
sev: select([['critical', 'Critical'], ['warning', 'Warning'], ['info', 'Info']], m.severity || 'critical'),
|
||||
group: select([['', 'No group'], ...(S.sgroups || []).map(g => [g.id, g.name])], m.group || ''), pubname: h('input', {value:m.publicName || ''}),
|
||||
pub: chk('Show on the public status page', m.public), priv: chk('Allow targets on private networks', m.allowPrivate), on: chk('Run this check', m.enabled) };
|
||||
const only = (kinds, el) => { const w = h('div', {class:'stack'}, el); w.kinds = kinds; return w; };
|
||||
const groups = [
|
||||
@@ -606,11 +609,12 @@ function monitorDialog(m) {
|
||||
groups.push(hbOnly); sync();
|
||||
dialogForm(e ? 'Edit ' + m.name : 'Add a monitor', null, [field('Name', f.name), field('Type', f.kind), ...groups,
|
||||
h('div', {class:'grid'}, field('Check every (seconds)', f.interval, 'At least 10.'), field('Timeout (seconds)', f.timeout), field('Failures before down', f.retries, 'A blip is ignored.')),
|
||||
h('div', {class:'grid'}, field('Alert severity', f.sev), field('Escalation policy', f.pol)), f.pub, f.on], e ? 'Save changes' : 'Add monitor',
|
||||
h('div', {class:'grid'}, field('Alert severity', f.sev), field('Escalation policy', f.pol)), f.pub,
|
||||
h('div', {class:'grid'}, field('Status page group', f.group, 'Groups are managed under Status page.'), field('Name on the status page', f.pubname, 'Leave empty to use the monitor name.')), f.on], e ? 'Save changes' : 'Add monitor',
|
||||
async () => {
|
||||
const body = {...m, name:f.name.value, kind:f.kind.value, target:f.target.value, intervalS:+f.interval.value, timeoutS:+f.timeout.value, retries:+f.retries.value,
|
||||
expect:f.expect.value, keyword:f.keyword.value, warnDays:+f.warn.value, graceS:+f.grace.value, policyId:f.pol.value, severity:f.sev.value,
|
||||
public:f.pub.box.checked, allowPrivate:f.priv.box.checked, enabled:f.on.box.checked};
|
||||
public:f.pub.box.checked, group:f.group.value, publicName:f.pubname.value.trim(), allowPrivate:f.priv.box.checked, enabled:f.on.box.checked};
|
||||
const saved = await api(e ? 'PUT' : 'POST', V1 + '/monitors' + (e ? '/' + m.id : ''), body);
|
||||
toast(e ? 'Monitor saved.' : 'Monitor added.'); refresh();
|
||||
if (!e && saved.kind === 'heartbeat') { const done = modal('Heartbeat address', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'Have your job request this address each time it runs. It goes down when the requests stop.'), cmdBox(`${origin()}/api/v1/hb/${saved.token}`), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }
|
||||
@@ -687,10 +691,22 @@ views.incidents = async () => {
|
||||
h('td', {}, h('a', {href:'#incidents/' + i.id}, i.title), i.public ? h('div', {}, badge('On status page', 'accent')) : null),
|
||||
h('td', {}, incStatus(i.status)), h('td', {}, userName(i.lead) || 'Nobody'), h('td', {class:'num'}, fmtTime(i.created)))));
|
||||
};
|
||||
function declareDialog(alertIds, title) {
|
||||
const f = { title: h('input', {value:title || ''}), sev: select(SEVS, 'sev2'), sum: h('textarea'), lead: select(userOpts('Me'), '') };
|
||||
dialogForm('Declare an incident', 'Everyone subscribed to the incidents topic and every channel is told.', [field('Title', f.title, 'What is wrong, in a few words.'), field('Severity', f.sev), field('What we know so far', f.sum), field('Incident lead', f.lead)], 'Declare incident',
|
||||
async () => { const i = await api('POST', V1 + '/incidents', {title:f.title.value, severity:f.sev.value, summary:f.sum.value, lead:f.lead.value, alertIds:alertIds || []}); toast('Incident declared.'); location.hash = 'incidents/' + i.id; });
|
||||
// monitors that are on the status page, which are the components an incident can affect
|
||||
const publicMonitors = async () => (await api('GET', V1 + '/monitors')).filter(m => m.public && m.enabled);
|
||||
const monName = m => m.publicName || m.name;
|
||||
// a list of checkboxes, one per component; values() gives the ids that are ticked
|
||||
function componentPicker(mons, selected) {
|
||||
const boxes = mons.map(m => chk(monName(m), (selected || []).includes(m.id)));
|
||||
const box = h('div', {class:'stack'}, boxes.length ? boxes : h('p', {class:'small dim', style:'margin:0'}, 'No monitors are on the status page yet. Tick "Show on the public status page" on a monitor.'));
|
||||
return Object.assign(box, {values: () => mons.filter((m, i) => boxes[i].box.checked).map(m => m.id)});
|
||||
}
|
||||
async function declareDialog(alertIds, title) {
|
||||
const mons = await publicMonitors(), picker = componentPicker(mons, []);
|
||||
const f = { title: h('input', {value:title || ''}), sev: select(SEVS, 'sev2'), sum: h('textarea'), lead: select(userOpts('Me'), ''), pub: chk('Post it on the status page and tell subscribers', false) };
|
||||
const comps = h('div', {class:'stack'}, field('Affected components', picker, 'Shown on the status page.'));
|
||||
f.pub.box.onchange = () => { comps.style.display = f.pub.box.checked ? '' : 'none'; }; comps.style.display = 'none';
|
||||
dialogForm('Declare an incident', 'Everyone subscribed to the incidents topic and every channel is told.', [field('Title', f.title, 'What is wrong, in a few words.'), field('Severity', f.sev), field('What we know so far', f.sum, 'If you post it on the status page, customers see this text.'), field('Incident lead', f.lead), f.pub, comps], 'Declare incident',
|
||||
async () => { const i = await api('POST', V1 + '/incidents', {title:f.title.value, severity:f.sev.value, summary:f.sum.value, lead:f.lead.value, alertIds:alertIds || [], public:f.pub.box.checked, components:picker.values()}); toast('Incident declared.'); location.hash = 'incidents/' + i.id; });
|
||||
}
|
||||
async function incidentDetail(id) {
|
||||
S.noHeader = true;
|
||||
@@ -698,13 +714,24 @@ async function incidentDetail(id) {
|
||||
const patch = act(async (body, msg) => { await api('PATCH', `${V1}/incidents/${id}`, body); toast(msg || 'Incident updated.'); refresh(); });
|
||||
const status = select(STATUSES, i.status, {onchange:e => patch({status:e.target.value})}), sev = select(SEVS, i.severity, {onchange:e => patch({severity:e.target.value})});
|
||||
const lead = select(userOpts('Nobody'), i.lead, {onchange:e => patch({lead:e.target.value})}), comms = select(userOpts('Nobody'), i.comms, {onchange:e => patch({comms:e.target.value})});
|
||||
const mons = await publicMonitors(), picker = componentPicker(mons, i.components);
|
||||
const pub = chk('Show on the public status page', i.public, v => patch({public:v}));
|
||||
const post = () => {
|
||||
const f = { status: select(STATUSES, i.status), text: h('textarea', {placeholder:'What customers should know'}) };
|
||||
dialogForm('Post an update', 'Customers and subscribers see this text and the status. The timeline and names stay internal.', [field('Status', f.status), field('Update', f.text, 'Leave empty to use the standard wording for the status.')], 'Post update',
|
||||
async () => { await api('PATCH', `${V1}/incidents/${id}`, {status:f.status.value, note:f.text.value}); toast('Update posted.'); refresh(); });
|
||||
};
|
||||
const sum = h('textarea', {}, i.summary), pm = h('textarea', {style:'min-height:220px', class:'mono'}, i.postmortem), note = h('input', {placeholder:'Add a comment for the timeline'});
|
||||
const say = act(async () => { if (!note.value.trim()) return; await api('POST', `${V1}/incidents/${id}/comments`, {text:note.value}); note.value = ''; refresh(); });
|
||||
note.addEventListener('keydown', e => e.key === 'Enter' && say());
|
||||
setTimeout(() => { S.noHeader = false; });
|
||||
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#incidents', class:'small'}, 'Back to incidents'), h('h1', {}, i.title), h('p', {}, 'Declared ' + fmtTime(i.created) + (i.resolved ? ', resolved ' + fmtTime(i.resolved) : ''))), h('div', {}, incStatus(i.status))),
|
||||
card('Status', h('div', {class:'grid'}, field('Status', status), field('Severity', sev), field('Lead', lead), field('Communications', comms)), pub),
|
||||
card('Status', h('div', {class:'grid'}, field('Status', status), field('Severity', sev), field('Lead', lead), field('Communications', comms))),
|
||||
card('Status page', h('div', {class:'stack'}, pub,
|
||||
i.public ? h('p', {class:'small dim', style:'margin:0'}, 'Changing the status above, or posting an update, adds a post to the public page and emails subscribers.') : h('p', {class:'small dim', style:'margin:0'}, 'Turning this on posts the summary below and tells subscribers.'),
|
||||
field('Affected components', picker, 'The page shows these as affected until the incident is resolved.'),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => patch({components:picker.values()}, 'Components saved.')}, 'Save components'),
|
||||
i.public ? h('button', {class:'btn', onclick:post}, 'Post an update') : null))),
|
||||
card('Summary', h('div', {class:'stack'}, sum, h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => patch({summary:sum.value}, 'Summary saved.')}, 'Save summary')))),
|
||||
card('Timeline', timeline(i.timeline || []), h('div', {class:'actions', style:'margin-top:16px'}, h('div', {style:'flex:1;min-width:200px'}, note), h('button', {class:'ghost', onclick:say}, 'Add comment'))),
|
||||
card('Post-mortem', h('div', {class:'stack'}, pm, h('div', {class:'actions form-actions'},
|
||||
@@ -739,6 +766,85 @@ views.topics = async () => {
|
||||
card('From the command line', cmdBox(`curl -H "Authorization: Bearer <api key>" -H "Title: Disk full" -H "Priority: urgent" -d "web-1 is at 98%" ${base}/alerts\ncurl -H "Authorization: Bearer <api key>" -N ${base}/alerts/sse`)));
|
||||
};
|
||||
|
||||
// ---------- status page ----------
|
||||
const MSTATE = {scheduled:['Scheduled', ''], in_progress:['In progress', 'accent'], completed:['Completed', 'success'], canceled:['Canceled', '']};
|
||||
views.statuspage = async () => {
|
||||
const which = S.spTab || 'page';
|
||||
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Status page sections'}, [['page', 'Page'], ['groups', 'Groups'], ['maintenance', 'Maintenance'], ['subscribers', 'Subscribers']].map(([k, l]) =>
|
||||
h('button', {class:which === k ? 'on' : '', 'aria-pressed':which === k, onclick:() => { S.spTab = k; refresh(); }}, l)));
|
||||
const admin = isAdmin();
|
||||
if (which === 'groups') {
|
||||
S.headerAction = admin ? ['Add group', () => groupDialog()] : null;
|
||||
const [groups, mons] = await Promise.all([api('GET', V1 + '/status-groups'), api('GET', V1 + '/monitors')]);
|
||||
S.sgroups = groups;
|
||||
if (!groups.length) return h('div', {}, tabs, emptyState('status', 'No groups yet', 'Groups put related components under one heading, such as Website, API and Infrastructure. Components without a group are listed first.', admin ? h('button', {class:'btn', onclick:() => groupDialog()}, 'Add group') : null));
|
||||
return h('div', {}, tabs, table(['Group', 'Components', 'Order', ''], groups.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)).map(g => {
|
||||
const inside = mons.filter(m => m.public && m.group === g.id);
|
||||
return h('tr', {}, h('td', {}, g.name, g.description ? h('div', {class:'small dim'}, g.description) : null), h('td', {}, inside.length ? inside.map(monName).join(', ') : 'None yet'), h('td', {class:'num'}, g.order),
|
||||
h('td', {}, admin ? h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => groupDialog(g)}, 'Edit'),
|
||||
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${g.name}?`, body:'Its components move to the top of the page, ungrouped. The monitors are not changed. This cannot be undone.', confirm:'Delete group'}, async () => { await api('DELETE', `${V1}/status-groups/${g.id}`); toast('Group deleted.'); refresh(); })}, 'Delete')) : null));
|
||||
})));
|
||||
}
|
||||
if (which === 'maintenance') {
|
||||
S.headerAction = admin ? ['Schedule maintenance', () => maintenanceDialog()] : null;
|
||||
const [list, mons] = await Promise.all([api('GET', V1 + '/maintenance'), api('GET', V1 + '/monitors')]);
|
||||
const name = id => { const m = mons.find(x => x.id === id); return m ? monName(m) : null; };
|
||||
if (!list.length) return h('div', {}, tabs, emptyState('status', 'No maintenance scheduled', 'Planned work is announced on the status page and sent to subscribers when it is scheduled, when it starts and when it ends. Affected components show as under maintenance meanwhile.', admin ? h('button', {class:'btn', onclick:() => maintenanceDialog()}, 'Schedule maintenance') : null));
|
||||
return h('div', {}, tabs, table(['Maintenance', 'Window', 'Status', ''], list.map(m => { const st = MSTATE[m.state] || [m.state, ''], open = m.state === 'scheduled' || m.state === 'in_progress';
|
||||
return h('tr', {}, h('td', {}, m.title, h('div', {class:'small dim'}, m.components.map(name).filter(Boolean).join(', ') || 'No components named')), h('td', {class:'num'}, fmtTime(m.start) + ' to ' + fmtTime(m.end)), h('td', {}, badge(st[0], st[1])),
|
||||
h('td', {}, admin ? h('div', {class:'actions'}, open ? h('button', {class:'ghost', onclick:() => maintenanceDialog(m)}, 'Edit') : null,
|
||||
open ? h('button', {class:'ghost danger', onclick:ask({title:`Cancel ${m.title}?`, body:'Subscribers are told it is canceled. It stays in the history.', confirm:'Cancel maintenance', cancel:'Keep it'}, async () => { await api('POST', `${V1}/maintenance/${m.id}/cancel`); toast('Maintenance canceled.'); refresh(); })}, 'Cancel') : null,
|
||||
h('button', {class:'ghost danger', onclick:ask({title:`Delete ${m.title}?`, body:'It is removed from the status page and its history. This cannot be undone.', confirm:'Delete maintenance'}, async () => { await api('DELETE', `${V1}/maintenance/${m.id}`); toast('Maintenance deleted.'); refresh(); })}, 'Delete')) : null));
|
||||
})));
|
||||
}
|
||||
if (which === 'subscribers') {
|
||||
S.headerAction = null;
|
||||
if (!admin) return h('div', {}, tabs, emptyState('users', 'Administrators only', 'Ask an administrator to see who follows the status page.'));
|
||||
const list = await api('GET', V1 + '/status-subscribers');
|
||||
if (!list.length) return h('div', {}, tabs, emptyState('users', 'No subscribers yet', 'People subscribe with the button on the public page. Confirmed email addresses and webhooks appear here.'));
|
||||
return h('div', {}, tabs, table(['Type', 'Address', 'Status', 'Subscribed', ''], list.map(s => h('tr', {}, h('td', {}, s.kind === 'email' ? 'Email' : 'Webhook'), h('td', {class:'mono small'}, s.target),
|
||||
h('td', {}, s.confirmed ? badge('Confirmed', 'success') : badge('Waiting for confirmation')), h('td', {class:'num'}, fmtTime(s.created)),
|
||||
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost danger', onclick:ask({title:`Remove ${s.target}?`, body:'They stop getting updates. They can subscribe again from the page.', confirm:'Remove subscriber'}, async () => { await api('DELETE', `${V1}/status-subscribers/${s.id}`); toast('Subscriber removed.'); refresh(); })}, 'Remove')))))));
|
||||
}
|
||||
S.headerAction = null;
|
||||
const r = await api('GET', V1 + '/statuspage'), c = r.settings;
|
||||
const f = { title: h('input', {value:c.title}), desc: h('textarea', {}, c.description), logo: h('input', {type:'url', value:c.logoUrl, placeholder:'https://example.com/logo.svg'}),
|
||||
accent: h('input', {value:c.accent, placeholder:'#1f5c5c'}), support: h('input', {value:c.supportUrl, placeholder:'https://example.com/support or mailto:help@example.com'}),
|
||||
footer: h('input', {value:c.footer}), domain: h('input', {value:c.domain, placeholder:'status.example.com'}) };
|
||||
const save = act(async () => {
|
||||
await api('PUT', V1 + '/statuspage', {title:f.title.value, description:f.desc.value, logoUrl:f.logo.value, accent:f.accent.value, supportUrl:f.support.value, footer:f.footer.value, domain:f.domain.value});
|
||||
toast('Status page saved.'); refresh();
|
||||
});
|
||||
if (!admin) [...Object.values(f)].forEach(i => i.disabled = true);
|
||||
return h('div', {}, tabs,
|
||||
card('Address', h('div', {class:'stack'}, h('p', {class:'small dim', style:'margin:0'}, 'Monitors and incidents appear here when you mark them to show on the public status page.'),
|
||||
h('div', {}, h('div', {class:'small dim'}, 'Status page'), h('a', {href:r.pageUrl, target:'_blank', rel:'noopener', class:'mono'}, r.pageUrl)),
|
||||
h('div', {}, h('div', {class:'small dim'}, 'RSS feed'), h('a', {href:r.feedUrl, target:'_blank', rel:'noopener', class:'mono'}, r.feedUrl)),
|
||||
r.email ? null : h('p', {class:'small dim', style:'margin:0'}, 'Email subscriptions are off until an SMTP server is set (SANDBAR_SMTP_HOST). Webhooks and RSS work without it.'))),
|
||||
card('Branding', h('div', {class:'stack'}, field('Title', f.title), field('Description', f.desc, 'Shown under the title.'), h('div', {class:'grid'}, field('Logo address', f.logo, 'An https:// address of an image.'),
|
||||
field('Accent color', f.accent, 'Buttons and links, as #rrggbb. Leave empty for the default. White text must stay readable on it.')),
|
||||
h('div', {class:'grid'}, field('Contact link', f.support, 'Adds a Contact us button.'), field('Footer text', f.footer)))),
|
||||
card('Custom domain', h('div', {class:'stack'}, field('Host name', f.domain, 'Serve the page at the root of this host name, such as status.example.com. Leave empty to use the address above only.'),
|
||||
h('p', {class:'small dim', style:'margin:0'}, 'Point a DNS record for it at the server that runs Sandbar, and have your reverse proxy forward that host name to Sandbar with the Host header unchanged. Sandbar serves only the status page on that host. The proxy provides the certificate.'))),
|
||||
admin ? h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:save}, 'Save status page')) : null);
|
||||
};
|
||||
function groupDialog(g) {
|
||||
const e = !!g; g = g || {order:(S.sgroups || []).length + 1};
|
||||
const f = { name: h('input', {value:g.name || ''}), desc: h('input', {value:g.description || ''}), order: h('input', {type:'number', value:g.order})};
|
||||
dialogForm(e ? 'Edit ' + g.name : 'Add a group', null, [field('Name', f.name), field('Description', f.desc, 'Optional, shown under the name.'), field('Order', f.order, 'Groups are listed from the lowest number to the highest.')], e ? 'Save changes' : 'Add group',
|
||||
async () => { await api(e ? 'PUT' : 'POST', V1 + '/status-groups' + (e ? '/' + g.id : ''), {name:f.name.value, description:f.desc.value, order:+f.order.value || 0}); toast('Group saved.'); refresh(); });
|
||||
}
|
||||
async function maintenanceDialog(m) {
|
||||
const e = !!m, mons = await publicMonitors(), soon = Math.ceil(Date.now() / 3600000) * 3600 + 3600;
|
||||
m = m || {start:soon, end:soon + 3600, components:[]};
|
||||
const picker = componentPicker(mons, m.components);
|
||||
const f = { title: h('input', {value:m.title || ''}), msg: h('textarea', {}, m.message || ''), start: h('input', {type:'datetime-local', value:fromUnix(m.start)}), end: h('input', {type:'datetime-local', value:fromUnix(m.end)}) };
|
||||
dialogForm(e ? 'Edit ' + m.title : 'Schedule maintenance', 'Subscribers are told now, when it starts and when it ends. Times are in your time zone.',
|
||||
[field('Title', f.title, 'What is happening, in a few words.'), field('Details', f.msg, 'What customers should expect.'), h('div', {class:'grid'}, field('Starts', f.start), field('Ends', f.end)), field('Affected components', picker, 'They show as under maintenance during the window.')],
|
||||
e ? 'Save changes' : 'Schedule maintenance',
|
||||
async () => { await api(e ? 'PUT' : 'POST', V1 + '/maintenance' + (e ? '/' + m.id : ''), {title:f.title.value, message:f.msg.value, start:toUnix(f.start.value), end:toUnix(f.end.value), components:picker.values()}); toast('Maintenance saved.'); refresh(); });
|
||||
}
|
||||
|
||||
// ---------- channels ----------
|
||||
views.channels = async () => {
|
||||
S.headerAction = isAdmin() ? ['Add channel', () => channelDialog()] : null;
|
||||
|
||||
Reference in new issue
Block a user