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;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Status</title>
|
||||
<title>__TITLE__</title>
|
||||
<link rel="alternate" type="application/rss+xml" title="__TITLE__" href="__BASE__/feed.rss">
|
||||
<style>
|
||||
/* tokens:begin Design tokens, the Lightwell design language (docs/design.md). This is the only place raw colors and sizes are defined. */
|
||||
:root {
|
||||
@@ -33,18 +34,36 @@
|
||||
/* tokens:end */
|
||||
|
||||
*, *::before, *::after { box-sizing:border-box; }
|
||||
[hidden] { display:none !important; }
|
||||
body { margin:0; background:var(--bg); color:var(--text); font:var(--text-base)/var(--leading) var(--font); -webkit-font-smoothing:antialiased; }
|
||||
h1, h2 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
|
||||
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-lg); margin:var(--s6) 0 var(--s3); }
|
||||
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s4) var(--s8); }
|
||||
h1, h2, h3 { margin:0; line-height:1.25; font-weight:600; letter-spacing:-.01em; }
|
||||
h1 { font-size:var(--text-2xl); } h2 { font-size:var(--text-lg); margin:var(--s6) 0 var(--s3); } h3 { font-size:var(--text-base); }
|
||||
main { max-width:var(--content-max); margin:0 auto; padding:var(--s6) var(--s4) var(--s5); }
|
||||
footer { max-width:var(--content-max); margin:0 auto; padding:0 var(--s4) var(--s8); color:var(--text-muted); font-size:var(--text-sm); }
|
||||
footer .links { display:flex; gap:var(--s4); flex-wrap:wrap; margin-bottom:var(--s2); }
|
||||
a { color:var(--accent-text); }
|
||||
p { margin:0; }
|
||||
.dim { color:var(--text-muted); } .small { font-size:var(--text-sm); } .num { font-variant-numeric:tabular-nums; }
|
||||
.head { display:flex; align-items:center; justify-content:space-between; gap:var(--s4); flex-wrap:wrap; }
|
||||
.brand { display:flex; align-items:center; gap:var(--s3); min-width:0; }
|
||||
.brand img { max-height:40px; max-width:160px; object-fit:contain; }
|
||||
.brand a { color:inherit; text-decoration:none; }
|
||||
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
|
||||
.btn, .ghost { display:inline-flex; align-items:center; justify-content:center; min-height:36px; padding:var(--s2) var(--s4); border-radius:var(--radius); font:inherit; font-weight:500; text-decoration:none; cursor:pointer; border:var(--border-w) solid transparent; }
|
||||
.btn { background:var(--accent); color:var(--accent-contrast); }
|
||||
.ghost { background:var(--surface); color:var(--text); border-color:var(--border-strong); }
|
||||
.btn:hover { filter:brightness(1.1); } .ghost:hover { background:var(--surface-2); }
|
||||
.desc { margin-top:var(--s2); color:var(--text-muted); }
|
||||
.banner { margin-top:var(--s4); padding:var(--s4) var(--s5); border-radius:var(--radius-lg); border:var(--border-w) solid var(--border); font-weight:600; background:var(--surface-2); }
|
||||
.banner.operational { background:var(--success-bg); color:var(--success); border-color:var(--success); }
|
||||
.banner.degraded { background:var(--warning-bg); color:var(--warning); border-color:var(--warning); }
|
||||
.banner.degraded, .banner.maintenance { background:var(--warning-bg); color:var(--warning); border-color:var(--warning); }
|
||||
.banner.outage { background:var(--danger-bg); color:var(--danger); border-color:var(--danger); }
|
||||
.notice { margin-top:var(--s4); padding:var(--s3) var(--s4); border-radius:var(--radius); border:var(--border-w) solid var(--border-strong); background:var(--surface); display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
|
||||
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; }
|
||||
.card + .card { margin-top:var(--s3); }
|
||||
.row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||
.row:last-child { border-bottom:0; }
|
||||
.ghead { display:flex; align-items:baseline; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); background:var(--surface-2); border-bottom:var(--border-w) solid var(--border); }
|
||||
.svc { padding:var(--s3) var(--s4) var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||
.svc:last-child { border-bottom:0; }
|
||||
.svc .top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
|
||||
@@ -60,29 +79,52 @@
|
||||
.badge-success { background:var(--success-bg); color:var(--success); }
|
||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||
.badge-danger { background:var(--danger-bg); color:var(--danger); }
|
||||
.badge-accent { background:var(--accent-soft); color:var(--accent-text); }
|
||||
.inc { padding:var(--s4); border-bottom:var(--border-w) solid var(--border); }
|
||||
.inc:last-child { border-bottom:0; }
|
||||
.inc ul { margin:var(--s2) 0 0; padding-left:var(--s5); }
|
||||
.inc h3 a { color:inherit; }
|
||||
.posts { list-style:none; margin:var(--s3) 0 0; padding:0 0 0 var(--s4); border-left:2px solid var(--border); display:flex; flex-direction:column; gap:var(--s3); }
|
||||
.posts p { white-space:pre-wrap; }
|
||||
.month { font-size:var(--text-sm); font-weight:600; color:var(--text-muted); margin:var(--s5) 0 var(--s2); }
|
||||
.sub { padding:var(--s4); display:grid; gap:var(--s4); }
|
||||
.sub form { display:flex; gap:var(--s2); flex-wrap:wrap; align-items:flex-end; }
|
||||
.sub label { display:flex; flex-direction:column; gap:var(--s1); flex:1; min-width:200px; font-size:var(--text-sm); font-weight:500; }
|
||||
input { font:inherit; min-height:36px; padding:var(--s2) var(--s3); border-radius:var(--radius); border:var(--border-w) solid var(--border-strong); background:var(--surface); color:var(--text); }
|
||||
.mono { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:var(--text-sm); word-break:break-all; }
|
||||
:focus-visible { outline:2px solid var(--focus); outline-offset:2px; border-radius:var(--radius); }
|
||||
@media (pointer:coarse) { .btn, .ghost, input { min-height:44px; } input { font-size:16px; } }
|
||||
@media (prefers-reduced-motion:no-preference) { .btn, .ghost { transition:background-color .12s, filter .12s; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tip" id="tip" role="presentation" hidden></div>
|
||||
<main>
|
||||
<h1>Status</h1>
|
||||
<div id="banner" class="banner" role="status">Loading</div>
|
||||
<h2>Services</h2>
|
||||
<div class="card" id="services"></div>
|
||||
<h2>Incidents</h2>
|
||||
<div class="card" id="incidents"></div>
|
||||
<main id="main" tabindex="-1">
|
||||
<div class="head">
|
||||
<div class="brand" id="brand"></div>
|
||||
<div class="actions" id="actions"></div>
|
||||
</div>
|
||||
<p class="desc" id="desc" hidden></p>
|
||||
<div id="notice"></div>
|
||||
<div id="view" aria-live="polite"><div class="banner" role="status">Loading</div></div>
|
||||
</main>
|
||||
<footer id="foot"></footer>
|
||||
<script>
|
||||
const BASE = '__BASE__';
|
||||
const $ = id => document.getElementById(id);
|
||||
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
||||
const link = (href, text, cls) => { const a = el('a', cls, text); a.href = href; return a; };
|
||||
const home = BASE || '/';
|
||||
const when = t => new Date(t * 1000).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
|
||||
const label = { operational: 'All systems are operational', degraded: 'Some systems are affected', outage: 'There is a service outage' };
|
||||
const statusBadge = s => s === 'up' ? el('span', 'badge badge-success', 'Operational') : s === 'down' ? el('span', 'badge badge-danger', 'Down') : el('span', 'badge', 'Waiting for first check');
|
||||
const incStatus = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' };
|
||||
const monthOf = t => new Date(t * 1000).toLocaleDateString(undefined, {month:'long', year:'numeric'});
|
||||
|
||||
const overallLabel = { operational: 'All systems are operational', degraded: 'Some systems are affected', outage: 'There is a service outage', maintenance: 'Scheduled maintenance is in progress' };
|
||||
const stateBadge = {
|
||||
operational: ['Operational', 'success'], degraded: ['Degraded performance', 'warning'], partial_outage: ['Partial outage', 'warning'],
|
||||
major_outage: ['Major outage', 'danger'], maintenance: ['Under maintenance', 'accent'], pending: ['Waiting for first check', ''],
|
||||
};
|
||||
const badge = (text, tone) => el('span', 'badge' + (tone ? ' badge-' + tone : ''), text);
|
||||
const incLabel = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' };
|
||||
const incTone = s => s === 'resolved' ? 'success' : s === 'monitoring' ? 'accent' : 'warning';
|
||||
|
||||
// One bar per UTC day, oldest first. A day is green with no failed checks, amber when at least 99% passed,
|
||||
// and red below that. Color is never the only signal: the tooltip and the label say it in words.
|
||||
@@ -101,11 +143,11 @@ function showTip(e, lines) {
|
||||
const w = tip.offsetWidth, x = Math.min(Math.max(8, e.clientX - w / 2), innerWidth - w - 8);
|
||||
tip.style.left = x + 'px'; tip.style.top = Math.max(8, e.clientY - tip.offsetHeight - 14) + 'px';
|
||||
}
|
||||
function service(m, today) {
|
||||
function component(m, today) {
|
||||
const box = el('div', 'svc'), top = el('div', 'top');
|
||||
top.append(el('span', '', m.name));
|
||||
const right = el('span', 'small dim num');
|
||||
right.append(statusBadge(m.status), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
|
||||
const right = el('span', 'small dim num'), b = stateBadge[m.state] || stateBadge.pending;
|
||||
right.append(badge(b[0], b[1]), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
|
||||
top.append(right); box.append(top);
|
||||
const days = m.days || [], bars = el('div', 'bars');
|
||||
const bad = days.filter(d => dayClass(d) === 'bad').length, warn = days.filter(d => dayClass(d) === 'warn').length;
|
||||
@@ -113,12 +155,12 @@ function service(m, today) {
|
||||
bars.setAttribute('aria-label', m.name + ', the last ' + days.length + ' days: ' + (m.uptime >= 0 ? m.uptime.toFixed(2) + '% uptime' : 'no checks yet') +
|
||||
(bad || warn ? ', ' + bad + ' days with an outage and ' + warn + ' with short failures' : ', no failures'));
|
||||
days.forEach((d, i) => {
|
||||
const b = el('span', 'bar ' + dayClass(d));
|
||||
const s = el('span', 'bar ' + dayClass(d));
|
||||
const lines = dayText(d, i, days.length, today);
|
||||
b.addEventListener('pointerenter', e => showTip(e, lines));
|
||||
b.addEventListener('pointermove', e => showTip(e, lines));
|
||||
b.addEventListener('pointerleave', () => { tip.hidden = true; });
|
||||
bars.append(b);
|
||||
s.addEventListener('pointerenter', e => showTip(e, lines));
|
||||
s.addEventListener('pointermove', e => showTip(e, lines));
|
||||
s.addEventListener('pointerleave', () => { tip.hidden = true; });
|
||||
bars.append(s);
|
||||
});
|
||||
const axis = el('div', 'axis'); axis.setAttribute('aria-hidden', 'true');
|
||||
axis.append(el('span', '', days.length + ' days ago'), el('span', '', 'Today'));
|
||||
@@ -126,27 +168,206 @@ function service(m, today) {
|
||||
return box;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let d;
|
||||
try { d = await (await fetch('/status.json')).json(); } catch (e) { $('banner').textContent = 'The status could not be loaded. Try again in a moment.'; return; }
|
||||
$('banner').className = 'banner ' + d.overall;
|
||||
$('banner').textContent = label[d.overall] || d.overall;
|
||||
const worst = ['operational', 'pending', 'maintenance', 'degraded', 'partial_outage', 'major_outage'];
|
||||
function groups(d) {
|
||||
const out = [], byId = {};
|
||||
const open = (id, g) => { byId[id] = { g, items: [] }; out.push(byId[id]); };
|
||||
open('', null);
|
||||
for (const g of d.groups) open(g.id, g);
|
||||
for (const m of d.monitors) (byId[m.group] || byId['']).items.push(m);
|
||||
return out.filter(x => x.items.length);
|
||||
}
|
||||
function services(d) {
|
||||
const wrap = el('div');
|
||||
if (!d.monitors.length) { const c = el('div', 'card'); c.append(el('div', 'row dim', 'No services are published yet.')); wrap.append(c); return wrap; }
|
||||
for (const { g, items } of groups(d)) {
|
||||
const card = el('div', 'card');
|
||||
if (g) {
|
||||
const head = el('div', 'ghead'), left = el('div');
|
||||
left.append(el('h3', '', g.name));
|
||||
if (g.description) left.append(el('div', 'small dim', g.description));
|
||||
const w = items.map(i => i.state).reduce((a, s) => worst.indexOf(s) > worst.indexOf(a) ? s : a, 'operational'), b = stateBadge[w];
|
||||
head.append(left, badge(b[0], b[1])); card.append(head);
|
||||
}
|
||||
for (const m of items) card.append(component(m, d.today));
|
||||
wrap.append(card);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const sv = $('services'); sv.replaceChildren();
|
||||
if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.'));
|
||||
for (const m of d.monitors) sv.append(service(m, d.today));
|
||||
function posts(list) {
|
||||
const ul = el('ul', 'posts');
|
||||
for (const u of list) {
|
||||
const li = el('li'), head = el('div', 'small');
|
||||
head.append(el('strong', '', incLabel[u.status] || 'Update'), el('span', 'dim num', ' · ' + when(u.time)));
|
||||
li.append(head, el('p', '', u.text)); ul.append(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
function incident(i, full) {
|
||||
const b = el('div', 'inc'), head = el('h3');
|
||||
head.append(full ? document.createTextNode(i.title) : link(BASE + '/incidents/' + i.id, i.title), document.createTextNode(' '), badge(incLabel[i.status] || i.status, incTone(i.status)));
|
||||
b.append(head);
|
||||
b.append(el('div', 'small dim', 'Started ' + when(i.created) + (i.resolved ? ', resolved ' + when(i.resolved) : '') + (i.components.length ? ' · Affects ' + i.components.join(', ') : '')));
|
||||
b.append(posts(i.updates));
|
||||
return b;
|
||||
}
|
||||
function maintenance(m) {
|
||||
const b = el('div', 'inc'), head = el('h3');
|
||||
head.append(document.createTextNode(m.title + ' '), m.state === 'in_progress' ? badge('In progress', 'accent') : m.state === 'scheduled' ? badge('Scheduled', '') : badge('Completed', 'success'));
|
||||
b.append(head, el('div', 'small dim', when(m.start) + ' to ' + when(m.end) + (m.components.length ? ' · Affects ' + m.components.join(', ') : '')));
|
||||
if (m.message) { const p = el('p', '', m.message); p.style.whiteSpace = 'pre-wrap'; p.style.marginTop = 'var(--s2)'; b.append(p); }
|
||||
return b;
|
||||
}
|
||||
|
||||
const ic = $('incidents'); ic.replaceChildren();
|
||||
// ---------- branding, header, footer ----------
|
||||
function brand(d) {
|
||||
const s = d.settings;
|
||||
if (s.accent) { document.documentElement.style.setProperty('--accent', s.accent); }
|
||||
const b = $('brand'); b.replaceChildren();
|
||||
if (s.logoUrl) { const img = el('img'); img.src = s.logoUrl; img.alt = ''; img.referrerPolicy = 'no-referrer'; img.addEventListener('error', () => img.remove()); b.append(img); }
|
||||
const h = el('h1'); h.append(link(home, s.title)); b.append(h);
|
||||
const desc = $('desc'); desc.textContent = s.description || ''; desc.hidden = !s.description;
|
||||
const a = $('actions'); a.replaceChildren();
|
||||
const sub = el('button', 'btn', 'Subscribe to updates'); sub.type = 'button'; sub.setAttribute('aria-expanded', 'false'); sub.setAttribute('aria-controls', 'subpanel');
|
||||
sub.addEventListener('click', () => { const p = $('subpanel'); if (!p) return location.assign(home + '#subscribe'); p.hidden = !p.hidden; sub.setAttribute('aria-expanded', String(!p.hidden)); if (!p.hidden) p.querySelector('input').focus(); });
|
||||
a.append(sub);
|
||||
if (s.supportUrl) a.append(link(s.supportUrl, 'Contact us', 'ghost'));
|
||||
const f = $('foot'); f.replaceChildren();
|
||||
const links = el('div', 'links');
|
||||
links.append(link(BASE + '/history', 'Incident history'), link(BASE + '/feed.rss', 'RSS feed'));
|
||||
f.append(links);
|
||||
if (s.footer) f.append(el('p', '', s.footer));
|
||||
}
|
||||
|
||||
// ---------- subscribe ----------
|
||||
async function post(path, body) {
|
||||
const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.error || 'Something went wrong. Try again in a moment.');
|
||||
return j;
|
||||
}
|
||||
function subscribePanel(d) {
|
||||
const p = el('div', 'card sub'); p.id = 'subpanel'; p.hidden = location.hash !== '#subscribe';
|
||||
p.append(el('h3', '', 'Get notified when something changes'));
|
||||
const msg = el('p', 'small'); msg.setAttribute('role', 'status');
|
||||
const form = (kind, label, type, hint, button) => {
|
||||
const f = el('form'), l = el('label', '', label), i = el('input');
|
||||
i.type = type; i.required = true; i.name = kind; i.autocomplete = kind === 'email' ? 'email' : 'off';
|
||||
if (kind === 'webhook') i.placeholder = 'https://example.com/hooks/status';
|
||||
l.append(i); f.append(l);
|
||||
const b = el('button', 'btn', button); b.type = 'submit'; f.append(b);
|
||||
f.addEventListener('submit', async e => {
|
||||
e.preventDefault(); b.disabled = true; msg.textContent = '';
|
||||
try { const j = await post(BASE + '/subscribe', { [kind]: i.value.trim() }); msg.textContent = j.message; i.value = ''; }
|
||||
catch (err) { msg.textContent = err.message; }
|
||||
b.disabled = false;
|
||||
});
|
||||
const w = el('div'); w.append(f); if (hint) w.append(el('p', 'small dim', hint)); w.lastChild.style.marginTop = 'var(--s2)';
|
||||
return w;
|
||||
};
|
||||
if (d.subscribe.email) p.append(form('email', 'Email address', 'email', 'We send a link to confirm. Every update comes with a link to unsubscribe.', 'Subscribe by email'));
|
||||
if (d.subscribe.webhook) p.append(form('webhook', 'Webhook address', 'url', 'We send a JSON request for every update. Your server has to answer a test request first.', 'Subscribe with a webhook'));
|
||||
const rss = el('div'); rss.append(el('p', 'small', 'Or follow the RSS feed:'));
|
||||
const a = link(BASE + '/feed.rss', location.origin + BASE + '/feed.rss', 'mono'); rss.append(a);
|
||||
p.append(rss, msg);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------- pages ----------
|
||||
async function getJSON(path) {
|
||||
const r = await fetch(path);
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
return r.json();
|
||||
}
|
||||
async function showHome() {
|
||||
const d = await getJSON('/status.json');
|
||||
document.title = d.settings.title;
|
||||
brand(d);
|
||||
const v = el('div');
|
||||
const ban = el('div', 'banner ' + d.overall, overallLabel[d.overall] || d.overall); ban.setAttribute('role', 'status');
|
||||
v.append(ban);
|
||||
if (d.subscribe.email || d.subscribe.webhook) { const sp = subscribePanel(d); sp.style.marginTop = 'var(--s4)'; v.append(sp); }
|
||||
if (d.maintenance.length) {
|
||||
v.append(el('h2', '', 'Scheduled maintenance'));
|
||||
const c = el('div', 'card'); for (const m of d.maintenance) c.append(maintenance(m)); v.append(c);
|
||||
}
|
||||
v.append(el('h2', '', 'Services'), services(d));
|
||||
v.append(el('h2', '', 'Incidents'));
|
||||
const ic = el('div', 'card');
|
||||
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));
|
||||
for (const i of d.incidents) {
|
||||
const b = el('div', 'inc');
|
||||
const head = el('div'); head.append(el('strong', '', i.title + ' '), el('span', 'badge ' + (i.status === 'resolved' ? 'badge-success' : 'badge-warning'), incStatus[i.status] || i.status));
|
||||
b.append(head, el('div', 'small dim', 'Started ' + when(i.created) + (i.resolved ? ', resolved ' + when(i.resolved) : '')));
|
||||
if (i.updates.length) { const ul = el('ul', 'small'); for (const u of i.updates) ul.append(el('li', '', when(u.time) + ': ' + u.text)); b.append(ul); }
|
||||
ic.append(b);
|
||||
for (const i of d.incidents) ic.append(incident(i, false));
|
||||
v.append(ic);
|
||||
const more = el('p', 'small'); more.style.marginTop = 'var(--s3)'; more.append(link(BASE + '/history', 'See all past incidents'));
|
||||
v.append(more);
|
||||
const old = $('subpanel'), open = old && !old.hidden;
|
||||
$('view').replaceChildren(v);
|
||||
if (open) { $('subpanel').hidden = false; }
|
||||
}
|
||||
async function showHistory() {
|
||||
const first = await getJSON('/status.json');
|
||||
document.title = 'History - ' + first.settings.title;
|
||||
brand(first);
|
||||
const v = el('div'), list = el('div'), btn = el('button', 'ghost', 'Show older'); btn.type = 'button'; btn.style.marginTop = 'var(--s4)';
|
||||
v.append(el('h2', '', 'Incident history'), list, btn);
|
||||
$('view').replaceChildren(v);
|
||||
let next = 0, last = '';
|
||||
const load = async () => {
|
||||
const h = await getJSON('/status/api/history' + (next ? '?before=' + next : ''));
|
||||
let card = null;
|
||||
for (const e of h.entries) {
|
||||
const m = monthOf(e.time);
|
||||
if (m !== last) { last = m; v.insertBefore(el('div', 'month', m), btn); card = el('div', 'card'); v.insertBefore(card, btn); }
|
||||
card.append(e.type === 'incident' ? incident(e.incident, false) : maintenance(e.maintenance));
|
||||
}
|
||||
if (!list.children.length && !h.entries.length && !next) list.append(el('div', 'card')), list.firstChild.append(el('div', 'row dim', 'Nothing has been posted yet.'));
|
||||
next = h.next; btn.hidden = !next;
|
||||
};
|
||||
btn.addEventListener('click', () => load().catch(fail));
|
||||
await load();
|
||||
}
|
||||
async function showIncident(id) {
|
||||
const [d, i] = await Promise.all([getJSON('/status.json'), getJSON('/status/api/incidents/' + encodeURIComponent(id))]);
|
||||
document.title = i.title + ' - ' + d.settings.title;
|
||||
brand(d);
|
||||
const v = el('div'), back = el('p', 'small'); back.style.marginTop = 'var(--s5)'; back.append(link(BASE + '/history', 'Back to incident history'));
|
||||
const c = el('div', 'card'); c.style.marginTop = 'var(--s3)'; c.append(incident(i, true));
|
||||
v.append(back, c);
|
||||
$('view').replaceChildren(v);
|
||||
}
|
||||
|
||||
function fail(e) {
|
||||
const v = $('view');
|
||||
if (e && e.message === '404') { v.replaceChildren(el('div', 'banner', 'That page does not exist. Go back to the ')); v.firstChild.append(link(home, 'status page'), document.createTextNode('.')); return; }
|
||||
if (!v.querySelector('.card')) v.replaceChildren(el('div', 'banner', 'The status could not be loaded. Try again in a moment.'));
|
||||
}
|
||||
|
||||
// ---------- notices from confirm and unsubscribe links ----------
|
||||
function notices() {
|
||||
const q = new URLSearchParams(location.search), n = $('notice');
|
||||
const note = (text, ...extra) => { const d = el('div', 'notice'); d.setAttribute('role', 'status'); d.append(el('span', '', text), ...extra); n.replaceChildren(d); return d; };
|
||||
if (q.get('notice') === 'confirmed') note('You are subscribed. You will get an email for each update.');
|
||||
else if (q.get('notice') === 'invalid') note('That link is not valid. Subscribe again to get a new one.');
|
||||
const token = q.get('unsubscribe');
|
||||
if (token) {
|
||||
const b = el('button', 'btn', 'Unsubscribe'); b.type = 'button';
|
||||
const d = note('Stop getting updates from this page?', b);
|
||||
b.addEventListener('click', async () => {
|
||||
b.disabled = true;
|
||||
try { const j = await post(BASE + '/unsubscribe', { token }); d.replaceChildren(el('span', '', j.message)); history.replaceState(null, '', location.pathname); }
|
||||
catch (e) { d.replaceChildren(el('span', '', e.message)); }
|
||||
});
|
||||
}
|
||||
}
|
||||
load(); setInterval(load, 30000);
|
||||
|
||||
const rel = location.pathname.slice(BASE.length).replace(/\/+$/, '');
|
||||
let run;
|
||||
if (rel === '/history') run = showHistory;
|
||||
else if (rel.startsWith('/incidents/')) run = () => showIncident(decodeURIComponent(rel.slice(11)));
|
||||
else run = showHome;
|
||||
notices();
|
||||
run().catch(fail);
|
||||
if (run === showHome) setInterval(() => { if (!document.hidden && !document.activeElement.matches('input')) showHome().catch(() => {}); }, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user