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()} -
    -
    - - 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 -
    - - -
    - - 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()} -
    - {if @props.instanceNumber then "Instance #{@props.instanceNumber}" else @props.taskId} -
    - - - - - -
    - -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 -
    -
    {@props.text}
    -
    - 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 +
    + + +
    + + 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: -> +
    + +
      +
    • +
      + @setState({searchValue: e.target.value})} /> + + + +
      +
    • +
    +
    + +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: -> +
    + {@renderClose()} + {@renderExpand()} + {@renderInstanceInfo()} + {@renderTaskLegend()} + + @props.scrollToBottom(@props.taskGroupId) } title="Scroll to Bottom"> + @props.scrollToTop(@props.taskGroupId) } title="Scroll to Top"> + +
    + +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: -> +
    + +
      + {@renderListItems()} +
    +
    + +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'), ] };