Skip to content

Commit 32eb409

Browse files
authored
feat(W-19005486): add support for SF Trust API to status command (#3357)
* feat: add status types * feat: retrieve and format data from SF Trust API * refactor: update json return * refactor: print system status from either Heroku API or Trust API * feat: format incident details from Trust API * refactor: update calculation for incident title padding * refactor: update status types * feat: add localization and alternative service names for status api response * test: first test updates * test: add tests for SF Trust API * feat: add startTime param to maintenances endpoint request * test: add test for API failures * fix: update logic for classifying incidents by system
1 parent cc64191 commit 32eb409

File tree

6 files changed

+1127
-98
lines changed

6 files changed

+1127
-98
lines changed

cspell-dictionary.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ allkeys
99
amyapp
1010
amannn
1111
aname
12+
APAC
1213
appname
1314
apresharedkey
1415
armel
@@ -38,6 +39,7 @@ buildpack
3839
buildpacks
3940
buildpath
4041
cachecompl
42+
CEDARPRIVATESPACES
4143
ckey
4244
clearsign
4345
clientsecret
@@ -77,6 +79,7 @@ ECCN
7779
echoerr
7880
Edmonds
7981
elif
82+
EMEA
8083
envfile
8184
envl
8285
eventsource
@@ -88,6 +91,7 @@ favoriteid
8891
favoriting
8992
filebase
9093
filesize
94+
FIRPRIVATESPACES
9195
flagscompletions
9296
fooapp
9397
fooexample

packages/cli/src/commands/status.ts

Lines changed: 161 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,19 @@ import {hux} from '@heroku/heroku-cli-util'
44
import {capitalize} from '@oclif/core/lib/util'
55
import {formatDistanceToNow} from 'date-fns'
66
import HTTP from '@heroku/http-call'
7+
import {
8+
TrustInstance,
9+
TrustIncident,
10+
TrustMaintenance,
11+
HerokuStatus,
12+
FormattedTrustStatus,
13+
SystemStatus,
14+
Localization,
15+
} from '../lib/types/status'
716

8-
import {maxBy} from '../lib/status/util'
17+
import {getMaxUpdateTypeLength} from '../lib/status/util'
18+
19+
const errorMessage = 'Heroku platform status is unavailable at this time. Refer to https://status.salesforce.com/products/Heroku or try again later.'
920

1021
const printStatus = (status: string) => {
1122
const colorize = (color as any)[status]
@@ -18,6 +29,106 @@ const printStatus = (status: string) => {
1829
return colorize(message)
1930
}
2031

32+
const getTrustStatus = async () => {
33+
const trustHost = process.env.SF_TRUST_STAGING ? 'https://status-api-stg.test.edgekey.net/v1' : 'https://api.status.salesforce.com/v1'
34+
const currentDateTime = new Date(Date.now()).toISOString()
35+
let instances: TrustInstance[] = []
36+
let activeIncidents: TrustIncident[] = []
37+
let maintenances: TrustMaintenance[] = []
38+
let localizations: Localization[] = []
39+
40+
try {
41+
const [instanceResponse, activeIncidentsResponse, maintenancesResponse, localizationsResponse] = await Promise.all([
42+
HTTP.get<TrustInstance[]>(`${trustHost}/instances?products=Heroku`),
43+
HTTP.get<TrustIncident[]>(`${trustHost}/incidents/active`),
44+
HTTP.get<TrustMaintenance[]>(`${trustHost}/maintenances?startTime=${currentDateTime}&limit=10&offset=0&product=Heroku&locale=en`),
45+
HTTP.get<Localization[]>(`${trustHost}/localizations?locale=en`),
46+
])
47+
instances = instanceResponse.body
48+
activeIncidents = activeIncidentsResponse.body
49+
maintenances = maintenancesResponse.body
50+
localizations = localizationsResponse.body
51+
} catch {
52+
ux.error(errorMessage, {exit: 1})
53+
}
54+
55+
return formatTrustResponse(instances, activeIncidents, maintenances, localizations)
56+
}
57+
58+
const determineIncidentSeverity = (incidents: TrustIncident[]) => {
59+
const severityArray: string[] = []
60+
incidents.forEach(incident => {
61+
incident.IncidentImpacts.forEach(impact => {
62+
if (!impact.endTime && impact.severity) {
63+
severityArray.push(impact.severity)
64+
}
65+
})
66+
})
67+
if (severityArray.includes('major')) return 'red'
68+
if (severityArray.includes('minor')) return 'yellow'
69+
return 'green'
70+
}
71+
72+
const formatTrustResponse = (instances: TrustInstance[], activeIncidents: TrustIncident[], maintenances: TrustMaintenance[], localizations: Localization[]): FormattedTrustStatus => {
73+
const systemStatus: SystemStatus[] = []
74+
const incidents: TrustIncident[] = []
75+
const scheduled: TrustMaintenance[] = []
76+
const instanceKeyArray = new Set(instances.map(instance => instance.key))
77+
const herokuActiveIncidents = activeIncidents.filter(incident => {
78+
return incident.instanceKeys.some(key => instanceKeyArray.has(key))
79+
})
80+
const toolsIncidents = herokuActiveIncidents.filter(incident => {
81+
const tools = ['TOOLS', 'Tools', 'CLI', 'Dashboard', 'Platform API']
82+
return tools.some(tool => incident.serviceKeys.includes(tool))
83+
})
84+
const appsIncidents = herokuActiveIncidents.filter(incident => {
85+
return incident.serviceKeys.includes('HerokuApps') || incident.serviceKeys.includes('Apps')
86+
})
87+
const dataIncidents = herokuActiveIncidents.filter(incident => {
88+
return incident.serviceKeys.includes('HerokuData') || incident.serviceKeys.includes('Data')
89+
})
90+
91+
if (appsIncidents.length > 0) {
92+
const severity = determineIncidentSeverity(appsIncidents)
93+
systemStatus.push({system: 'Apps', status: severity})
94+
incidents.push(...appsIncidents)
95+
} else {
96+
systemStatus.push({system: 'Apps', status: 'green'})
97+
}
98+
99+
if (dataIncidents.length > 0) {
100+
const severity = determineIncidentSeverity(dataIncidents)
101+
systemStatus.push({system: 'Data', status: severity})
102+
incidents.push(...dataIncidents)
103+
} else {
104+
systemStatus.push({system: 'Data', status: 'green'})
105+
}
106+
107+
if (toolsIncidents.length > 0) {
108+
const severity = determineIncidentSeverity(toolsIncidents)
109+
systemStatus.push({system: 'Tools', status: severity})
110+
incidents.push(...toolsIncidents)
111+
} else {
112+
systemStatus.push({system: 'Tools', status: 'green'})
113+
}
114+
115+
if (maintenances.length > 0) scheduled.push(...maintenances)
116+
117+
if (incidents.length > 0) {
118+
incidents.forEach(incident => {
119+
incident.IncidentEvents.forEach(event => {
120+
event.localizedType = localizations.find((l: any) => l.modelKey === event.type)?.text
121+
})
122+
})
123+
}
124+
125+
return {
126+
status: systemStatus,
127+
incidents,
128+
scheduled,
129+
}
130+
}
131+
21132
export default class Status extends Command {
22133
static description = 'display current status of the Heroku platform'
23134

@@ -27,30 +138,65 @@ export default class Status extends Command {
27138

28139
async run() {
29140
const {flags} = await this.parse(Status)
30-
const apiPath = '/api/v4/current-status'
141+
const herokuApiPath = '/api/v4/current-status'
142+
let herokuStatus
143+
let formattedTrustStatus
31144

32-
const host = process.env.HEROKU_STATUS_HOST || 'https://status.heroku.com'
33-
const {body} = await HTTP.get<any>(host + apiPath)
145+
if (process.env.TRUST_ONLY) {
146+
formattedTrustStatus = await getTrustStatus()
147+
} else {
148+
try {
149+
// Try calling the Heroku status API first
150+
const herokuHost = process.env.HEROKU_STATUS_HOST || 'https://status.heroku.com'
151+
const herokuStatusResponse = await HTTP.get<HerokuStatus>(herokuHost + herokuApiPath)
152+
herokuStatus = herokuStatusResponse.body
153+
} catch {
154+
// If the Heroku status API call fails, call the SF Trust API
155+
formattedTrustStatus = await getTrustStatus()
156+
}
157+
}
158+
159+
if (!herokuStatus && !formattedTrustStatus) ux.error(errorMessage, {exit: 1})
34160

35161
if (flags.json) {
36-
hux.styledJSON(body)
162+
hux.styledJSON(herokuStatus ?? formattedTrustStatus)
37163
return
38164
}
39165

40-
for (const item of body.status) {
41-
const message = printStatus(item.status)
166+
const systemStatus = herokuStatus ? herokuStatus.status : formattedTrustStatus?.status
167+
168+
if (systemStatus) {
169+
for (const item of systemStatus) {
170+
const message = printStatus(item.status)
42171

43-
this.log(`${(item.system + ':').padEnd(11)}${message}`)
172+
this.log(`${(item.system + ':').padEnd(11)}${message}`)
173+
}
174+
} else {
175+
ux.error(errorMessage, {exit: 1})
44176
}
45177

46-
for (const incident of body.incidents) {
47-
ux.log()
48-
hux.styledHeader(`${incident.title} ${color.yellow(incident.created_at)} ${color.cyan(incident.full_url)}`)
178+
if (herokuStatus) {
179+
for (const incident of herokuStatus.incidents) {
180+
ux.log()
181+
hux.styledHeader(`${incident.title} ${color.yellow(incident.created_at)} ${color.cyan(incident.full_url)}`)
182+
183+
const padding = getMaxUpdateTypeLength(incident.updates.map(update => update.update_type))
184+
for (const u of incident.updates) {
185+
ux.log(`${color.yellow(u.update_type.padEnd(padding))} ${new Date(u.updated_at).toISOString()} (${formatDistanceToNow(new Date(u.updated_at))} ago)`)
186+
ux.log(`${u.contents}\n`)
187+
}
188+
}
189+
} else if (formattedTrustStatus) {
190+
for (const incident of formattedTrustStatus.incidents) {
191+
ux.log()
192+
hux.styledHeader(`${incident.id} ${color.yellow(incident.createdAt)} ${color.cyan(`https://status.salesforce.com/incidents/${incident.id}`)}`)
49193

50-
const padding = maxBy(incident.updates, (i: any) => i.update_type.length).update_type.length + 0
51-
for (const u of incident.updates) {
52-
ux.log(`${color.yellow(u.update_type.padEnd(padding))} ${new Date(u.updated_at).toISOString()} (${formatDistanceToNow(new Date(u.updated_at))} ago)`)
53-
ux.log(`${u.contents}\n`)
194+
const padding = getMaxUpdateTypeLength(incident.IncidentEvents.map(event => event.localizedType ?? event.type))
195+
for (const event of incident.IncidentEvents) {
196+
const eventType = event.localizedType ?? event.type
197+
ux.log(`${color.yellow(eventType.padEnd(padding))} ${new Date(event.createdAt).toISOString()} (${formatDistanceToNow(new Date(event.createdAt))} ago)`)
198+
ux.log(`${event.message}\n`)
199+
}
54200
}
55201
}
56202
}
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
export function maxBy<T>(arr: T[], fn: (i: T) => number): T | undefined {
2-
let max: {element: T; i: number} | undefined
3-
for (const cur of arr) {
4-
const i = fn(cur)
5-
if (!max || i > max.i) {
6-
max = {i, element: cur}
1+
export function getMaxUpdateTypeLength(updateTypes: string[]): number {
2+
let max = 0
3+
for (const update of updateTypes) {
4+
if (!max || update.length > max) {
5+
max = update.length
76
}
87
}
98

10-
return max && max.element
9+
return max
1110
}

0 commit comments

Comments
 (0)