@@ -0,0 +1,453 @@
// Team settings: the rota, who is in the team, where its alerts come from,
// what it escalates through, and which of its alerts are heartbeats.
//
// Everything here was API-only until now, which meant a team owner had to use
// curl to set up escalation — the feature this whole line of work exists for.
//
// The server decides what a role may do: an owner's edits succeed, a member's
// are refused with 403, and a non-member gets 404 for the lot. This view hides
// the controls a member cannot use, because a form that always fails is worse
// than no form, but it is not the thing enforcing anything.
import * as api from './api.js' ;
import { h , clear , spinner , confirm } from './ui.js' ;
import { state , currentTeam , users as allUsers } from './state.js' ;
import { isoDate , addDays } from './format.js' ;
const view = ( ) => document . getElementById ( 'view-team' ) ;
let teamID = null ;
let data = null ; // { team, members, integrations, escalation, deadman, schedule, users }
let error = null ;
let freshKey = null ; // an integration key, shown once, until the view is left
export function show ( ) {
if ( ! data ) clear ( view ( ) , spinner ( ) ) ;
refresh ( ) ;
}
function selectedTeam ( ) {
const teams = state . teams || [ ] ;
return teams . find ( ( t ) => t . id === teamID ) || currentTeam ( ) ;
}
export async function refresh ( ) {
const team = selectedTeam ( ) ;
if ( ! team ) {
data = null ;
render ( ) ;
return ;
}
teamID = team . id ;
try {
// A member may read all of this; only the writes are owner-only.
const [ members , integrations , escalation , deadman , schedule , users ] = await Promise . all ( [
api . teamMembers ( team . id ) ,
api . integrations ( team . id ) ,
api . escalation ( team . id ) ,
api . deadman ( team . id ) ,
api . schedule ( team . id , isoDate ( new Date ( ) ) , isoDate ( addDays ( new Date ( ) , 30 ) ) ) ,
allUsers ( ) ,
] ) ;
data = { team , members , integrations , escalation , deadman , schedule , users } ;
error = null ;
} catch ( err ) {
error = err . message ;
}
render ( ) ;
}
function isOwner ( ) {
return data ? . team ? . role === 'owner' || state . me ? . user ? . is _admin ;
}
function render ( ) {
if ( ! data ) {
clear ( view ( ) , error
? h ( 'div' , { class : 'load-error' , text : error } )
: h ( 'div' , { class : 'card' } , h ( 'p' , { class : 'muted' , text : 'You are not in a team yet.' } ) ) ) ;
return ;
}
clear ( view ( ) ,
error && h ( 'div' , { class : 'load-error' , text : ` Showing older data: ${ error } ` } ) ,
teamPicker ( ) ,
! isOwner ( ) && h ( 'div' , { class : 'card' } ,
h ( 'p' , { class : 'muted small' , text : 'You are a member of this team. Only an owner can change its settings.' } ) ) ,
scheduleCard ( ) ,
escalationCard ( ) ,
integrationsCard ( ) ,
deadmanCard ( ) ,
membersCard ( ) ,
) ;
}
// Only shown to somebody in more than one team, like the queue's filter chips.
function teamPicker ( ) {
if ( ( state . teams || [ ] ) . length < 2 ) {
return h ( 'div' , { class : 'card' } , h ( 'h2' , { text : data . team . name } ) ) ;
}
const select = h ( 'select' , { class : 'team-picker' } ,
... state . teams . map ( ( t ) => h ( 'option' , {
value : String ( t . id ) , text : t . name , selected : t . id === teamID ,
} ) ) ) ;
select . addEventListener ( 'change' , ( ) => {
teamID = Number ( select . value ) ;
data = null ;
freshKey = null ;
show ( ) ;
} ) ;
return h ( 'div' , { class : 'card' } , h ( 'h2' , { text : 'Team' } ) , select ) ;
}
// --- schedule --------------------------------------------------------------
// The rota is one person per UTC day. The on-call page shows it; this is where
// it is set, which until now was the TUI's job and the TUI cannot do it any
// more.
function scheduleCard ( ) {
const rows = ( data . schedule || [ ] ) . map ( ( e ) =>
h ( 'tr' , { } ,
h ( 'td' , { text : e . date } ) ,
h ( 'td' , { } , h ( 'strong' , { text : e . username } ) ) ,
h ( 'td' , { } , isOwner ( ) && h ( 'button' , {
class : 'btn-sm danger' , type : 'button' , text : 'Clear' ,
onclick : ( ) => act ( ( ) => api . unassignSchedule ( teamID , e . id ) ) ,
} ) ) ,
) ) ;
return h ( 'div' , { class : 'card' } ,
h ( 'h2' , { text : 'On-call rota' } ) ,
h ( 'p' , { class : 'muted small' , text : 'One person per UTC day, for the next 30 days.' } ) ,
rows . length
? h ( 'table' , { class : 'admin-table' } , h ( 'tbody' , { } , rows ) )
: h ( 'p' , { class : 'muted' , text : 'Nobody is scheduled.' } ) ,
isOwner ( ) && assignForm ( ) ,
) ;
}
function assignForm ( ) {
const who = memberSelect ( ) ;
const from = h ( 'input' , { type : 'date' , required : true , value : isoDate ( new Date ( ) ) } ) ;
const days = h ( 'input' , { type : 'number' , min : '1' , max : '31' , value : '1' , class : 'setting-value' } ) ;
const replace = h ( 'input' , { type : 'checkbox' } ) ;
const form = h ( 'form' , { class : 'stacked-form' } ,
h ( 'label' , { } , 'Who ' , who ) ,
h ( 'label' , { } , 'From ' , from ) ,
h ( 'label' , { } , 'Days ' , days ) ,
// Taking a day somebody else holds has to be asked for, the same rule the
// API enforces: a plain assignment that silently moved a shift would move
// who gets paged without telling either of them.
h ( 'label' , { class : 'checkbox' } , replace , ' Take days somebody else holds' ) ,
h ( 'button' , { class : 'btn' , type : 'submit' , text : 'Assign' } ) ) ;
form . addEventListener ( 'submit' , ( e ) => {
e . preventDefault ( ) ;
const start = new Date ( from . value + 'T00:00:00Z' ) ;
const dates = [ ] ;
for ( let i = 0 ; i < Number ( days . value || 1 ) ; i ++ ) dates . push ( isoDate ( addDays ( start , i ) ) ) ;
act ( ( ) => api . assignSchedule ( teamID , Number ( who . value ) , dates , replace . checked ) ) ;
} ) ;
return form ;
}
function memberSelect ( selected ) {
return h ( 'select' , { } ,
... ( data . members || [ ] ) . map ( ( m ) => h ( 'option' , {
value : String ( m . user _id ) , text : m . username , selected : m . user _id === selected ,
} ) ) ) ;
}
// --- escalation ------------------------------------------------------------
// The ladder is edited as a whole and sent as a whole, because the API replaces
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided.
let draft = null ;
function escalationCard ( ) {
const esc = data . escalation ;
if ( ! draft ) {
draft = {
repeat _count : esc . repeat _count || 0 ,
fallback _topic : esc . fallback _topic || '' ,
levels : ( esc . levels || [ ] ) . map ( ( l ) => ( {
timeout _seconds : l . timeout _seconds ,
targets : ( l . targets || [ ] ) . map ( ( t ) => ( { kind : t . kind , user _id : t . user _id } ) ) ,
} ) ) ,
} ;
}
const body = [ ] ;
if ( ! draft . levels . length ) {
body . push ( h ( 'p' , { class : 'muted' } ,
'No ladder. An unacknowledged incident re-pages the same person every ' ,
'reminder interval and nobody else is woken.' ) ) ;
}
draft . levels . forEach ( ( level , i ) => {
body . push ( h ( 'div' , { class : 'ladder-level' } ,
h ( 'div' , { class : 'ladder-head' } ,
h ( 'strong' , { text : ` Level ${ i + 1 } ` } ) ,
isOwner ( ) && h ( 'button' , {
class : 'btn-sm danger' , type : 'button' , text : 'Remove' ,
onclick : ( ) => { draft . levels . splice ( i , 1 ) ; render ( ) ; } ,
} ) ) ,
h ( 'label' , { } , 'Wait ' , minutesInput ( level . timeout _seconds , ( secs ) => {
level . timeout _seconds = secs ;
} ) , ' before the next level' ) ,
h ( 'div' , { class : 'ladder-targets' } ,
... level . targets . map ( ( t , ti ) => targetRow ( level , t , ti ) ) ,
isOwner ( ) && h ( 'button' , {
class : 'btn-sm' , type : 'button' , text : '+ target' ,
onclick : ( ) => { level . targets . push ( { kind : 'oncall' } ) ; render ( ) ; } ,
} ) ) ,
) ) ;
} ) ;
if ( isOwner ( ) ) {
body . push ( h ( 'button' , {
class : 'btn-sm' , type : 'button' , text : '+ level' ,
onclick : ( ) => {
draft . levels . push ( { timeout _seconds : 300 , targets : [ { kind : 'oncall' } ] } ) ;
render ( ) ;
} ,
} ) ) ;
const repeat = h ( 'input' , {
type : 'number' , min : '0' , max : '10' , class : 'setting-value' ,
value : String ( draft . repeat _count ) ,
oninput : ( e ) => { draft . repeat _count = Number ( e . target . value ) ; } ,
} ) ;
const fallback = h ( 'input' , {
type : 'text' , value : draft . fallback _topic , placeholder : 'terdut-oncall-all' ,
oninput : ( e ) => { draft . fallback _topic = e . target . value ; } ,
} ) ;
body . push ( h ( 'label' , { } , 'Repeat the whole ladder ' , repeat , ' more times' ) ) ;
body . push ( h ( 'label' , { } , 'Then page this ntfy topic once ' , fallback ) ) ;
body . push ( h ( 'button' , {
class : 'btn' , type : 'button' , text : 'Save ladder' ,
onclick : ( ) => act ( ( ) => api . setEscalation ( teamID , draft ) , { resetDraft : true } ) ,
} ) ) ;
}
return h ( 'div' , { class : 'card' } ,
h ( 'h2' , { text : 'Escalation' } ) ,
h ( 'p' , { class : 'muted small' } ,
'When a level’ s wait passes and nobody has acknowledged, the next level is ' ,
'paged. Acknowledging or resolving stops it; snoozing pauses it.' ) ,
... body ,
) ;
}
function targetRow ( level , target , index ) {
const kind = h ( 'select' , { } ,
h ( 'option' , { value : 'oncall' , text : 'Whoever is on call' , selected : target . kind === 'oncall' } ) ,
h ( 'option' , { value : 'user' , text : 'A specific person' , selected : target . kind === 'user' } ) ) ;
kind . addEventListener ( 'change' , ( ) => {
target . kind = kind . value ;
target . user _id = kind . value === 'user' ? ( data . members [ 0 ] || { } ) . user _id : undefined ;
render ( ) ;
} ) ;
const who = target . kind === 'user'
? memberSelect ( target . user _id )
: null ;
if ( who ) {
who . addEventListener ( 'change' , ( ) => { target . user _id = Number ( who . value ) ; } ) ;
}
return h ( 'div' , { class : 'target-row' } , kind , who ,
isOwner ( ) && h ( 'button' , {
class : 'btn-sm danger' , type : 'button' , text : '× ' ,
title : 'Remove this target' ,
onclick : ( ) => { level . targets . splice ( index , 1 ) ; render ( ) ; } ,
} ) ) ;
}
function minutesInput ( seconds , onChange ) {
const input = h ( 'input' , {
type : 'number' , min : '1' , class : 'setting-value' ,
value : String ( Math . max ( 1 , Math . round ( seconds / 60 ) ) ) ,
oninput : ( e ) => onChange ( Number ( e . target . value ) * 60 ) ,
} ) ;
return h ( 'span' , { } , input , ' minutes' ) ;
}
// --- integrations ----------------------------------------------------------
function integrationsCard ( ) {
const rows = ( data . integrations || [ ] ) . map ( ( i ) =>
h ( 'tr' , { } ,
h ( 'td' , { } , h ( 'strong' , { text : i . name } ) ) ,
h ( 'td' , { class : 'muted small' , text : i . kind } ) ,
h ( 'td' , { class : 'muted small' , text : i . last _used _at ? 'in use' : 'never used' } ) ,
h ( 'td' , { } , isOwner ( ) && 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.' ,
confirmLabel : 'Revoke' ,
danger : true ,
} ) ) ) return ;
act ( ( ) => api . deleteIntegration ( teamID , i . id ) ) ;
} ,
} ) ) ,
) ) ;
return h ( 'div' , { class : 'card' } ,
h ( 'h2' , { text : 'Alert sources' } ) ,
h ( 'p' , { class : 'muted small' } ,
'Alerts arrive on an integration key, which says both that the sender may ' ,
'post and which team the alerts belong to.' ) ,
rows . length
? h ( 'table' , { class : 'admin-table' } , h ( 'tbody' , { } , rows ) )
: h ( 'p' , { class : 'muted' , text : 'No alert source yet, so nothing can reach this team.' } ) ,
freshKey && newKeyPanel ( ) ,
isOwner ( ) && ! freshKey && newIntegrationForm ( ) ,
) ;
}
// The key is returned exactly once. Say so, show it large, and give the
// Alertmanager snippet with it already in place — the next thing anybody does
// with it is paste it into a config.
function newKeyPanel ( ) {
const url = freshKey . url || ` ${ location . origin } /api/integrations/ ${ freshKey . key } /alertmanager ` ;
const snippet = ` receivers:
- name: terdut
webhook_configs:
- url: ${ url }
send_resolved: true ` ;
return h ( 'div' , { class : 'key-panel' } ,
h ( 'strong' , { text : 'Copy this now — it is not shown again.' } ) ,
h ( 'pre' , { class : 'key-url' } , h ( 'code' , { text : url } ) ) ,
h ( 'button' , {
class : 'btn-sm' , type : 'button' , text : 'Copy URL' ,
onclick : ( ) => navigator . clipboard ? . writeText ( url ) ,
} ) ,
h ( 'p' , { class : 'muted small' , text : 'Alertmanager receiver:' } ) ,
h ( 'pre' , { } , h ( 'code' , { text : snippet } ) ) ,
h ( 'button' , {
class : 'btn-sm' , type : 'button' , text : 'Done' ,
onclick : ( ) => { freshKey = null ; render ( ) ; } ,
} ) ,
) ;
}
function newIntegrationForm ( ) {
const name = h ( 'input' , { type : 'text' , placeholder : 'prod alertmanager' , required : true } ) ;
const form = h ( 'form' , { class : 'inline-form' } , name ,
h ( 'button' , { class : 'btn' , type : 'submit' , text : 'Add' } ) ) ;
form . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ( ) ;
try {
freshKey = await api . createIntegration ( teamID , name . value . trim ( ) ) ;
await refresh ( ) ;
} catch ( err ) {
error = err . message ;
render ( ) ;
}
} ) ;
return form ;
}
// --- dead man's switches ---------------------------------------------------
function deadmanCard ( ) {
const d = data . deadman || { } ;
const matchers = h ( 'input' , {
type : 'text' , value : d . matchers || '' , placeholder : 'alertname=Watchdog' ,
class : 'wide' ,
} ) ;
const timeout = h ( 'input' , {
type : 'number' , min : '0' , class : 'setting-value' ,
value : String ( Math . round ( ( d . timeout _seconds || 0 ) / 60 ) ) ,
} ) ;
const severity = h ( 'select' , { } ,
... [ 'critical' , 'error' , 'warning' , 'info' ] . map ( ( s ) =>
h ( 'option' , { value : s , text : s , selected : ( d . severity || 'critical' ) === s } ) ) ) ;
const form = h ( 'form' , { class : 'stacked-form' } ,
h ( 'label' , { } , 'Heartbeat alerts ' , matchers ) ,
h ( 'label' , { } , 'Declare dead after ' , timeout , ' minutes of silence' ) ,
h ( 'label' , { } , 'Open the incident at severity ' , severity ) ,
h ( 'button' , { class : 'btn' , type : 'submit' , text : 'Save switches' } ) ) ;
form . addEventListener ( 'submit' , ( e ) => {
e . preventDefault ( ) ;
act ( ( ) => api . setDeadman ( teamID , {
matchers : matchers . value . trim ( ) ,
timeout _seconds : Number ( timeout . value ) * 60 ,
severity : severity . value ,
} ) ) ;
} ) ;
return h ( 'div' , { class : 'card' } ,
h ( 'h2' , { text : 'Dead man’ s switches' } ) ,
h ( 'p' , { class : 'muted small' } ,
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ' ,
'quiet for longer than the timeout opens an incident. ' ,
h ( 'code' , { text : 'alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat' } ) ,
' — semicolons separate switches, commas separate conditions, and every ' ,
'switch must name an alertname. Leave empty to watch nothing.' ) ,
isOwner ( ) ? form : h ( 'p' , { class : 'muted' , text : d . matchers || 'Nothing watched.' } ) ,
) ;
}
// --- members ---------------------------------------------------------------
function membersCard ( ) {
const rows = ( data . members || [ ] ) . map ( ( m ) =>
h ( 'tr' , { } ,
h ( 'td' , { } , h ( 'strong' , { text : m . username } ) ) ,
h ( 'td' , { class : 'muted small' , text : m . role } ) ,
h ( 'td' , { } , isOwner ( ) && h ( 'button' , {
class : 'btn-sm' , type : 'button' ,
text : m . role === 'owner' ? 'Make member' : 'Make owner' ,
onclick : ( ) => act ( ( ) =>
api . addTeamMember ( teamID , m . user _id , m . role === 'owner' ? 'member' : 'owner' ) ) ,
} ) , isOwner ( ) && h ( 'button' , {
class : 'btn-sm danger' , type : 'button' , text : 'Remove' ,
onclick : ( ) => act ( ( ) => api . removeTeamMember ( teamID , m . user _id ) ) ,
} ) ) ,
) ) ;
const inTeam = new Set ( ( data . members || [ ] ) . map ( ( m ) => m . user _id ) ) ;
const candidates = ( data . users || [ ] ) . filter ( ( u ) => ! inTeam . has ( u . id ) && ! u . disabled _at ) ;
const pick = h ( 'select' , { } ,
... candidates . map ( ( u ) => h ( 'option' , { value : String ( u . id ) , text : u . username } ) ) ) ;
const role = h ( 'select' , { } ,
h ( 'option' , { value : 'member' , text : 'member' } ) ,
h ( 'option' , { value : 'owner' , text : 'owner' } ) ) ;
const form = h ( 'form' , { class : 'inline-form' } , pick , role ,
h ( 'button' , { class : 'btn' , type : 'submit' , text : 'Add' } ) ) ;
form . addEventListener ( 'submit' , ( e ) => {
e . preventDefault ( ) ;
act ( ( ) => api . addTeamMember ( teamID , Number ( pick . value ) , role . value ) ) ;
} ) ;
return h ( 'div' , { class : 'card' } ,
h ( 'h2' , { text : 'Members' } ) ,
h ( 'table' , { class : 'admin-table' } , h ( 'tbody' , { } , rows ) ) ,
isOwner ( ) && candidates . length > 0 && form ,
) ;
}
// --- plumbing --------------------------------------------------------------
// act runs a write and reloads. Errors are shown rather than thrown away: a
// 409 from the last-owner guard or the schedule's conflict rule is the server
// explaining itself, and the reader needs to see it.
async function act ( fn , { resetDraft = false } = { } ) {
try {
await fn ( ) ;
error = null ;
if ( resetDraft ) draft = null ;
} catch ( err ) {
error = err . message ;
}
if ( ! resetDraft ) draft = null ;
await refresh ( ) ;
}