Skip to content

Commit 37bd4c2

Browse files
committed
refactor log-displayer to class, add tests for dashboard and logs, add back misc lib tests
1 parent bcd0194 commit 37bd4c2

File tree

10 files changed

+245
-171
lines changed

10 files changed

+245
-171
lines changed

cspell-dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ logfile
133133
logplex
134134
lolex
135135
lowercasedb
136+
lshift
136137
lshztxe
137138
ltrim
138139
mactive

packages/cli/src/commands/logs.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {color} from '@heroku-cli/color'
22
import {Command, flags} from '@heroku-cli/command'
33
import {ProcessTypeCompletion} from '@heroku-cli/command/lib/completions.js'
4-
import logDisplayer from '../lib/run/log-displayer.js'
54
import tsheredoc from 'tsheredoc'
65

6+
import {LogDisplayer} from '../lib/run/log-displayer.js'
7+
78
const heredoc = tsheredoc.default
89

910
export default class Logs extends Command {
@@ -79,13 +80,15 @@ export default class Logs extends Command {
7980
if (forceColors)
8081
color.enabled = true
8182

82-
await logDisplayer(this.heroku, {
83+
const options = {
8384
app,
8485
dyno,
8586
lines: num || 100,
8687
source,
8788
tail,
8889
type: type || ps,
89-
})
90+
}
91+
const displayer = new LogDisplayer(this.heroku)
92+
await displayer.display(options)
9093
}
9194
}

packages/cli/src/lib/run/log-displayer.ts

Lines changed: 125 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -15,116 +15,149 @@ interface LogDisplayerOptions {
1515
type?: string
1616
}
1717

