@@ -4,17 +4,19 @@ import contrib from 'blessed-contrib'
4
4
import meow from 'meow'
5
5
import ora from 'ora'
6
6
7
- import { outputFlags , validationFlags } from '../flags'
7
+ import { outputFlags } from '../flags'
8
8
import { handleApiCall , handleUnsuccessfulApiResponse } from '../utils/api-helpers'
9
9
import { AuthError , InputError } from '../utils/errors'
10
10
import { printFlagList } from '../utils/formatting'
11
11
import { getDefaultKey , setupSdk } from '../utils/sdk'
12
12
13
13
import type { CliSubcommand } from '../utils/meow-with-subcommands'
14
14
import type { Ora } from "ora"
15
+ import chalk from 'chalk'
15
16
16
17
export 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.` ,
18
20
async run ( argv , importMeta , { parentName } ) {
19
21
const name = parentName + ' analytics'
20
22
@@ -36,31 +38,53 @@ export const analytics: CliSubcommand = {
36
38
}
37
39
}
38
40
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
+
39
62
// Internal functions
40
63
41
64
type CommandContext = {
42
65
scope : string
43
- time : string
44
- repo : string | undefined
66
+ time : number
67
+ repo : string
45
68
outputJson : boolean
46
69
}
47
70
48
71
function setupCommand ( name : string , description : string , argv : readonly string [ ] , importMeta : ImportMeta ) : void | CommandContext {
49
72
const flags : { [ key : string ] : any } = {
50
73
...outputFlags ,
51
- ...validationFlags ,
74
+ ...analyticsFlags
52
75
}
53
76
54
77
const cli = meow ( `
55
78
Usage
56
- $ ${ name } <scope> <time>
79
+ $ ${ name } --scope= <scope> --time= <time filter >
57
80
58
81
Options
59
82
${ printFlagList ( flags , 6 ) }
60
83
61
84
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
64
88
` , {
65
89
argv,
66
90
description,
@@ -69,117 +93,140 @@ function setupCommand (name: string, description: string, argv: readonly string[
69
93
} )
70
94
71
95
const {
72
- json : outputJson
96
+ json : outputJson ,
97
+ scope,
98
+ time,
99
+ repo
73
100
} = cli . flags
74
101
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'" )
79
104
}
80
105
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 ' )
83
108
}
84
109
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
87
116
}
88
117
89
- const repo = scope === 'repo' ? cli . input [ 1 ] : undefined
118
+ return < CommandContext > {
119
+ scope, time, repo, outputJson
120
+ }
121
+ }
90
122
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' )
92
126
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 )
95
129
}
96
130
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.' )
99
135
}
100
136
101
- return < CommandContext > {
102
- scope, time, repo, outputJson
137
+ const data = formatData ( result . data )
138
+
139
+ if ( outputJson ) {
140
+ return console . log ( data )
103
141
}
142
+
143
+ return displayAnalyticsScreen ( data )
104
144
}
105
145
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 > {
107
147
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' )
109
149
110
150
if ( result . success === false ) {
111
- return handleUnsuccessfulApiResponse ( 'getOrgAnalytics ' , result , spinner )
151
+ return handleUnsuccessfulApiResponse ( 'getRepoAnalytics ' , result , spinner )
112
152
}
113
-
114
153
spinner . stop ( )
115
154
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
+ }
132
158
133
- return acc
134
- } , { } )
159
+ const data = formatData ( result . data )
135
160
136
161
if ( outputJson ) {
137
162
return console . log ( data )
138
163
}
139
164
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
+ }
143
167
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 ( ) } ` )
150
170
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
+ )
157
190
158
- screen . append ( bar ) //must append before setting data
191
+ screen . append ( line )
159
192
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
+ }
165
197
166
- screen . render ( )
167
-
168
- screen . key ( [ 'escape' , 'q' , 'C-c' ] , function ( ) {
169
- return process . exit ( 0 ) ;
170
- } )
198
+ line . setData ( [ lineData ] )
171
199
}
172
200
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
179
221
}
180
- spinner . stop ( )
222
+ }
223
+
224
+ type FormattedAnalyticsData = {
225
+ [ key : string ] : AnalyticsData
226
+ }
181
227
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 ) => {
183
230
const formattedDate = new Date ( current . created_at ) . toLocaleDateString ( )
184
231
185
232
if ( acc [ formattedDate ] ) {
@@ -198,11 +245,9 @@ async function fetchRepoAnalyticsData (repo: string, time: string, spinner: Ora,
198
245
199
246
return acc
200
247
} , { } )
248
+ }
201
249
202
- if ( outputJson ) {
203
- return console . log ( data )
204
- }
205
-
250
+ const displayAnalyticsScreen = ( data : FormattedAnalyticsData ) => {
206
251
const screen = blessed . screen ( )
207
252
// eslint-disable-next-line
208
253
const grid = new contrib . grid ( { rows : 4 , cols : 4 , screen} )
@@ -222,48 +267,39 @@ async function fetchRepoAnalyticsData (repo: string, time: string, spinner: Ora,
222
267
, maxHeight : 9 , barBgColor : 'magenta' } )
223
268
224
269
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 )
227
272
228
273
bar . setData (
229
- { titles : Object . keys ( top5AlertTypes )
230
- , data : Object . values ( top5AlertTypes ) } )
274
+ { titles : Object . keys ( top5 )
275
+ , data : Object . values ( top5 ) } )
231
276
232
277
screen . render ( )
233
278
234
- screen . key ( [ 'escape' , 'q' , 'C-c' ] , function ( ) {
235
- return process . exit ( 0 ) ;
236
- } )
279
+ screen . key ( [ 'escape' , 'q' , 'C-c' ] , ( ) => process . exit ( 0 ) )
237
280
}
238
281
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 )
243
284
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
+ } , { } )
267
303
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 ) )
269
305
}
0 commit comments