diff --git a/packages/xo-server/src/job-executor.js b/packages/xo-server/src/job-executor.js
index 6a0e45e6e..4f5457d62 100644
--- a/packages/xo-server/src/job-executor.js
+++ b/packages/xo-server/src/job-executor.js
@@ -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}.`, {
event: 'job.start',
userId: job.userId,
jobId: job.id,
key: job.key,
})
+ if (onStart !== undefined) {
+ onStart(runJobId)
+ }
try {
if (job.type === 'call') {
diff --git a/packages/xo-server/src/xo-mixins/jobs.js b/packages/xo-server/src/xo-mixins/jobs.js
index 40dfa4d29..af5ffb3a0 100644
--- a/packages/xo-server/src/xo-mixins/jobs.js
+++ b/packages/xo-server/src/xo-mixins/jobs.js
@@ -30,7 +30,12 @@ export default class Jobs {
}
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) {
@@ -69,10 +74,13 @@ export default class Jobs {
if (runningJobs[id]) {
throw new Error(`job ${id} is already running`)
}
- runningJobs[id] = true
- return this._executor.exec(job)::lastly(() => {
- delete runningJobs[id]
- })
+ return this._executor
+ .exec(job, runJobId => {
+ runningJobs[id] = runJobId
+ })
+ ::lastly(() => {
+ delete runningJobs[id]
+ })
}
async runJobSequence (idSequence) {
diff --git a/packages/xo-web/src/common/intl/messages.js b/packages/xo-web/src/common/intl/messages.js
index 4f6e10abc..e6a7d7384 100644
--- a/packages/xo-web/src/common/intl/messages.js
+++ b/packages/xo-web/src/common/intl/messages.js
@@ -274,8 +274,9 @@ const messages = {
jobServerTimezone: 'Server',
runJob: 'Run job',
runJobVerbose: 'One shot running started. See overview for logs.',
- jobStarted: 'Started',
jobFinished: 'Finished',
+ jobInterrupted: 'Interrupted',
+ jobStarted: 'Started',
saveBackupJob: 'Save',
deleteBackupSchedule: 'Remove backup job',
deleteBackupScheduleQuestion:
diff --git a/packages/xo-web/src/common/xo/index.js b/packages/xo-web/src/common/xo/index.js
index cc936bdb4..9939ca812 100644
--- a/packages/xo-web/src/common/xo/index.js
+++ b/packages/xo-web/src/common/xo/index.js
@@ -1866,10 +1866,28 @@ export const createSrLvm = (host, nameLabel, nameDescription, device) =>
// Job logs ----------------------------------------------------------
-export const deleteJobsLog = id =>
- _call('log.delete', { namespace: 'jobs', id })::tap(
- subscribeJobsLogs.forceRefresh
- )
+export const deleteJobsLogs = async ids => {
+ const { length } = ids
+ if (length === 0) {
+ return
+ }
+ if (length !== 1) {
+ const vars = { nLogs: length }
+ try {
+ await confirm({
+ title: _('logDeleteMultiple', vars),
+ body:
{_('logDeleteMultipleMessage', vars)}
,
+ })
+ } catch (_) {
+ return
+ }
+ }
+
+ return _call('log.delete', {
+ namespace: 'jobs',
+ id: ids.map(resolveId),
+ })::tap(subscribeJobsLogs.forceRefresh)
+}
// Logs
diff --git a/packages/xo-web/src/xo-app/logs/index.js b/packages/xo-web/src/xo-app/logs/index.js
index 9f572a3d8..7a66cda77 100644
--- a/packages/xo-web/src/xo-app/logs/index.js
+++ b/packages/xo-web/src/xo-app/logs/index.js
@@ -1,8 +1,6 @@
import _, { FormattedDuration } from 'intl'
-import ActionButton from 'action-button'
-import ActionRowButton from 'action-row-button'
+import addSubscriptions from 'add-subscriptions'
import BaseComponent from 'base-component'
-import ButtonGroup from 'button-group'
import classnames from 'classnames'
import Icon from 'icon'
import NoObjects from 'no-objects'
@@ -12,13 +10,14 @@ import renderXoItem from 'render-xo-item'
import Select from 'form/select'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
-import { alert, confirm } from 'modal'
+import { alert } from 'modal'
import { Card, CardHeader, CardBlock } from 'card'
import { connectStore, formatSize, formatSpeed } from 'utils'
import { createFilter, createGetObject, createSelector } from 'selectors'
-import { deleteJobsLog, subscribeJobsLogs } from 'xo'
-import { forEach, get, includes, isEmpty, map, orderBy } from 'lodash'
+import { deleteJobsLogs, subscribeJobs, subscribeJobsLogs } from 'xo'
+import { forEach, includes, keyBy, map, orderBy } from 'lodash'
import { FormattedDate } from 'react-intl'
+import { get } from 'xo-defined'
// ===================================================================
@@ -270,10 +269,28 @@ class Log extends BaseComponent {
const showCalls = log =>
alert(_('jobModalTitle', { job: log.jobId }), )
+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 = [
{
name: _('jobId'),
- itemRenderer: log => log.jobId,
+ itemRenderer: log => log.jobId.slice(4, 8),
sortCriteria: log => log.jobId,
},
{
@@ -283,8 +300,8 @@ const LOG_COLUMNS = [
},
{
name: _('jobTag'),
- itemRenderer: log => get(log, 'calls[0].params.tag'),
- sortCriteria: log => get(log, 'calls[0].params.tag'),
+ itemRenderer: log => get(getCallTag, log),
+ sortCriteria: log => get(getCallTag, log),
},
{
name: _('jobStart'),
@@ -329,9 +346,9 @@ const LOG_COLUMNS = [
},
{
name: _('jobStatus'),
- itemRenderer: log => (
+ itemRenderer: (log, { jobs }) => (
- {log.status === 'finished' && (
+ {log.status === 'finished' ? (
{_('jobFinished')}
+ ) : log.status === 'started' ? (
+ log.id === get(() => jobs[log.jobId].runId) ? (
+ {_('jobStarted')}
+ ) : (
+ {_('jobInterrupted')}
+ )
+ ) : (
+ {_('jobUnknown')}
)}
- {log.status === 'started' && (
- {_('jobStarted')}
- )}
- {log.status !== 'started' &&
- log.status !== 'finished' && (
- {_('jobUnknown')}
- )}{' '}
-
-
-
-
-
-
-
-
-
-
),
sortCriteria: log => (log.hasErrors ? ' ' : log.status),
},
]
-@propTypes({
- jobKeys: propTypes.array.isRequired,
-})
-export default class LogList extends Component {
- constructor (props) {
- super(props)
- this.state = {
- logsToClear: [],
- }
- this.filters = {
- onError: 'hasErrors?',
- successful: 'status:finished !hasErrors?',
- jobCallSkipped: '!hasErrors? callSkipped?',
- }
- }
+const LOG_FILTERS = {
+ onError: 'hasErrors?',
+ successful: 'status:finished !hasErrors?',
+ jobCallSkipped: '!hasErrors? callSkipped?',
+}
- componentWillMount () {
- this.componentWillUnmount = subscribeJobsLogs(rawLogs => {
- const logs = {}
- const logsToClear = []
- forEach(rawLogs, (log, logKey) => {
- const data = log.data
- const { time } = log
- if (
- data.event === 'job.start' &&
- includes(this.props.jobKeys, data.key)
- ) {
- logsToClear.push(logKey)
- logs[logKey] = {
- logKey,
- jobId: data.jobId.slice(4, 8),
- key: data.key,
- 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,
+export default [
+ propTypes({
+ jobKeys: propTypes.array.isRequired,
+ }),
+ addSubscriptions(({ jobKeys }) => ({
+ logs: cb =>
+ subscribeJobsLogs(rawLogs => {
+ const logs = {}
+ forEach(rawLogs, (log, id) => {
+ const data = log.data
+ const { time } = log
+ if (data.event === 'job.start' && includes(jobKeys, data.key)) {
+ logs[id] = {
+ id,
+ jobId: data.jobId,
+ key: data.key,
+ userId: data.userId,
start: time,
+ calls: {},
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
+ } else {
+ const runJobId = data.runJobId
+ const entry = logs[runJobId]
+ if (!entry) {
+ return
+ }
+ if (data.event === 'job.end') {
+ entry.end = time
+ 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 => {
- if (log.end === undefined) {
- log.status = 'started'
- } else if (!log.meta) {
- log.meta = 'success'
- }
- log.calls = orderBy(log.calls, ['time'], ['desc'])
- })
+ forEach(logs, log => {
+ if (log.end === undefined) {
+ log.status = 'started'
+ } else if (!log.meta) {
+ log.meta = 'success'
+ }
+ log.calls = orderBy(log.calls, ['time'], ['desc'])
+ })
- this.setState({
- logs: orderBy(logs, ['time'], ['desc']),
- logsToClear,
- })
- })
- }
-
- _deleteAllLogs = () => {
- return confirm({
- title: _('removeAllLogsModalTitle'),
- body: {_('removeAllLogsModalWarning')}
,
- }).then(() => deleteJobsLog(this.state.logsToClear))
- }
-
- render () {
- const { logs } = this.state
-
- return (
-
-
- Logs
-
-
-
-
-
-
-
- )
- }
-}
+ cb(orderBy(logs, ['time'], ['desc']))
+ }),
+ jobs: cb => subscribeJobs(jobs => cb(keyBy(jobs, 'id'))),
+ })),
+ ({ logs, jobs }) => (
+
+
+ Logs
+
+
+
+
+
+ ),
+].reduceRight((value, decorator) => decorator(value))