diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 5b6be76..1a1ef27 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -845,6 +845,11 @@ kbd { .status-table th, .status-table td { white-space: nowrap; } .status-table td.wrap { white-space: normal; min-width: 12em; } .status-table .source-row td { border-bottom-style: dashed; } +.status-table tr.clickable { cursor: pointer; } +.status-table tr.clickable:hover td, .status-table tr.clickable:focus-visible td { background: rgba(127, 127, 127, .1); } +.switch-facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; } +.switch-facts dt { opacity: .7; } +.switch-facts dd { margin: 0; } .status-table .source-row td:first-child { padding-left: 16px; } .source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index 1aa8fc6..300a304 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -150,6 +150,8 @@ export const deleteIntegration = (id, integrationID) => export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`); export const createDeadmanSwitch = (id, body) => call('POST', `/teams/${id}/deadman/switches`, { body }); +export const updateDeadmanSwitch = (id, switchID, body) => + call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body }); export const deleteDeadmanSwitch = (id, switchID) => call('DELETE', `/teams/${id}/deadman/switches/${switchID}`); diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index 86537d3..a601445 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -735,36 +735,19 @@ function minutesInput(seconds, onChange) { // --- integrations ---------------------------------------------------------- function integrationsCard() { - const rows = (data.integrations || []).map((i) => - h('tr', {}, - h('td', {}, sourceBadge(i.status)), - h('td', { class: 'wrap' }, - h('strong', { text: i.name }), - h('div', { class: 'muted small', text: i.kind })), - // When the key last posted, and when an alert last arrived on it. They - // differ: a payload with nothing usable in it stamps only the first. - h('td', { class: 'muted small' }, timeCell(i.last_used_at)), - h('td', { class: 'muted small' }, timeCell(i.last_alert_at)), - h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours', - text: String(i.alerts_24h ?? 0) }), - h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })), - h('td', {}, isOwner() && h('div', { class: 'row-actions' }, - h('button', { - class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i), - }), - h('button', { - class: 'btn-sm danger', type: 'button', text: 'Revoke', - onclick: async () => { - if (!(await confirm({ - title: `Revoke ${i.name}?`, - text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.', - confirmLabel: 'Revoke', - danger: true, - }))) return; - act(() => api.deleteIntegration(teamID, i.id)); - }, - }))), - )); + const rows = (data.integrations || []).map((i) => clickableRow(h('tr', {}, + h('td', {}, sourceBadge(i.status)), + h('td', { class: 'wrap' }, + h('strong', { text: i.name }), + h('div', { class: 'muted small', text: i.kind })), + // When the key last posted, and when an alert last arrived on it. They + // differ: a payload with nothing usable in it stamps only the first. + h('td', { class: 'muted small' }, timeCell(i.last_used_at)), + h('td', { class: 'muted small' }, timeCell(i.last_alert_at)), + h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours', + text: String(i.alerts_24h ?? 0) }), + h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })), + ), () => openSourceDetail(i))); return h('div', { class: 'card' }, h('div', { class: 'card-head' }, @@ -780,9 +763,9 @@ function integrationsCard() { ? h('div', { class: 'table-scroll' }, h('table', { class: 'admin-table status-table' }, h('thead', {}, h('tr', {}, - h('th', { text: 'Status' }), h('th', { text: 'Source' }), + h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }), - h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))), + h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }))), h('tbody', {}, rows))) : h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }), ); @@ -844,6 +827,34 @@ function openNameSheet({ title, submit, value, run }) { openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]); } +function openSourceDetail(i) { + openDetailSheet(i.name, [ + sheetFact('Status', sourceBadge(i.status)), + sheetFact('Kind', i.kind), + sheetFact('Last webhook', timeCell(i.last_used_at)), + sheetFact('Last alert', timeCell(i.last_alert_at)), + sheetFact('Alerts, 24h', String(i.alerts_24h ?? 0)), + sheetFact('Created', h('span', { title: when(i.created_at), text: ago(i.created_at) })), + ], + isOwner() && h('button', { + class: 'btn', type: 'button', text: 'Rename', + onclick: () => { closeSheet(); openRenameSource(i); }, + }), + isOwner() && h('button', { + class: 'btn btn-danger', type: 'button', text: 'Revoke', + onclick: async () => { + closeSheet(); + if (!(await confirm({ + title: `Revoke ${i.name}?`, + text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.', + confirmLabel: 'Revoke', + danger: true, + }))) return; + act(() => api.deleteIntegration(teamID, i.id)); + }, + })); +} + function openNewSource() { openNameSheet({ title: 'New source', submit: 'Add source', value: '', @@ -899,6 +910,34 @@ const triggeredCell = (iso, incidentID) => { : h('span', { title: when(iso), text: ago(iso) }); }; +// A list row that opens its details: the list is for finding the thing, the +// sheet it opens is where the buttons are. Links inside the row keep working. +function clickableRow(tr, open) { + tr.classList.add('clickable'); + tr.tabIndex = 0; + tr.addEventListener('click', (e) => { + if (!e.target.closest('a')) open(); + }); + tr.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.target === tr) open(); + }); + return tr; +} + +// Label/value rows for a details sheet, and the sheet itself: facts above, then +// Close and whatever the viewer may do. Falsy actions (a non-owner's) drop out. +const sheetFact = (label, value) => [h('dt', { text: label }), h('dd', {}, value)]; + +function openDetailSheet(title, facts, ...actions) { + openSheet(() => [ + h('h2', { class: 'sheet-title', text: title }), + h('dl', { class: 'switch-facts' }, facts), + h('div', { class: 'sheet-actions' }, + h('button', { class: 'btn', type: 'button', text: 'Close', onclick: () => closeSheet() }), + ...actions), + ]); +} + function switchRows(sw) { const main = h('tr', {}, h('td', {}, switchBadge(sw.status)), @@ -908,19 +947,8 @@ function switchRows(sw) { h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)), h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)), h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }), - h('td', {}, isOwner() && h('button', { - class: 'btn-sm danger', type: 'button', text: 'Remove', - onclick: async () => { - if (!(await confirm({ - title: `Remove ${sw.name}?`, - text: 'It stops being watched. An incident it already opened stays open until it is resolved.', - confirmLabel: 'Remove', - danger: true, - }))) return; - act(() => api.deleteDeadmanSwitch(teamID, sw.id)); - }, - })), ); + clickableRow(main, () => openSwitchDetail(sw)); // One heartbeat is the switch's own times; several are worth telling apart, // since a live cluster must not hide a dead one. @@ -935,7 +963,7 @@ function switchRows(sw) { && h('code', { class: 'small', text: src.fingerprint })), h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)), h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)), - h('td'), h('td'))) + h('td'))) : []; return [main, ...sources]; } @@ -947,7 +975,7 @@ function deadmanCard() { h('div', { class: 'card-head' }, h('h2', { text: 'Dead man’s switches' }), isOwner() && h('button', { - class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch, + class: 'btn', type: 'button', text: 'New switch', onclick: () => openSwitchForm(), })), h('p', { class: 'muted small' }, 'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ', @@ -956,9 +984,9 @@ function deadmanCard() { ? h('div', { class: 'table-scroll' }, h('table', { class: 'admin-table status-table' }, h('thead', {}, h('tr', {}, - h('th', { text: 'Status' }), h('th', { text: 'Switch' }), + h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }), - h('th', { text: 'Silent after' }), h('th'))), + h('th', { text: 'Silent after' }))), h('tbody', {}, switches.flatMap(switchRows)))) : h('p', { class: 'muted', text: 'Nothing watched.' }), ); @@ -966,16 +994,22 @@ function deadmanCard() { // The form lives in the sheet, not on the page: most visits are to look at the // list, and a form that is always open is the page this replaced. -function openNewSwitch() { +function openSwitchForm(existing) { const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true }); const matcher = h('input', { type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true, }); const timeout = h('input', { - type: 'number', min: '1', value: '15', class: 'setting-value', required: true, + type: 'number', min: '1', step: 'any', value: '15', class: 'setting-value', required: true, }); const severity = h('select', {}, ...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s }))); + if (existing) { + name.value = existing.name; + matcher.value = existing.matcher; + timeout.value = String(existing.timeout_seconds / 60); + severity.value = existing.severity; + } const problem = h('p', { class: 'load-error', hidden: true }); const form = h('form', { class: 'stacked-form' }, @@ -990,17 +1024,20 @@ function openNewSwitch() { problem, h('div', { class: 'sheet-actions' }, h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }), - h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' }))); + h('button', { class: 'btn btn-primary', type: 'submit', text: existing ? 'Save' : 'Add switch' }))); form.addEventListener('submit', async (e) => { e.preventDefault(); try { - await api.createDeadmanSwitch(teamID, { + const body = { name: name.value.trim(), matcher: matcher.value.trim(), timeout_seconds: Math.round(Number(timeout.value) * 60), severity: severity.value, - }); + }; + await (existing + ? api.updateDeadmanSwitch(teamID, existing.id, body) + : api.createDeadmanSwitch(teamID, body)); } catch (err) { problem.textContent = err.message; problem.hidden = false; @@ -1010,7 +1047,47 @@ function openNewSwitch() { refresh(); }); - openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]); + openSheet(() => [ + h('h2', { class: 'sheet-title', text: existing ? 'Edit switch' : 'New switch' }), form]); +} + +// What the list row has no room for, and where Edit and Delete live. +function openSwitchDetail(sw) { + const sources = sw.sources.length > 1 + ? [sheetFact('Sources', h('div', {}, ...sw.sources.map((src) => h('div', { class: 'source-labels' }, + switchBadge(src.status), + ...Object.entries(src.labels || {}) + .filter(([k]) => k !== 'alertname') + .map(([k, v]) => labelChip(k, v)), + h('span', { class: 'muted small' }, ' ', timeCell(src.last_heartbeat_at))))))] + : []; + + openDetailSheet(sw.name, [ + sheetFact('Status', switchBadge(sw.status)), + sheetFact('Matcher', h('code', { text: sw.matcher })), + sheetFact('Silent after', duration(sw.timeout_seconds * 1000)), + sheetFact('Severity', sw.severity), + sheetFact('Last heartbeat', timeCell(sw.last_heartbeat_at)), + sheetFact('Last triggered', triggeredCell(sw.last_triggered_at, sw.open_incident_id)), + sources, + ], + isOwner() && h('button', { + class: 'btn', type: 'button', text: 'Edit', + onclick: () => { closeSheet(); openSwitchForm(sw); }, + }), + isOwner() && h('button', { + class: 'btn btn-danger', type: 'button', text: 'Delete', + onclick: async () => { + closeSheet(); + if (!(await confirm({ + title: `Remove ${sw.name}?`, + text: 'It stops being watched. An incident it already opened stays open until it is resolved.', + confirmLabel: 'Remove', + danger: true, + }))) return; + act(() => api.deleteDeadmanSwitch(teamID, sw.id)); + }, + })); } // --- members --------------------------------------------------------------- @@ -1114,45 +1191,18 @@ function membersCard() { const members = data.members || []; const owners = members.filter((m) => m.role === 'owner').length; - const rows = members.map((m) => { - const lastOwner = m.role === 'owner' && owners === 1; - return h('tr', {}, - h('td', {}, memberBadge(m)), - h('td', { class: 'wrap' }, - h('strong', { text: m.username }), - m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }), - m.problem && h('div', { class: 'target-problem', text: m.problem })), - h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()), - h('td', { class: 'muted small' }, shiftCell(m)), - h('td', { class: 'muted small' }, timeCell(m.last_active_at)), - h('td', { class: 'muted small' }, - h('span', { title: when(m.joined_at), text: ago(m.joined_at) })), - h('td', {}, isOwner() && h('div', { class: 'row-actions' }, - h('button', { - class: 'btn-sm', type: 'button', text: 'Edit', - // The server refuses to edit a membership the groups grant. - disabled: m.source === 'oidc', - title: m.source === 'oidc' ? SSO_MANAGED : null, - onclick: () => openEditMember(m), - }), - h('button', { - class: 'btn-sm danger', type: 'button', text: 'Remove', - disabled: lastOwner || m.source === 'oidc', - title: m.source === 'oidc' ? SSO_MANAGED - : lastOwner ? 'A team needs an owner. Make somebody else one first.' : null, - onclick: async () => { - if (!(await confirm({ - title: `Remove ${m.username}?`, - text: 'They lose access to this team. Rota days already assigned to them are not ' - + 'changed, so reassign those from the Rota tab.', - confirmLabel: 'Remove', - danger: true, - }))) return; - act(() => api.removeTeamMember(teamID, m.user_id)); - }, - }))), - ); - }); + const rows = members.map((m) => clickableRow(h('tr', {}, + h('td', {}, memberBadge(m)), + h('td', { class: 'wrap' }, + h('strong', { text: m.username }), + m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }), + m.problem && h('div', { class: 'target-problem', text: m.problem })), + h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()), + h('td', { class: 'muted small' }, shiftCell(m)), + h('td', { class: 'muted small' }, timeCell(m.last_active_at)), + h('td', { class: 'muted small' }, + h('span', { title: when(m.joined_at), text: ago(m.joined_at) })), + ), () => openMemberDetail(m, owners))); return [oidcGroupsCard(), h('div', { class: 'card' }, h('div', { class: 'card-head' }, @@ -1167,14 +1217,49 @@ function membersCard() { ? h('div', { class: 'table-scroll' }, h('table', { class: 'admin-table status-table' }, h('thead', {}, h('tr', {}, - h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }), - h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }), - h('th'))), + h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Role' }), + h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }))), h('tbody', {}, rows))) : h('p', { class: 'muted', text: 'Nobody is in this team.' }), )]; } +function openMemberDetail(m, owners) { + const lastOwner = m.role === 'owner' && owners === 1; + openDetailSheet(m.username, [ + sheetFact('Status', memberBadge(m)), + sheetFact('Role', h('span', {}, m.role, m.source === 'oidc' && ssoBadge())), + sheetFact('Rota', shiftCell(m)), + sheetFact('Last active', timeCell(m.last_active_at)), + sheetFact('Joined', h('span', { title: when(m.joined_at), text: ago(m.joined_at) })), + m.problem && sheetFact('Problem', h('span', { class: 'target-problem', text: m.problem })), + ], + isOwner() && h('button', { + class: 'btn', type: 'button', text: 'Edit', + // The server refuses to edit a membership the groups grant. + disabled: m.source === 'oidc', + title: m.source === 'oidc' ? SSO_MANAGED : null, + onclick: () => { closeSheet(); openEditMember(m); }, + }), + isOwner() && h('button', { + class: 'btn btn-danger', type: 'button', text: 'Remove', + disabled: lastOwner || m.source === 'oidc', + title: m.source === 'oidc' ? SSO_MANAGED + : lastOwner ? 'A team needs an owner. Make somebody else one first.' : null, + onclick: async () => { + closeSheet(); + if (!(await confirm({ + title: `Remove ${m.username}?`, + text: 'They lose access to this team. Rota days already assigned to them are not ' + + 'changed, so reassign those from the Rota tab.', + confirmLabel: 'Remove', + danger: true, + }))) return; + act(() => api.removeTeamMember(teamID, m.user_id)); + }, + })); +} + // One sheet for both jobs a member's row has: who, and as what. Adding is // choosing a person and a role; editing is the same with the person fixed. The // API is one call either way — POST upserts the role.