@@ -4,17 +4,19 @@ import contrib from 'blessed-contrib'
44import meow from 'meow'
55import ora from 'ora'
66
7- import { outputFlags , validationFlags } from '../flags'
7+ import { outputFlags } from '../flags'
88import { handleApiCall , handleUnsuccessfulApiResponse } from '../utils/api-helpers'
99import { AuthError , InputError } from '../utils/errors'
1010import { printFlagList } from '../utils/formatting'
1111import { getDefaultKey , setupSdk } from '../utils/sdk'
1212
1313import type { CliSubcommand } from '../utils/meow-with-subcommands'
1414import type { Ora } from "ora"
15+ import chalk from 'chalk'
1516
1617export const analytics : CliSubcommand = {
17- description : 'Look up analytics data' ,
18+ description : `Look up analytics data \n
19+ Default parameters are set to show the organization-level analytics over the last 7 days.` ,
1820 async run ( argv , importMeta , { parentName } ) {
1921 const name = parentName + ' analytics'
2022
@@ -36,31 +38,53 @@ export const analytics: CliSubcommand = {
3638 }
3739}
3840
41+ const analyticsFlags : { [ key : string ] : any } = {
42+ scope : {
43+ type : 'string' ,
44+ shortFlag : 's' ,
45+ default : 'org' ,
46+ description : "Scope of the analytics data - either 'org' or 'repo'"
47+ } ,
48+ time : {
49+ type : 'number' ,
50+ shortFlag : 't' ,
51+ default : 7 ,
52+ description : 'Time filter - either 7, 30 or 90'
53+ } ,
54+ repo : {
55+ type : 'string' ,
56+ shortFlag : 'r' ,
57+ default : '' ,
58+ description : "Name of the repository"
59+ } ,
60+ }
61+
3962// Internal functions
4063
4164type CommandContext = {
4265 scope : string
43- time : string
44- repo : string | undefined
66+ time : number
67+ repo : string
4568 outputJson : boolean
4669}
4770
4871function setupCommand ( name : string , description : string , argv : readonly string [ ] , importMeta : ImportMeta ) : void | CommandContext {
4972 const flags : { [ key : string ] : any } = {
5073 ...outputFlags ,
51- ...validationFlags ,
74+ ...analyticsFlags
5275 }
5376
5477 const cli = meow ( `
5578 Usage
56- $ ${ name } <scope> <time>
79+ $ ${ name } --scope= <scope> --time= <time filter >
5780
5881 Options
5982 ${ printFlagList ( flags , 6 ) }
6083
6184 Examples
62- $ ${ name } org 7
63- $ ${ name } org 30
85+ $ ${ name } --scope=org --time=7
86+ $ ${ name } --scope=org --time=30
87+ $ ${ name } --scope=repo --repo=test-repo --time=30
6488 ` , {
6589 argv,
6690 description,
@@ -69,117 +93,140 @@ function setupCommand (name: string, description: string, argv: readonly string[
6993 } )
7094
7195 const {
72- json : outputJson
96+ json : outputJson ,
97+ scope,
98+ time,
99+ repo
73100 } = cli . flags
74101
75- const scope = cli . input [ 0 ]
76-
77- if ( ! scope ) {
78- throw new InputError ( 'Please provide a scope to get analytics data' )
102+ if ( scope !== 'org' && scope !== 'repo' ) {
103+ throw new InputError ( "The scope must either be 'org' or 'repo'" )
79104 }
80105
81- if ( ! cli . input . length ) {
82- throw new InputError ( 'Please provide a scope and a time to get analytics data ' )
106+ if ( time !== 7 && time !== 30 && time !== 90 ) {
107+ throw new InputError ( 'The time filter must either be 7, 30 or 90 ' )
83108 }
84109
85- if ( scope && ! [ 'org' , 'repo' ] . includes ( scope ) ) {
86- throw new InputError ( "The scope must either be 'scope' or 'repo'" )
110+ if ( scope === 'repo' && ! repo ) {
111+ console . error (
112+ `${ chalk . bgRed . white ( 'Input error' ) } : Please provide a repository name when using the repository scope. \n`
113+ )
114+ cli . showHelp ( )
115+ return
87116 }
88117
89- const repo = scope === 'repo' ? cli . input [ 1 ] : undefined
118+ return < CommandContext > {
119+ scope, time, repo, outputJson
120+ }
121+ }
90122
91- const time = scope === 'repo' ? cli . input [ 2 ] : cli . input [ 1 ]
123+ async function fetchOrgAnalyticsData ( time : number , spinner : Ora , apiKey : string , outputJson : boolean ) : Promise < void > {
124+ const socketSdk = await setupSdk ( apiKey )
125+ const result = await handleApiCall ( socketSdk . getOrgAnalytics ( time . toString ( ) ) , 'fetching analytics data' )
92126
93- if ( ! time ) {
94- throw new InputError ( 'Please provide a time to get analytics data' )
127+ if ( result . success === false ) {
128+ return handleUnsuccessfulApiResponse ( 'getOrgAnalytics' , result , spinner )
95129 }
96130
97- if ( time && ! [ '7' , '30' , '60' ] . includes ( time ) ) {
98- throw new InputError ( 'The time filter must either be 7, 30 or 60' )
131+ spinner . stop ( )
132+
133+ if ( ! result . data . length ) {
134+ return console . log ( 'No analytics data is available for this organization yet.' )
99135 }
100136
101- return < CommandContext > {
102- scope, time, repo, outputJson
137+ const data = formatData ( result . data )
138+
139+ if ( outputJson ) {
140+ return console . log ( data )
103141 }
142+
143+ return displayAnalyticsScreen ( data )
104144}
105145
106- async function fetchOrgAnalyticsData ( time : string , spinner : Ora , apiKey : string , outputJson : boolean ) : Promise < void > {
146+ async function fetchRepoAnalyticsData ( repo : string , time : number , spinner : Ora , apiKey : string , outputJson : boolean ) : Promise < void > {
107147 const socketSdk = await setupSdk ( apiKey )
108- const result = await handleApiCall ( socketSdk . getOrgAnalytics ( time ) , 'fetching analytics data' )
148+ const result = await handleApiCall ( socketSdk . getRepoAnalytics ( repo , time . toString ( ) ) , 'fetching analytics data' )
109149
110150 if ( result . success === false ) {
111- return handleUnsuccessfulApiResponse ( 'getOrgAnalytics ' , result , spinner )
151+ return handleUnsuccessfulApiResponse ( 'getRepoAnalytics ' , result , spinner )
112152 }
113-
114153 spinner . stop ( )
115154
116- const data = result . data . reduce ( ( acc : { [ key : string ] : any } , current ) => {
117- const formattedDate = new Date ( current . created_at ) . toLocaleDateString ( )
118-
119- if ( acc [ formattedDate ] ) {
120- acc [ formattedDate ] . total_critical_alerts += current . total_critical_alerts
121- acc [ formattedDate ] . total_high_alerts += current . total_high_alerts
122- acc [ formattedDate ] . total_critical_added += current . total_critical_added
123- acc [ formattedDate ] . total_high_added += current . total_high_added
124- acc [ formattedDate ] . total_critical_prevented += current . total_critical_prevented
125- acc [ formattedDate ] . total_high_prevented += current . total_high_prevented
126- acc [ formattedDate ] . total_medium_prevented += current . total_medium_prevented
127- acc [ formattedDate ] . total_low_prevented += current . total_low_prevented
128- } else {
129- acc [ formattedDate ] = current
130- acc [ formattedDate ] . created_at = formattedDate
131- }
155+ if ( ! result . data . length ) {
156+ return console . log ( 'No analytics data is available for this organization yet.' )
157+ }
132158
133- return acc
134- } , { } )
159+ const data = formatData ( result . data )
135160
136161 if ( outputJson ) {
137162 return console . log ( data )
138163 }
139164
140- const screen = blessed . screen ( )
141- // eslint-disable-next-line
142- const grid = new contrib . grid ( { rows : 4 , cols : 4 , screen} )
165+ return displayAnalyticsScreen ( data )
166+ }
143167
144- renderLineCharts ( grid , screen , 'Total critical alerts' , [ 0 , 0 , 1 , 2 ] , data , 'total_critical_alerts' )
145- renderLineCharts ( grid , screen , 'Total high alerts' , [ 0 , 2 , 1 , 2 ] , data , 'total_high_alerts' )
146- renderLineCharts ( grid , screen , 'Total critical alerts added to main' , [ 1 , 0 , 1 , 2 ] , data , 'total_critical_added' )
147- renderLineCharts ( grid , screen , 'Total high alerts added to main' , [ 1 , 2 , 1 , 2 ] , data , 'total_high_added' )
148- renderLineCharts ( grid , screen , 'Total critical alerts prevented from main' , [ 2 , 0 , 1 , 2 ] , data , 'total_critical_prevented' )
149- renderLineCharts ( grid , screen , 'Total high alerts prevented from main' , [ 2 , 2 , 1 , 2 ] , data , 'total_high_prevented' )
168+ const renderLineCharts = ( grid : any , screen : any , title : string , coords : number [ ] , data : FormattedAnalyticsData , label : string ) => {
169+ const formattedDates = Object . keys ( data ) . map ( d => `${ new Date ( d ) . getMonth ( ) + 1 } /${ new Date ( d ) . getDate ( ) } ` )
150170
151- const bar = grid . set ( 3 , 0 , 1 , 2 , contrib . bar ,
152- { label : 'Top 5 alert types'
153- , barWidth : 10
154- , barSpacing : 17
155- , xOffset : 0
156- , maxHeight : 9 , barBgColor : 'magenta' } )
171+ // @ts -ignore
172+ const alertsCounts = Object . values ( data ) . map ( d => d [ label ] )
173+
174+ const line = grid . set ( ...coords , contrib . line ,
175+ { style :
176+ { line : "cyan" ,
177+ text : "cyan" ,
178+ baseline : "black"
179+ } ,
180+ xLabelPadding : 0 ,
181+ xPadding : 0 ,
182+ xOffset : 0 ,
183+ wholeNumbersOnly : true ,
184+ legend : {
185+ width : 1
186+ } ,
187+ label : title
188+ }
189+ )
157190
158- screen . append ( bar ) //must append before setting data
191+ screen . append ( line )
159192
160- const top5AlertTypes = Object . values ( data ) [ 0 ] . top_five_alert_types
161-
162- bar . setData (
163- { titles : Object . keys ( top5AlertTypes )
164- , data : Object . values ( top5AlertTypes ) } )
193+ const lineData = {
194+ x : formattedDates . reverse ( ) ,
195+ y : alertsCounts
196+ }
165197
166- screen . render ( )
167-
168- screen . key ( [ 'escape' , 'q' , 'C-c' ] , function ( ) {
169- return process . exit ( 0 ) ;
170- } )
198+ line . setData ( [ lineData ] )
171199}
172200
173- async function fetchRepoAnalyticsData ( repo : string , time : string , spinner : Ora , apiKey : string , outputJson : boolean ) : Promise < void > {
174- const socketSdk = await setupSdk ( apiKey )
175- const result = await handleApiCall ( socketSdk . getRepoAnalytics ( repo , time ) , 'fetching analytics data' )
176-
177- if ( result . success === false ) {
178- return handleUnsuccessfulApiResponse ( 'getRepoAnalytics' , result , spinner )
201+ type AnalyticsData = {
202+ id : number ,
203+ created_at : string
204+ repository_id : string
205+ organization_id : number
206+ repository_name : string
207+ total_critical_alerts : number
208+ total_high_alerts : number
209+ total_medium_alerts : number
210+ total_low_alerts : number
211+ total_critical_added : number
212+ total_high_added : number
213+ total_medium_added : number
214+ total_low_added : number
215+ total_critical_prevented : number
216+ total_high_prevented : number
217+ total_medium_prevented : number
218+ total_low_prevented : number
219+ top_five_alert_types : {
220+ [ key : string ] : number
179221 }
180- spinner . stop ( )
222+ }
223+
224+ type FormattedAnalyticsData = {
225+ [ key : string ] : AnalyticsData
226+ }
181227
182- const data = result . data . reduce ( ( acc : { [ key : string ] : any } , current ) => {
228+ const formatData = ( data : AnalyticsData [ ] ) => {
229+ return data . reduce ( ( acc : { [ key : string ] : any } , current ) => {
183230 const formattedDate = new Date ( current . created_at ) . toLocaleDateString ( )
184231
185232 if ( acc [ formattedDate ] ) {
@@ -198,11 +245,9 @@ async function fetchRepoAnalyticsData (repo: string, time: string, spinner: Ora,
198245
199246 return acc
200247 } , { } )
248+ }
201249
202- if ( outputJson ) {
203- return console . log ( data )
204- }
205-
250+ const displayAnalyticsScreen = ( data : FormattedAnalyticsData ) => {
206251 const screen = blessed . screen ( )
207252 // eslint-disable-next-line
208253 const grid = new contrib . grid ( { rows : 4 , cols : 4 , screen} )
@@ -222,48 +267,39 @@ async function fetchRepoAnalyticsData (repo: string, time: string, spinner: Ora,
222267 , maxHeight : 9 , barBgColor : 'magenta' } )
223268
224269 screen . append ( bar ) //must append before setting data
225-
226- const top5AlertTypes = Object . values ( data ) [ 0 ] . top_five_alert_types
270+
271+ const top5 = extractTop5Alerts ( data )
227272
228273 bar . setData (
229- { titles : Object . keys ( top5AlertTypes )
230- , data : Object . values ( top5AlertTypes ) } )
274+ { titles : Object . keys ( top5 )
275+ , data : Object . values ( top5 ) } )
231276
232277 screen . render ( )
233278
234- screen . key ( [ 'escape' , 'q' , 'C-c' ] , function ( ) {
235- return process . exit ( 0 ) ;
236- } )
279+ screen . key ( [ 'escape' , 'q' , 'C-c' ] , ( ) => process . exit ( 0 ) )
237280}
238281
239- const renderLineCharts = ( grid : any , screen : any , title : string , coords : number [ ] , data : { [ key : string ] : { [ key : string ] : number } } , label : string ) => {
240- const formattedDates = Object . keys ( data ) . map ( d => `${ new Date ( d ) . getMonth ( ) + 1 } /${ new Date ( d ) . getDate ( ) } ` )
241-
242- const alertsCounts = Object . values ( data ) . map ( d => d [ label ] )
282+ const extractTop5Alerts = ( data : FormattedAnalyticsData ) => {
283+ const allTop5Alerts = Object . values ( data ) . map ( d => d . top_five_alert_types )
243284
244- const line = grid . set ( ...coords , contrib . line ,
245- { style :
246- { line : "cyan" ,
247- text : "cyan" ,
248- baseline : "black"
249- } ,
250- xLabelPadding : 0 ,
251- xPadding : 0 ,
252- xOffset : 0 ,
253- wholeNumbersOnly : true ,
254- legend : {
255- width : 1
256- } ,
257- label : title
258- }
259- )
260-
261- screen . append ( line )
262-
263- const lineData = {
264- x : formattedDates . reverse ( ) ,
265- y : alertsCounts
266- }
285+ const aggTop5Alerts = allTop5Alerts . reduce ( ( acc , current ) => {
286+ const alertTypes = Object . keys ( current )
287+
288+ alertTypes . forEach ( type => {
289+ if ( ! acc [ type ] ) {
290+ // @ts -ignore
291+ acc [ type ] = current [ type ]
292+ } else {
293+ // @ts -ignore
294+ if ( acc [ type ] < current [ type ] ) {
295+ // @ts -ignore
296+ acc [ type ] = current [ type ]
297+ }
298+ }
299+ } )
300+
301+ return acc
302+ } , { } )
267303
268- line . setData ( [ lineData ] )
304+ return Object . fromEntries ( Object . entries ( aggTop5Alerts ) . sort ( ( a : [ string , number ] , b : [ string , number ] ) => b [ 1 ] - a [ 1 ] ) . slice ( 0 , 5 ) )
269305}
0 commit comments