feat(xo-web/logs): display real job status (#2688)

This commit is contained in:
Julien Fontanet 2018-02-26 18:02:39 +01:00 committed by GitHub
parent 80e66415d7
commit ee47e40d1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 165 additions and 164 deletions

View File

@ -70,13 +70,16 @@ export default class JobExecutor {
) )
} }
async exec (job) { async exec (job, onStart) {
const runJobId = this._logger.notice(`Starting execution of ${job.id}.`, { const runJobId = this._logger.notice(`Starting execution of ${job.id}.`, {
event: 'job.start', event: 'job.start',
userId: job.userId, userId: job.userId,
jobId: job.id, jobId: job.id,
key: job.key, key: job.key,
}) })
if (onStart !== undefined) {
onStart(runJobId)
}
try { try {
if (job.type === 'call') { if (job.type === 'call') {

View File

@ -30,7 +30,12 @@ export default class Jobs {
} }
async getAllJobs () { async getAllJobs () {
return /* await */ this._jobs.get() const jobs = await this._jobs.get()
const runningJobs = this._runningJobs
jobs.forEach(job => {
job.runId = runningJobs[job.id]
})
return jobs
} }
async getJob (id) { async getJob (id) {
@ -69,10 +74,13 @@ export default class Jobs {
if (runningJobs[id]) { if (runningJobs[id]) {
throw new Error(`job ${id} is already running`) throw new Error(`job ${id} is already running`)
} }
runningJobs[id] = true return this._executor
return this._executor.exec(job)::lastly(() => { .exec(job, runJobId => {
delete runningJobs[id] runningJobs[id] = runJobId
}) })
::lastly(() => {
delete runningJobs[id]
})
} }
async runJobSequence (idSequence) { async runJobSequence (idSequence) {

View File

@ -274,8 +274,9 @@ const messages = {
jobServerTimezone: 'Server', jobServerTimezone: 'Server',
runJob: 'Run job', runJob: 'Run job',
runJobVerbose: 'One shot running started. See overview for logs.', runJobVerbose: 'One shot running started. See overview for logs.',
jobStarted: 'Started',
jobFinished: 'Finished', jobFinished: 'Finished',
jobInterrupted: 'Interrupted',
jobStarted: 'Started',
saveBackupJob: 'Save', saveBackupJob: 'Save',
deleteBackupSchedule: 'Remove backup job', deleteBackupSchedule: 'Remove backup job',
deleteBackupScheduleQuestion: deleteBackupScheduleQuestion:

View File

@ -1866,10 +1866,28 @@ export const createSrLvm = (host, nameLabel, nameDescription, device) =>
// Job logs ---------------------------------------------------------- // Job logs ----------------------------------------------------------
export const deleteJobsLog = id => export const deleteJobsLogs = async ids => {
_call('log.delete', { namespace: 'jobs', id })::tap( const { length } = ids
subscribeJobsLogs.forceRefresh if (length === 0) {
) return
}
if (length !== 1) {
const vars = { nLogs: length }
try {
await confirm({
title: _('logDeleteMultiple', vars),
body: <p>{_('logDeleteMultipleMessage', vars)}</p>,
})
} catch (_) {
return
}
}
return _call('log.delete', {
namespace: 'jobs',
id: ids.map(resolveId),
})::tap(subscribeJobsLogs.forceRefresh)
}
// Logs // Logs

View File

@ -1,8 +1,6 @@
import _, { FormattedDuration } from 'intl' import _, { FormattedDuration } from 'intl'
import ActionButton from 'action-button' import addSubscriptions from 'add-subscriptions'
import ActionRowButton from 'action-row-button'
import BaseComponent from 'base-component' import BaseComponent from 'base-component'
import ButtonGroup from 'button-group'
import classnames from 'classnames' import classnames from 'classnames'
import Icon from 'icon' import Icon from 'icon'
import NoObjects from 'no-objects' import NoObjects from 'no-objects'
@ -12,13 +10,14 @@ import renderXoItem from 'render-xo-item'
import Select from 'form/select' import Select from 'form/select'
import SortedTable from 'sorted-table' import SortedTable from 'sorted-table'
import Tooltip from 'tooltip' import Tooltip from 'tooltip'
import { alert, confirm } from 'modal' import { alert } from 'modal'
import { Card, CardHeader, CardBlock } from 'card' import { Card, CardHeader, CardBlock } from 'card'
import { connectStore, formatSize, formatSpeed } from 'utils' import { connectStore, formatSize, formatSpeed } from 'utils'
import { createFilter, createGetObject, createSelector } from 'selectors' import { createFilter, createGetObject, createSelector } from 'selectors'
import { deleteJobsLog, subscribeJobsLogs } from 'xo' import { deleteJobsLogs, subscribeJobs, subscribeJobsLogs } from 'xo'
import { forEach, get, includes, isEmpty, map, orderBy } from 'lodash' import { forEach, includes, keyBy, map, orderBy } from 'lodash'
import { FormattedDate } from 'react-intl' import { FormattedDate } from 'react-intl'
import { get } from 'xo-defined'
// =================================================================== // ===================================================================
@ -270,10 +269,28 @@ class Log extends BaseComponent {
const showCalls = log => const showCalls = log =>
alert(_('jobModalTitle', { job: log.jobId }), <Log log={log} />) alert(_('jobModalTitle', { job: log.jobId }), <Log log={log} />)
const LOG_ACTIONS = [
{
handler: deleteJobsLogs,
icon: 'delete',
label: _('remove'),
},
]
const LOG_ACTIONS_INDIVIDUAL = [
{
handler: showCalls,
icon: 'preview',
label: _('logDisplayDetails'),
},
]
const getCallTag = log => log.calls[0].params.tag
const LOG_COLUMNS = [ const LOG_COLUMNS = [
{ {
name: _('jobId'), name: _('jobId'),
itemRenderer: log => log.jobId, itemRenderer: log => log.jobId.slice(4, 8),
sortCriteria: log => log.jobId, sortCriteria: log => log.jobId,
}, },
{ {
@ -283,8 +300,8 @@ const LOG_COLUMNS = [
}, },
{ {
name: _('jobTag'), name: _('jobTag'),
itemRenderer: log => get(log, 'calls[0].params.tag'), itemRenderer: log => get(getCallTag, log),
sortCriteria: log => get(log, 'calls[0].params.tag'), sortCriteria: log => get(getCallTag, log),
}, },
{ {
name: _('jobStart'), name: _('jobStart'),
@ -329,9 +346,9 @@ const LOG_COLUMNS = [
}, },
{ {
name: _('jobStatus'), name: _('jobStatus'),
itemRenderer: log => ( itemRenderer: (log, { jobs }) => (
<span> <span>
{log.status === 'finished' && ( {log.status === 'finished' ? (
<span <span
className={classnames( className={classnames(
'tag', 'tag',
@ -342,161 +359,115 @@ const LOG_COLUMNS = [
> >
{_('jobFinished')} {_('jobFinished')}
</span> </span>
) : log.status === 'started' ? (
log.id === get(() => jobs[log.jobId].runId) ? (
<span className='tag tag-warning'>{_('jobStarted')}</span>
) : (
<span className='tag tag-danger'>{_('jobInterrupted')}</span>
)
) : (
<span className='tag tag-default'>{_('jobUnknown')}</span>
)} )}
{log.status === 'started' && (
<span className='tag tag-warning'>{_('jobStarted')}</span>
)}
{log.status !== 'started' &&
log.status !== 'finished' && (
<span className='tag tag-default'>{_('jobUnknown')}</span>
)}{' '}
<span className='pull-right'>
<ButtonGroup>
<Tooltip content={_('logDisplayDetails')}>
<ActionRowButton
icon='preview'
handler={showCalls}
handlerParam={log}
/>
</Tooltip>
<Tooltip content={_('remove')}>
<ActionRowButton
handler={deleteJobsLog}
handlerParam={log.logKey}
icon='delete'
/>
</Tooltip>
</ButtonGroup>
</span>
</span> </span>
), ),
sortCriteria: log => (log.hasErrors ? ' ' : log.status), sortCriteria: log => (log.hasErrors ? ' ' : log.status),
}, },
] ]
@propTypes({ const LOG_FILTERS = {
jobKeys: propTypes.array.isRequired, onError: 'hasErrors?',
}) successful: 'status:finished !hasErrors?',
export default class LogList extends Component { jobCallSkipped: '!hasErrors? callSkipped?',
constructor (props) { }
super(props)
this.state = {
logsToClear: [],
}
this.filters = {
onError: 'hasErrors?',
successful: 'status:finished !hasErrors?',
jobCallSkipped: '!hasErrors? callSkipped?',
}
}
componentWillMount () { export default [
this.componentWillUnmount = subscribeJobsLogs(rawLogs => { propTypes({
const logs = {} jobKeys: propTypes.array.isRequired,
const logsToClear = [] }),
forEach(rawLogs, (log, logKey) => { addSubscriptions(({ jobKeys }) => ({
const data = log.data logs: cb =>
const { time } = log subscribeJobsLogs(rawLogs => {
if ( const logs = {}
data.event === 'job.start' && forEach(rawLogs, (log, id) => {
includes(this.props.jobKeys, data.key) const data = log.data
) { const { time } = log
logsToClear.push(logKey) if (data.event === 'job.start' && includes(jobKeys, data.key)) {
logs[logKey] = { logs[id] = {
logKey, id,
jobId: data.jobId.slice(4, 8), jobId: data.jobId,
key: data.key, key: data.key,
userId: data.userId, userId: data.userId,
start: time,
calls: {},
time,
}
} else {
const runJobId = data.runJobId
const entry = logs[runJobId]
if (!entry) {
return
}
logsToClear.push(logKey)
if (data.event === 'job.end') {
entry.end = time
entry.duration = time - entry.start
entry.status = 'finished'
} else if (data.event === 'jobCall.start') {
entry.calls[logKey] = {
callKey: logKey,
params: data.params,
method: data.method,
start: time, start: time,
calls: {},
time, time,
} }
} else if (data.event === 'jobCall.end') { } else {
const call = entry.calls[data.runCallId] const runJobId = data.runJobId
const entry = logs[runJobId]
if (data.error) { if (!entry) {
call.error = data.error return
if (isSkippedError(data.error)) { }
entry.callSkipped = true if (data.event === 'job.end') {
} else { entry.end = time
entry.hasErrors = true entry.duration = time - entry.start
entry.status = 'finished'
} else if (data.event === 'jobCall.start') {
entry.calls[id] = {
callKey: id,
params: data.params,
method: data.method,
start: time,
time,
}
} else if (data.event === 'jobCall.end') {
const call = entry.calls[data.runCallId]
if (data.error) {
call.error = data.error
if (isSkippedError(data.error)) {
entry.callSkipped = true
} else {
entry.hasErrors = true
}
entry.meta = 'error'
} else {
call.returnedValue = data.returnedValue
call.end = time
} }
entry.meta = 'error'
} else {
call.returnedValue = data.returnedValue
call.end = time
} }
} }
} })
})
forEach(logs, log => { forEach(logs, log => {
if (log.end === undefined) { if (log.end === undefined) {
log.status = 'started' log.status = 'started'
} else if (!log.meta) { } else if (!log.meta) {
log.meta = 'success' log.meta = 'success'
} }
log.calls = orderBy(log.calls, ['time'], ['desc']) log.calls = orderBy(log.calls, ['time'], ['desc'])
}) })
this.setState({ cb(orderBy(logs, ['time'], ['desc']))
logs: orderBy(logs, ['time'], ['desc']), }),
logsToClear, jobs: cb => subscribeJobs(jobs => cb(keyBy(jobs, 'id'))),
}) })),
}) ({ logs, jobs }) => (
} <Card>
<CardHeader>
_deleteAllLogs = () => { <Icon icon='log' /> Logs
return confirm({ </CardHeader>
title: _('removeAllLogsModalTitle'), <CardBlock>
body: <p>{_('removeAllLogsModalWarning')}</p>, <NoObjects
}).then(() => deleteJobsLog(this.state.logsToClear)) actions={LOG_ACTIONS}
} collection={logs}
columns={LOG_COLUMNS}
render () { component={SortedTable}
const { logs } = this.state data-jobs={jobs}
emptyMessage={_('noLogs')}
return ( filters={LOG_FILTERS}
<Card> individualActions={LOG_ACTIONS_INDIVIDUAL}
<CardHeader> />
<Icon icon='log' /> Logs<span className='pull-right'> </CardBlock>
<ActionButton </Card>
disabled={isEmpty(logs)} ),
btnStyle='danger' ].reduceRight((value, decorator) => decorator(value))
handler={this._deleteAllLogs}
icon='delete'
/>
</span>
</CardHeader>
<CardBlock>
<NoObjects
collection={logs}
columns={LOG_COLUMNS}
component={SortedTable}
emptyMessage={_('noLogs')}
filters={this.filters}
/>
</CardBlock>
</Card>
)
}
}