18-
function readLogs(logplexURL: string, isTail: boolean, recreateSessionTimeout?: number) {
19-
return new Promise<void>((resolve, reject) => {
20-
const userAgent = process.env.HEROKU_DEBUG_USER_AGENT || 'heroku-run'
21-
const proxy = process.env.https_proxy || process.env.HTTPS_PROXY
22-
23-
// Custom fetch function to handle headers and proxy
24-
const customFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
25-
const headers = new Headers(init?.headers)
26-
headers.set('User-Agent', userAgent)
27-
28-
const fetchOptions: RequestInit = {
29-
...init,
30-
headers,
31-
}
32-
33-
// If proxy is set, we need to handle it through environment variables
34-
// The fetch implementation will automatically use https_proxy/HTTPS_PROXY
35-
return fetch(input, fetchOptions)
36-
}
18+
export class LogDisplayer {
19+
private heroku: APIClient
20+
// private options: LogDisplayerOptions
3721

38-
const es = new EventSource(logplexURL, {
39-
fetch: customFetch,
40-
})
22+
constructor(heroku: APIClient) {
23+
this.heroku = heroku
24+
}
4125

42-
es.addEventListener('error', (err: Event) => {
43-
// The new eventsource package provides message and code properties on errors
44-
const errorEvent = err as any
45-
if (errorEvent && (errorEvent.code || errorEvent.message)) {
46-
const msg = (isTail && (errorEvent.code === 404 || errorEvent.code === 403))
47-
? 'Log stream timed out. Please try again.'
48-
: `Logs eventsource failed with: ${errorEvent.code}${errorEvent.message ? ` ${errorEvent.message}` : ''}`
49-
reject(new Error(msg))
50-
es.close()
26+
async display(options: LogDisplayerOptions): Promise<void> {
27+
this.setupProcessHandlers()
28+
29+
const firApp = (await this.getGenerationByAppId(options)) === 'fir'
30+
const isTail = firApp || options.tail
31+
32+
const requestBodyParameters = this.buildRequestBodyParameters(firApp, options)
33+
34+
let recreateLogSession = false
35+
do {
36+
const logSession = await this.createLogSession(requestBodyParameters, options.app)
37+
38+
try {
39+
await this.readLogs(
40+
logSession.logplex_url,
41+
isTail,
42+
firApp ? Number(process.env.HEROKU_LOG_STREAM_TIMEOUT || '15') * 60 * 1000 : undefined,
43+
)
44+
} catch (error: unknown) {
45+
const {message} = error as Error
46+
if (message === 'Fir log stream timeout')
47+
recreateLogSession = true
48+
else
49+
ux.error(message, {exit: 1})
5150
}
51+
} while (recreateLogSession)
52+
}
5253

53-
if (!isTail) {
54-
resolve()
55-
es.close()
54+
private setupProcessHandlers(): void {
55+
process.stdout.on('error', err => {
56+
if (err.code === 'EPIPE') {
57+
// eslint-disable-next-line n/no-process-exit
58+
process.exit(0)
59+
} else {
60+
ux.error(err.stack, {exit: 1})
5661
}
57-
58-
// should only land here if --tail and no error status or message
5962
})
63+
}
6064

61-
es.addEventListener('message', (e: MessageEvent) => {
62-
e.data.trim().split(/\n+/).forEach((line: string) => {
63-
ux.stdout(colorize(line))
64-
})
65-
})
65+
private async getGenerationByAppId(options: LogDisplayerOptions): Promise<string> {
66+
const generation = await getGenerationByAppId(options.app, this.heroku)
67+
return generation || ''
68+
}
6669

67-
if (isTail && recreateSessionTimeout) {
68-
setTimeout(() => {
69-
reject(new Error('Fir log stream timeout'))
70-
es.close()
71-
}, recreateSessionTimeout)
70+
private buildRequestBodyParameters(firApp: boolean, options: LogDisplayerOptions): Record<string, any> {
71+
const requestBodyParameters = {
72+
source: options.source,
7273
}
73-
})
74-
}
7574

76-
async function logDisplayer(heroku: APIClient, options: LogDisplayerOptions) {
77-
process.stdout.on('error', err => {
78-
if (err.code === 'EPIPE') {
79-
// eslint-disable-next-line n/no-process-exit
80-
process.exit(0)
75+
if (firApp) {
76+
process.stderr.write(color.cyan.bold('Fetching logs...\n\n'))
77+
Object.assign(requestBodyParameters, {
78+
dyno: options.dyno,
79+
type: options.type,
80+
})
8181
} else {
82-
ux.error(err.stack, {exit: 1})
82+
Object.assign(requestBodyParameters, {
83+
dyno: options.dyno || options.type,
84+
lines: options.lines,
85+
tail: options.tail,
86+
})
8387
}
84-
})
8588

86-
const firApp = (await getGenerationByAppId(options.app, heroku)) === 'fir'
87-
const isTail = firApp || options.tail
88-
89-
const requestBodyParameters = {
90-
source: options.source,
91-
}
92-
93-
if (firApp) {
94-
process.stderr.write(color.cyan.bold('Fetching logs...\n\n'))
95-
Object.assign(requestBodyParameters, {
96-
dyno: options.dyno,
97-
type: options.type,
98-
})
99-
} else {
100-
Object.assign(requestBodyParameters, {
101-
dyno: options.dyno || options.type,
102-
lines: options.lines,
103-
tail: options.tail,
104-
})
89+
return requestBodyParameters
10590
}
10691

107-
let recreateLogSession = false
108-
do {
109-
const {body: logSession} = await heroku.post<LogSession>(`/apps/${options.app}/log-sessions`, {
92+
private async createLogSession(requestBodyParameters: Record<string, any>, app: string): Promise<LogSession> {
93+
const {body: logSession} = await this.heroku.post<LogSession>(`/apps/${app}/log-sessions`, {
11094
body: requestBodyParameters,
11195
headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'},
11296
})
97+
return logSession
98+
}
11399

114-
try {
115-
await readLogs(
116-
logSession.logplex_url,
117-
isTail,
118-
firApp ? Number(process.env.HEROKU_LOG_STREAM_TIMEOUT || '15') * 60 * 1000 : undefined,
119-
)
120-
} catch (error: unknown) {
121-
const {message} = error as Error
122-
if (message === 'Fir log stream timeout')
123-
recreateLogSession = true
124-
else
125-
ux.error(message, {exit: 1})
126-
}
127-
} while (recreateLogSession)
100+
private readLogs(logplexURL: string, isTail: boolean, recreateSessionTimeout?: number): Promise<void> {
101+
return new Promise<void>((resolve, reject) => {
102+
const userAgent = process.env.HEROKU_DEBUG_USER_AGENT || 'heroku-run'
103+
const proxy = process.env.https_proxy || process.env.HTTPS_PROXY
104+
105+
// Custom fetch function to handle headers and proxy
106+
const customFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
107+
const headers = new Headers(init?.headers)
108+
headers.set('User-Agent', userAgent)
109+
110+
const fetchOptions: RequestInit = {
111+
...init,
112+
headers,
113+
}
114+
115+
// If proxy is set, we need to handle it through environment variables
116+
// The fetch implementation will automatically use https_proxy/HTTPS_PROXY
117+
return fetch(input, fetchOptions)
118+
}
119+
120+
const es = new EventSource(logplexURL, {
121+
fetch: customFetch,
122+
})
123+
124+
es.addEventListener('error', (err: Event) => {
125+
// The new eventsource package provides message and code properties on errors
126+
const errorEvent = err as any
127+
if (errorEvent && (errorEvent.code || errorEvent.message)) {
128+
const msg = (isTail && (errorEvent.code === 404 || errorEvent.code === 403))
129+
? 'Log stream timed out. Please try again.'
130+
: `Logs eventsource failed with: ${errorEvent.code}${errorEvent.message ? ` ${errorEvent.message}` : ''}`
131+
reject(new Error(msg))
132+
es.close()
133+
}
134+
135+
if (!isTail) {
136+
resolve()
137+
es.close()
138+
}
139+
140+
// should only land here if --tail and no error status or message
141+
})
142+
143+
es.addEventListener('message', (e: MessageEvent) => {
144+
e.data.trim().split(/\n+/).forEach((line: string) => {
145+
ux.stdout(colorize(line))
146+
})
147+
})
148+
149+
if (isTail && recreateSessionTimeout) {
150+
setTimeout(() => {
151+
reject(new Error('Fir log stream timeout'))
152+
es.close()
153+
}, recreateSessionTimeout)
154+
}
155+
})
156+
}
128157
}
129158

130-
export default logDisplayer
159+
// Default export for backward compatibility
160+
export default async function logDisplayer(heroku: APIClient, options: LogDisplayerOptions): Promise<void> {
161+
const displayer = new LogDisplayer(heroku)
162+
await displayer.display(options)
163+
}

packages/cli/src/lib/utils/sparkline.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,27 @@ export function sparkline(values: number[]): string {
1515
return ''
1616
}
1717

18-
// Find min and max for normalization
19-
const min = Math.min(...validValues)
20-
const max = Math.max(...validValues)
18+
// Unicode block characters for different heights
19+
const ticks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
20+
21+
// NPM sparkline algorithm
22+
function lshift(n: number, bits: number): number {
23+
// eslint-disable-next-line prefer-exponentiation-operator
24+
return Math.floor(n) * Math.pow(2, bits)
25+
}
2126

22-
// If all values are the same, return flat line
23-
if (min === max) {
27+
const max = Math.max.apply(null, validValues)
28+
const min = Math.min.apply(null, validValues)
29+
const f = Math.floor(lshift(max - min, 8) / (ticks.length - 1))
30+
if (f < 1) {
2431
return '▁'.repeat(validValues.length)
2532
}
2633

27-
// Unicode block characters for different heights
28-
const blocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
34+
const results: string[] = []
35+
for (const validValue of validValues) {
36+
const value = ticks[Math.floor(lshift(validValue - min, 8) / f)]
37+
results.push(value)
38+
}
2939

30-
// Normalize values to 0-7 range and map to block characters
31-
return validValues.map(value => {
32-
const normalized = ((value - min) / (max - min)) * (blocks.length - 1)
33-
const index = Math.round(normalized)
34-
return blocks[Math.max(0, Math.min(blocks.length - 1, index))]
35-
}).join('')
40+
return results.join('')
3641
}

packages/cli/test/unit/commands/dashboard.unit.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,10 @@ describe('dashboard', function () {
160160
.reply(200, {data: {}})
161161

162162
await runCommand(Cmd, [])
163+
163164
expect(stdout.output).to.contain(heredoc(`
164-
myapp
165+
=== ⬢ myapp
166+
165167
166168
Dynos: 1 | Standard-1X
167169
Last release: ${ago(now)}

0 commit comments

Comments
 (0)