diff --git a/SingularityUI/app/actions/activeTasks.coffee b/SingularityUI/app/actions/activeTasks.coffee
new file mode 100644
index 0000000000..c8d87613af
--- /dev/null
+++ b/SingularityUI/app/actions/activeTasks.coffee
@@ -0,0 +1,13 @@
+fetchTasksForRequest = (requestId, state='active') ->
+ params = {
+ property: 'taskId'
+ }
+ $.ajax
+ url: "#{ config.apiRoot }/history/request/#{ requestId }/tasks/#{ state }?#{ $.param(params) }"
+
+updateActiveTasks = (requestId) ->
+ (dispatch) ->
+ fetchTasksForRequest(requestId).done (tasks) ->
+ dispatch({tasks, type: 'REQUEST_ACTIVE_TASKS'})
+
+module.exports = { updateActiveTasks, fetchTasksForRequest }
diff --git a/SingularityUI/app/actions/log.coffee b/SingularityUI/app/actions/log.coffee
new file mode 100644
index 0000000000..381817a1aa
--- /dev/null
+++ b/SingularityUI/app/actions/log.coffee
@@ -0,0 +1,338 @@
+Q = require 'q'
+
+{ fetchTasksForRequest } = require './activeTasks'
+
+fetchData = (taskId, path, offset=undefined, length=0) ->
+ length = Math.max(length, 0) # API breaks if you request a negative length
+ $.ajax
+ url: "#{ config.apiRoot }/sandbox/#{ taskId }/read?#{$.param({path, length, offset})}"
+
+fetchTaskHistory = (taskId) ->
+ $.ajax
+ url: "#{ config.apiRoot }/history/task/#{ taskId }"
+
+initializeUsingActiveTasks = (requestId, path, search) ->
+ (dispatch) ->
+ deferred = Q.defer()
+ fetchTasksForRequest(requestId).done (tasks) ->
+ taskIds = _.sortBy(_.pluck(tasks, 'taskId'), (taskId) -> taskId.instanceNo).map((taskId) -> taskId.id)
+ dispatch(initialize(requestId, path, search, taskIds)).then ->
+ deferred.resolve()
+ deferred.promise
+
+initialize = (requestId, path, search, taskIds) ->
+ (dispatch, getState) ->
+ { viewMode } = getState()
+
+ if viewMode is 'unified'
+ taskIdGroups = [taskIds]
+ else
+ taskIdGroups = taskIds.map (taskId) -> [taskId]
+
+ dispatch(init(requestId, taskIdGroups, path, search))
+
+ groupPromises = taskIdGroups.map (taskIds, taskGroupId) ->
+ taskInitPromises = taskIds.map (taskId) ->
+ taskInitDeferred = Q.defer()
+ resolvedPath = path.replace('$TASK_ID', taskId)
+ fetchData(taskId, resolvedPath).done ({offset}) ->
+ dispatch(initTask(taskId, offset, resolvedPath, true))
+ taskInitDeferred.resolve()
+ .error ({status}) ->
+ if status is 404
+ app.caughtError()
+ dispatch(taskFileDoesNotExist(taskGroupId, taskId))
+ taskInitDeferred.resolve()
+ else
+ taskInitDeferred.reject()
+ return taskInitDeferred.promise
+
+ taskStatusPromises = taskIds.map (taskId) ->
+ dispatch(updateTaskStatus(taskGroupId, taskId))
+
+ Promise.all(taskInitPromises, taskStatusPromises).then ->
+ dispatch(taskGroupFetchPrevious(taskGroupId)).then ->
+ dispatch(taskGroupReady(taskGroupId))
+
+ Promise.all(groupPromises)
+
+init = (requestId, taskIdGroups, path, search) ->
+ {
+ requestId
+ taskIdGroups
+ path
+ search
+ type: 'LOG_INIT'
+ }
+
+addTaskGroup = (taskIds, search) ->
+ {
+ taskIds
+ search
+ type: 'LOG_ADD_TASK_GROUP'
+ }
+
+initTask = (taskId, offset, path, exists) ->
+ {
+ taskId
+ offset
+ path
+ exists
+ type: 'LOG_TASK_INIT'
+ }
+
+taskFileDoesNotExist = (taskGroupId, taskId) ->
+ {
+ taskId
+ taskGroupId
+ type: 'LOG_TASK_FILE_DOES_NOT_EXIST'
+ }
+
+taskGroupReady = (taskGroupId) ->
+ {
+ taskGroupId
+ type: 'LOG_TASK_GROUP_READY'
+ }
+
+taskHistory = (taskGroupId, taskId, taskHistory) ->
+ {
+ taskGroupId
+ taskId
+ taskHistory
+ type: 'LOG_TASK_HISTORY'
+ }
+
+getTasks = (taskGroup, tasks) ->
+ taskGroup.taskIds.map (taskId) -> tasks[taskId]
+
+updateFilesizes = ->
+ (dispatch, getState) ->
+ { tasks } = getState()
+ for taskId of tasks
+ fetchData(taskId, tasks[taskId.path]).done ({offset}) ->
+ dispatch(taskFilesize(taskId, offset))
+
+updateGroups = ->
+ (dispatch, getState) ->
+ getState().taskGroups.map (taskGroup, taskGroupId) ->
+ unless taskGroup.pendingRequests
+ if taskGroup.top
+ dispatch(taskGroupFetchPrevious(taskGroupId))
+ if taskGroup.bottom or taskGroup.tailing
+ dispatch(taskGroupFetchNext(taskGroupId))
+
+updateTaskStatuses = ->
+ (dispatch, getState) ->
+ {tasks, taskGroups} = getState()
+ taskGroups.map (taskGroup, taskGroupId) ->
+ getTasks(taskGroup, tasks).map ({taskId, terminated}) ->
+ if terminated
+ Promise.resolve()
+ else
+ dispatch(updateTaskStatus(taskGroupId, taskId))
+
+updateTaskStatus = (taskGroupId, taskId) ->
+ (dispatch, getState) ->
+ fetchTaskHistory(taskId, ['taskUpdates']).done (data) ->
+ dispatch(taskHistory(taskGroupId, taskId, data))
+
+taskGroupFetchNext = (taskGroupId) ->
+ (dispatch, getState) ->
+ {tasks, taskGroups, logRequestLength, maxLines} = getState()
+
+ taskGroup = taskGroups[taskGroupId]
+ tasks = getTasks(taskGroup, tasks)
+
+ # bail early if there's already a pending request
+ if taskGroup.pendingRequests
+ return Promise.resolve()
+
+ dispatch({taskGroupId, type: 'LOG_REQUEST_START'})
+ promises = tasks.map ({taskId, maxOffset, path, initialDataLoaded}) ->
+ if initialDataLoaded
+ xhr = fetchData(taskId, path, maxOffset, logRequestLength)
+ xhr.done ({data, offset, nextOffset}) ->
+ if data.length > 0
+ nextOffset = offset + data.length
+ dispatch(taskData(taskGroupId, taskId, data, offset, nextOffset, true, maxLines))
+ else
+ Promise.resolve() # reject("initialDataLoaded is false for task #{taskId}")
+
+ Promise.all(promises).then -> dispatch({taskGroupId, type: 'LOG_REQUEST_END'})
+
+taskGroupFetchPrevious = (taskGroupId) ->
+ (dispatch, getState) ->
+ {tasks, taskGroups, logRequestLength, maxLines} = getState()
+
+ taskGroup = taskGroups[taskGroupId]
+ tasks = getTasks(taskGroup, tasks)
+
+ # bail early if all tasks are at the top
+ if _.all(tasks.map ({minOffset}) -> minOffset is 0)
+ return Promise.resolve()
+
+ # bail early if there's already a pending request
+ if taskGroup.pendingRequests
+ return Promise.resolve()
+
+ dispatch({taskGroupId, type: 'LOG_REQUEST_START'})
+ promises = tasks.map ({taskId, minOffset, path, initialDataLoaded}) ->
+ if minOffset > 0 and initialDataLoaded
+ xhr = fetchData(taskId, path, Math.max(minOffset - logRequestLength, 0), Math.min(logRequestLength, minOffset))
+ xhr.done ({data, offset, nextOffset}) ->
+ if data.length > 0
+ nextOffset = offset + data.length
+ dispatch(taskData(taskGroupId, taskId, data, offset, nextOffset, false, maxLines))
+ else
+ Promise.resolve() # reject("initialDataLoaded is false for task #{taskId}")
+
+ Promise.all(promises).then -> dispatch({taskGroupId, type: 'LOG_REQUEST_END'})
+
+taskData = (taskGroupId, taskId, data, offset, nextOffset, append, maxLines) ->
+ {
+ taskGroupId
+ taskId
+ data
+ offset
+ nextOffset
+ append
+ maxLines
+ type: 'LOG_TASK_DATA'
+ }
+
+taskFilesize = (taskId, filesize) ->
+ {
+ taskId
+ filesize
+ type: 'LOG_TASK_FILESIZE'
+ }
+
+taskGroupTop = (taskGroupId, visible) ->
+ (dispatch, getState) ->
+ if getState().taskGroups[taskGroupId].top != visible
+ dispatch({taskGroupId, visible, type: 'LOG_TASK_GROUP_TOP'})
+ if visible
+ dispatch(taskGroupFetchPrevious(taskGroupId))
+
+taskGroupBottom = (taskGroupId, visible, tailing=false) ->
+ (dispatch, getState) ->
+ { taskGroups, tasks } = getState()
+ taskGroup = taskGroups[taskGroupId]
+ if taskGroup.tailing != tailing
+ if tailing is false or _.all(getTasks(taskGroup, tasks).map(({maxOffset, filesize}) -> maxOffset >= filesize))
+ dispatch({taskGroupId, tailing, type: 'LOG_TASK_GROUP_TAILING'})
+ if taskGroup.bottom != visible
+ dispatch({taskGroupId, visible, type: 'LOG_TASK_GROUP_BOTTOM'})
+ if visible
+ dispatch(taskGroupFetchNext(taskGroupId))
+
+clickPermalink = (offset) ->
+ {
+ offset
+ type: 'LOG_CLICK_OFFSET_LINK'
+ }
+
+selectLogColor = (color) ->
+ {
+ color
+ type: 'LOG_SELECT_COLOR'
+ }
+
+switchViewMode = (newViewMode) ->
+ (dispatch, getState) ->
+ { taskGroups, path, activeRequest, search, viewMode } = getState()
+
+ if newViewMode in ['custom', viewMode]
+ return
+
+ taskIds = _.flatten(_.pluck(taskGroups, 'taskIds'))
+
+ dispatch({viewMode: newViewMode, type: 'LOG_SWITCH_VIEW_MODE'})
+ dispatch(initialize(activeRequest.requestId, path, search, taskIds))
+
+setCurrentSearch = (newSearch) -> # TODO: can we do something less heavyweight?
+ (dispatch, getState) ->
+ {activeRequest, path, taskGroups, currentSearch} = getState()
+ if newSearch != currentSearch
+ dispatch(initialize(activeRequest.requestId, path, newSearch, _.flatten(_.pluck(taskGroups, 'taskIds'))))
+
+toggleTaskLog = (taskId) ->
+ (dispatch, getState) ->
+ {search, path, tasks, viewMode} = getState()
+ if taskId of tasks
+ # only remove task if it's not the last one
+ if Object.keys(tasks).length > 1
+ dispatch({taskId, type: 'LOG_REMOVE_TASK'})
+ else
+ return
+ else
+ if viewMode is 'split'
+ dispatch(addTaskGroup([taskId], search))
+
+ resolvedPath = path.replace('$TASK_ID', taskId)
+
+ fetchData(taskId, resolvedPath).done ({offset}) ->
+ dispatch(initTask(taskId, offset, resolvedPath, true))
+
+ getState().taskGroups.map (taskGroup, taskGroupId) ->
+ if taskId in taskGroup.taskIds
+ dispatch(updateTaskStatus(taskGroupId, taskId))
+ dispatch(taskGroupFetchPrevious(taskGroupId)).then ->
+ dispatch(taskGroupReady(taskGroupId))
+
+removeTaskGroup = (taskGroupId) ->
+ (dispatch, getState) ->
+ { taskIds } = getState().taskGroups[taskGroupId]
+ dispatch({taskGroupId, taskIds, type: 'LOG_REMOVE_TASK_GROUP'})
+
+expandTaskGroup = (taskGroupId) ->
+ (dispatch, getState) ->
+ { taskIds } = getState().taskGroups[taskGroupId]
+ dispatch({taskGroupId, taskIds, type: 'LOG_EXPAND_TASK_GROUP'})
+
+scrollToTop = (taskGroupId) ->
+ (dispatch, getState) ->
+ { taskIds } = getState().taskGroups[taskGroupId]
+ dispatch({taskGroupId, taskIds, type: 'LOG_SCROLL_TO_TOP'})
+ dispatch(taskGroupFetchNext(taskGroupId))
+
+scrollAllToTop = () ->
+ (dispatch, getState) ->
+ dispatch({type: 'LOG_SCROLL_ALL_TO_TOP'})
+ getState().taskGroups.map (taskGroup, taskGroupId) ->
+ dispatch(taskGroupFetchNext(taskGroupId))
+
+scrollToBottom = (taskGroupId) ->
+ (dispatch, getState) ->
+ { taskIds } = getState().taskGroups[taskGroupId]
+ dispatch({taskGroupId, taskIds, type: 'LOG_SCROLL_TO_BOTTOM'})
+ dispatch(taskGroupFetchPrevious(taskGroupId))
+
+scrollAllToBottom = () ->
+ (dispatch, getState) ->
+ dispatch({type: 'LOG_SCROLL_ALL_TO_BOTTOM'})
+ getState().taskGroups.map (taskGroup, taskGroupId) ->
+ dispatch(taskGroupFetchPrevious(taskGroupId))
+
+module.exports = {
+ initialize
+ initializeUsingActiveTasks
+ taskGroupFetchNext
+ taskGroupFetchPrevious
+ clickPermalink
+ updateGroups
+ updateTaskStatuses
+ updateFilesizes
+ taskGroupTop
+ taskGroupBottom
+ selectLogColor
+ switchViewMode
+ setCurrentSearch
+ toggleTaskLog
+ scrollToTop
+ scrollAllToTop
+ scrollToBottom
+ scrollAllToBottom
+ removeTaskGroup
+ expandTaskGroup
+}
diff --git a/SingularityUI/app/assets/index.mustache b/SingularityUI/app/assets/index.mustache
index b8d5a0480c..c424469c3b 100644
--- a/SingularityUI/app/assets/index.mustache
+++ b/SingularityUI/app/assets/index.mustache
@@ -44,6 +44,7 @@
redirectOnUnauthorizedUrl: "{{{redirectOnUnauthorizedUrl}}}"
};
+
{{#navColor}}
diff --git a/SingularityUI/app/components/aggregateTail/AggregateTail.cjsx b/SingularityUI/app/components/aggregateTail/AggregateTail.cjsx
deleted file mode 100644
index cf8d512e67..0000000000
--- a/SingularityUI/app/components/aggregateTail/AggregateTail.cjsx
+++ /dev/null
@@ -1,256 +0,0 @@
-React = require 'react'
-ReactDOM = require 'react-dom'
-BackboneReactComponent = require 'backbone-react-component'
-Header = require './Header'
-IndividualTail = require './IndividualTail'
-InterleavedTail = require './InterleavedTail'
-Utils = require '../../utils'
-Help = require './Help'
-vex = require 'vex.dialog'
-
-AggregateTail = React.createClass
- mixins: [Backbone.React.Component.mixin]
-
- # ============================================================================
- # Lifecycle Methods |
- # ============================================================================
-
- # Single Mode: Backwards compatability mode for the old URL format. Disables all task switching controls.
- getInitialState: ->
- params = Utils.getQueryParams()
- viewingInstances: if @props.singleMode then [@props.singleModeTaskId] else (if params.taskIds then params.taskIds.split(',').slice(0, 6) else [])
- color: @getActiveColor()
- splitView: !(params.view is 'unified')
- search: if params.grep then params.grep else ''
- offset: @props.initialOffset
-
- componentWillMount: ->
- # Automatically map backbone collections and models to the state of this component
- Backbone.React.Component.mixin.on(@, {
- collections: {
- activeTasks: @props.activeTasks
- }
- });
-
- $(window).on("blur", @onWindowBlur)
- $(window).on("focus", @onWindowFocus)
-
- componentDidMount: ->
- if @state.viewingInstances.length is 1
- document.title = "Tail of #{@props.path.replace('$TASK_ID', @state.viewingInstances[0])}"
- else
- document.title = "Tail of #{@props.path.replace('$TASK_ID', 'Task Directory')}"
-
- initialViewingInstancesOnUpdate: ->
- runningTasks = (task for task in @state.activeTasks when task.lastTaskState == 'TASK_RUNNING')
- if runningTasks.length > 0
- return runningTasks
- else
- return @state.activeTasks
-
- componentDidUpdate: (prevProps, prevState) ->
- if prevState.activeTasks.length is 0 and @state.activeTasks.length > 0 and not Utils.getQueryParams()?.taskIds and !@props.singleMode
- @setState
- viewingInstances: _.pluck(@initialViewingInstancesOnUpdate(), 'id').slice(0, 6)
-
- componentWillUnmount: ->
- Backbone.React.Component.mixin.off(@);
- $(window).off("blur", @onWindowBlur)
- $(window).off("focus", @onWindowFocus)
-
- # ============================================================================
- # Event Handlers |
- # ============================================================================
-
- handleOffsetLink: (offset) ->
- this.setState {offset}
-
- onWindowBlur: ->
- @blurTimer = _.delay( =>
- for k, tail of @refs
- if tail.isTailing()
- tail.stopTailing()
- $(window).one("focus", =>
- tail.startTailing()
- )
- , 900000) # 15 minutes
-
- onWindowFocus: ->
- clearTimeout(@blurTimer)
-
- toggleViewingInstance: (taskId) ->
- if @props.singleMode
- return
-
- if taskId in @state.viewingInstances
- viewing = _.without @state.viewingInstances, taskId
- else
- viewing = @state.viewingInstances.concat(taskId)
-
- if 0 < viewing.length <= 6
- @setState
- viewingInstances: viewing
- history.replaceState @state, '', location.href.replace(location.search, "?taskIds=#{viewing.join(',')}&view=#{@getViewString(@state.splitView)}&grep=#{@state.search}")
- if viewing.length is 1
- document.title = "Tail of #{@props.path.replace('$TASK_ID', viewing[0])}"
- else
- document.title = "Tail of #{@props.path.replace('$TASK_ID', 'Task Directory')}"
-
- showOnlyInstance: (taskId) ->
- if @props.singleMode
- return
-
- @setState
- viewingInstances: [taskId]
- history.replaceState @state, '', location.href.replace(location.search, "?taskIds=#{taskId}&view=#{@getViewString(@state.splitView)}&grep=#{@state.search}")
-
- selectTasks: (selectFuncion) ->
- if @props.singleMode
- return
-
- viewing = _.pluck(selectFuncion(_.sortBy(@state.activeTasks, (task) => task.taskId.instanceNo)), 'id')
- @setState
- viewingInstances: viewing
- history.replaceState @state, '', location.href.replace(location.search, "?taskIds=#{viewing.join(',')}&view=#{@getViewString(@state.splitView)}&grep=#{@state.search}")
-
- setSearch: (search) ->
- @setState
- search: search
- history.replaceState @state, '', location.href.replace(location.search, "?taskIds=#{@state.viewingInstances.join(',')}&view=#{@getViewString(@state.splitView)}&grep=#{search}")
-
- scrollAllTop: ->
- for tail of @refs
- @refs[tail].scrollToTop()
-
- scrollAllBottom: ->
- for tail of @refs
- @refs[tail].scrollToBottom()
-
- getColumnWidth: ->
- instances = @state.viewingInstances.length
- if instances is 1
- return 12
- else if instances in [2, 4]
- return 6
- else if instances in [3, 5, 6]
- return 4
- else
- return 1
-
- setLogColor: (color) ->
- localStorage.setItem('singularityLogColor', color)
- @setState
- color: color
-
- getActiveColor: ->
- localStorage.getItem('singularityLogColor')
-
- toggleView: ->
- splitView = !@state.splitView
- @setState
- splitView: splitView
- viewString = @getViewString(splitView)
- history.replaceState @state, '', location.href.replace(location.search, "?taskIds=#{@state.viewingInstances.join(',')}&view=#{viewString}&grep=#{@state.search}")
-
- getViewString: (splitView) ->
- if splitView then 'split' else 'unified'
-
- toggleHelp: ->
- vex.open
- content: '
'
- contentClassName: 'help-dialog'
- ReactDOM.render( , $('#help-target').get()[0])
-
- # ============================================================================
- # Rendering |
- # ============================================================================
-
- getRowType: ->
- if !@state.splitView
- return 'unified'
-
- if @state.viewingInstances.length > 3 then 'tail-row-half' else 'tail-row'
-
- getInstanceNumber: (taskId) ->
- @state.activeTasks.filter((t) =>
- t.id is taskId
- )[0]?.taskId.instanceNo
-
- renderTail: ->
- if @state.splitView
- return @renderIndividualTails()
- else
- return @renderInterleavedTail()
-
- renderIndividualTails: ->
- @state.viewingInstances.sort((a, b) =>
- @getInstanceNumber(a) > @getInstanceNumber(b)
- ).map((taskId, i) =>
- if @props.logLines[taskId]
-
-
-
- )
-
- renderInterleavedTail: ->
- logLines = @state.viewingInstances.map((taskId) =>
- @props.logLines[taskId]
- )
- ajaxErrors = @state.viewingInstances.map((taskId) =>
- @props.ajaxError[taskId]
- )
- if _.filter(logLines, (l) => l isnt undefined).length is @state.viewingInstances.length
-
-
-
-
- render: ->
-
-
-
- {@renderTail()}
-
-
-
-module.exports = AggregateTail
diff --git a/SingularityUI/app/components/aggregateTail/ColorLegend.cjsx b/SingularityUI/app/components/aggregateTail/ColorLegend.cjsx
deleted file mode 100644
index f6c39f526b..0000000000
--- a/SingularityUI/app/components/aggregateTail/ColorLegend.cjsx
+++ /dev/null
@@ -1,18 +0,0 @@
-React = require 'react'
-
-ColorLegend = React.createClass
-
- renderColors: ->
- _.keys(@props.colors).map (taskId) =>
-
-
{taskId}
-
-
- render: ->
-
-
-module.exports = ColorLegend
diff --git a/SingularityUI/app/components/aggregateTail/Contents.cjsx b/SingularityUI/app/components/aggregateTail/Contents.cjsx
deleted file mode 100644
index 55547d7da3..0000000000
--- a/SingularityUI/app/components/aggregateTail/Contents.cjsx
+++ /dev/null
@@ -1,206 +0,0 @@
-React = require 'react'
-ReactDOM = require 'react-dom'
-ReactList = require 'react-list'
-LogLine = require './LogLine'
-Loader = require './Loader'
-
-Utils = require '../../utils'
-
-Contents = React.createClass
-
- # ============================================================================
- # Lifecycle Methods |
- # ============================================================================
-
- getInitialState: ->
- @state =
- isLoading: false
- loadingText: ''
- linesToRender: []
- loadingFromTop: false
-
- componentDidMount: ->
- @scrollNode = ReactDOM.findDOMNode(@refs.scrollContainer)
- @currentOffset = parseInt @props.offset
- if @props.taskState not in Utils.TERMINAL_TASK_STATES and not @props.ajaxError.present and not @props.offset
- @startTailingPoll()
-
- componentDidUpdate: (prevProps, prevState) ->
- if @tailingPoll and not @state.loadingFromTop
- @scrollToBottom()
-
- # Stop tailing if the task dies
- if @props.taskState in Utils.TERMINAL_TASK_STATES or @props.ajaxError.present
- @stopTailingPoll()
-
- # Update our loglines components only if needed
- if prevProps.logLines?.length isnt @props.logLines?.length
- @setState
- linesToRender: @renderLines(@props.offset)
-
- componentWillReceiveProps: (nextProps) ->
- if nextProps.offset isnt @props.offset
- @setState
- linesToRender: @renderLines(nextProps.offset)
-
- componentWillUnmount: ->
- @stopTailingPoll()
-
- # ============================================================================
- # Event Handlers |
- # ============================================================================
-
- handleScroll: (e) ->
- node = @scrollNode
- # Are we at the bottom?
- if $(node).scrollTop() + $(node).innerHeight() >= node.scrollHeight - 20
- if @props.moreToFetch()
- @props.fetchNext()
- else if @props.taskState not in Utils.TERMINAL_TASK_STATES and @props.logLines.length > 0 and not @state.loadingFromTop
- @startTailingPoll()
- # Or the top?
- else if $(node).scrollTop() is 0
- if not @tailingPoll and @props.logLines[0]?.offset > 0
- @setState
- isLoading: true
- loadingText: 'Fetching'
- @props.fetchPrevious( =>
- @setState
- isLoading: false
- loadingText: ''
- )
- else
- @stopTailingPoll()
-
- handleKeyDown: (e) ->
- if e.keyCode is 38
- @stopTailingPoll()
-
- startTailingPoll: ->
- # Make sure there isn't one already running
- @stopTailingPoll()
- @setState
- isLoading: true
- loadingText: 'Tailing'
- @tailingPoll = setInterval =>
- if @props.reachedEndOfFile() and not @props.reachedStartOfFile() and @scrollNode.scrollHeight <= $(@scrollNode).innerHeight()
- @setState
- isLoading: true
- loadingText: 'Loading'
- @props.fetchPrevious( => )
- else
- @setState
- isLoading: true
- loadingText: 'Tailing'
- @props.fetchNext()
- , 2000
-
- stopTailingPoll: ->
- if @tailingPoll
- clearInterval @tailingPoll
- @tailingPoll = null
- @setState
- isLoading: false
- loadingText: ''
- loadingFromTop: false
-
- loadFromTop: ->
- # Make sure there isn't one already running
- @stopTailingPoll()
- @setState
- isLoading: true
- loadingText: 'Loading'
- loadingFromTop: true
- @tailingPoll = setInterval =>
- if @state.linesToRender and @refs.lines.getVisibleRange()[1] < @state.linesToRender.length * 2
- @stopTailingPoll()
- else
- @props.fetchNext()
- , 2000
-
- # ============================================================================
- # Rendering |
- # ============================================================================
-
- renderError: ->
- if @props.ajaxError.present
-
-
-
{@props.ajaxError.message}
-
-
-
- renderLines: (offset) ->
- if @props.logLines and @props.logLines.length > 0
- if @props.colorMap
- colors = @props.colorMap(@props.logLines)
- else
- colors = {}
- colors[@props.logLines[0].taskId] = 'hsla(0, 0, 0, 0)'
- @props.logLines.map((l, i) =>
- link = window.location.href.replace(window.location.search, '').replace(window.location.hash, '')
- link += "?taskIds=#{@props.taskId}##{l.offset}"
- isHighlighted = l.offset is offset
- isFirstLine = i is 0
- isLastLine = i is @props.logLines.length - 1
-
- )
-
- lineRenderer: (index, key) ->
- @state.linesToRender[index]
-
- getLineHeight: (index) ->
- if index in [0, @state.linesToRender.length]
- return 40
- else
- return 20
-
- render: ->
-
-
- {@renderError()}
-
-
-
-
-
-
- # ============================================================================
- # Utility Methods |
- # ============================================================================
-
- isTailing: ->
- !(_.isNull(@tailingPoll) or _.isUndefined(@tailingPoll))
-
- scrollToLine: (line) ->
- @refs.lines.scrollTo(line)
-
- scrollToTop: ->
- @refs.lines.scrollTo(0)
-
- scrollToBottom: ->
- @refs.lines.scrollTo(@state.linesToRender?.length || 0)
-
-module.exports = Contents
diff --git a/SingularityUI/app/components/aggregateTail/Header.cjsx b/SingularityUI/app/components/aggregateTail/Header.cjsx
deleted file mode 100644
index 5f0b8b6ef4..0000000000
--- a/SingularityUI/app/components/aggregateTail/Header.cjsx
+++ /dev/null
@@ -1,202 +0,0 @@
-React = require 'react'
-ReactDOM = require 'react-dom'
-
-Header = React.createClass
-
- getInitialState: ->
- searchVal: @props.search
-
- handleSearchChange: (event) ->
- @setState
- searchVal: event.target.value
-
- setSearch: (val) ->
- @props.setSearch(val)
-
- handleSearchKeyDown: (event) ->
- if event.keyCode is 13 # Enter: commit search and close
- @setSearch(@state.searchVal)
- $("#searchDDToggle").dropdown("toggle")
- else if event.keyCode is 27 # Escape: clear search and commit
- @setState
- searchVal: ''
- @setSearch('')
- $("#searchDDToggle").dropdown("toggle")
-
- handleSearchToggle: (event) ->
- ReactDOM.findDOMNode(@refs.searchInput).focus()
-
- handleTasksKeyDown: (event) ->
- if event.keyCode is 70
- @props.selectTasks((tasks) =>
- _.first(tasks, 6)
- )
- else if event.keyCode is 76
- @props.selectTasks((tasks) =>
- _.last(tasks, 6)
- )
- else if event.keyCode is 79
- @props.selectTasks((tasks) =>
- _.first(_.filter(tasks, (t) =>
- t.taskId.instanceNo % 2 is 1
- ), 6)
- )
- else if event.keyCode is 69
- @props.selectTasks((tasks) =>
- if tasks.length <= 1
- return tasks
- _.first(_.filter(tasks, (t) =>
- t.taskId.instanceNo % 2 is 0
- ), 6)
- )
- else if 49 <= event.keyCode <= 57
- @props.selectTasks((tasks) =>
- _.filter(tasks, (t) =>
- t.taskId.instanceNo is parseInt(String.fromCharCode(event.keyCode))
- )
- )
-
- renderBreadcrumbs: ->
- path = @props.path
- if @props.viewingInstances.length is 1
- path = path.replace('$TASK_ID', @props.viewingInstances[0])
- else
- path = path.replace('$TASK_ID', 'Task Directory')
- segments = path.split('/')
- return segments.map (s, i) =>
- path = segments.slice(0, i + 1).join('/')
- if i < segments.length - 1
- return (
-
- {s}
-
- )
- else
- return (
-
-
- {s}
-
-
- )
-
- renderTasksDropdown: ->
- if @props.singleMode
- return null
-
-
-
- renderListItems: ->
- tasks = _.sortBy(@props.activeTasks, (t) => t.taskId.instanceNo).map (task, i) =>
- taskId = task.id
-
- @props.toggleViewingInstance(taskId)}>
-
- Instance {task.taskId.instanceNo}
-
-
- if tasks.length > 0
- return tasks
- else
- return No running instances
-
- renderViewButtons: ->
- if @props.viewingInstances.length > 1
-
- Unified
- Split
-
-
- renderColorList: ->
-
-
- renderAnchorButtons: ->
-
-
-
-
-
-
-
-
-
- renderHelpButton: ->
-
-
-
-
- renderSearch: ->
-
-
- render: ->
-
-
-
-
-
- {@renderBreadcrumbs()}
-
-
-
- {@renderSearch()}
- {@renderTasksDropdown()}
- {@renderColorList()}
- {@renderViewButtons()}
- {@renderAnchorButtons()}
- {@renderHelpButton()}
-
-
-
-
-module.exports = Header
diff --git a/SingularityUI/app/components/aggregateTail/Help.cjsx b/SingularityUI/app/components/aggregateTail/Help.cjsx
deleted file mode 100644
index 2c750624d2..0000000000
--- a/SingularityUI/app/components/aggregateTail/Help.cjsx
+++ /dev/null
@@ -1,50 +0,0 @@
-React = require 'react'
-
-Help = React.createClass
-
- render: ->
-
-
Log View Help
-
- The log view allows viewing and tailing of files inside the task sandbox.
- The same file can be viewed across up to 6 tasks at time.
-
-
Grep
-
- Enter a string here to only display lines in the file that contain a match. Regular expressions are supported. The button will turn blue to indicate a search string is being applied.
- Shortcuts while the dropdown is open:
-
-
- return Commit the search
- esc Clear the search
-
-
Select Instances
-
- This dropdown contains all running instances of the request. Select up to 6 of them to tail the file across.
- Shortcuts while the dropdown is open:
-
-
- f Select (up to) the first 6 instances
- l Select (up to) the last 6 instances
- e Select (up to) the first 6 even-numbered instances
- o Select (up to) the first 6 odd-numbered instances
- [0-9] Select the corresponding numbered instance
-
-
Color Scheme
-
- Set the color scheme used by the file viewer.
-
-
Unified/Split
-
- Toggle between Unified and Split views of the log lines.
- Split View: Displays each task in a seperate pane.
- Unified View: Interleaves the log lines, ordered based on timestamps contained in them.
- This assumes Singularity is able to locate and parse timestamps in the file being viewed.
-
-
Bottom/Top
-
- Jump to the bottom or top of the file(s) being viewed.
-
-
-
-module.exports = Help
diff --git a/SingularityUI/app/components/aggregateTail/IndividualHeader.cjsx b/SingularityUI/app/components/aggregateTail/IndividualHeader.cjsx
deleted file mode 100644
index 66ce53a5a4..0000000000
--- a/SingularityUI/app/components/aggregateTail/IndividualHeader.cjsx
+++ /dev/null
@@ -1,52 +0,0 @@
-React = require 'react'
-ReactDOM = require 'react-dom'
-StatusIndicator = require './StatusIndicator'
-
-IndividualHeader = React.createClass
-
- componentDidUpdate: (prevProps, prevState) ->
- target = ReactDOM.findDOMNode(@refs.ttTarget)
- if @props.task.task?.taskId? and target
- $(target).tooltip(container: 'body', template: '')
-
- headerTarget = ReactDOM.findDOMNode(@refs.headerTarget)
- if @props.taskState and @props.taskState != prevProps.taskState and headerTarget
- if @props.taskState in ['TASK_KILLED', 'TASK_FAILED', 'TASK_LOST']
- $(headerTarget).addClass('status-changed-stopped')
- else if @props.taskState is 'TASK_FINISHED'
- $(headerTarget).addClass('status-changed-finished')
-
- componentWillUnmount: () ->
- target = ReactDOM.findDOMNode(@refs.ttTarget)
- if @props.task.task?.taskId? and target
- $(target).tooltip('destroy')
-
- getTooltipText: ->
- task = @props.task
- "Deploy ID: #{task.task?.taskId?.deployId or ''}\nHost: #{task.task?.taskId?.host or ''}"
-
- renderClose: ->
- if @props.onlyTask
- return null
-
-
- renderExpand: ->
- if @props.onlyTask
- return null
-
-
- render: ->
-
- {@renderClose()}
- {@renderExpand()}
-
-
-
-
-
-
-
-
-module.exports = IndividualHeader
diff --git a/SingularityUI/app/components/aggregateTail/IndividualTail.cjsx b/SingularityUI/app/components/aggregateTail/IndividualTail.cjsx
deleted file mode 100644
index 4849829217..0000000000
--- a/SingularityUI/app/components/aggregateTail/IndividualTail.cjsx
+++ /dev/null
@@ -1,149 +0,0 @@
-React = require 'react'
-BackboneReactComponent = require 'backbone-react-component'
-IndividualHeader = require "./IndividualHeader"
-Contents = require "./Contents"
-
-TaskHistory = require '../../models/TaskHistory'
-
-IndividualTail = React.createClass
- mixins: [Backbone.React.Component.mixin]
-
- # ============================================================================
- # Lifecycle Methods |
- # ============================================================================
-
- componentWillMount: ->
- # Get the task info
- @task = new TaskHistory {taskId: @props.taskId}
- @startTaskStatusPoll()
-
- @props.logLines.grep = @props.search
-
- # Automatically map backbone collections and models to the state of this component
- Backbone.React.Component.mixin.on(@, {
- collections: {
- logLines: @props.logLines
- },
- models: {
- ajaxError: @props.ajaxError
- task: @task
- }
- });
-
- componentDidMount: ->
- if @props.offset?
- @props.logLines.fetchOffset(@props.offset)
- else
- @props.logLines.fetchInitialData()
-
- componentWillReceiveProps: (nextProps) ->
- if nextProps.search isnt @props.search
- @props.logLines.grep = nextProps.search
- @props.logLines.reset()
- @props.logLines.fetchInitialData().done _.delay(@refs.contents.scrollToBottom, 200)
-
- componentWillUnmount: ->
- Backbone.React.Component.mixin.off(@)
- @stopTaskStatusPoll()
-
- # ============================================================================
- # Event Handlers |
- # ============================================================================
-
- startTaskStatusPoll: ->
- @task.fetch()
- @taskPoll = setInterval =>
- @task.fetch()
- , 5000
-
- stopTaskStatusPoll: ->
- clearInterval @taskPoll
-
- moreToFetch: ->
- @props.logLines.state.get('moreToFetch')
-
- reachedStartOfFile: ->
- @props.logLines.getMinOffset() is 0
-
- reachedEndOfFile: ->
- @props.logLines.state.get('reachedEndOfFile')
-
- fetchNext: ->
- _.defer(@props.logLines.fetchNext)
-
- fetchPrevious: (callback) ->
- @prevLineCount = @props.logLines.length
- _.defer( =>
- xhr = @props.logLines.fetchPrevious()
- if xhr
- xhr.done =>
- newLines = @props.logLines.length - @prevLineCount
- if newLines > 0
- @scrollToLine(newLines)
- callback()
- )
-
- isTailing: ->
- @refs.contents.isTailing()
-
- stopTailing: ->
- @refs.contents.stopTailingPoll()
-
- startTailing: ->
- @refs.contents.startTailingPoll()
-
- scrollToLine: (line) ->
- @refs.contents.scrollToLine(line)
-
- scrollToTop: ->
- @refs.contents.stopTailingPoll()
- if @props.logLines.getMinOffset() is 0
- @refs.contents.scrollToTop()
- else
- @props.logLines.reset()
- @props.logLines.fetchFromStart().done =>
- @refs.contents.scrollToTop()
- @refs.contents.loadFromTop()
-
- scrollToBottom: ->
- if @props.logLines.state.get('moreToFetch') is true
- @props.logLines.reset()
- @props.logLines.fetchInitialData().done _.delay(@refs.contents.scrollToBottom, 200)
- else
- @refs.contents.scrollToBottom()
-
- # ============================================================================
- # Rendering |
- # ============================================================================
-
- render: ->
-
- @props.closeTail(@props.taskId)}
- expandTail={() => @props.expandTail(@props.taskId)}
- taskState={_.last(@state.task.taskUpdates)?.taskState}
- task={@state.task}
- onlyTask={@props.onlyTask} />
-
-
-
-module.exports = IndividualTail
diff --git a/SingularityUI/app/components/aggregateTail/InterleavedHeader.cjsx b/SingularityUI/app/components/aggregateTail/InterleavedHeader.cjsx
deleted file mode 100644
index 2b67b0ae41..0000000000
--- a/SingularityUI/app/components/aggregateTail/InterleavedHeader.cjsx
+++ /dev/null
@@ -1,27 +0,0 @@
-React = require 'react'
-ColorLegend = require './ColorLegend'
-
-InterleavedHeader = React.createClass
-
- getInitialState: ->
- @state =
- showLegend: false
-
- toggleLegend: ->
- @setState
- showLegend: !@state.showLegend
-
- renderLegend: ->
- if @state.showLegend
-
-
- render: ->
-
-
Viewing {@props.numTasks} Tasks
-
-
-
- {@renderLegend()}
-
-
-module.exports = InterleavedHeader
diff --git a/SingularityUI/app/components/aggregateTail/InterleavedTail.cjsx b/SingularityUI/app/components/aggregateTail/InterleavedTail.cjsx
deleted file mode 100644
index 6442686f0c..0000000000
--- a/SingularityUI/app/components/aggregateTail/InterleavedTail.cjsx
+++ /dev/null
@@ -1,223 +0,0 @@
-React = require 'react'
-BackboneReactComponent = require 'backbone-react-component'
-InterleavedHeader = require "./InterleavedHeader"
-Contents = require "./Contents"
-
-TaskHistory = require '../../models/TaskHistory'
-LogLines = require '../../collections/LogLines'
-
-InterleavedTail = React.createClass
- mixins: [Backbone.React.Component.mixin]
-
- # ============================================================================
- # Lifecycle Methods |
- # ============================================================================
-
- getInitialState: ->
- @state =
- mergedLines: []
-
- componentWillMount: ->
- # Get the task info
- @task = new TaskHistory {taskId: @props.taskId}
-
- for logLines in @props.logLines
- logLines.grep = @props.search
-
- models = {}
- models.ajaxError = @props.ajaxErrors[0]
-
- Backbone.React.Component.mixin.on(@, {
- models: models
- });
-
- componentDidMount: ->
- for logLines in @props.logLines
- logLines.reset()
-
- for logLines in @props.logLines
- logLines.fetchInitialData(=>
- @resetMergedLines()
- @refs.contents.scrollToBottom()
- )
-
- componentWillReceiveProps: (nextProps) ->
- if nextProps.logLines isnt @props.logLines
- _.each(nextProps.logLines, (logLines) => logLines.reset())
- promises = []
- for logLines in nextProps.logLines
- promises.push(logLines.fetchInitialData())
- Promise.all(promises).then =>
- @resetMergedLines()
- if nextProps.search isnt @props.search
- _.each(nextProps.logLines, (logLines) =>
- logLines.grep = nextProps.search
- logLines.reset()
- )
- for logLines in @props.logLines
- logLines.fetchInitialData(=>
- @resetMergedLines()
- @refs.contents.scrollToBottom()
- )
-
- componentWillUnmount: ->
- Backbone.React.Component.mixin.off(@)
-
- # ============================================================================
- # Event Handlers |
- # ============================================================================
-
- mergeLines: (lines, beginning = false) ->
- if beginning
- newLines = LogLines.merge(lines).concat(@state.mergedLines)
- else
- newLines = @state.mergedLines.concat(LogLines.merge lines)
- @setState
- mergedLines: newLines
-
- moreToFetch: ->
- _.some(@props.logLines, (logLines) =>
- logLines.state.get('moreToFetch')
- )
-
- reachedStartOfFile: ->
- _.some(@props.logLines, (logLines) -> logLines.getMinOffset() is 0)
-
- reachedEndOfFile: ->
- _.some(@props.logLines, (logLines) -> logLines.state.get('reachedEndOfFile'))
-
- fetchNext: ->
- promises = []
- oldLineCount = @props.logLines.map (logLines) => {taskId: logLines.taskId, length: logLines.length}
- for logLines in @props.logLines
- promises.push(logLines.fetchNext())
-
- Promise.all(promises).then => _.delay =>
- newLineCount = @props.logLines.map (logLines) => {taskId: logLines.taskId, length: logLines.length}
- deltas = newLineCount.map (count) =>
- taskId: count.taskId
- delta: count.length - _.findWhere(oldLineCount, {taskId: count.taskId}).length
-
- newLines = []
- for delta in deltas
- lines = _.findWhere(@props.logLines, {taskId: delta.taskId}).toJSON()
- slice = lines.slice(lines.length - delta.delta, lines.length)
- newLines.push(slice)
- @mergeLines(newLines)
- , 300
-
- fetchPrevious: (callback) ->
- promises = []
- oldLineCount = @props.logLines.map (logLines) => {taskId: logLines.taskId, length: logLines.length}
- for logLines in @props.logLines
- promises.push(logLines.fetchPrevious())
-
- Promise.all(promises).then => _.delay =>
- newLineCount = @props.logLines.map (logLines) => {taskId: logLines.taskId, length: logLines.length}
- deltas = newLineCount.map (count) =>
- taskId: count.taskId
- delta: count.length - _.findWhere(oldLineCount, {taskId: count.taskId}).length
-
- newLines = []
- for delta in deltas
- lines = _.findWhere(@props.logLines, {taskId: delta.taskId}).toJSON()
- slice = lines.slice(0, delta.delta)
- newLines.push(slice)
-
- @mergeLines(newLines, true)
- totalNew = _.reduce(deltas, (memo, delta) =>
- memo + delta.delta
- , 0)
- if totalNew > 0
- @scrollToLine(totalNew)
- callback()
- , 300
-
- isTailing: ->
- @refs.contents.isTailing()
-
- stopTailing: ->
- @refs.contents.stopTailingPoll()
-
- startTailing: ->
- @refs.contents.startTailingPoll()
-
- scrollToLine: (line) ->
- @refs.contents.scrollToLine(line)
-
- scrollToTop: ->
- @refs.contents.stopTailingPoll()
- if _.every(@props.logLines, (logLines) => logLines.getMinOffset() is 0)
- @refs.contents.scrollToTop()
- else
- _.each(@props.logLines, (logLines) => logLines.reset())
- promises = []
- _.each(@props.logLines, (logLines) => promises.push(logLines.fetchFromStart()))
- Promise.all(promises).then =>
- @resetMergedLines()
- @refs.contents.scrollToTop()
- @refs.contents.loadFromTop()
-
- scrollToBottom: ->
- if _.every(@props.logLines, (logLines) => logLines.state.get('moreToFetch') is true)
- _.each(@props.logLines, (logLines) => logLines.reset())
- for logLines in @props.logLines
- logLines.fetchInitialData(=>
- @resetMergedLines()
- @refs.contents.scrollToBottom()
- )
- else
- @refs.contents.scrollToBottom()
-
- resetMergedLines: ->
- @setState
- mergedLines: []
- @mergeLines(@props.logLines.map((logLines) => logLines.toJSON()))
-
- # ============================================================================
- # Rendering |
- # ============================================================================
-
- taskIdToColorMap: (logLines) ->
- if !logLines
- return {}
-
- map = {}
- taskIds = _.uniq(logLines.map((line) =>
- line.taskId
- )).sort()
- if taskIds.length is 1
- map[taskIds[0]] = 'hsla(0, 0, 0, 0)'
- else
- interval = 360 / taskIds.length
- colors = taskIds.map (taskId, i) =>
- "hsla(#{interval * i}, 100%, 50%, 0.1)"
- for taskId, i in taskIds
- map[taskId] = colors[i]
- map
-
- render: ->
-
-
-
-
-
-module.exports = InterleavedTail
diff --git a/SingularityUI/app/components/aggregateTail/Loader.cjsx b/SingularityUI/app/components/aggregateTail/Loader.cjsx
deleted file mode 100644
index 5eab694a75..0000000000
--- a/SingularityUI/app/components/aggregateTail/Loader.cjsx
+++ /dev/null
@@ -1,13 +0,0 @@
-React = require 'react'
-
-Loader = React.createClass
-
- render: ->
- if @props.isVisable
-
- else
-
-
-module.exports = Loader
diff --git a/SingularityUI/app/components/logs/ColorDropdown.cjsx b/SingularityUI/app/components/logs/ColorDropdown.cjsx
new file mode 100644
index 0000000000..9a269fa327
--- /dev/null
+++ b/SingularityUI/app/components/logs/ColorDropdown.cjsx
@@ -0,0 +1,37 @@
+React = require 'react'
+classNames = require 'classnames'
+
+{ connect } = require 'react-redux'
+{ selectLogColor } = require '../../actions/log'
+
+class ColorDropdown extends React.Component
+ renderColorChoices: ->
+ activeColor = @props.activeColor
+
+ @props.colors.map (color, index) =>
+ colorClass = color.toLowerCase().replace(' ', '-')
+ className = classNames
+ active: @props.activeColor is colorClass
+
+ @props.selectLogColor(colorClass)}>
+ {color}
+
+
+
+ render: ->
+
+
+
+
+
+ {@renderColorChoices()}
+
+
+
+mapStateToProps = (state) ->
+ colors: state.colors
+ activeColor: state.activeColor
+
+mapDispatchToProps = { selectLogColor }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(ColorDropdown)
\ No newline at end of file
diff --git a/SingularityUI/app/components/logs/FileNotFound.cjsx b/SingularityUI/app/components/logs/FileNotFound.cjsx
new file mode 100644
index 0000000000..c4af678723
--- /dev/null
+++ b/SingularityUI/app/components/logs/FileNotFound.cjsx
@@ -0,0 +1,11 @@
+React = require 'react'
+
+class FileNotFound extends React.Component
+ render: ->
+
+
+
{ @props.fileName } does not exist.
+
+
+
+module.exports = FileNotFound
\ No newline at end of file
diff --git a/SingularityUI/app/components/logs/Header.cjsx b/SingularityUI/app/components/logs/Header.cjsx
new file mode 100644
index 0000000000..71ed1a98cf
--- /dev/null
+++ b/SingularityUI/app/components/logs/Header.cjsx
@@ -0,0 +1,82 @@
+React = require 'react'
+ColorDropdown = require './ColorDropdown'
+SearchDropdown = require './SearchDropdown'
+TasksDropdown = require './TasksDropdown'
+
+{ connect } = require 'react-redux'
+{ switchViewMode, scrollAllToTop, scrollAllToBottom } = require '../../actions/log'
+
+class Header extends React.Component
+ @propTypes:
+ requestId: React.PropTypes.string.isRequired
+ path: React.PropTypes.string.isRequired
+ multipleTasks: React.PropTypes.bool.isRequired
+ viewMode: React.PropTypes.string.isRequired
+
+ switchViewMode: React.PropTypes.func.isRequired
+ scrollAllToBottom: React.PropTypes.func.isRequired
+ scrollAllToTop: React.PropTypes.func.isRequired
+
+ renderBreadcrumbs: ->
+ @props.path.split('/').map (subpath, i) ->
+ if subpath is '$TASK_ID'
+ Task ID
+ else
+ {subpath}
+
+ renderViewButtons: ->
+ if @props.multipleTasks
+
+ @props.switchViewMode('unified')}>Unified
+ @props.switchViewMode('split')}>Split
+
+
+ renderAnchorButtons: ->
+ if @props.taskGroupCount > 1
+
+
+
+
+
+
+
+
+
+ render: ->
+
+
+
+
+
+ {@renderBreadcrumbs()}
+
+
+
+
+
+
+ {@renderViewButtons()}
+ {@renderAnchorButtons()}
+
+
+
+
+mapStateToProps = (state) ->
+ taskGroupCount: state.taskGroups.length
+ multipleTasks: state.taskGroups.length > 1 or (state.taskGroups.length > 0 and state.taskGroups[0].taskIds.length > 1)
+ path: state.path
+ viewMode: state.viewMode
+ requestId: state.activeRequest.requestId
+
+mapDispatchToProps = { switchViewMode, scrollAllToBottom, scrollAllToTop }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(Header)
diff --git a/SingularityUI/app/components/logs/LoadingSpinner.cjsx b/SingularityUI/app/components/logs/LoadingSpinner.cjsx
new file mode 100644
index 0000000000..7a79223399
--- /dev/null
+++ b/SingularityUI/app/components/logs/LoadingSpinner.cjsx
@@ -0,0 +1,22 @@
+React = require 'react'
+classNames = require 'classnames'
+
+class LoadingSpinner extends React.Component
+ @propTypes:
+ text: React.PropTypes.string
+ centered: React.PropTypes.bool
+
+ render: ->
+ className = classNames
+ 'page-loader': true
+ centered: @props.centered
+
+ if @props.children.length > 0
+
+
+
{ @props.children }
+
+ else
+
+
+module.exports = LoadingSpinner
\ No newline at end of file
diff --git a/SingularityUI/app/components/logs/LogContainer.cjsx b/SingularityUI/app/components/logs/LogContainer.cjsx
new file mode 100644
index 0000000000..9e00881b86
--- /dev/null
+++ b/SingularityUI/app/components/logs/LogContainer.cjsx
@@ -0,0 +1,54 @@
+React = require 'react'
+Interval = require 'react-interval'
+Header = require './Header'
+TaskGroupContainer = require './TaskGroupContainer'
+
+{ connect } = require 'react-redux'
+
+{ updateGroups, updateTaskStatuses } = require '../../actions/log'
+
+class LogContainer extends React.Component
+ @propTypes:
+ taskGroupsCount: React.PropTypes.number.isRequired
+ ready: React.PropTypes.bool.isRequired
+
+ updateGroups: React.PropTypes.func.isRequired
+ updateTaskStatuses: React.PropTypes.func.isRequired
+
+ renderTaskGroups: ->
+ rows = []
+
+ row = []
+ for i in [1..Math.min(@props.taskGroupsCount, 3)]
+ row.push
+
+ rows.push row
+
+ if @props.taskGroupsCount > 3
+ row = []
+ for i in [4..Math.min(@props.taskGroupsCount, 6)]
+ row.push
+ rows.push row
+
+ rowClassName = 'row tail-row'
+
+ if rows.length > 1
+ rowClassName = 'row tail-row-half'
+
+ rows.map (row, i) -> {row}
+
+ render: ->
+
+
+
+
+ {@renderTaskGroups()}
+
+
+mapStateToProps = (state) ->
+ taskGroupsCount: state.taskGroups.length
+ ready: _.all(_.pluck(state.taskGroups, 'ready'))
+
+mapDispatchToProps = { updateGroups, updateTaskStatuses }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(LogContainer)
diff --git a/SingularityUI/app/components/aggregateTail/LogLine.cjsx b/SingularityUI/app/components/logs/LogLine.cjsx
similarity index 53%
rename from SingularityUI/app/components/aggregateTail/LogLine.cjsx
rename to SingularityUI/app/components/logs/LogLine.cjsx
index c8b2f286be..c832cb6a1c 100644
--- a/SingularityUI/app/components/aggregateTail/LogLine.cjsx
+++ b/SingularityUI/app/components/logs/LogLine.cjsx
@@ -1,15 +1,28 @@
React = require 'react'
classNames = require 'classnames'
-LogLine = React.createClass
+{ connect } = require 'react-redux'
+{ clickPermalink } = require '../../actions/log'
- shouldComponentUpdate: (nextProps) ->
- (@props.offset isnt nextProps.offset) or (@props.isHighlighted isnt nextProps.isHighlighted) or (@props.content isnt nextProps.content) or (@props.isLastLine isnt nextProps.isLastLine) or (@props.isFirstLine isnt nextProps.isFirstLine)
+class LogLine extends React.Component
+ @propTypes:
+ offset: React.PropTypes.number.isRequired
+ isHighlighted: React.PropTypes.bool.isRequired
+ content: React.PropTypes.string.isRequired
+ taskId: React.PropTypes.string.isRequired
+ showDebugInfo: React.PropTypes.bool
+ color: React.PropTypes.string
+
+ search: React.PropTypes.string
+ clickPermalink: React.PropTypes.func.isRequired
highlightContent: (content) ->
search = @props.search
if not search or _.isEmpty(search)
- return content
+ if @props.showDebugInfo
+ return "#{ @props.offset } | #{ @props.timestamp } | #{ content }"
+ else
+ return content
regex = RegExp(search, 'g')
matches = []
@@ -37,20 +50,13 @@ LogLine = React.createClass
'search-match': s.match
{s.text}
- handleClick: (e) ->
- e.preventDefault()
- window.history.pushState({}, window.document.title, @props.offsetLink) # have to do it this janky way because of the hash
- @props.handleOffsetLink(@props.offset)
-
render: ->
divClass = classNames
line: true
highlightLine: @props.isHighlighted
- 'first-line': @props.isFirstLine
- 'last-line': @props.isLastLine
-module.exports = LogLine
+mapStateToProps = (state, ownProps) ->
+ search: state.search
+ showDebugInfo: state.showDebugInfo
+ path: state.path
+
+mapDispatchToProps = { clickPermalink }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(LogLine)
diff --git a/SingularityUI/app/components/logs/LogLines.cjsx b/SingularityUI/app/components/logs/LogLines.cjsx
new file mode 100644
index 0000000000..94b2a07f6f
--- /dev/null
+++ b/SingularityUI/app/components/logs/LogLines.cjsx
@@ -0,0 +1,132 @@
+React = require 'react'
+LogLine = require './LogLine'
+Humanize = require 'humanize-plus'
+LogLines = require '../../collections/LogLines'
+
+{ connect } = require 'react-redux'
+{ taskGroupTop, taskGroupBottom } = require '../../actions/log'
+
+sum = (numbers) ->
+ total = 0
+ for n in numbers
+ total += n
+ total
+
+class LogLines extends React.Component
+ @propTypes:
+ taskGroupTop: React.PropTypes.func.isRequired
+ taskGroupBottom: React.PropTypes.func.isRequired
+
+ taskGroupId: React.PropTypes.number.isRequired
+ logLines: React.PropTypes.array.isRequired
+
+ initialDataLoaded: React.PropTypes.bool.isRequired
+ reachedStartOfFile: React.PropTypes.bool.isRequired
+ reachedEndOfFile: React.PropTypes.bool.isRequired
+ bytesRemainingBefore: React.PropTypes.number.isRequired
+ bytesRemainingAfter: React.PropTypes.number.isRequired
+ activeColor: React.PropTypes.string.isRequired
+
+ componentDidMount: ->
+ window.addEventListener 'resize', @handleScroll
+
+ componentWillUnmount: ->
+ window.removeEventListener 'resize', @handleScroll
+
+ componentDidUpdate: (prevProps, prevState) ->
+ if prevProps.updatedAt isnt @props.updatedAt
+ if @props.tailing
+ @refs.tailContents.scrollTop = @refs.tailContents.scrollHeight
+ else if @props.prependedLineCount > 0 or @props.linesRemovedFromTop > 0
+ @refs.tailContents.scrollTop += 20 * (@props.prependedLineCount - @props.linesRemovedFromTop)
+ else
+ @handleScroll()
+
+ renderLoadingPrevious: ->
+ if @props.initialDataLoaded
+ if not @props.reachedStartOfFile
+ if @props.search
+ Searching for '{@props.search}'... ({Humanize.filesize(@props.bytesRemainingBefore)} remaining)
+ else
+ Loading previous... ({Humanize.filesize(@props.bytesRemainingBefore)} remaining)
+
+ renderLogLines: ->
+ @props.logLines.map ({data, offset, taskId, timestamp}) =>
+
+
+ renderLoadingMore: ->
+ if @props.terminated
+ return null
+ if @props.initialDataLoaded
+ if @props.reachedEndOfFile
+ if @props.search
+ Tailing for '{@props.search}'...
+ else
+ Tailing...
+ else
+ if @props.search
+ Searching for '{@props.search}'... ({Humanize.filesize(@props.bytesRemainingAfter)} remaining)
+ else
+ Loading more... ({Humanize.filesize(@props.bytesRemainingAfter)} remaining)
+
+
+ handleScroll: =>
+ {scrollTop, scrollHeight, clientHeight} = @refs.tailContents
+
+ if scrollTop < clientHeight
+ @props.taskGroupTop(@props.taskGroupId, true)
+ else
+ @props.taskGroupTop(@props.taskGroupId, false)
+
+ if scrollTop + clientHeight > scrollHeight - clientHeight
+ @props.taskGroupBottom(@props.taskGroupId, true, (scrollTop + clientHeight > scrollHeight - 20))
+ else
+ @props.taskGroupBottom(@props.taskGroupId, false)
+
+ render: ->
+
+
+ {@renderLoadingPrevious()}
+ {@renderLogLines()}
+ {@renderLoadingMore()}
+
+
+
+mapStateToProps = (state, ownProps) ->
+ taskGroup = state.taskGroups[ownProps.taskGroupId]
+ tasks = taskGroup.taskIds.map (taskId) -> state.tasks[taskId]
+
+ colorMap = {}
+ if taskGroup.taskIds.length > 1
+ i = 0
+ for taskId in taskGroup.taskIds
+ colorMap[taskId] = "hsla(#{(360 / taskGroup.taskIds.length) * i}, 100%, 50%, 0.1)"
+ i++
+
+ logLines: taskGroup.logLines
+ updatedAt: taskGroup.updatedAt
+ tailing: taskGroup.tailing
+ prependedLineCount: taskGroup.prependedLineCount
+ linesRemovedFromTop: taskGroup.linesRemovedFromTop
+ activeColor: state.activeColor
+ top: taskGroup.top
+ bottom: taskGroup.bottom
+ initialDataLoaded: _.all(_.pluck(tasks, 'initialDataLoaded'))
+ terminated: _.all(_.pluck(tasks, 'terminated'))
+ reachedStartOfFile: _.all(tasks.map ({minOffset}) -> minOffset is 0)
+ reachedEndOfFile: _.all(tasks.map ({maxOffset, filesize}) -> maxOffset >= filesize)
+ bytesRemainingBefore: sum(_.pluck(tasks, 'minOffset'))
+ bytesRemainingAfter: sum(tasks.map ({filesize, maxOffset}) -> Math.max(filesize - maxOffset, 0))
+ colorMap: colorMap
+ search: state.search
+
+mapDispatchToProps = { taskGroupTop, taskGroupBottom }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(LogLines)
diff --git a/SingularityUI/app/components/logs/SearchDropdown.cjsx b/SingularityUI/app/components/logs/SearchDropdown.cjsx
new file mode 100644
index 0000000000..af581831fb
--- /dev/null
+++ b/SingularityUI/app/components/logs/SearchDropdown.cjsx
@@ -0,0 +1,57 @@
+React = require 'react'
+ReactDOM = require 'react-dom'
+
+{ connect } = require 'react-redux'
+{ setCurrentSearch } = require '../../actions/log'
+
+class SearchDropdown extends React.Component
+ @propTypes:
+ search: React.PropTypes.string.isRequired
+
+ constructor: (props) ->
+ super(props)
+ @state = {
+ searchValue: props.search
+ }
+
+ handleSearchToggle: =>
+ ReactDOM.findDOMNode(@refs.searchInput).focus()
+
+ handleSearchUpdate: =>
+ @props.setCurrentSearch(@state.searchValue)
+
+ toggleSearchDropdown: =>
+ $(ReactDOM.findDOMNode(@refs.searchButton)).dropdown("toggle")
+
+ handleSearchKeyDown: (event) =>
+ if event.keyCode is 13 # Enter: commit search and close
+ @handleSearchUpdate()
+ @toggleSearchDropdown()
+ else if event.keyCode is 27 # Escape: clear search and commit
+ @setState
+ searchValue: @props.search
+ @toggleSearchDropdown()
+
+ render: ->
+
+
+mapStateToProps = (state) ->
+ search: state.search
+
+mapDispatchToProps = { setCurrentSearch }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(SearchDropdown)
\ No newline at end of file
diff --git a/SingularityUI/app/components/logs/TaskGroupContainer.cjsx b/SingularityUI/app/components/logs/TaskGroupContainer.cjsx
new file mode 100644
index 0000000000..6f0aa46f12
--- /dev/null
+++ b/SingularityUI/app/components/logs/TaskGroupContainer.cjsx
@@ -0,0 +1,55 @@
+React = require 'react'
+TaskGroupHeader = require './TaskGroupHeader'
+LogLines = require './LogLines'
+LoadingSpinner = require './LoadingSpinner'
+FileNotFound = require './FileNotFound'
+classNames = require 'classnames'
+
+{ connect } = require 'react-redux'
+
+class TaskGroupContainer extends React.Component
+ @propTypes:
+ taskGroupId: React.PropTypes.number.isRequired
+ taskGroupContainerCount: React.PropTypes.number.isRequired
+
+ initialDataLoaded: React.PropTypes.bool.isRequired
+ fileExists: React.PropTypes.bool.isRequired
+ terminated: React.PropTypes.bool.isRequired
+
+ getContainerWidth: ->
+ return (12 / @props.taskGroupContainerCount)
+
+ renderLogLines: ->
+ if @props.logDataLoaded
+ return
+ else if @props.initialDataLoaded and not @props.fileExists
+ return
+ else
+ return Loading logs...
+
+ render: ->
+ className = "col-md-#{ @getContainerWidth() } tail-column"
+
+
+ {@renderLogLines()}
+
+
+
+mapStateToProps = (state, ownProps) ->
+ unless ownProps.taskGroupId of state.taskGroups
+ return {
+ initialDataLoaded: false
+ fileExists: false
+ logDataLoaded: false
+ terminated: false
+ }
+ taskGroup = state.taskGroups[ownProps.taskGroupId]
+ tasks = taskGroup.taskIds.map (taskId) -> state.tasks[taskId]
+
+ initialDataLoaded: _.all(_.pluck(tasks, 'initialDataLoaded'))
+ logDataLoaded: _.all(_.pluck(tasks, 'logDataLoaded'))
+ fileExists: _.any(_.pluck(tasks, 'exists'))
+ terminated: _.all(_.pluck(tasks, 'terminated'))
+ path: state.path
+
+module.exports = connect(mapStateToProps)(TaskGroupContainer)
\ No newline at end of file
diff --git a/SingularityUI/app/components/logs/TaskGroupHeader.cjsx b/SingularityUI/app/components/logs/TaskGroupHeader.cjsx
new file mode 100644
index 0000000000..502584ab9e
--- /dev/null
+++ b/SingularityUI/app/components/logs/TaskGroupHeader.cjsx
@@ -0,0 +1,68 @@
+React = require 'react'
+TaskStatusIndicator = require './TaskStatusIndicator'
+
+{ getInstanceNumberFromTaskId } = require '../../utils'
+
+{ connect } = require 'react-redux'
+
+{ removeTaskGroup, expandTaskGroup, scrollToTop, scrollToBottom } = require '../../actions/log'
+
+class TaskGroupHeader extends React.Component
+ @propTypes:
+ taskGroupId: React.PropTypes.number.isRequired
+ tasks: React.PropTypes.array.isRequired
+
+ toggleLegend: ->
+ # TODO
+
+ renderInstanceInfo: ->
+ if @props.tasks.length > 1
+ Viewing Instances {@props.tasks.map(({taskId}) -> getInstanceNumberFromTaskId(taskId)).join(', ')}
+ else if @props.tasks.length > 0
+
+
+
+
+ else
+
+
+ renderTaskLegend: ->
+ if @props.tasks.length > 1
+
+
+
+
+ renderClose: ->
+ if @props.taskGroupsCount > 1
+ @props.removeTaskGroup(@props.taskGroupId)} title="Close Task">
+
+ renderExpand: ->
+ if @props.taskGroupsCount > 1
+ @props.expandTaskGroup(@props.taskGroupId)} title="Show only this Task">
+
+ render: ->
+
+
+mapStateToProps = (state, ownProps) ->
+ unless ownProps.taskGroupId of state.taskGroups
+ return {
+ taskGroupsCount: state.taskGroups.length
+ tasks: []
+ }
+ taskGroupsCount: state.taskGroups.length
+ tasks: state.taskGroups[ownProps.taskGroupId].taskIds.map (taskId) -> state.tasks[taskId]
+
+mapDispatchToProps = { scrollToTop, scrollToBottom, removeTaskGroup, expandTaskGroup }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(TaskGroupHeader)
\ No newline at end of file
diff --git a/SingularityUI/app/components/aggregateTail/StatusIndicator.cjsx b/SingularityUI/app/components/logs/TaskStatusIndicator.cjsx
similarity index 71%
rename from SingularityUI/app/components/aggregateTail/StatusIndicator.cjsx
rename to SingularityUI/app/components/logs/TaskStatusIndicator.cjsx
index 8f850f3b42..f3a2d16f80 100644
--- a/SingularityUI/app/components/aggregateTail/StatusIndicator.cjsx
+++ b/SingularityUI/app/components/logs/TaskStatusIndicator.cjsx
@@ -1,7 +1,9 @@
React = require 'react'
Utils = require '../../utils'
-StatusIndicator = React.createClass
+class TaskStatusIndicator extends React.Component
+ @propTypes:
+ status: React.PropTypes.string
getClassName: ->
if @props.status in Utils.TERMINAL_TASK_STATES
@@ -16,6 +18,6 @@ StatusIndicator = React.createClass
{@props.status.toLowerCase().replace('_', ' ')}
else
-
+
-module.exports = StatusIndicator
+module.exports = TaskStatusIndicator
diff --git a/SingularityUI/app/components/logs/TasksDropdown.cjsx b/SingularityUI/app/components/logs/TasksDropdown.cjsx
new file mode 100644
index 0000000000..2f8c414936
--- /dev/null
+++ b/SingularityUI/app/components/logs/TasksDropdown.cjsx
@@ -0,0 +1,39 @@
+React = require 'react'
+{ toggleTaskLog } = require '../../actions/log'
+
+{ connect } = require 'react-redux'
+
+class TasksDropdown extends React.Component
+ renderListItems: ->
+ if @props.activeTasks and @props.taskIds
+ tasks = _.sortBy(@props.activeTasks, (t) => t.taskId.instanceNo).map (task, i) =>
+
+ @props.toggleTaskLog(task.taskId.id)}>
+
+ Instance {task.taskId.instanceNo}
+
+
+ if tasks.length > 0
+ return tasks
+ else
+ return No running instances
+ else
+ Loading active tasks...
+
+ render: ->
+
+
+mapStateToProps = (state) ->
+ activeTasks: state.activeRequest.activeTasks
+ taskIds: _.flatten(_.pluck(state.taskGroups, 'taskIds'))
+
+mapDispatchToProps = { toggleTaskLog }
+
+module.exports = connect(mapStateToProps, mapDispatchToProps)(TasksDropdown)
\ No newline at end of file
diff --git a/SingularityUI/app/controllers/LogViewer.cjsx b/SingularityUI/app/controllers/LogViewer.cjsx
new file mode 100644
index 0000000000..e6104226f8
--- /dev/null
+++ b/SingularityUI/app/controllers/LogViewer.cjsx
@@ -0,0 +1,49 @@
+Controller = require './Controller'
+
+LogLines = require '../collections/LogLines'
+
+LogView = require '../views/logView'
+
+Redux = require 'redux'
+thunk = require 'redux-thunk'
+logger = require 'redux-logger'
+rootReducer = require '../reducers'
+LogActions = require '../actions/log'
+ActiveTasks = require '../actions/activeTasks'
+
+class LogViewer extends Controller
+ initialize: ({@requestId, @path, @initialOffset, taskIds, viewMode, search}) ->
+ @title 'Tail of ' + @path
+
+ initialState = {
+ viewMode,
+ colors: ['Default', 'Light', 'Dark'],
+ logRequestLength: 30000,
+ activeRequest: {
+ @requestId
+ }
+ }
+
+ middlewares = [thunk.default]
+
+ if window.localStorage.enableReduxLogging
+ middlewares.push(logger())
+
+ @store = Redux.createStore(rootReducer, initialState, Redux.compose(Redux.applyMiddleware.apply(this, middlewares)))
+
+ if taskIds.length > 0
+ initPromise = @store.dispatch(LogActions.initialize(@requestId, @path, search, taskIds))
+ else
+ initPromise = @store.dispatch(LogActions.initializeUsingActiveTasks(@requestId, @path, search))
+
+ initPromise.then =>
+ @store.dispatch(ActiveTasks.updateActiveTasks(@requestId))
+
+ # create log view
+ @view = new LogView @store
+
+ @setView @view # does nothing
+ app.showView @view
+ window.getStateJSON = () => JSON.stringify(@store.getState())
+
+module.exports = LogViewer
diff --git a/SingularityUI/app/reducers/activeRequest.coffee b/SingularityUI/app/reducers/activeRequest.coffee
new file mode 100644
index 0000000000..bebdf45379
--- /dev/null
+++ b/SingularityUI/app/reducers/activeRequest.coffee
@@ -0,0 +1,12 @@
+ACTIONS = {
+ LOG_INIT: (state, {requestId}) ->
+ return Object.assign({}, state, {requestId})
+ REQUEST_ACTIVE_TASKS: (state, {tasks}) ->
+ return Object.assign({}, state, {activeTasks: tasks})
+}
+
+module.exports = (state={}, action) ->
+ if action.type of ACTIONS
+ return ACTIONS[action.type](state, action)
+ else
+ return state
\ No newline at end of file
diff --git a/SingularityUI/app/reducers/index.coffee b/SingularityUI/app/reducers/index.coffee
new file mode 100644
index 0000000000..4bd42ae5df
--- /dev/null
+++ b/SingularityUI/app/reducers/index.coffee
@@ -0,0 +1,46 @@
+{ combineReducers } = require 'redux'
+
+taskGroups = require './taskGroups'
+activeRequest = require './activeRequest'
+tasks = require './tasks'
+
+path = (state='', action) ->
+ if action.type is 'LOG_INIT'
+ return action.path
+ return state
+
+activeColor = (state='default', action) ->
+ if action.type is 'LOG_INIT'
+ return window.localStorage.logColor || 'default'
+ else if action.type is 'LOG_SELECT_COLOR'
+ window.localStorage.logColor = action.color
+ return action.color
+ return state
+
+colors = (state=[]) -> state
+
+viewMode = (state='custom', action) ->
+ if action.type is 'LOG_SWITCH_VIEW_MODE'
+ return action.viewMode
+ return state
+
+search = (state='', action) ->
+ if action.type is 'LOG_INIT'
+ return action.search
+ return state
+
+logRequestLength = (state=30000, action) ->
+ return state
+
+maxLines = (state=100000, action) ->
+ return state
+
+showDebugInfo = (state=false, action) ->
+ if action.type is 'LOG_INIT'
+ return Boolean(window.localStorage.showDebugInfo) || false
+ if action.type is 'LOG_DEBUG_INFO'
+ window.localStorage.showDebugInfo = action.value
+ return action.value
+ return state
+
+module.exports = combineReducers({showDebugInfo, taskGroups, tasks, activeRequest, path, activeColor, colors, viewMode, search, logRequestLength, maxLines})
\ No newline at end of file
diff --git a/SingularityUI/app/reducers/taskGroups.coffee b/SingularityUI/app/reducers/taskGroups.coffee
new file mode 100644
index 0000000000..e8f51b9530
--- /dev/null
+++ b/SingularityUI/app/reducers/taskGroups.coffee
@@ -0,0 +1,233 @@
+{ combineReducers } = require 'redux'
+
+{ getInstanceNumberFromTaskId } = require '../utils'
+
+moment = require 'moment'
+
+buildTaskGroup = (taskIds, search) ->
+ {
+ taskIds
+ search
+ logLines: []
+ taskBuffer: {}
+ prependedLineCount: 0
+ linesRemovedFromTop: 0
+ updatedAt: +new Date()
+ top: false
+ bottom: false
+ tailing: false
+ ready: false
+ pendingRequests: false
+ detectedTimestamp: false
+ }
+
+resetTaskGroup = (tailing=false) -> {
+ logLines: []
+ taskBuffer: {}
+ top: true
+ bottom: true
+ updatedAt: +new Date()
+ tailing
+}
+
+updateTaskGroup = (state, taskGroupId, update) ->
+ newState = Object.assign([], state)
+ newState[taskGroupId] = Object.assign({}, state[taskGroupId], update)
+ return newState
+
+filterLogLines = (lines, search) ->
+ _.filter lines, ({data}) -> new RegExp(search).test(data)
+
+TIMESTAMP_REGEX = [
+ [/^(\d{2}:\d{2}:\d{2}\.\d{3})/, 'HH:mm:ss.SSS']
+ [/^[A-Z \[]+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})/, 'YYYY-MM-DD HH:mm:ss,SSS']
+ [/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})/, 'YYYY-MM-DD HH:mm:ss,SSS']
+]
+
+parseLineTimestamp = (line) ->
+ for group in TIMESTAMP_REGEX
+ match = line.match(group[0])
+ if match
+ return moment(match, group[1]).valueOf()
+ return null
+
+buildEmptyBuffer = (taskId, offset) -> { offset, taskId, data: '' }
+
+ACTIONS = {
+ # The logger is being initialized
+ LOG_INIT: (state, {taskIdGroups, search}) ->
+ return taskIdGroups.map (taskIds) -> buildTaskGroup(taskIds, search)
+
+ # Add a group of tasks to the logger
+ LOG_ADD_TASK_GROUP: (state, {taskIds, search}) ->
+ return _.sortBy(state.concat(buildTaskGroup(taskIds, search)), (taskGroup) -> getInstanceNumberFromTaskId(taskGroup.taskIds[0]))
+
+ # Remove a task from the logger
+ LOG_REMOVE_TASK: (state, {taskId}) ->
+ newState = []
+ for taskGroup in state
+ if taskId in taskGroup.taskIds
+ # remove task group if it only has one task
+ if taskGroup.taskIds.length is 1
+ continue
+
+ # remove task
+ newTaskIds = _.without(taskGroup.taskIds, taskId)
+
+ # remove task loglines
+ newLogLines = taskGroup.logLines.filter (logLine) -> logLine.taskId isnt taskId
+
+ newState.push(Object.assign({}, taskGroup, {tasksIds: newTasksIds, logLines: newLogLines}))
+ else
+ newState.push(taskGroup)
+ return newState
+
+ # The logger has either entered or exited the top
+ LOG_TASK_GROUP_TOP: (state, {taskGroupId, visible}) ->
+ return updateTaskGroup(state, taskGroupId, {top: visible, tailing: false})
+
+ # The logger has either entered or exited the bottom
+ LOG_TASK_GROUP_BOTTOM: (state, {taskGroupId, visible}) ->
+ return updateTaskGroup(state, taskGroupId, {bottom: visible})
+
+ # An entire task group is ready
+ LOG_TASK_GROUP_READY: (state, {taskGroupId}) ->
+ return updateTaskGroup(state, taskGroupId, {ready: true, updatedAt: +new Date(), top: true, bottom: true, tailing: true})
+
+ LOG_TASK_GROUP_TAILING: (state, {taskGroupId, tailing}) ->
+ return updateTaskGroup(state, taskGroupId, {tailing})
+
+ LOG_REMOVE_TASK_GROUP: (state, {taskGroupId}) ->
+ newState = []
+ for i in [0..state.length-1]
+ unless i is taskGroupId
+ newState.push(state[i])
+ return newState
+
+ LOG_EXPAND_TASK_GROUP: (state, {taskGroupId}) ->
+ return [state[taskGroupId]]
+
+ LOG_SCROLL_TO_TOP: (state, {taskGroupId}) ->
+ return updateTaskGroup(state, taskGroupId, resetTaskGroup())
+
+ LOG_SCROLL_ALL_TO_TOP: (state) ->
+ return state.map (taskGroup) -> Object.assign({}, taskGroup, resetTaskGroup())
+
+ LOG_SCROLL_TO_BOTTOM: (state, {taskGroupId}) ->
+ return updateTaskGroup(state, taskGroupId, resetTaskGroup(true))
+
+ LOG_SCROLL_ALL_TO_BOTTOM: (state) ->
+ return state.map (taskGroup) -> Object.assign({}, taskGroup, resetTaskGroup(true))
+
+ LOG_REQUEST_START: (state, {taskGroupId}) ->
+ return updateTaskGroup(state, taskGroupId, {pendingRequests: true})
+
+ LOG_REQUEST_END: (state, {taskGroupId}) ->
+ return updateTaskGroup(state, taskGroupId, {pendingRequests: false})
+
+ # We've received logging data for a task
+ LOG_TASK_DATA: (state, {taskGroupId, taskId, offset, nextOffset, maxLines, data, append}) ->
+ taskGroup = state[taskGroupId]
+
+ # bail early if no data
+ if data.length is 0 and task.loadedData
+ return state
+
+ # split task data into separate lines, attempt to parse timestamp
+ currentOffset = offset
+ lines = _.initial(data.match /[^\n]*(\n|$)/g).map (data) ->
+ currentOffset += data.length
+
+ data = data.replace('\r', '') # carriage return screws stuff up
+
+ timestamp = parseLineTimestamp(data)
+
+ if timestamp
+ detectedTimestamp = true
+
+ {timestamp, data, offset: currentOffset - data.length, taskId}
+
+ # task buffers
+ taskBuffer = taskGroup.taskBuffer[taskId] || buildEmptyBuffer(taskId, 0)
+
+ if append
+ if taskBuffer.offset + taskBuffer.data.length is offset
+ firstLine = _.first(lines)
+ lines = _.rest(lines)
+ taskBuffer = {offset: taskBuffer.offset, data: taskBuffer.data + firstLine.data, taskId}
+ if taskBuffer.data.endsWith('\n')
+ taskBuffer.timestamp = parseLineTimestamp(taskBuffer.data)
+ lines.unshift(taskBuffer)
+ taskBuffer = buildEmptyBuffer(taskId, nextOffset)
+ if lines.length > 0
+ lastLine = _.last(lines)
+ if not lastLine.data.endsWith('\n')
+ taskBuffer = lastLine
+ lines = _.initial(lines)
+ else
+ if nextOffset is taskBuffer.offset
+ lastLine = _.last(lines)
+ lines = _.initial(lines)
+ taskBuffer = {offset: nextOffset - lastLine.data.length, data: lastLine.data + taskBuffer.data, taskId}
+ if lines.length > 0
+ taskBuffer.timestamp = parseLineTimestamp(taskBuffer.data)
+ lines.push(taskBuffer)
+ taskBuffer = buildEmptyBuffer(taskId, offset)
+ if lines.length > 0
+ firstLine = _.first(lines)
+ if firstLine.offset > 0
+ taskBuffer = firstLine
+ lines = _.rest(lines)
+
+ newTaskBuffer = Object.assign({}, taskGroup.taskBuffer)
+ newTaskBuffer[taskId] = taskBuffer
+
+ # backfill old timestamps
+ if taskGroup.logLines.length > 0
+ lastTimestamp = _.last(taskGroup.logLines).timestamp
+ else
+ lastTimestamp = 0
+
+ lines = lines.map (line) ->
+ if line.timestamp
+ lastTimestamp = line.timestamp
+ else
+ line.timestamp = lastTimestamp
+ return line
+
+ prependedLineCount = 0
+ linesRemovedFromTop = 0
+ updatedAt = +new Date()
+
+ # search
+ if taskGroup.search
+ lines = filterLogLines(lines, taskGroup.search)
+
+ # merge lines
+ newLogLines = Object.assign([], taskGroup.logLines)
+ if append
+ newLogLines = newLogLines.concat(lines)
+ if newLogLines.length > maxLines
+ linesRemovedFromTop = newLogLines.length - maxLines
+ newLogLines = newLogLines.slice(newLogLines.length - maxLines)
+ else
+ newLogLines = lines.concat(newLogLines)
+ prependedLineCount = lines.length
+ if newLogLines.length > maxLines
+ newLogLines = newLogLines.slice(0, maxLines)
+
+ # sort lines by timestamp if unified view
+ if taskGroup.taskIds.length > 1
+ newLogLines = _.sortBy(newLogLines, ({timestamp, offset}) -> [timestamp, offset])
+
+ # update state
+ newState = Object.assign([], state)
+ newState[taskGroupId] = Object.assign({}, state[taskGroupId], {taskBuffer: newTaskBuffer, logLines: newLogLines, prependedLineCount, linesRemovedFromTop, updatedAt})
+ return newState
+}
+
+module.exports = (state=[], action) ->
+ if action.type of ACTIONS
+ return ACTIONS[action.type](state, action)
+ else
+ return state
\ No newline at end of file
diff --git a/SingularityUI/app/reducers/tasks.coffee b/SingularityUI/app/reducers/tasks.coffee
new file mode 100644
index 0000000000..a0503b05d3
--- /dev/null
+++ b/SingularityUI/app/reducers/tasks.coffee
@@ -0,0 +1,98 @@
+updateTask = (state, taskId, updates) ->
+ newState = Object.assign({}, state)
+ newState[taskId] = Object.assign({}, state[taskId], updates)
+ return newState
+
+buildTask = (taskId, offset=0) ->
+ {
+ taskId
+ minOffset: offset
+ maxOffset: offset
+ filesize: offset
+ initialDataLoaded: false
+ logDataLoaded: false
+ terminated: false
+ exists: false
+ }
+
+getLastTaskUpdate = (taskUpdates) ->
+ if taskUpdates.length > 0
+ _.last(_.sortBy(taskUpdates, (taskUpdate) -> taskUpdate.timestamp)).taskState
+ else
+ return null
+
+isTerminalTaskState = (taskState) ->
+ taskState in ['TASK_FINISHED', 'TASK_KILLED', 'TASK_FAILED', 'TASK_LOST', 'TASK_ERROR']
+
+ACTIONS = {
+ LOG_INIT: (state, {taskIdGroups}) ->
+ newState = {}
+ for taskIdGroup in taskIdGroups
+ for taskId in taskIdGroup
+ newState[taskId] = buildTask(taskId)
+ return newState
+ LOG_ADD_TASK_GROUP: (state, {taskIds}) ->
+ newState = Object.assign({}, state)
+ for taskId in taskIds
+ newState[taskId] = buildTask(taskId)
+ return newState
+ LOG_REMOVE_TASK: (state, {taskId}) ->
+ newState = Object.assign({}, state)
+ delete newState[taskId]
+ return newState
+ LOG_TASK_INIT: (state, {taskId, path, offset, exists}) ->
+ updateTask(state, taskId, {
+ path
+ exists
+ minOffset: offset
+ maxOffset: offset
+ filesize: offset
+ initialDataLoaded: true
+ })
+ LOG_TASK_FILE_DOES_NOT_EXIST: (state, {taskId}) ->
+ updateTask(state, taskId, {exists: false, initialDataLoaded: true})
+ LOG_SCROLL_TO_TOP: (state, {taskIds}) ->
+ newState = Object.assign({}, state)
+ for taskId in taskIds
+ newState[taskId] = Object.assign({}, state[taskId], {minOffset: 0, maxOffset: 0, logDataLoaded: false})
+ return newState
+ LOG_SCROLL_ALL_TO_TOP: (state) ->
+ newState = {}
+ for taskId of state
+ newState[taskId] = Object.assign({}, state[taskId], {minOffset: 0, maxOffset: 0, logDataLoaded: false})
+ return newState
+ LOG_SCROLL_TO_BOTTOM: (state, {taskIds}) ->
+ newState = Object.assign({}, state)
+ for taskId in taskIds
+ newState[taskId] = Object.assign({}, state[taskId], {minOffset: state[taskId].filesize, maxOffset: state[taskId].filesize, logDataLoaded: false})
+ return newState
+ LOG_SCROLL_ALL_TO_BOTTOM: (state) ->
+ newState = {}
+ for taskId of state
+ newState[taskId] = Object.assign({}, state[taskId], {minOffset: state[taskId].filesize, maxOffset: state[taskId].filesize, logDataLoaded: false})
+ return newState
+ LOG_TASK_FILESIZE: (state, {taskId, filesize}) ->
+ updateTask(state, taskId, {filesize})
+ LOG_TASK_DATA: (state, {taskId, offset, nextOffset}) ->
+ {minOffset, maxOffset, filesize} = state[taskId]
+ updateTask(state, taskId, {logDataLoaded: true, minOffset: Math.min(minOffset, offset), maxOffset: Math.max(maxOffset, nextOffset), filesize: Math.max(nextOffset, filesize)})
+ LOG_TASK_HISTORY: (state, {taskId, taskHistory}) ->
+ lastTaskStatus = getLastTaskUpdate(taskHistory.taskUpdates)
+ updateTask(state, taskId, {lastTaskStatus, terminated: isTerminalTaskState(lastTaskStatus)})
+ LOG_REMOVE_TASK_GROUP: (state, {taskIds}) ->
+ newState = Object.assign({}, state)
+ for taskId in taskIds
+ delete newState[taskId]
+ return newState
+ LOG_EXPAND_TASK_GROUP: (state, {taskIds}) ->
+ newState = {}
+ for taskId in taskIds
+ newState[taskId] = state[taskId]
+ return newState
+}
+
+module.exports = (state={}, action) ->
+ if action.type of ACTIONS
+ return ACTIONS[action.type](state, action)
+ else
+ return state
\ No newline at end of file
diff --git a/SingularityUI/app/router.coffee b/SingularityUI/app/router.coffee
index 8da60b73a5..7a2581d68e 100644
--- a/SingularityUI/app/router.coffee
+++ b/SingularityUI/app/router.coffee
@@ -11,7 +11,6 @@ RequestsTableController = require 'controllers/RequestsTable'
TasksTableController = require 'controllers/TasksTable'
TaskDetailController = require 'controllers/TaskDetail'
-TailController = require 'controllers/Tail'
RacksController = require 'controllers/Racks'
SlavesController = require 'controllers/Slaves'
@@ -20,9 +19,12 @@ NotFoundController = require 'controllers/NotFound'
DeployDetailController = require 'controllers/DeployDetail'
-AggregateTailController = require 'controllers/AggregateTail'
+LogViewerController = require 'controllers/LogViewer'
+
TaskSearchController = require 'controllers/TaskSearch'
+Utils = require './utils'
+
class Router extends Backbone.Router
routes:
@@ -97,8 +99,16 @@ class Router extends Backbone.Router
app.bootstrapController new TaskDetailController {taskId, filePath}
tail: (taskId, path = '') ->
- offset = parseInt(window.location.hash.substr(1), 10) || null
- app.bootstrapController new TailController {taskId, path, offset}
+ initialOffset = parseInt(window.location.hash.substr(1), 10) || null
+ splits = taskId.split('-')
+ requestId = splits.slice(0, splits.length - 5).join('-')
+ params = Utils.getQueryParams()
+
+ search = params.search || ''
+
+ path = path.replace(taskId, '$TASK_ID')
+
+ app.bootstrapController new LogViewerController {requestId, path, initialOffset, taskIds: [taskId], search, viewMode: 'split'}
racks: (state = 'all') ->
app.bootstrapController new RacksController {state}
@@ -113,7 +123,16 @@ class Router extends Backbone.Router
app.bootstrapController new DeployDetailController {requestId, deployId}
aggregateTail: (requestId, path = '') ->
- offset = parseInt(window.location.hash.substr(1), 10) || null
- app.bootstrapController new AggregateTailController {requestId, path, offset}
+ initialOffset = parseInt(window.location.hash.substr(1), 10) || null
+
+ params = Utils.getQueryParams()
+ if params.taskIds
+ taskIds = params.taskIds.split(',')
+ else
+ taskIds = []
+ viewMode = params.viewMode || 'split'
+ search = params.search || ''
+
+ app.bootstrapController new LogViewerController {requestId, path, initialOffset, taskIds, viewMode, search}
module.exports = Router
diff --git a/SingularityUI/app/utils.coffee b/SingularityUI/app/utils.coffee
index 92e5f3c2b6..e8fc5c5ab5 100644
--- a/SingularityUI/app/utils.coffee
+++ b/SingularityUI/app/utils.coffee
@@ -223,6 +223,10 @@ class Utils
else
fuzzyObject.score
+ @getInstanceNumberFromTaskId: (taskId) ->
+ splits = taskId.split('-')
+ splits[splits.length - 3]
+
# e.g. `myModel.fetch().error Utils.ignore404`
@ignore404: (response) -> app.caughtError() if response.status is 404
diff --git a/SingularityUI/app/views/logView.cjsx b/SingularityUI/app/views/logView.cjsx
new file mode 100644
index 0000000000..699fd60a47
--- /dev/null
+++ b/SingularityUI/app/views/logView.cjsx
@@ -0,0 +1,21 @@
+View = require './view'
+React = require 'react'
+ReactDOM = require 'react-dom'
+LogContainer = require '../components/logs/LogContainer'
+{ Provider } = require 'react-redux'
+
+class LogView extends View
+ initialize: (store) ->
+ window.addEventListener 'viewChange', @handleViewChange
+ @component =
+
+ handleViewChange: =>
+ unmounted = ReactDOM.unmountComponentAtNode @el
+ if unmounted
+ window.removeEventListener 'viewChange', @handleViewChange
+
+ render: ->
+ $(@el).addClass 'tail-root'
+ ReactDOM.render @component, @el
+
+module.exports = LogView
diff --git a/SingularityUI/gulpfile.js b/SingularityUI/gulpfile.js
index 2085df0402..ead7da86e6 100644
--- a/SingularityUI/gulpfile.js
+++ b/SingularityUI/gulpfile.js
@@ -9,6 +9,7 @@ var stylus = require('gulp-stylus');
var nib = require('nib');
var concat = require('gulp-concat');
+var merge = require('webpack-merge');
var serverBase = process.env.SINGULARITY_BASE_URI || '/singularity'
@@ -59,7 +60,7 @@ gulp.task('fonts', function() {
});
gulp.task('scripts', function () {
- return gulp.src(webpackConfig.entry)
+ return gulp.src(webpackConfig.entry.app)
.pipe(webpack(webpackConfig))
.pipe(gulp.dest(dest + '/static/js'))
});
@@ -105,7 +106,7 @@ gulp.task('build', ['clean'], function () {
gulp.task('serve', ['html', 'styles', 'fonts', 'images', 'css-images'], function () {
gulp.watch('app/**/*.styl', ['styles'])
- new WebpackDevServer(require('webpack')(webpackConfig), {
+ new WebpackDevServer(require('webpack')(merge(webpackConfig, {devtool: 'eval'})), {
contentBase: dest,
historyApiFallback: true
}).listen(3334, "localhost", function (err) {
diff --git a/SingularityUI/package.json b/SingularityUI/package.json
index 148f6280af..e3ea4606e6 100644
--- a/SingularityUI/package.json
+++ b/SingularityUI/package.json
@@ -27,16 +27,23 @@
"eonasdan-bootstrap-datetimepicker": "^4.15.35",
"fuse.js": "~1.2.2",
"fuzzy": "~0.1.1",
+ "humanize-plus": "^1.8.1",
"jquery": "~1.11.1",
"juration": "*",
"linkifyjs": "~2.0.0-beta.9",
"messenger": "git://github.com/HubSpot/messenger#set-main-field",
"moment": "2.11.2",
+ "q": "^1.4.1",
"node-uuid": "^1.4.7",
"react": "~0.14.2",
"react-bootstrap": "^0.28.3",
"react-dom": "~0.14.2",
+ "react-interval": "^1.2.1",
"react-list": "~0.7.3",
+ "react-redux": "^4.4.1",
+ "redux": "^3.3.1",
+ "redux-logger": "^2.6.1",
+ "redux-thunk": "^2.0.1",
"select2": "~3.5.1",
"sortable": "git://github.com/HubSpot/sortable.git#v0.6.0",
"typeahead.js": "~0.10.4",
@@ -60,6 +67,7 @@
"nib": "^1.1.0",
"webpack": "^1.12.14",
"webpack-dev-server": "^1.14.1",
+ "webpack-merge": "^0.8.4",
"webpack-stream": "^3.1.0"
}
}
diff --git a/SingularityUI/webpack.config.js b/SingularityUI/webpack.config.js
index aa4827d8be..2ec7e01750 100644
--- a/SingularityUI/webpack.config.js
+++ b/SingularityUI/webpack.config.js
@@ -4,7 +4,30 @@ var path = require('path');
dest = path.resolve(__dirname, '../SingularityService/target/generated-resources/assets');
module.exports = {
- entry: './app/initialize.coffee',
+ entry: {
+ app: './app/initialize.coffee',
+ vendor: [
+ 'react',
+ 'jquery',
+ 'underscore',
+ 'clipboard',
+ 'select2',
+ 'handlebars',
+ 'moment',
+ 'messenger',
+ 'bootstrap',
+ 'classnames',
+ 'react-interval',
+ 'backbone-react-component',
+ 'react-dom',
+ 'fuzzy',
+ 'datatables',
+ 'sortable',
+ 'juration',
+ 'backbone',
+ 'vex-js'
+ ],
+ },
output: {
path: dest,
filename: 'app.js'
@@ -43,6 +66,10 @@ module.exports = {
compress: {
warnings: false
}
- })
+ }),
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': '"production"'
+ }),
+ new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.bundle.js'),
]
};