Skip to content

Instantly share code, notes, and snippets.

@stillinbeta
Created August 4, 2014 22:32
Show Gist options
  • Save stillinbeta/fb30bb6d0ca999b92e27 to your computer and use it in GitHub Desktop.
Save stillinbeta/fb30bb6d0ca999b92e27 to your computer and use it in GitHub Desktop.
# The Incident Dashboard module
Rearview.module('Dashboard', (Dashboard, App, Backbone, Marionette, $, _) ->
Dashboard.Router = Backbone.Router.extend
routes: {
"dashboard(/)": "dashboard",
"dashboard/:team(/)": "dashboard"
}
## Layouts #################################################################
class Dashboard.DashboardLayout extends Backbone.Marionette.Layout
template: '#dashboardlayout-tmpl'
id: 'dashboard-layout'
className: 'layout'
regions: {
datenav: '#datenav-region'
visualization: '#visualization-region'
incidents: '#incidents-region'
}
class Dashboard.DatenavLayout extends Backbone.Marionette.Layout
template: '#datenav-tmpl'
id: 'datenav-layout'
regions: {
datefilter: '#datefilter'
teamfilter: '#teamfilter'
offhours: '#stat-offhours'
daytime: '#stat-daytime'
minor: '#stat-minor'
alertless: '#stat-alertless'
}
# the incident timeline layout
class Dashboard.IncidentTimelineLayout extends Backbone.Marionette.Layout
template: '#incidenttimelinelayout-tmpl'
className: 'incidenttimeline'
regions: {
aggregates: '.aggregates',
timeline: '.timeline',
}
## Controllers #############################################################
class Dashboard.Defaults extends Backbone.Marionette.Controller
team: 'all'
timeRangeEnd: moment.utc().toDate(),
timerangeStart: moment.utc().subtract(1, 'weeks').toDate(),
alertOfftime: true
alertOntime: true
alertMinor: true
alertLess: true
setDefaults: (team) =>
if @team != team
@team = team
App.vent.trigger('set:team', @team)
class Dashboard.Url extends Backbone.Marionette.Controller
initialize: (router, defaults) ->
@router = router
@team = defaults.team
navigate: =>
@router.navigate("/dashboard/#{@team}/#{@week}/#{@alertLevels}/#{@timeStart}",
{replace: true})
## Views ###################################################################
class Dashboard.DateFilterView extends Backbone.View
tagName: 'input'
attributes: {
type: 'week',
name: 'range',
value: moment().utc().subtract('weeks', 1).format("YYYY-[W]WW"),
max: moment().utc().format("YYYY-[W]WW"),
}
render: =>
log.debug("dateview render")
@$el.on('change', @onChange)
@onChange()
onChange: =>
week_re = /(\d{4})-W(\d{2})/i
match = week_re.exec(@el.value)
if not match
return false # invalid value
start = moment.utc().year(match[1]).isoWeeks(match[2]).startOf('isoWeek')
end = moment(start).add(1, 'weeks')
@trigger('range:updated', start, end)
App.vent.trigger('range:updated', start, end)
return false
class Dashboard.TeamFilterView extends Backbone.View
className: "teamFilterWrap"
initialize: =>
App.vent.on('teamsUpdated', @update)
App.vent.on('set:team', @update)
render: =>
log.debug("teamview render")
@$el.append("<select name=\"team\" placeholder=\"Select a team…\"><option value=\"\">All teams</option></select>")
@select = @$el.find('select')
# @select.chosen({width: "100%", allow_single_deselect: true})
@$el.on('change', @onChange)
@onChange()
update: (selected) =>
log.debug("teamview update! selected: #{selected}")
@select.find('option[value!=""]').remove()
Dashboard.teams.each((team) =>
@select.append("<option value=\"#{team.name()}\">#{team.name()}</option>")
)
onChange: =>
log.debug(@select[0].value)
App.vent.trigger("filter:team", @select[0].value)
class Dashboard.MetadataView extends Backbone.Marionette.ItemView
template: '#metadata-tmpl'
initialize: ->
@collection.on("add remove change", _.debounce(@render, 100))
@collection.on("reset", @render)
@render
serializeData: =>
serialized = {}
if @collection.length >= 0
serialized['incidentcount'] = @collection.length
serialized['pagecount'] = @collection.totalpages()
serialized['pagepeople'] = @collection.uniquepeople().length
return serialized
class Dashboard.StatToggleView extends Backbone.Marionette.ItemView
template: '#stattoggle-tmpl'
className: 'stattoggle-face'
defaults: {
incidenttype: null
name: null
}
# needs to receive an incident type, a reader-friendly name, and a filteredcollection
initialize: (options) ->
@options = _.defaults(options, @defaults)
@collection.on("add remove", _.debounce(@render, 100))
@collection.on("reset", @render)
@render()
serializeData: =>
json = {}
json['name'] = @options.name
json['count'] = @collection.filter((i) => i.incidentType() == @options.incidenttype).length
return json
onRender: =>
toggle = @$el.find(':checkbox')
toggle.attr('checked', 'checked')
toggle.attr('checked', @options.filter.get(@options.incidenttype))
toggle.on('change', @onChange)
onChange: (evt) =>
if evt.target.checked
@trigger('incidenttype:selected', @options.incidenttype)
@options.filter.trigger("incidenttype:selected", @options.incidenttype)
if not evt.target.checked
@trigger('incidenttype:deselected', @options.incidenttype)
@options.filter.trigger("incidenttype:deselected", @options.incidenttype)
class Dashboard.IncidentListItemView extends Backbone.Marionette.ItemView
template: '#incidentdetail-tmpl'
className: 'incidentdetail'
serializeData: =>
serialized = @model.toJSON()
serialized['duration'] = @model.duration().humanize()
serialized['alerts'] = _.map(@model.sortedPeoplePaged(), (person) =>
user = @options.users.get(person.pagerduty_person_id)
if user
person['avatar_url'] = user.get('avatar_url')
person['email'] = user.get('email')
return person
)
serialized['alert_count'] = serialized['alerts'].length
serialized['people'] = @model.uniquePeoplePaged().length
return serialized
onRender: =>
@$el.attr("data-incidenttype", @model.incidentType())
@$el.attr("data-incidentid", @model.id)
@$el.on('click', (evt) =>
App.vent.trigger('incident:focus', @model.id)
)
@$el.on('mouseenter', (evt) =>
App.vent.trigger('incident:focus', @model.id)
)
class Dashboard.IncidentListView extends Backbone.Marionette.CollectionView
itemView: Dashboard.IncidentListItemView
className: 'incidents'
onRender: =>
@visibleCache = new Rearview.Incidents()
$(window).on("resize", _.debounce(@resizeHandler, 100))
@resizeHandler()
@$el.on("scroll", _.debounce(@visibleIncidents, 15))
@$el.on('mouseleave', (evt) =>
App.vent.trigger('incident:blur', null))
@visibleIncidents()
resizeHandler: =>
@bounds = @el.getBoundingClientRect()
visibleIncidents: =>
if @bounds.height == 0
return
views = @children.filter((incident) =>
rect = incident.el.getBoundingClientRect()
return rect.bottom >= @bounds.top && rect.top <= @bounds.bottom)
visible = new Rearview.Incidents(_.pluck(views, 'model'))
ids = visible.pluck('id')
if _.isEqual(visible, @visibleCache)
return
@visibleCache = visible
App.vent.trigger('incidents:focus', @visibleCache)
class Dashboard.IncidentTimelineView extends Backbone.View
className: 'd3incidents'
defaults: {
width: 0,
height: 0,
paddingY: 0,
paddingX: 0,
dot_radii: {
offhours: 9,
daytime: 7,
minor: 3,
alertless: 3
}
low_priority: ['minor', 'alertless']
anim_duration: 350,
anim_offset: 100,
rangeEnd: moment.utc().toDate(),
rangeStart: moment.utc().subtract(1, 'weeks').toDate(),
focusEnd: new Date(0),
focusStart: new Date(0),
focusIncident: null,
}
initialize: (options) ->
log.debug("d3 init")
@options = _.defaults(options, @defaults)
@setup()
@collection.on("add remove change", _.debounce(@render, 100))
@collection.on("reset", @render)
@collection.once("reset", @resizeHandler)
$(window).on("resize", _.debounce(@resizeHandler, 100))
App.vent.on('range:updated', @setDateRange)
App.vent.on('incidents:focus', @setFocusRange)
App.vent.on('incident:focus', @setFocusIncident)
App.vent.on('incident:blur', @setFocusIncident)
setDateRange: (start, end) =>
# The event sends moment instances, d3 needs dates.
@options.rangeStart = start.toDate()
@options.rangeEnd = end.toDate()
@render()
setFocusRange: (incidents) =>
extents = d3.extent(incidents.starts())
@options.focusStart = extents[0]
@options.focusEnd = extents[1]
@renderFocusOverlay()
setFocusIncident: (incidentid) =>
@options.focusIncident = @collection.get(incidentid)
@renderFocusDot()
getDateRangeDisplayExtents: =>
# return [moment.utc(@options.rangeStart).subtract(12, 'hours').toDate(),
# moment.utc(@options.rangeEnd).add(12, 'hours').toDate()]
return [@options.rangeStart, @options.rangeEnd]
resizeHandler: =>
@recalculateDimensions()
@resize()
@render()
recalculateDimensions: =>
@options.width = @$el.width()
@options.height = @$el.height()
log.debug("Recalculating dimensions: #{@options.width} x #{@options.height}")
@chartwidth = @options.width - (2 * @options.paddingX)
@chartheight = @options.height - (2 * @options.paddingY)
@chartleft = @options.paddingX
@chartright = @options.paddingX + @chartwidth
@charttop = @options.paddingY
@chartbottom = @options.paddingY + @chartheight
@timeAxisYPos = @chartheight - 50
@criticalDotsCYPos = @chartheight - 80
@minorDotsCYPos = @chartheight - 60
@barpadding = 5
@maxbarheight = @criticalDotsCYPos - @options.dot_radii.offhours - @barpadding
@spineHeightOvershoot = {
offhours: @barpadding,
daytime: @barpadding + (@options.dot_radii.offhours - @options.dot_radii.daytime),
}
setup: =>
# chart setup goes here
# render will be called multiple times, so any common content
# (axes, groups, etc) should only be append()ed here, otherwise
# a new one will get added on render every time
log.debug("Timeline setup")
@recalculateDimensions()
@svg = d3.select(@el).append("svg")
# prepare scales
@timescale = d3.time.scale.utc().nice(d3.time.day)
.domain(@getDateRangeDisplayExtents())
@peopleScale = d3.scale.linear()
# y-axis (people paged)
@peopleAxis = d3.svg.axis()
.scale(@peopleScale)
.orient('left')
.tickFormat(-> return null)
@peopleAxisLabels = d3.svg.axis()
.scale(@peopleScale)
.orient('right')
.tickSize(0)
.tickFormat( (e) =>
# only return round numbers, you can't have 2.5 people paged
if Math.floor(e) != e
return
else
# label the topmost value in the chart
if e == @peopleScale.domain()[1]
return "#{e} #{Swag.helpers.inflect(e, 'person', 'people')}"
else
return e)
@peopleAxisg = @svg.append('g')
.classed('axis', true)
.classed('yaxis', true)
.classed('peopleaxis', true)
.classed('hidden', true)
.call(@peopleAxis)
@peopleAxisLabelsg = @svg.append('g')
.classed('axis', true)
.classed('yaxis', true)
.classed('peopleaxis', true)
.classed('hidden', true)
.call(@peopleAxisLabels)
@vis = @svg.append('g')
# x-axis major tickmarks (days)
@timeDaysAxis = d3.svg.axis()
.scale(@timescale)
.orient('bottom')
.ticks(d3.time.day.utc, 1)
.tickFormat('')
.tickSize(24)
.tickPadding(6)
@timeDaysAxisLabels = d3.svg.axis()
.scale(@timescale)
.orient('bottom')
.ticks(d3.time.hour.utc, 12)
.tickFormat((d) =>
# only draw labels at noon, between the date boundaries
if d.getUTCHours() == 12
# if the month changed
# or it's the first label, show the month
if d.getUTCDate() == 1 or d.toDateString() == @options.rangeStart.toDateString()
formatter = d3.time.format.utc('%a %d %b')
else
formatter = d3.time.format.utc('%a %d')
return formatter(d)
else
return null)
.tickSize(0)
.tickPadding(30)
# x-axis minor ticks (hours)
# Only label 9a and 6p
@timeHoursAxis = d3.svg.axis()
.scale(@timescale)
.orient('bottom')
.ticks(d3.time.hour.utc, 3)
.tickFormat((d) ->
hours = d.getUTCHours()
if hours == 9
return '9a'
else if hours == 18
return '6p'
else
return null)
.tickPadding(2)
@timeHoursAxisg = @vis.append('g')
.classed('axis', true)
.classed('xaxis', true)
.classed('timehoursaxis', true)
.call(@timeHoursAxis)
@timeDaysAxisg = @vis.append('g')
.classed('axis', true)
.classed('xaxis', true)
.classed('timedaysaxis', true)
.call(@timeDaysAxis)
@timeDaysAxisLabelsg = @vis.append('g')
.classed('axis', true)
.classed('xaxis', true)
.classed('timedaysaxis', true)
.call(@timeDaysAxisLabels)
@incidentdotgroups = {}
_.each(_.keys(@defaults.dot_radii), (incidenttype) =>
@incidentdotgroups[incidenttype] = @vis.append('g')
.classed('incidentgroup', true)
.attr('data-incidenttype', incidenttype)
)
@incidentBarGroups = {}
@incidentBarGroups['daytime'] = @vis.append('g')
.classed('incidentbars', true)
.attr('data-incidenttype', 'daytime')
@incidentBarGroups['offhours'] = @vis.append('g')
.classed('incidentbars', true)
.attr('data-incidenttype', 'offhours')
# XXX TODO: nighttime shading
# @nightsg = @vis.append('g')
# .classed('nights')
@focusOverlay = @vis.append('rect')
.classed('focusOverlay', true)
@focusIncidentDot = @vis.append('circle')
.classed('focusIncidentDot', true)
.classed('hidden', true)
@resize()
@render()
return @
resize: =>
log.debug("Resizing")
@svg.attr("width", @options.width)
.attr("height", @options.height)
@vis.attr("transform", "translate(#{@options.paddingX},#{@options.paddingY})")
.attr("width", @chartwidth)
.attr("height", @chartheight)
@timescale.rangeRound([0, @chartwidth])
@peopleScale.rangeRound([@maxbarheight, 0]) # TODO rangeround or range?
@peopleAxis.tickSize(@options.width)
@timeHoursAxisg.attr('transform', "translate(0, #{@timeAxisYPos})")
@timeDaysAxisg.attr('transform', "translate(0, #{@timeAxisYPos})")
@timeDaysAxisLabelsg.attr('transform', "translate(0, #{@timeAxisYPos})")
@peopleAxisg.attr('transform', "translate(#{@options.width}, 0)")
# place labels just below the line they relate to
@peopleAxisLabelsg.attr('transform', "translate(5, 10)")
@focusOverlay.attr('height', @chartheight)
render: =>
log.debug("Rendering timeline")
if @options.width == 0 || @options.height == 0
log.debug("Skipping render, chart is #{@options.width}x#{@options.height}")
return @
if not @collection.contains(@options.focusIncident)
log.debug("Stale focusIncident, unsetting")
@options.focusIncident = null
# update pages scale and render axes
maxpeople = @collection.peopleextent()[1]
@peopleScale.domain([0, maxpeople])
@peopleAxis.ticks(maxpeople)
@peopleAxisLabels.ticks(maxpeople)
@peopleAxisg.transition().duration(@options.anim_duration).call(@peopleAxis)
@peopleAxisg.classed('hidden', @collection.length == 0)
@peopleAxisLabelsg.transition().duration(@options.anim_duration).call(@peopleAxisLabels)
@peopleAxisLabelsg.classed('hidden', @collection.length == 0)
# update timescale and render axes
@timescale.domain(@getDateRangeDisplayExtents())
# TODO: animate time transitions, right now there aren't any
# time_transition = .transition().duration(250)
@timeDaysAxisg.transition().duration(@options.anim_duration).call(@timeDaysAxis)
@timeDaysAxisLabelsg.transition().duration(@options.anim_duration).call(@timeDaysAxisLabels)
@timeHoursAxisg.transition().duration(@options.anim_duration).call(@timeHoursAxis)
# draw incident dots
_.each(_.keys(@defaults.dot_radii), (incidenttype, index) =>
dotgroup = @incidentdotgroups[incidenttype]
.selectAll('.incidentdot.' + incidenttype)
.data(@collection.filter((i) -> i.incidentType() == incidenttype),
(d) -> return d.id )
# update
# enter
dotgroup.enter().append('circle')
.attr('class', (d) -> return "incidentdot #{d.incidentType()}")
.attr('r', Rearview.zeroish)
.attr('cy', (d) =>
if d.incidentType() in @defaults.low_priority
return @minorDotsCYPos
else
return @criticalDotsCYPos)
.attr('cx', (d) => return @timescale(d.opened_at()))
.attr('data-incidentid', (d) -> return d.id)
# enter+update
# stagger animation of the four groups of incidents
# see http://vis.berkeley.edu/papers/animated_transitions/
dotgroup.transition().duration(@options.anim_duration).delay(index*@options.anim_offset)
.attr('r', (d) =>
return @defaults.dot_radii[d.incidentType()])
.attr('cy', (d) =>
if d.incidentType() in @defaults.low_priority
return @minorDotsCYPos
else
return @criticalDotsCYPos)
.attr('cx', (d) => return @timescale(d.opened_at()))
# exit
dotgroup.exit().transition()
.delay((d) =>
if d.is_critical() then @options.anim_duration else 0)
.duration(@options.anim_duration)
.attr('r', Rearview.zeroish)
)
barwidth = (d) =>
x0 = @timescale(d.opened_at())
if d.closed_at()?
x1 = @timescale(d.closed_at())
else
x1 = @timescale(new Date())
width = x1-x0
if width < 0
width = 0
return width
# draw bars
_.each(_.keys(@incidentBarGroups), (incidenttype, index) =>
bars = @incidentBarGroups[incidenttype].selectAll('.incidentbarg')
.data(@collection.filter((i) -> i.incidentType() == incidenttype),
(d) -> return d.id)
# UPDATE
# ENTER
# a group for each new bar, because I want to draw multiple
# shapes for each bar.
newbars = bars.enter().append('g')
.classed('incidentbarg', true)
.attr('data-incident', (d) -> d.get('id'))
.attr('data-service', (d) -> d.get('service'))
.attr('data-team', (d) -> d.get('team'))
.attr('data-numpages', (d) -> d.counts().total)
# draw the new bars
newbars.append('rect')
.classed('incidentbar', true)
.attr('x', (d) => @timescale(d.opened_at()))
.attr('width', barwidth)
.attr('height', Rearview.zeroish)
# draw the bar spines.
newbars.append('rect')
.classed('incidentbarspine', true)
.attr('x', (d) => @timescale(d.opened_at()) - 1)
.attr('width', 1)
.attr('y', @maxbarheight + @spineHeightOvershoot[incidenttype])
.attr('height', Rearview.zeroish)
# ENTER+UPDATE: update bar positions
bars.select('.incidentbar')
.transition().duration(@options.anim_duration).delay(index*@options.anim_offset)
.attr('x', (d) => @timescale(d.opened_at()))
.attr('width', barwidth)
.attr('y', (d) => @peopleScale(d.uniquePeoplePaged().length))
.attr('height', (d) => @maxbarheight - @peopleScale(d.uniquePeoplePaged().length))
bars.select('.incidentbarspine')
.transition().duration(@options.anim_duration).delay(index*@options.anim_offset)
.attr('x', (d) => @timescale(d.opened_at()) - 1)
.attr('width', 1)
.attr('y', (d) => @peopleScale(d.uniquePeoplePaged().length))
.attr('height', (d) =>
@maxbarheight - @peopleScale(d.uniquePeoplePaged().length) + @spineHeightOvershoot[incidenttype] )
exitbars = bars.exit()
exitbars.select('.incidentbar')
.transition().duration(@options.anim_duration)
.attr('y', @maxbarheight)
.attr('height', Rearview.zeroish)
exitbars.select('.incidentbarspine')
.transition().duration(@options.anim_duration)
.attr('y', @maxbarheight + @spineHeightOvershoot[incidenttype])
.attr('height', Rearview.zeroish)
)
@renderFocusOverlay()
@renderFocusDot()
return @
renderFocusOverlay: =>
if _.isUndefined(@options.focusStart) or _.isUndefined(@options.focusEnd)
return
left = @timescale(@options.focusStart) - 5
right = @timescale(@options.focusEnd) + 5
if left < 0
left = 0
if right > @chartwidth
right = @chartwidth
width = right - left
if width <= 0
width = Rearview.zeroish
@focusOverlay.transition().duration(100)
.style('opacity', '1')
.attr('width', width)
.attr('transform', "translate(#{left}, 0)")
.transition()
.delay(1000)
.duration(1000)
.style('opacity', '0')
renderFocusDot: =>
if not @options.focusIncident
@focusIncidentDot.classed('hidden', true)
return
incidentType = @options.focusIncident.incidentType()
cy = if incidentType in @options.low_priority \
then @minorDotsCYPos
else @criticalDotsCYPos
cx = @timescale(@options.focusIncident.opened_at())
r = @options.dot_radii[incidentType]
@focusIncidentDot.attr('class', "focusIncidentDot incidentdot #{incidentType}")
.attr('cy', cy)
.attr('cx', cx)
.attr('r', r)
Dashboard.on('before:start', ->
log.debug('Dashboard: before start')
@incidents = new Rearview.Incidents()
@filteredIncidents = new Rearview.FilteredIncidents(@incidents)
@filter = new Rearview.IncidentFilter({filtered: @filteredIncidents})
@services = new Rearview.Services()
@teams = new Rearview.Teams()
@users = new Rearview.Users()
@services.on('reset', -> App.vent.trigger('servicesUpdated'))
@teams.on('reset', -> App.vent.trigger('teamsUpdated'))
@users.on('reset', -> App.vent.trigger('usersUpdated'))
@services.fetch({reset: true})
@teams.fetch({reset: true})
@users.fetch({reset: true})
)
Dashboard.addInitializer(->
@router = new Dashboard.Router()
@defaults = new Dashboard.Defaults()
log.debug("defaults:")
log.debug(@defaults)
@urlController = new Dashboard.Url(@router, @defaults)
@router.on('route:dashboard', (team) =>
@defaults.setDefaults(team) if team?
)
log.debug('Dashboard: initializing')
@dashboardLayout = new Dashboard.DashboardLayout()
@datenavLayout = new Dashboard.DatenavLayout()
Rearview.global.show(@dashboardLayout)
@dashboardLayout.datenav.show(@datenavLayout)
@loadingView = new Rearview.LoadingView()
@incidentList = new Dashboard.IncidentListView({
collection: @filteredIncidents
itemViewOptions: { users: @users }
})
App.vent.on('range:updated', (start, end) =>
@dashboardLayout.incidents.show(@loadingView)
@incidents.reset()
@incidents.once('sync', =>
@incidentList = new Dashboard.IncidentListView({
collection: @filteredIncidents
itemViewOptions: { users: @users }
})
@dashboardLayout.incidents.show(@incidentList)
@incidentList.resizeHandler()
@incidentList.visibleIncidents()
)
@incidents.fetch({
data: {
start: start.format(),
end: end.format(),
}
})
)
App.vent.on('filter:team', (team) =>
log.debug("filtering teams...")
if team == ''
@filter.unset('team')
else
@filter.set({team: team})
)
@itlview = new Dashboard.IncidentTimelineView({collection: @filteredIncidents})
@dashboardLayout.visualization.show(@itlview)
@datenavLayout.datefilter.show(new Dashboard.DateFilterView())
@datenavLayout.teamfilter.show(new Dashboard.TeamFilterView())
@toggle_offhours = new Dashboard.StatToggleView({
incidenttype: 'offhours',
name: "Off-hours Critical",
collection: @filteredIncidents,
filter: @filter,
})
@toggle_daytime = new Dashboard.StatToggleView({
incidenttype: 'daytime',
name: "Daytime Critical",
collection: @filteredIncidents,
filter: @filter,
})
@toggle_minor = new Dashboard.StatToggleView({
incidenttype: 'minor',
name: "Minor",
collection: @filteredIncidents,
filter: @filter,
})
@toggle_alertless = new Dashboard.StatToggleView({
incidenttype: 'alertless',
name: "Alertless",
collection: @filteredIncidents,
filter: @filter,
})
@datenavLayout.offhours.show(@toggle_offhours)
@datenavLayout.daytime.show(@toggle_daytime)
@datenavLayout.minor.show(@toggle_minor)
@datenavLayout.alertless.show(@toggle_alertless)
@dashboardLayout.incidents.show(@loadingView)
# @incidents.fetch({reset: true})
# autofetch = =>
# log.debug("Autofetching at #{new Date()}...")
# @incidents.fetch()
# window.setInterval(autofetch, 30*1000)
)
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment