Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
charliegerard committed Aug 26, 2024
1 parent 0897ec7 commit 3e87983
Showing 1 changed file with 156 additions and 120 deletions.
276 changes: 156 additions & 120 deletions src/commands/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import contrib from 'blessed-contrib'
import meow from 'meow'
import ora from 'ora'

import { outputFlags, validationFlags } from '../flags'
import { outputFlags } from '../flags'
import { handleApiCall, handleUnsuccessfulApiResponse } from '../utils/api-helpers'
import { AuthError, InputError } from '../utils/errors'
import { printFlagList } from '../utils/formatting'
import { getDefaultKey, setupSdk } from '../utils/sdk'

import type { CliSubcommand } from '../utils/meow-with-subcommands'
import type { Ora } from "ora"

Check warning on line 14 in src/commands/analytics.ts

View workflow job for this annotation

GitHub Actions / Linting / Test (20, ubuntu-latest)

There should be at least one empty line between import groups
import chalk from 'chalk'

Check warning on line 15 in src/commands/analytics.ts

View workflow job for this annotation

GitHub Actions / Linting / Test (20, ubuntu-latest)

`chalk` import should occur before import of `meow`

export const analytics: CliSubcommand = {
description: 'Look up analytics data',
description: `Look up analytics data \n
Default parameters are set to show the organization-level analytics over the last 7 days.`,
async run (argv, importMeta, { parentName }) {
const name = parentName + ' analytics'

Expand All @@ -36,31 +38,53 @@ export const analytics: CliSubcommand = {
}
}

const analyticsFlags: { [key: string]: any } = {
scope: {
type: 'string',
shortFlag: 's',
default: 'org',
description: "Scope of the analytics data - either 'org' or 'repo'"
},
time: {
type: 'number',
shortFlag: 't',
default: 7,
description: 'Time filter - either 7, 30 or 90'
},
repo: {
type: 'string',
shortFlag: 'r',
default: '',
description: "Name of the repository"
},
}

// Internal functions

type CommandContext = {
scope: string
time: string
repo: string | undefined
time: number
repo: string
outputJson: boolean
}

function setupCommand (name: string, description: string, argv: readonly string[], importMeta: ImportMeta): void|CommandContext {
const flags: { [key: string]: any } = {
...outputFlags,
...validationFlags,
...analyticsFlags
}

const cli = meow(`
Usage
$ ${name} <scope> <time>
$ ${name} --scope=<scope> --time=<time filter>
Options
${printFlagList(flags, 6)}
Examples
$ ${name} org 7
$ ${name} org 30
$ ${name} --scope=org --time=7
$ ${name} --scope=org --time=30
$ ${name} --scope=repo --repo=test-repo --time=30
`, {
argv,
description,
Expand All @@ -69,117 +93,140 @@ function setupCommand (name: string, description: string, argv: readonly string[
})

const {
json: outputJson
json: outputJson,
scope,
time,
repo
} = cli.flags

const scope = cli.input[0]

if (!scope) {
throw new InputError('Please provide a scope to get analytics data')
if (scope !== 'org' && scope !== 'repo') {
throw new InputError("The scope must either be 'org' or 'repo'")
}

if (!cli.input.length) {
throw new InputError('Please provide a scope and a time to get analytics data')
if (time !== 7 && time !== 30 && time !== 90) {
throw new InputError('The time filter must either be 7, 30 or 90')
}

if (scope && !['org', 'repo'].includes(scope)) {
throw new InputError("The scope must either be 'scope' or 'repo'")
if(scope === 'repo' && !repo){
console.error(
`${chalk.bgRed.white('Input error')}: Please provide a repository name when using the repository scope. \n`
)
cli.showHelp()
return
}

const repo = scope === 'repo' ? cli.input[1] : undefined
return <CommandContext>{
scope, time, repo, outputJson
}
}

const time = scope === 'repo' ? cli.input[2] : cli.input[1]
async function fetchOrgAnalyticsData (time: number, spinner: Ora, apiKey: string, outputJson: boolean): Promise<void> {
const socketSdk = await setupSdk(apiKey)
const result = await handleApiCall(socketSdk.getOrgAnalytics(time.toString()), 'fetching analytics data')

if (!time) {
throw new InputError('Please provide a time to get analytics data')
if (result.success === false) {
return handleUnsuccessfulApiResponse('getOrgAnalytics', result, spinner)
}

if (time && !['7', '30', '60'].includes(time)) {
throw new InputError('The time filter must either be 7, 30 or 60')
spinner.stop()

if(!result.data.length){
return console.log('No analytics data is available for this organization yet.')
}

return <CommandContext>{
scope, time, repo, outputJson
const data = formatData(result.data)

if(outputJson){
return console.log(data)
}

return displayAnalyticsScreen(data)
}

async function fetchOrgAnalyticsData (time: string, spinner: Ora, apiKey: string, outputJson: boolean): Promise<void> {
async function fetchRepoAnalyticsData (repo: string, time: number, spinner: Ora, apiKey: string, outputJson: boolean): Promise<void> {
const socketSdk = await setupSdk(apiKey)
const result = await handleApiCall(socketSdk.getOrgAnalytics(time), 'fetching analytics data')
const result = await handleApiCall(socketSdk.getRepoAnalytics(repo, time.toString()), 'fetching analytics data')

if (result.success === false) {
return handleUnsuccessfulApiResponse('getOrgAnalytics', result, spinner)
return handleUnsuccessfulApiResponse('getRepoAnalytics', result, spinner)
}

spinner.stop()

const data = result.data.reduce((acc: { [key: string]: any }, current) => {
const formattedDate = new Date(current.created_at).toLocaleDateString()

if (acc[formattedDate]) {
acc[formattedDate].total_critical_alerts += current.total_critical_alerts
acc[formattedDate].total_high_alerts += current.total_high_alerts
acc[formattedDate].total_critical_added += current.total_critical_added
acc[formattedDate].total_high_added += current.total_high_added
acc[formattedDate].total_critical_prevented += current.total_critical_prevented
acc[formattedDate].total_high_prevented += current.total_high_prevented
acc[formattedDate].total_medium_prevented += current.total_medium_prevented
acc[formattedDate].total_low_prevented += current.total_low_prevented
} else {
acc[formattedDate] = current
acc[formattedDate].created_at = formattedDate
}
if(!result.data.length){
return console.log('No analytics data is available for this organization yet.')
}

return acc
}, {})
const data = formatData(result.data)

if(outputJson){
return console.log(data)
}

const screen = blessed.screen()
// eslint-disable-next-line
const grid = new contrib.grid({rows: 4, cols: 4, screen})
return displayAnalyticsScreen(data)
}

renderLineCharts(grid, screen, 'Total critical alerts', [0,0,1,2], data, 'total_critical_alerts')
renderLineCharts(grid, screen, 'Total high alerts', [0,2,1,2], data, 'total_high_alerts')
renderLineCharts(grid, screen, 'Total critical alerts added to main', [1,0,1,2], data, 'total_critical_added')
renderLineCharts(grid, screen, 'Total high alerts added to main', [1,2,1,2], data, 'total_high_added')
renderLineCharts(grid, screen, 'Total critical alerts prevented from main', [2,0,1,2], data, 'total_critical_prevented')
renderLineCharts(grid, screen, 'Total high alerts prevented from main', [2,2,1,2], data, 'total_high_prevented')
const renderLineCharts = (grid: any, screen: any, title: string, coords: number[], data: FormattedAnalyticsData, label: string) => {
const formattedDates = Object.keys(data).map(d => `${new Date(d).getMonth()+1}/${new Date(d).getDate()}`)

const bar = grid.set(3, 0, 1, 2, contrib.bar,
{ label: 'Top 5 alert types'
, barWidth: 10
, barSpacing: 17
, xOffset: 0
, maxHeight: 9, barBgColor: 'magenta' })
// @ts-ignore
const alertsCounts = Object.values(data).map(d => d[label])

const line = grid.set(...coords, contrib.line,
{ style:
{ line: "cyan",
text: "cyan",
baseline: "black"
},
xLabelPadding: 0,
xPadding: 0,
xOffset: 0,
wholeNumbersOnly: true,
legend: {
width: 1
},
label: title
}
)

screen.append(bar) //must append before setting data
screen.append(line)

const top5AlertTypes = Object.values(data)[0].top_five_alert_types

bar.setData(
{ titles: Object.keys(top5AlertTypes)
, data: Object.values(top5AlertTypes)})
const lineData = {
x: formattedDates.reverse(),
y: alertsCounts
}

screen.render()

screen.key(['escape', 'q', 'C-c'], function() {
return process.exit(0);
})
line.setData([lineData])
}

async function fetchRepoAnalyticsData (repo: string, time: string, spinner: Ora, apiKey: string, outputJson: boolean): Promise<void> {
const socketSdk = await setupSdk(apiKey)
const result = await handleApiCall(socketSdk.getRepoAnalytics(repo, time), 'fetching analytics data')

if (result.success === false) {
return handleUnsuccessfulApiResponse('getRepoAnalytics', result, spinner)
type AnalyticsData = {
id: number,
created_at: string
repository_id: string
organization_id: number
repository_name: string
total_critical_alerts: number
total_high_alerts: number
total_medium_alerts: number
total_low_alerts: number
total_critical_added: number
total_high_added: number
total_medium_added: number
total_low_added: number
total_critical_prevented: number
total_high_prevented: number
total_medium_prevented: number
total_low_prevented: number
top_five_alert_types: {
[key: string]: number
}
spinner.stop()
}

type FormattedAnalyticsData = {
[key: string]: AnalyticsData
}

const data = result.data.reduce((acc: { [key: string]: any }, current) => {
const formatData = (data: AnalyticsData[]) => {
return data.reduce((acc: { [key: string]: any }, current) => {
const formattedDate = new Date(current.created_at).toLocaleDateString()

if (acc[formattedDate]) {
Expand All @@ -198,11 +245,9 @@ async function fetchRepoAnalyticsData (repo: string, time: string, spinner: Ora,

return acc
}, {})
}

if(outputJson){
return console.log(data)
}

const displayAnalyticsScreen = (data: FormattedAnalyticsData) => {
const screen = blessed.screen()
// eslint-disable-next-line
const grid = new contrib.grid({rows: 4, cols: 4, screen})
Expand All @@ -222,48 +267,39 @@ async function fetchRepoAnalyticsData (repo: string, time: string, spinner: Ora,
, maxHeight: 9, barBgColor: 'magenta' })

screen.append(bar) //must append before setting data

const top5AlertTypes = Object.values(data)[0].top_five_alert_types
const top5 = extractTop5Alerts(data)

bar.setData(
{ titles: Object.keys(top5AlertTypes)
, data: Object.values(top5AlertTypes)})
{ titles: Object.keys(top5)
, data: Object.values(top5)})

screen.render()

screen.key(['escape', 'q', 'C-c'], function() {
return process.exit(0);
})
screen.key(['escape', 'q', 'C-c'], () => process.exit(0))
}

const renderLineCharts = (grid: any, screen: any, title: string, coords: number[], data: {[key: string]: {[key: string]: number}}, label: string) => {
const formattedDates = Object.keys(data).map(d => `${new Date(d).getMonth()+1}/${new Date(d).getDate()}`)

const alertsCounts = Object.values(data).map(d => d[label])
const extractTop5Alerts = (data: FormattedAnalyticsData) => {
const allTop5Alerts = Object.values(data).map(d => d.top_five_alert_types)

const line = grid.set(...coords, contrib.line,
{ style:
{ line: "cyan",
text: "cyan",
baseline: "black"
},
xLabelPadding: 0,
xPadding: 0,
xOffset: 0,
wholeNumbersOnly: true,
legend: {
width: 1
},
label: title
}
)

screen.append(line)

const lineData = {
x: formattedDates.reverse(),
y: alertsCounts
}
const aggTop5Alerts = allTop5Alerts.reduce((acc, current) => {
const alertTypes = Object.keys(current)

alertTypes.forEach(type => {
if(!acc[type]){
// @ts-ignore
acc[type] = current[type]
} else {
// @ts-ignore
if(acc[type] < current[type]){
// @ts-ignore
acc[type] = current[type]
}
}
})

return acc
}, {})

line.setData([lineData])
return Object.fromEntries(Object.entries(aggTop5Alerts).sort((a: [string, number], b: [string, number]) => b[1] - a[1]).slice(0,5))
}

0 comments on commit 3e87983

Please sign in to comment.