@@ -1,89 +1,135 @@
|
|||||||
import React, {useEffect, useMemo, useState} from 'react';
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
|
import moment from "moment";
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {Card, Space, Table, Timeline} from 'antd';
|
import {Card, Space, Table} from 'antd';
|
||||||
import {Bar, BarChart, CartesianGrid, LabelList, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
import {gql, useQuery} from "@apollo/client";
|
||||||
|
import {DateTimeFormatterFunction} from "../../utils/DateFormatter";
|
||||||
|
import {isEmpty} from "lodash";
|
||||||
|
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
|
||||||
|
|
||||||
|
const transformDataForChart = (durations) => {
|
||||||
|
const output = {};
|
||||||
|
output.total = durations.total;
|
||||||
|
return durations.summations.forEach((summation) => {
|
||||||
|
output[summation.status] = summation.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const getColor = (key) => {
|
||||||
|
// Generate a random color
|
||||||
|
const randomColor = '#' + Math.floor(Math.random()*16777215).toString(16);
|
||||||
|
return randomColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export function JobLifecycleComponent({job, ...rest}) {
|
export function JobLifecycleComponent({job, ...rest}) {
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [lifecycleData, setLifecycleData] = useState(null);
|
const [lifecycleData, setLifecycleData] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Used for tracking external state changes.
|
||||||
const getLifecycleData = async () => {
|
const {data} = useQuery(gql`
|
||||||
if (job && job.id) {
|
query get_job_test($id: uuid!){
|
||||||
try {
|
jobs_by_pk(id:$id){
|
||||||
setLoading(true);
|
id
|
||||||
const response = await axios.post("/job/lifecycle", {jobids: job.id});
|
status
|
||||||
const data = response.data.transition[job.id];
|
|
||||||
setLifecycleData(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error getting Job Lifecycle Data: ${err.message}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
`, {
|
||||||
|
variables: {
|
||||||
|
id: job.id
|
||||||
|
},
|
||||||
|
fetchPolicy: 'cache-only'
|
||||||
|
});
|
||||||
|
|
||||||
getLifecycleData();
|
/**
|
||||||
|
* Gets the lifecycle data for the job.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const getLifecycleData = useCallback(async () => {
|
||||||
|
if (job && job.id) {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await axios.post("/job/lifecycle", {jobids: job.id});
|
||||||
|
const data = response.data.transition[job.id];
|
||||||
|
setLifecycleData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error getting Job Lifecycle Data: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}, [job]);
|
}, [job]);
|
||||||
|
|
||||||
const columnKeys = [
|
useEffect(() => {
|
||||||
'start', 'end', 'value', 'prev_value', 'next_value', 'duration', 'type', 'created_at', 'updated_at', 'start_readable', 'end_readable','duration'
|
if (!data) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
getLifecycleData().catch(err => console.error(`Error getting Job Lifecycle Data: ${err.message}`));
|
||||||
|
}, 1000);
|
||||||
|
}, [data, getLifecycleData]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Value',
|
||||||
|
dataIndex: 'value',
|
||||||
|
key: 'value',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Start',
|
||||||
|
dataIndex: 'start',
|
||||||
|
key: 'start',
|
||||||
|
render: (text) => DateTimeFormatterFunction(text),
|
||||||
|
sorter: (a, b) => moment(a.start).unix() - moment(b.start).unix(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Relative Start',
|
||||||
|
dataIndex: 'start_readable',
|
||||||
|
key: 'start_readable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'End',
|
||||||
|
dataIndex: 'end',
|
||||||
|
key: 'end',
|
||||||
|
sorter: (a, b) => {
|
||||||
|
if (isEmpty(a.end) || isEmpty(b.end)) {
|
||||||
|
if (isEmpty(a.end) && isEmpty(b.end)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return isEmpty(a.end) ? 1 : -1;
|
||||||
|
}
|
||||||
|
return moment(a.end).unix() - moment(b.end).unix();
|
||||||
|
},
|
||||||
|
render: (text) => isEmpty(text) ? 'N/A' : DateTimeFormatterFunction(text)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Relative End',
|
||||||
|
dataIndex: 'end_readable',
|
||||||
|
key: 'end_readable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Duration',
|
||||||
|
dataIndex: 'duration_readable',
|
||||||
|
key: 'duration_readable',
|
||||||
|
sorter: (a, b) => a.duration - b.duration,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const columns = columnKeys.map(key => ({
|
|
||||||
title: key.charAt(0).toUpperCase() + key.slice(1),
|
|
||||||
dataIndex: key,
|
|
||||||
key: key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
|
||||||
const durationsData = useMemo(() => {
|
|
||||||
if (!lifecycleData) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const transformedData = Object.entries(lifecycleData.durations).map(([name, {value, humanReadable}]) => {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
amt: value,
|
|
||||||
pv: humanReadable,
|
|
||||||
uv: value,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return [transformedData];
|
|
||||||
}, [lifecycleData]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('LifeCycle Data');
|
||||||
console.dir(lifecycleData, {depth: null})
|
console.dir(lifecycleData, {depth: null})
|
||||||
console.dir(durationsData, {depth: null})
|
}, [lifecycleData]);
|
||||||
|
|
||||||
}, [lifecycleData,durationsData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card loading={loading} title='Job Lifecycle Component'>
|
<Card loading={loading} title='Job Lifecycle Component'>
|
||||||
{!loading ? (
|
{!loading ? (
|
||||||
lifecycleData ? (
|
lifecycleData && lifecycleData.lifecycle && lifecycleData.durations ? (
|
||||||
<Space direction='vertical' style={{width: '100%'}}>
|
<Space direction='vertical' style={{width: '100%'}}>
|
||||||
<Space direction='horizontal' style={{width: '100%'}} align='start'>
|
<Space direction='horizontal' style={{width: '100%'}} align='start'>
|
||||||
<Card type='inner' title='Timeline Format'>
|
|
||||||
<Timeline>
|
|
||||||
{lifecycleData.lifecycle.map((item, index) => (
|
|
||||||
<Timeline.Item key={index}
|
|
||||||
color={item.value === 'Open' ? 'green' : item.value === 'Scheduled' ? 'yellow' : 'red'}>
|
|
||||||
{item.value} - {item.start_readable}
|
|
||||||
</Timeline.Item>
|
|
||||||
))}
|
|
||||||
</Timeline>
|
|
||||||
</Card>
|
|
||||||
<Card type='inner' title='Durations'>
|
<Card type='inner' title='Durations'>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<BarChart
|
||||||
width={500}
|
width={500}
|
||||||
height={300}
|
height={300}
|
||||||
data={durationsData}
|
data={transformDataForChart(lifecycleData.durations)}
|
||||||
margin={{
|
margin={{
|
||||||
top: 20,
|
top: 20,
|
||||||
right: 30,
|
right: 30,
|
||||||
@@ -96,13 +142,19 @@ export function JobLifecycleComponent({job, ...rest}) {
|
|||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="pv" stackId="a" fill="#8884d8" />
|
{lifecycleData.durations.summations.map((summation, idx) => {
|
||||||
<Bar dataKey="uv" stackId="a" fill="#82ca9d" />
|
|
||||||
|
return (
|
||||||
|
<Bar key={idx} dataKey={summation.status} stackId="a" fill={getColor(summation.status)} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
<Card type='inner' title='Table Format'>
|
<Card type='inner' title='Transitions'>
|
||||||
<Table columns={columns} dataSource={lifecycleData.lifecycle}/>
|
<Table columns={columns} dataSource={lifecycleData.lifecycle}/>
|
||||||
</Card>
|
</Card>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export function DateTimeFormatter(props) {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
export function DateTimeFormatterFunction(date) {
|
||||||
|
return moment(date).format("MM/DD/YYYY hh:mm a");
|
||||||
|
}
|
||||||
export function TimeFormatter(props) {
|
export function TimeFormatter(props) {
|
||||||
return props.children
|
return props.children
|
||||||
? moment(props.children).format(props.format ? props.format : "hh:mm a")
|
? moment(props.children).format(props.format ? props.format : "hh:mm a")
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ exports.QUERY_PAYMENTS_FOR_EXPORT = `
|
|||||||
}`;
|
}`;
|
||||||
|
|
||||||
exports.QUERY_TRANSITIONS_BY_JOBID = `query QUERY_TRANSITIONS_BY_JOBID($jobids: [uuid!]!) {
|
exports.QUERY_TRANSITIONS_BY_JOBID = `query QUERY_TRANSITIONS_BY_JOBID($jobids: [uuid!]!) {
|
||||||
transitions(where: {jobid: {_in: $jobids}}, order_by: {created_at: asc}) {
|
transitions(where: {jobid: {_in: $jobids}}, order_by: {end: desc}) {
|
||||||
start
|
start
|
||||||
end
|
end
|
||||||
value
|
value
|
||||||
|
|||||||
@@ -1,57 +1,14 @@
|
|||||||
const _ = require("lodash");
|
const _ = require("lodash");
|
||||||
const queries = require("../graphql-client/queries");
|
const queries = require("../graphql-client/queries");
|
||||||
const moment = require("moment");
|
const moment = require("moment");
|
||||||
|
const durationToHumanReadable = require("../utils/durationToHumanReadable");
|
||||||
const calculateStatusDuration = (transitions) => {
|
const calculateStatusDuration = require("../utils/calculateStatusDuration");
|
||||||
let statusDuration = {};
|
|
||||||
|
|
||||||
transitions.forEach((transition, index) => {
|
|
||||||
let duration = transition.duration_minutes;
|
|
||||||
|
|
||||||
// If there is no prev_value, it is the first transition
|
|
||||||
if (!transition.prev_value) {
|
|
||||||
statusDuration[transition.value] = {
|
|
||||||
value: duration,
|
|
||||||
humanReadable: transition.duration_readable
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// If there is no next_value, it is the last transition (the active one)
|
|
||||||
else if (!transition.next_value) {
|
|
||||||
if (statusDuration[transition.value]) {
|
|
||||||
statusDuration[transition.value].value += duration;
|
|
||||||
statusDuration[transition.value].humanReadable = transition.duration_readable;
|
|
||||||
} else {
|
|
||||||
statusDuration[transition.value] = {
|
|
||||||
value: duration,
|
|
||||||
humanReadable: transition.duration_readable
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// For all other transitions
|
|
||||||
else {
|
|
||||||
if (statusDuration[transition.value]) {
|
|
||||||
statusDuration[transition.value].value += duration;
|
|
||||||
statusDuration[transition.value].humanReadable = transition.duration_readable;
|
|
||||||
} else {
|
|
||||||
statusDuration[transition.value] = {
|
|
||||||
value: duration,
|
|
||||||
humanReadable: transition.duration_readable
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return statusDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const jobLifecycle = async (req, res) => {
|
const jobLifecycle = async (req, res) => {
|
||||||
const {jobids} = req.body;
|
const {jobids} = req.body;
|
||||||
|
|
||||||
const jobIDs = _.isArray(jobids) ? jobids : [jobids];
|
const jobIDs = _.isArray(jobids) ? jobids : [jobids];
|
||||||
|
|
||||||
const client = req.userGraphQLClient;
|
const client = req.userGraphQLClient;
|
||||||
|
|
||||||
const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, {jobids: jobIDs,});
|
const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, {jobids: jobIDs,});
|
||||||
|
|
||||||
const transitions = resp.transitions;
|
const transitions = resp.transitions;
|
||||||
@@ -67,19 +24,21 @@ const jobLifecycle = async (req, res) => {
|
|||||||
const transitionsByJobId = _.groupBy(resp.transitions, 'jobid');
|
const transitionsByJobId = _.groupBy(resp.transitions, 'jobid');
|
||||||
|
|
||||||
const groupedTransitions = {};
|
const groupedTransitions = {};
|
||||||
moment.relativeTimeThreshold('m', 30)
|
|
||||||
for (let jobId in transitionsByJobId) {
|
for (let jobId in transitionsByJobId) {
|
||||||
let lifecycle = transitionsByJobId[jobId].map(transition => {
|
let lifecycle = transitionsByJobId[jobId].map(transition => {
|
||||||
if (transition.start) {
|
transition.start_readable = transition.start ? moment(transition.start).fromNow() : 'N/A';
|
||||||
transition.start_readable = moment(transition.start).fromNow();
|
transition.end_readable = transition.end ? moment(transition.end).fromNow() : 'N/A';
|
||||||
}
|
|
||||||
if (transition.end) {
|
if (transition.duration) {
|
||||||
transition.end_readable = moment(transition.end).fromNow();
|
transition.duration_seconds = Math.round(transition.duration / 1000);
|
||||||
}
|
transition.duration_minutes = Math.round(transition.duration_seconds / 60);
|
||||||
if(transition.duration){
|
let duration = moment.duration(transition.duration);
|
||||||
transition.duration_seconds = Math.round(transition.duration / 1000);
|
transition.duration_readable = durationToHumanReadable(duration);
|
||||||
transition.duration_minutes = Math.round(transition.duration_seconds / 60);
|
} else {
|
||||||
transition.duration_readable = moment.duration(transition.duration).humanize();
|
transition.duration_seconds = 0;
|
||||||
|
transition.duration_minutes = 0;
|
||||||
|
transition.duration_readable = 'N/A';
|
||||||
}
|
}
|
||||||
return transition;
|
return transition;
|
||||||
});
|
});
|
||||||
@@ -90,13 +49,10 @@ const jobLifecycle = async (req, res) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.dir(groupedTransitions, {depth: null})
|
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
jobIDs,
|
jobIDs,
|
||||||
transition: groupedTransitions,
|
transition: groupedTransitions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
module.exports = jobLifecycle;
|
module.exports = jobLifecycle;
|
||||||
59
server/utils/calculateStatusDuration.js
Normal file
59
server/utils/calculateStatusDuration.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const moment = require('moment');
|
||||||
|
const durationToHumanReadable = require("./durationToHumanReadable");
|
||||||
|
/**
|
||||||
|
* Calculate the duration of each status of a job
|
||||||
|
* @param transitions
|
||||||
|
* @returns {{}}
|
||||||
|
*/
|
||||||
|
const calculateStatusDuration = (transitions) => {
|
||||||
|
let statusDuration = {};
|
||||||
|
let totalDuration = 0;
|
||||||
|
let summations = [];
|
||||||
|
|
||||||
|
transitions.forEach((transition, index) => {
|
||||||
|
let duration = transition.duration;
|
||||||
|
totalDuration += duration;
|
||||||
|
|
||||||
|
if (!transition.prev_value) {
|
||||||
|
statusDuration[transition.value] = {
|
||||||
|
value: duration,
|
||||||
|
humanReadable: transition.duration_readable
|
||||||
|
};
|
||||||
|
} else if (!transition.next_value) {
|
||||||
|
if (statusDuration[transition.value]) {
|
||||||
|
statusDuration[transition.value].value += duration;
|
||||||
|
statusDuration[transition.value].humanReadable = transition.duration_readable;
|
||||||
|
} else {
|
||||||
|
statusDuration[transition.value] = {
|
||||||
|
value: duration,
|
||||||
|
humanReadable: transition.duration_readable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (statusDuration[transition.value]) {
|
||||||
|
statusDuration[transition.value].value += duration;
|
||||||
|
statusDuration[transition.value].humanReadable = transition.duration_readable;
|
||||||
|
} else {
|
||||||
|
statusDuration[transition.value] = {
|
||||||
|
value: duration,
|
||||||
|
humanReadable: transition.duration_readable
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let [status, {value, humanReadable}] of Object.entries(statusDuration)) {
|
||||||
|
if (status !== 'total') {
|
||||||
|
summations.push({status, value, humanReadable});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const humanReadableTotal = durationToHumanReadable(moment.duration(totalDuration));
|
||||||
|
|
||||||
|
return {
|
||||||
|
summations,
|
||||||
|
total: totalDuration,
|
||||||
|
humanReadableTotal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
module.exports = calculateStatusDuration;
|
||||||
22
server/utils/durationToHumanReadable.js
Normal file
22
server/utils/durationToHumanReadable.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
const durationToHumanReadable = (duration) => {
|
||||||
|
if (!duration) return 'N/A';
|
||||||
|
|
||||||
|
let parts = [];
|
||||||
|
|
||||||
|
let years = duration.years();
|
||||||
|
let months = duration.months();
|
||||||
|
let days = duration.days();
|
||||||
|
let hours = duration.hours();
|
||||||
|
let minutes = duration.minutes();
|
||||||
|
let seconds = duration.seconds();
|
||||||
|
|
||||||
|
if (years) parts.push(years + ' year' + (years > 1 ? 's' : ''));
|
||||||
|
if (months) parts.push(months + ' month' + (months > 1 ? 's' : ''));
|
||||||
|
if (days) parts.push(days + ' day' + (days > 1 ? 's' : ''));
|
||||||
|
if (hours) parts.push(hours + ' hour' + (hours > 1 ? 's' : ''));
|
||||||
|
if (minutes) parts.push(minutes + ' minute' + (minutes > 1 ? 's' : ''));
|
||||||
|
if (!minutes && !hours && !days && !months && !years && seconds) parts.push(seconds + ' second' + (seconds > 1 ? 's' : ''));
|
||||||
|
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
module.exports = durationToHumanReadable;
|
||||||
Reference in New Issue
Block a user