@@ -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 : 'Sourc e' } ) ,
h ( 'th' , { text : 'Status' } ) , h ( 'th' , { text : 'Nam e' } ) ,
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 ( 't d' , { 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.