Skip to content

Commit

Permalink
[backend/frontend] adding decay chart in indicator lifecycle overview (
Browse files Browse the repository at this point in the history
  • Loading branch information
aHenryJard authored and frapuks committed Feb 5, 2024
1 parent f789994 commit e4b938e
Show file tree
Hide file tree
Showing 10 changed files with 511 additions and 98 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import React, { FunctionComponent } from 'react';
import { IndicatorDetails_indicator$data } from '@components/observations/indicators/__generated__/IndicatorDetails_indicator.graphql';
import Chart from '@components/common/charts/Chart';
import { ApexOptions } from 'apexcharts';
import moment from 'moment';
import { useTheme } from '@mui/styles';
import { Theme } from '@mui/material/styles/createTheme';

interface DecayChartProps {
indicator: IndicatorDetails_indicator$data,
}

const DecayChart : FunctionComponent<DecayChartProps> = ({ indicator }) => {
const theme = useTheme<Theme>();

const decayCurveColor = theme.palette.primary.main;
const reactionPointColor = theme.palette.text.primary;
const scoreColor = theme.palette.success.main;
const revokeColor = theme.palette.secondary.main;

const chartLabelBackgroundColor = theme.palette.background.paper;
const chartInfoTextColor = theme.palette.text.primary;
const chartBackgroundColor = theme.palette.background.default;
const graphLineThickness = 4;

// Time in millisecond cannot be set as number in GraphQL because it's too long
// So the time in data is stored as Date and must be converted to time in ms to be drawn on the chart.
const convertTimeForChart = (time: Date) => {
return moment(time).valueOf();
};

// This is the chart serie data, aka the curve.
const decayCurveDataPoints: { x: number; y: number }[] = [];
if (indicator.decayChartData && indicator.decayChartData.live_score_serie) {
indicator.decayChartData.live_score_serie.forEach((dataPoint) => {
decayCurveDataPoints.push({
x: convertTimeForChart(dataPoint.time),
y: dataPoint.score,
});
});
}

const graphLinesAnnotations = [];
// Horizontal lines that shows reaction points
if (indicator.decay_applied_rule?.decay_points) {
indicator.decay_applied_rule.decay_points.forEach((reactionPoint) => {
const lineReactionValue = {
y: reactionPoint,
borderColor: reactionPoint === indicator.x_opencti_score ? scoreColor : reactionPointColor,
label: {
borderColor: reactionPoint === indicator.x_opencti_score ? scoreColor : reactionPointColor,
offsetY: 0,
style: {
color: reactionPoint === indicator.x_opencti_score ? scoreColor : chartInfoTextColor,
background: chartLabelBackgroundColor,
},
text: `${reactionPoint}`,
},
};
graphLinesAnnotations.push(lineReactionValue);
});

// Horizontal "red" area that show the revoke zone
const revokeScoreArea = {
y: indicator.decay_applied_rule.decay_revoke_score + 1, // trick to have a red line even if revoke score is 0
y2: 0,
borderColor: revokeColor,
fillColor: revokeColor,
label: {
text: `Revoke score: ${indicator.decay_applied_rule.decay_revoke_score}`,
borderColor: revokeColor,
style: {
color: revokeColor,
background: chartLabelBackgroundColor,
},
},
};
graphLinesAnnotations.push(revokeScoreArea);
}

const pointAnnotations = [];
if (indicator.decayChartData?.live_score_serie && indicator.decayChartData?.live_score_serie.length > 0) {
// circle on the curve that show the live score
pointAnnotations.push({
x: new Date().getTime(),
y: indicator.decayLiveDetails?.live_score,
marker: {
fillColor: decayCurveColor,
strokeColor: chartInfoTextColor,
strokeWidth: 1,
size: graphLineThickness,
fillOpacity: 0.2,
},
});

// circle on the curve that show the current stable score
const currentScore = indicator.decayChartData?.live_score_serie.find((point) => point.score === indicator.x_opencti_score);
if (currentScore !== undefined) {
pointAnnotations.push({
x: convertTimeForChart(currentScore.time),
y: currentScore.score,
marker: {
fillColor: scoreColor,
strokeColor: chartInfoTextColor,
size: graphLineThickness + 1,
strokeWidth: 1,
fillOpacity: 1,
radius: graphLineThickness,
},
label: {
text: `Score:${currentScore.score}`,
position: 'right',
borderColor: scoreColor,
borderWidth: 2,
style: {
color: scoreColor,
background: chartLabelBackgroundColor,
},
},
});
}
}

const chartOptions: ApexOptions = {
chart: {
id: 'Decay graph',
toolbar: { show: false },
type: 'line',
background: chartBackgroundColor,
selection: {
enabled: false,
},
},
xaxis: {
type: 'datetime',
title: {
text: 'Days',
style: {
color: chartInfoTextColor,
},
},
labels: {
style: {
colors: chartInfoTextColor,
},
datetimeFormatter: {
year: 'yyyy',
month: 'MMM yyyy',
day: 'dd MMM yyyy',
},
},
},
yaxis: {
min: 0,
max: 100,
title: {
text: 'Score',
style: {
color: chartInfoTextColor,
},
},
labels: {
style: {
colors: chartInfoTextColor,
},
},
},
annotations: {
yaxis: graphLinesAnnotations,
points: pointAnnotations,
},
grid: { show: false },
colors: [
decayCurveColor,
],
tooltip: {
theme: theme.palette.mode, // ApexChart uses 'dark'/'light', exactly the same values as we use in OpenCTI.
x: {
show: true,
format: 'dd MMM yyyy',
},
},
forecastDataPoints: {
// this draw the dash line after live score point
count: indicator.decayLiveDetails?.live_score,
fillOpacity: 0.5,
strokeWidth: graphLineThickness,
dashArray: 8,
},
};

const series = [
{
name: 'Score', // this is the text on the popover
data: decayCurveDataPoints,
},
];

return (
<Chart
series={series}
options={chartOptions}
/>
);
};

export default DecayChart;
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { FunctionComponent } from 'react';
import DialogContent from '@mui/material/DialogContent';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
Expand All @@ -13,6 +12,8 @@ import { SxProps } from '@mui/material';
import { Theme } from '@mui/material/styles/createTheme';
import { useTheme } from '@mui/styles';
import { IndicatorDetails_indicator$data } from '@components/observations/indicators/__generated__/IndicatorDetails_indicator.graphql';
import DecayChart from '@components/observations/indicators/DecayChart';
import moment from 'moment-timezone';
import { useFormatter } from '../../../../components/i18n';

interface DecayDialogContentProps {
Expand All @@ -28,49 +29,64 @@ export interface LabelledDecayHistory {

const DecayDialogContent : FunctionComponent<DecayDialogContentProps> = ({ indicator }) => {
const theme = useTheme<Theme>();
const { t_i18n, fldt } = useFormatter();
const { t_i18n } = useFormatter();

const indicatorDecayDetails = indicator.decayLiveDetails;

const decayHistory = indicator.decay_history ? [...indicator.decay_history] : [];
const decayLivePoints = indicatorDecayDetails?.live_points ? [...indicatorDecayDetails.live_points] : [];
const decayReactionPoints = indicator.decay_applied_rule?.decay_points ?? [];

const currentScoreLineStyle = {
color: theme.palette.success.main,
fontWeight: 'bold',
const getScoreLabelFor = (score: number) => {
if (score === indicator.decay_base_score) {
return 'Score at creation';
} if (score === indicator.x_opencti_score) {
return 'Current stable score';
} if (score === indicator.decay_applied_rule?.decay_revoke_score) {
return 'Revoke score';
}
return 'Stability threshold';
};
const revokeScoreLineStyle = {
color: theme.palette.error.main,

const getStyleFor = (score: number) => {
if (score === indicator.x_opencti_score) {
return {
color: theme.palette.success.main,
fontWeight: 'bold',
};
} if (score === indicator.decay_applied_rule?.decay_revoke_score) {
return { color: theme.palette.secondary.main };
}
return { color: theme.palette.text.primary };
};

const getDateAsTextFor = (history: LabelledDecayHistory) => {
if (indicator.x_opencti_score === null || indicator.x_opencti_score === undefined) {
return 'N/A';
} if (history.score < indicator.x_opencti_score) {
return moment(history.updated_at).fromNow();
}
return moment(history.updated_at).format('DD MMM yyyy HH:mm A');
};

const decayFullHistory: LabelledDecayHistory[] = [];
decayHistory.map((history, index) => (
decayHistory.map((history) => (
decayFullHistory.push({
score: history.score,
updated_at: history.updated_at,
label: index === 0 ? 'Score at creation' : 'Score updated',
style: index === decayHistory.length - 1 ? currentScoreLineStyle : {},
label: getScoreLabelFor(history.score),
style: getStyleFor(history.score),
})
));

decayLivePoints.map((history, index) => (
decayLivePoints.map((history) => (
decayFullHistory.push({
score: history.score,
updated_at: history.updated_at,
label: index === decayLivePoints.length - 1 ? 'Revoke score' : 'Score update planned',
style: index === decayLivePoints.length - 1 ? revokeScoreLineStyle : {},
label: getScoreLabelFor(history.score),
style: getStyleFor(history.score),
})
));

if (indicatorDecayDetails && indicatorDecayDetails.live_score && indicatorDecayDetails.live_score !== indicator.x_opencti_score) {
decayFullHistory.push({
score: indicatorDecayDetails.live_score,
updated_at: new Date(),
label: 'Current live score',
style: currentScoreLineStyle,
});
}

decayFullHistory.sort((a, b) => {
return new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
});
Expand All @@ -82,10 +98,10 @@ const DecayDialogContent : FunctionComponent<DecayDialogContentProps> = ({ indic
spacing={3}
style={{ borderColor: 'white', borderWidth: 1 }}
>
<Grid item={true} xs={8}>
<Typography variant="h6">
{t_i18n('Lifecycle key information')}
</Typography>
<Grid item={true} xs={6}>
<DecayChart indicator={indicator}/>
</Grid>
<Grid item={true} xs={6}>
<TableContainer component={Paper}>
<Table sx={{ maxHeight: 440 }} size="small" aria-label="lifecycle history">
<TableHead>
Expand All @@ -101,26 +117,15 @@ const DecayDialogContent : FunctionComponent<DecayDialogContentProps> = ({ indic
<TableRow key={index}>
<TableCell sx={history.style}>{t_i18n(history.label)}</TableCell>
<TableCell sx={history.style}>{history.score}</TableCell>
<TableCell sx={history.style}>{fldt(history.updated_at)}</TableCell>
<TableCell sx={history.style}>{getDateAsTextFor(history)}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Grid>
<Grid item={true} xs={4}>
<Typography variant="h6">
{t_i18n('Applied decay rule')}
</Typography>
<ul>
<li>{t_i18n('Base score:')} { indicator.decay_base_score }</li>
<li>{t_i18n('Lifetime (in days):')} { indicator.decay_applied_rule?.decay_lifetime ?? 'Not set'}</li>
<li>{t_i18n('Pound factor:')} { indicator.decay_applied_rule?.decay_pound ?? 'Not set'}</li>
<li>{t_i18n('Revoke score:')} { indicator.decay_applied_rule?.decay_revoke_score ?? 'Not set'}</li>
<li>{t_i18n('Reaction points:')} {decayReactionPoints.join(', ')}</li>
</ul>
</Grid>

</Grid>
</DialogContent>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ const IndicatorDetailsComponent: FunctionComponent<IndicatorDetailsComponentProp
TransitionComponent={Transition}
onClose={onDecayLifecycleClose}
fullWidth
maxWidth="md"
maxWidth='lg'
>
<DialogTitle>{t_i18n('Lifecycle details')}</DialogTitle>
<DecayDialogContent indicator={indicator} />
Expand Down Expand Up @@ -235,6 +235,12 @@ const IndicatorDetails = createFragmentContainer(IndicatorDetailsComponent, {
updated_at
}
}
decayChartData {
live_score_serie {
time
score
}
}
objectLabel {
id
value
Expand Down
Loading

0 comments on commit e4b938e

Please sign in to comment.