heroku-dash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,638 @@
1
+ import blessed from 'blessed'
2
+ import clipboard from 'clipboardy'
3
+ import {spawn} from 'node:child_process'
4
+ import {errorMessage} from '../api.js'
5
+ import {resolveHierarchy} from '../hierarchy.js'
6
+ import {appRows, clean, single, sortApps, STAGES, TABS} from './views.js'
7
+ import {detailContent, isValueClick} from './details.js'
8
+ import {badge, icons, paint, palette, rowLabel, SCANNER_INTERVAL, scannerFrame, shortcut, stageStyles, tabIcons} from './theme.js'
9
+
10
+ const SIDEBAR_WIDTH = '22%'
11
+ const frame = () => ({border: {type: 'line'}, style: {fg: palette.fg, bg: palette.bg, border: {fg: palette.border}, focus: {border: {fg: palette.accent}}}})
12
+
13
+ export class Dashboard {
14
+ constructor({api, catalog, context, refresh = 30, demo = false, screen, writeClipboard = clipboard.write}) {
15
+ Object.assign(this, {api, catalog, context, refresh, demo, writeClipboard})
16
+ this.screen = screen ?? blessed.screen({smartCSR: true, fullUnicode: true, title: 'heroku dash', dockBorders: true, autoPadding: true})
17
+ this.tab = 0
18
+ this.mode = 'pipelines'
19
+ this.team = context.team ?? null
20
+ this.breadcrumbTeam = null
21
+ this.pipeline = context.pipeline ?? null
22
+ this.app = null
23
+ this.rows = []
24
+ this.updatingRows = false
25
+ this.navItems = []
26
+ this.generation = 0
27
+ this.navGeneration = 0
28
+ this.config = null
29
+ this.revealed = new Set()
30
+ this.copying = false
31
+ this.busy = false
32
+ this.loading = new Map()
33
+ this.loadingFrame = 0
34
+ this.loadingTimer = null
35
+ this.closed = false
36
+ this.filter = ''
37
+ this.message = context.reason
38
+ this.messageTone = 'muted'
39
+ this.widgets()
40
+ this.bindings()
41
+ }
42
+
43
+ widgets() {
44
+ const parent = this.screen
45
+ this.header = blessed.box({parent, top: 0, height: 3, left: 0, right: 0, padding: {left: 2}, tags: false, style: {fg: palette.fg, bg: palette.panel}})
46
+ this.nav = blessed.list({parent, top: 3, bottom: 4, left: 0, width: SIDEBAR_WIDTH, ...frame(), label: ` ${icons.pipelines} Pipelines `, keys: true, mouse: true, tags: false,
47
+ scrollbar: {ch: '│', style: {bg: palette.border}}, style: {...frame().style, selected: {bg: palette.selected, fg: 'white', bold: true}, item: {fg: palette.fg}}})
48
+ this.tabs = blessed.box({parent, top: 3, height: 3, left: SIDEBAR_WIDTH, right: 0, ...frame(), padding: {left: 1}, style: {...frame().style, fg: palette.accent}})
49
+ this.summary = blessed.box({parent, top: 6, height: 5, left: SIDEBAR_WIDTH, right: 0, padding: {left: 2, right: 1}, style: {fg: palette.fg, bg: palette.bg}})
50
+ this.main = blessed.list({parent, top: 11, height: '40%-4', left: SIDEBAR_WIDTH, right: 0, ...frame(), label: ` ${icons.apps} Apps `, keys: true, mouse: true, tags: false,
51
+ scrollbar: {ch: '│', style: {bg: palette.border}}, style: {...frame().style, selected: {bg: palette.selected, fg: 'white'}, item: {fg: palette.fg}}})
52
+ this.detail = blessed.box({parent, top: '40%+7', bottom: 4, left: SIDEBAR_WIDTH, right: 0, ...frame(), label: ` ${icons.overview} Details `, padding: {left: 1, right: 1}, scrollable: true, alwaysScroll: true, keys: true, vi: true, mouse: true, tags: false,
53
+ scrollbar: {ch: '│', style: {bg: palette.border}}})
54
+ this.status = blessed.box({parent, bottom: 2, height: 2, left: 0, right: 0, padding: {left: 1}, tags: false, style: {fg: palette.muted, bg: palette.bg}})
55
+ this.footer = blessed.box({parent, bottom: 0, height: 2, left: 0, right: 0, padding: {left: 1}, tags: false, style: {fg: palette.fg, bg: palette.panel},
56
+ content: `${[['t', 'teams'], ['p', 'pipelines'], ['a', 'apps'], ['/', 'filter'], ['Enter', 'open'], ['Esc', 'back'], ['Tab', 'focus']].map(([key, text]) => shortcut(key, text)).join(' ')}\n${[['j/k', 'move'], ['1–7 / [ ] / h l', 'views'], ['R', 'refresh'], ['o', 'browser'], ['?', 'help'], ['q', 'quit']].map(([key, text]) => shortcut(key, text)).join(' ')}`})
57
+ this.small = blessed.box({parent, top: 0, left: 0, right: 0, bottom: 0, hidden: true, style: {fg: palette.fg, bg: palette.bg}, valign: 'middle', align: 'center', content: 'heroku dash\n\nPlease resize your terminal to at least 80 × 24.\n\nq / Ctrl-C to quit'})
58
+ this.screen.on('resize', () => this.render())
59
+ this.screen.once('destroy', () => this.close())
60
+ this.main.on('select item', () => {
61
+ if (this.updatingRows) return
62
+ this.drawDetail()
63
+ })
64
+ this.main.on('select', item => {
65
+ if (this.modal) return
66
+ const selected = this.rows[this.main.getItemIndex(item)]
67
+ if (selected?.kind === 'app') void this.openApp(selected.value, this.pipeline)
68
+ })
69
+ this.detail.on('click', mouse => {
70
+ if (this.closed || this.modal || this.small.visible) return
71
+ const row = this.rows[this.main.selected]
72
+ if (this.revealed.has(row?.key) && isValueClick(this.detail, row, mouse)) void this.copyConfig()
73
+ })
74
+ this.nav.on('select', item => {
75
+ if (this.modal) return
76
+ const selected = this.navItems[this.nav.getItemIndex(item)]
77
+ if (selected) void this.navigate(selected)
78
+ })
79
+ this.nav.focus()
80
+ }
81
+
82
+ bindings() {
83
+ // Bind list movement explicitly: Blessed's vi mode also treats l as Enter,
84
+ // which would open a sidebar item while switching to the next app view.
85
+ for (const list of [this.nav, this.main]) {
86
+ list.key(['j'], () => { list.down(); this.render() })
87
+ list.key(['k'], () => { list.up(); this.render() })
88
+ }
89
+ const key = (keys, action) => this.screen.key(keys, (...args) => {
90
+ if (!this.modal && !this.closed) action(...args)
91
+ })
92
+ this.screen.key(['C-c'], () => this.close())
93
+ this.screen.key(['C-l'], () => { this.screen.realloc(); this.render() })
94
+ key(['q'], () => this.close())
95
+ key(['tab'], () => {
96
+ const panes = [this.nav, this.main, this.detail]
97
+ panes[(panes.indexOf(this.screen.focused) + 1) % panes.length].focus()
98
+ this.render()
99
+ })
100
+ key(['S-tab'], () => {
101
+ const panes = [this.nav, this.main, this.detail]
102
+ panes[(panes.indexOf(this.screen.focused) + 2) % panes.length].focus()
103
+ this.render()
104
+ })
105
+ key(['t'], () => this.setMode('teams'))
106
+ key(['p'], () => this.setMode('pipelines'))
107
+ key(['a'], () => this.setMode('apps'))
108
+ key(['/'], () => void this.filterNav())
109
+ key(['escape'], () => void this.back())
110
+ key(['R'], () => void this.reload())
111
+ key(['[', 'left', 'h'], () => this.changeTab((this.tab + TABS.length - 1) % TABS.length))
112
+ key([']', 'right', 'l'], () => this.changeTab((this.tab + 1) % TABS.length))
113
+ for (let i = 0; i < TABS.length; i++) key([String(i + 1)], () => this.changeTab(i))
114
+ key(['v'], () => {
115
+ const selected = this.rows[this.main.selected]
116
+ if (this.app && TABS[this.tab] === 'Config' && selected?.kind === 'config') {
117
+ if (this.revealed.has(selected.key)) this.revealed.delete(selected.key)
118
+ else this.revealed.add(selected.key)
119
+ this.drawApp()
120
+ }
121
+ })
122
+ key(['s'], () => void this.scale())
123
+ key(['y'], () => void this.copyConfig())
124
+ key(['e'], () => void this.editConfig(false))
125
+ key(['n'], () => void this.editConfig(true))
126
+ key(['d'], () => void this.deleteConfig())
127
+ key(['m'], () => void this.maintenance())
128
+ key(['o'], () => this.openBrowser())
129
+ key(['?'], () => this.help())
130
+ }
131
+
132
+ async start() {
133
+ if (this.context.team) this.mode = 'pipelines'
134
+ this.drawNav()
135
+ if (this.context.app) await this.openApp(this.context.app, this.context.pipeline)
136
+ else if (this.pipeline) await this.openPipeline(this.pipeline)
137
+ else this.drawLanding()
138
+ const warnings = [...this.catalog.warnings, ...this.context.warnings ?? []]
139
+ if (warnings.length) this.setStatus(warnings.join(' | '), 'warning')
140
+ if (this.refresh && !this.closed) this.timer = setInterval(() => {
141
+ if (this.app && !this.modal && !this.busy && !this.closed) void this.loadApp(true)
142
+ }, this.refresh * 1000)
143
+ this.render()
144
+ }
145
+
146
+ setStatus(message, tone = 'info') { this.message = single(message); this.messageTone = tone; this.render() }
147
+
148
+ beginLoading(key, label) {
149
+ if (this.closed) return () => {}
150
+ const operation = {label}
151
+ this.loading.set(key, operation)
152
+ this.syncLoadingAnimation()
153
+ this.render()
154
+ return () => {
155
+ // A superseded request must not clear the indicator for its replacement.
156
+ if (this.loading.get(key) !== operation) return
157
+ this.loading.delete(key)
158
+ this.syncLoadingAnimation()
159
+ this.render()
160
+ }
161
+ }
162
+
163
+ syncLoadingAnimation() {
164
+ if (this.closed || !this.loading.size) {
165
+ clearInterval(this.loadingTimer)
166
+ this.loadingTimer = null
167
+ this.loadingFrame = 0
168
+ } else if (!this.loadingTimer) {
169
+ this.loadingTimer = setInterval(() => {
170
+ this.loadingFrame++
171
+ // Redraw only the status content: don't reset list selection, scroll
172
+ // position, or an input prompt while the user continues navigating.
173
+ this.drawStatus()
174
+ this.screen.render()
175
+ }, SCANNER_INTERVAL)
176
+ this.loadingTimer.unref()
177
+ }
178
+ }
179
+
180
+ drawStatus() {
181
+ const current = [...this.loading.values()].at(-1)
182
+ if (current) {
183
+ this.status.setContent(`${scannerFrame(this.loadingFrame)} ${paint(single(current.label), 'info')}`)
184
+ } else {
185
+ const icon = {error: 'error', warning: 'warning', success: 'success', info: 'overview', muted: 'clock'}[this.messageTone]
186
+ this.status.setContent(badge(icon, this.message ?? '', this.messageTone))
187
+ }
188
+ }
189
+
190
+ render() {
191
+ if (this.closed) return
192
+ const team = this.app || this.pipeline ? this.breadcrumbTeam?.name ?? 'Loading team…' : this.team?.name
193
+ const pipeline = this.pipeline?.name ?? (this.app ? this.data ? this.data.errors.coupling ? 'Pipeline unavailable' : 'No pipeline' : 'Loading pipeline…' : null)
194
+ const scope = [['teams', team], ['pipelines', pipeline], ['apps', this.app?.name]]
195
+ .filter(([, name]) => name).map(([icon, name]) => badge(icon, name, 'fg')).join(` ${paint(icons.chevron, 'muted')} `)
196
+ this.header.setContent(`${paint(`${icons.heroku} HEROKU DASH`, 'accent', true)} ${this.demo ? `${badge('staging', 'DEMO', 'info')} ` : ''}${this.api.readOnly ? badge('lock', 'READ ONLY', 'info') : badge('globe', 'LIVE', 'success')}\n${scope || badge('globe', 'All accessible resources', 'muted')}`)
197
+ const tabs = compact => TABS.map((tab, i) => paint(i === this.tab ? `[${i + 1} ${icons[tabIcons[i]]} ${tab}]` : `${i + 1} ${icons[tabIcons[i]]}${compact ? '' : ` ${tab}`}`, i === this.tab ? 'accent' : 'muted', i === this.tab)).join(' ')
198
+ const fullTabs = tabs(false)
199
+ const compact = blessed.unicode.strWidth(clean(fullTabs)) > this.tabs.width - 4
200
+ this.tabs.setContent(this.app ? compact ? tabs(true) : fullTabs : `${badge('pipelines', 'PIPELINE WORKSPACE')} ${paint('· Enter an app', 'muted')}`)
201
+ this.drawStatus()
202
+ if (this.screen.width < 80 || this.screen.height < 24) { this.small.show(); this.small.setFront() }
203
+ else this.small.hide()
204
+ this.screen.render()
205
+ }
206
+
207
+ setMode(mode) {
208
+ this.mode = mode
209
+ this.filter = ''
210
+ this.drawNav()
211
+ this.nav.focus()
212
+ this.render()
213
+ }
214
+
215
+ drawNav() {
216
+ let items
217
+ if (this.mode === 'teams') items = [{name: 'All teams / personal', id: null}, ...this.catalog.teams]
218
+ else if (this.mode === 'pipelines') items = this.catalog.pipelines.filter(p => !this.team || p.owner?.id === this.team.id)
219
+ else items = this.catalog.apps.filter(a => !this.team || a.team?.id === this.team.id || a.team?.name === this.team.name)
220
+ this.navItems = items.filter(item => item.name.toLowerCase().includes(this.filter.toLowerCase()))
221
+ this.nav.setLabel(` ${icons[this.mode]} ${this.mode.toUpperCase()}${this.filter ? ` ${icons.search} ${single(this.filter)}` : ''} `)
222
+ const tone = {teams: 'info', pipelines: 'accent', apps: 'cyan'}[this.mode]
223
+ this.nav.setItems(this.navItems.length ? this.navItems.map(item => rowLabel({label: item.name, icon: item.id ? this.mode : 'globe', tone})) : [rowLabel({label: 'No matching items', icon: 'search', tone: 'muted'})])
224
+ const id = this.mode === 'pipelines' ? this.pipeline?.id : this.mode === 'apps' ? this.app?.id : this.team?.id
225
+ const index = this.navItems.findIndex(item => item.id === id)
226
+ this.nav.select(Math.max(0, index))
227
+ this.render()
228
+ }
229
+
230
+ async navigate(selected) {
231
+ if (this.mode === 'teams') {
232
+ this.team = selected.id ? selected : null
233
+ this.pipeline = null
234
+ this.clearApp()
235
+ this.mode = 'pipelines'
236
+ this.filter = ''
237
+ this.drawNav()
238
+ this.drawLanding()
239
+ this.setStatus(`Browsing ${this.team?.name ?? 'all teams and personal apps'}. Press a for apps.`)
240
+ } else if (this.mode === 'pipelines') await this.openPipeline(selected)
241
+ else await this.openApp(selected)
242
+ }
243
+
244
+ clearApp() {
245
+ this.generation++
246
+ for (const key of ['app', 'pipeline', 'config']) this.loading.delete(key)
247
+ this.syncLoadingAnimation()
248
+ this.app = null
249
+ this.breadcrumbTeam = null
250
+ this.data = null
251
+ this.config = null
252
+ this.configError = null
253
+ this.revealed.clear()
254
+ this.busy = false
255
+ }
256
+
257
+ drawLanding() {
258
+ this.summary.setContent(`${badge('heroku', 'Your Heroku workspace')}\n\n${paint('Browse teams, pipelines, and apps with t / p / a. Select an item and press Enter.', 'muted')}`)
259
+ this.main.setLabel(` ${icons.heroku} Welcome `)
260
+ this.setRows([{icon: 'pipelines', label: 'Choose a pipeline or app in the sidebar', detail: 'Navigation\n\nTab cycles between sidebar, list, and details.\nj/k or arrow keys move through lists.\n/ filters the sidebar.\n? displays all shortcuts.\n\nUse --app, --pipeline, --remote, or --team to choose a starting context.'}])
261
+ }
262
+
263
+ async openPipeline(pipeline) {
264
+ this.clearApp()
265
+ this.pipeline = pipeline
266
+ const owner = pipeline.owner ?? this.catalog.pipelines.find(item => item.id === pipeline.id)?.owner
267
+ this.breadcrumbTeam = owner?.type === 'team' ? this.catalog.teams.find(item => item.id === owner.id) ?? null : owner ? {name: 'Personal'} : null
268
+ const generation = this.generation
269
+ this.busy = true
270
+ this.summary.setContent(`${badge('pipelines', pipeline.name)}\n\n${badge('refresh', 'Loading pipeline apps…', 'info')}`)
271
+ this.main.setLabel(` ${icons.apps} Pipeline apps `)
272
+ this.setRows([])
273
+ const finishLoading = this.beginLoading('pipeline', `Loading pipeline ${pipeline.name}…`)
274
+ try {
275
+ const [appsResult, hierarchy] = await Promise.all([
276
+ this.api.pipelineApps(pipeline.id).then(apps => ({apps}), error => ({error})),
277
+ resolveHierarchy(this.api, this.catalog, {pipeline}),
278
+ ])
279
+ if (this.closed || generation !== this.generation) return
280
+ this.pipeline = hierarchy.pipeline
281
+ this.breadcrumbTeam = hierarchy.team
282
+ if (appsResult.error) throw appsResult.error
283
+ const apps = sortApps(appsResult.apps)
284
+ this.pipelineApps = apps
285
+ this.summary.setContent(`${badge('pipelines', pipeline.name)}\n\n${STAGES.map(stage => badge(stageStyles[stage].icon, `${stage}: ${apps.filter(a => a.stage === stage).length}`, stageStyles[stage].tone)).join(' ')}`)
286
+ this.setRows(apps.length ? apps.map(app => ({kind: 'app', value: app, ...stageStyles[app.stage], emphasis: app.stage.toUpperCase(),
287
+ label: `${app.stage.toUpperCase().padEnd(13)} ${single(app.name)} · ${app.region?.name ?? '—'}`,
288
+ detail: `${single(app.name)}\n\nStage: ${app.stage}\nTeam: ${single(app.team?.name ?? 'Personal / shared')}\nRegion: ${single(app.region?.name)}\nStack: ${single(app.stack?.name)}\n\nEnter to view resources, add-ons, config, settings, releases, and metrics.`,
289
+ })) : [{icon: 'apps', tone: 'muted', label: 'This pipeline has no apps', detail: 'Press a to browse accessible apps.'}])
290
+ this.main.focus()
291
+ this.message = `Pipeline loaded. Select an app and press Enter.${hierarchy.errors.hierarchy ? ` · ${hierarchy.errors.hierarchy}` : ''}`
292
+ this.messageTone = hierarchy.errors.hierarchy ? 'warning' : 'success'
293
+ } catch (error) {
294
+ if (generation === this.generation) {
295
+ this.setRows([{icon: 'error', tone: 'error', label: 'Unable to load pipeline', detail: errorMessage(error)}])
296
+ this.message = errorMessage(error)
297
+ this.messageTone = 'error'
298
+ }
299
+ } finally {
300
+ if (generation === this.generation) { this.busy = false; this.render() }
301
+ finishLoading()
302
+ }
303
+ }
304
+
305
+ async openApp(app, pipeline = null) {
306
+ this.clearApp()
307
+ this.app = app
308
+ this.pipeline = pipeline
309
+ this.breadcrumbTeam = app.team ?? null
310
+ this.tab = 0
311
+ this.summary.setContent(`${badge('apps', app.name, 'cyan')}\n\n${badge('refresh', 'Loading app data…', 'info')}`)
312
+ this.setRows([])
313
+ this.main.focus()
314
+ await this.loadApp()
315
+ }
316
+
317
+ async loadApp(automatic = false) {
318
+ if (!this.app || this.busy || this.closed) return false
319
+ const generation = this.generation
320
+ const app = this.app
321
+ this.busy = true
322
+ const finishLoading = this.beginLoading('app', `${this.data ? 'Refreshing' : 'Loading'} app ${app.name}…`)
323
+ try {
324
+ const data = await this.api.appData(app.id)
325
+ if (this.closed || generation !== this.generation) return
326
+ const hierarchy = await resolveHierarchy(this.api, this.catalog, {app: data.app, pipeline: data.coupling?.pipeline})
327
+ if (this.closed || generation !== this.generation) return
328
+ this.pipeline = hierarchy.pipeline
329
+ this.breadcrumbTeam = hierarchy.team
330
+ Object.assign(data.errors, hierarchy.errors)
331
+ this.data = data
332
+ this.app = data.app
333
+ this.message = `${automatic ? 'Auto-refreshed' : 'Updated'} ${new Date(data.fetchedAt).toLocaleTimeString()}${Object.keys(data.errors).length ? ' · Some sections unavailable; see Overview.' : ''}`
334
+ this.messageTone = Object.keys(data.errors).length ? 'warning' : 'success'
335
+ this.drawApp()
336
+ return true
337
+ } catch (error) {
338
+ if (generation === this.generation) {
339
+ this.message = `${errorMessage(error)}${this.data ? ' · Showing previous snapshot.' : ''}`
340
+ this.messageTone = 'error'
341
+ if (!this.data) this.setRows([{icon: 'error', tone: 'error', label: 'Unable to load app', detail: errorMessage(error)}])
342
+ }
343
+ return false
344
+ } finally {
345
+ if (generation === this.generation) { this.busy = false; this.render() }
346
+ finishLoading()
347
+ }
348
+ }
349
+
350
+ drawApp() {
351
+ if (!this.data) return
352
+ const {app, formation, errors} = this.data
353
+ this.summary.setContent(`${badge('apps', app.name, 'cyan')} ${app.maintenance ? badge('warning', 'MAINTENANCE', 'warning') : badge('success', 'ACTIVE', 'success')}\n${badge('teams', app.team?.name ?? 'Personal / shared', 'muted')} · ${badge('globe', app.region?.name, 'info')} · ${badge('stack', app.stack?.name, 'muted')}\n${badge('resources', errors.formation ? 'Dynos unavailable' : `${formation.reduce((sum, f) => sum + f.quantity, 0)} configured dynos`, errors.formation ? 'warning' : 'fg')} · ${badge('addons', `${this.data.addons.length} add-ons`, 'fg')} · ${badge('refresh', this.refresh ? `refresh ${this.refresh}s` : 'manual refresh', 'muted')}`)
354
+ this.main.setLabel(` ${icons[tabIcons[this.tab]]} ${TABS[this.tab]} `)
355
+ this.setRows(appRows(TABS[this.tab], this.data, {config: this.config, configError: this.configError, revealed: this.revealed}), true)
356
+ }
357
+
358
+ setRows(rows, preserve = false) {
359
+ const selected = preserve ? this.main.selected : 0
360
+ // Blessed's setItems temporarily selects row zero. Ignore those synthetic
361
+ // selection events until the intended row is restored, so details aren't
362
+ // rendered for a temporary selection or a partially updated list.
363
+ this.updatingRows = true
364
+ try {
365
+ this.rows = rows
366
+ this.main.setItems(rows.map(rowLabel))
367
+ this.main.select(Math.min(selected, Math.max(0, rows.length - 1)))
368
+ } finally {
369
+ this.updatingRows = false
370
+ }
371
+ this.drawDetail()
372
+ }
373
+
374
+ drawDetail() {
375
+ const row = this.rows[this.main.selected]
376
+ this.detail.setLabel(` ${icons[row?.icon] ?? icons.overview} Details `)
377
+ this.detail.setContent(detailContent(row))
378
+ this.detail.setScroll(0)
379
+ this.render()
380
+ }
381
+
382
+ changeTab(index) {
383
+ if (!this.app) return
384
+ this.tab = index
385
+ this.revealed.clear()
386
+ this.main.select(0)
387
+ this.drawApp()
388
+ if (TABS[index] === 'Config' && !this.config) void this.loadConfig()
389
+ }
390
+
391
+ async loadConfig() {
392
+ if (!this.app || this.closed) return
393
+ const generation = this.generation
394
+ const sequence = this.configSequence = (this.configSequence ?? 0) + 1
395
+ const finishLoading = this.beginLoading('config', `Loading config vars for ${this.app.name}…`)
396
+ try {
397
+ const config = await this.api.config(this.app.id)
398
+ if (this.closed || generation !== this.generation || sequence !== this.configSequence) return
399
+ this.config = config
400
+ this.configError = null
401
+ } catch (error) {
402
+ if (generation !== this.generation || sequence !== this.configSequence) return
403
+ this.configError = errorMessage(error)
404
+ } finally {
405
+ finishLoading()
406
+ }
407
+ this.drawApp()
408
+ }
409
+
410
+ async back() {
411
+ if (this.app && this.pipeline) await this.openPipeline(this.pipeline)
412
+ else if (this.app || this.pipeline) {
413
+ this.clearApp()
414
+ this.pipeline = null
415
+ this.drawLanding()
416
+ this.nav.focus()
417
+ } else {
418
+ this.filter = ''
419
+ this.drawNav()
420
+ this.nav.focus()
421
+ }
422
+ this.render()
423
+ }
424
+
425
+ async reload() {
426
+ if (this.busy) return
427
+ if (this.app) {
428
+ this.revealed.clear()
429
+ await this.loadApp()
430
+ if (TABS[this.tab] === 'Config') await this.loadConfig()
431
+ } else if (this.pipeline) await this.openPipeline(this.pipeline)
432
+ else {
433
+ const sequence = ++this.navGeneration
434
+ this.setStatus('Refreshing teams, pipelines, and apps…')
435
+ const finishLoading = this.beginLoading('catalog', 'Refreshing teams, pipelines, and apps…')
436
+ try {
437
+ const catalog = await this.api.catalog()
438
+ if (this.closed || sequence !== this.navGeneration) return
439
+ this.catalog = catalog
440
+ this.drawNav()
441
+ this.setStatus(catalog.warnings.join(' | ') || 'Workspace refreshed.', catalog.warnings.length ? 'warning' : 'success')
442
+ } catch (error) { this.setStatus(errorMessage(error), 'error') }
443
+ finally { finishLoading() }
444
+ }
445
+ }
446
+
447
+ async filterNav() {
448
+ const query = await this.prompt(`Filter ${this.mode}`, 'Filter the sidebar by name. Leave blank to clear.', this.filter)
449
+ if (query === null) return
450
+ this.filter = query
451
+ this.drawNav()
452
+ this.nav.focus()
453
+ this.render()
454
+ }
455
+
456
+ prompt(title, description, initial = '', {secret = false, tone = 'accent', icon = 'keyboard'} = {}) {
457
+ if (this.closed) return Promise.resolve(null)
458
+ return new Promise(resolve => {
459
+ const previous = this.screen.focused
460
+ const modal = blessed.box({parent: this.screen, top: 'center', left: 'center', width: '85%', height: 14, ...frame(), label: ` ${icons[secret ? 'lock' : icon]} ${single(title)} `, style: {...frame().style, border: {fg: palette[tone]}}})
461
+ this.modal = modal
462
+ blessed.box({parent: modal, top: 1, left: 2, right: 2, height: 6, content: clean(description), tags: false, style: {fg: palette.fg, bg: palette.bg}})
463
+ const input = blessed.textbox({parent: modal, top: 8, left: 2, right: 2, height: 3, ...frame(), inputOnFocus: true, censor: secret, value: initial})
464
+ blessed.text({parent: modal, bottom: 0, left: 2, content: `${shortcut('Enter', 'continue')} ${shortcut('Esc', 'cancel')} ${shortcut('Ctrl-U', 'clear')}`, style: {bg: palette.bg}})
465
+ let finished = false
466
+ const finish = value => {
467
+ if (finished) return
468
+ finished = true
469
+ this.cancelPrompt = null
470
+ input.clearValue()
471
+ modal.destroy()
472
+ this.modal = null
473
+ if (!this.closed) { previous?.focus(); this.render() }
474
+ resolve(value)
475
+ }
476
+ this.cancelPrompt = () => finish(null)
477
+ input.on('submit', value => finish(value))
478
+ input.on('cancel', () => finish(null))
479
+ input.key(['C-c'], () => this.close())
480
+ input.key(['C-u'], () => { input.clearValue(); this.render() })
481
+ input.focus()
482
+ this.render()
483
+ })
484
+ }
485
+
486
+ writable() {
487
+ if (this.api.readOnly) { this.setStatus('Read-only mode: remote changes are disabled.', 'warning'); return false }
488
+ if (!this.app || !this.data || this.busy) return false
489
+ return true
490
+ }
491
+
492
+ async confirm(app, description) {
493
+ const value = await this.prompt('Confirm remote change', `${description}\n\nTarget: ${app.name}\nType the exact app name to apply this change.`, '', {icon: 'warning', tone: 'warning'})
494
+ if (value === null) { this.setStatus('Change cancelled.'); return null }
495
+ if (value !== app.name) { this.setStatus('App name did not match. Nothing changed.', 'warning'); return null }
496
+ return value
497
+ }
498
+
499
+ async mutate(action) {
500
+ // Lock navigation while a confirmed write is in flight. Its target and the
501
+ // subsequent refresh must remain the app named in the confirmation.
502
+ const modal = blessed.box({parent: this.screen, top: 'center', left: 'center', width: '70%', height: 5, ...frame(),
503
+ content: `\n ${badge('refresh', 'Applying confirmed change…', 'info')}`})
504
+ this.modal = modal
505
+ this.busy = true
506
+ this.setStatus('Applying change…')
507
+ const finishLoading = this.beginLoading('mutation', 'Applying confirmed change…')
508
+ try {
509
+ await action()
510
+ if (this.closed) return
511
+ this.busy = false
512
+ this.config = null
513
+ this.revealed.clear()
514
+ const refreshed = await this.loadApp()
515
+ if (TABS[this.tab] === 'Config') await this.loadConfig()
516
+ this.setStatus(refreshed ? 'Change applied. App data refreshed.' : `Change applied, but refresh failed. ${this.message}`, refreshed ? 'success' : 'warning')
517
+ } catch (error) { this.setStatus(errorMessage(error), 'error') }
518
+ finally { modal.destroy(); this.modal = null; this.busy = false; finishLoading(); this.render() }
519
+ }
520
+
521
+ async scale() {
522
+ if (TABS[this.tab] !== 'Resources' || !this.writable()) return
523
+ const row = this.rows[this.main.selected]
524
+ if (row?.kind !== 'formation') { this.setStatus(`Select a process type (${icons.resources}) to scale.`); return }
525
+ const app = this.app
526
+ const formation = row.value
527
+ const quantity = await this.prompt('Scale dynos · quantity', `${app.name} / ${formation.type}\nCurrent: ${formation.quantity} × ${formation.size}\nEnter desired quantity (0 stops this process).`, String(formation.quantity))
528
+ if (quantity === null) return
529
+ if (!/^\d+$/.test(quantity) || !Number.isSafeInteger(Number(quantity))) { this.setStatus('Quantity must be a non-negative integer.', 'warning'); return }
530
+ const size = await this.prompt('Scale dynos · size', `${app.name} / ${formation.type}\nEnter a Heroku dyno size (for example Standard-1X).`, formation.size)
531
+ if (size === null) return
532
+ if (!size.trim()) { this.setStatus('Dyno size cannot be blank.', 'warning'); return }
533
+ const confirmation = await this.confirm(app, `Scale ${formation.type}: ${formation.quantity} × ${formation.size} → ${quantity} × ${size.trim()}.\nThis can restart dynos and change billing.`)
534
+ if (confirmation) await this.mutate(() => this.api.scale(app.name, formation.type, Number(quantity), size, confirmation))
535
+ }
536
+
537
+ async copyConfig() {
538
+ if (TABS[this.tab] !== 'Config' || !this.app || !this.config || this.copying) return
539
+ const row = this.rows[this.main.selected]
540
+ if (row?.kind !== 'config' || typeof this.config[row.key] !== 'string') return
541
+ const generation = this.generation
542
+ this.copying = true
543
+ this.setStatus(`Copying ${row.key} to clipboard…`)
544
+ try {
545
+ // Copy the original value, not its masked, truncated, or sanitized display.
546
+ await this.writeClipboard(this.config[row.key])
547
+ if (!this.closed && generation === this.generation) this.setStatus(`Copied ${row.key} to clipboard.`, 'success')
548
+ } catch {
549
+ // Clipboard backend errors may include stdin. Never display that output.
550
+ if (!this.closed && generation === this.generation) this.setStatus('Could not copy value. Check your desktop session and clipboard tools; see README.', 'error')
551
+ } finally {
552
+ this.copying = false
553
+ }
554
+ }
555
+
556
+ async editConfig(isNew) {
557
+ if (TABS[this.tab] !== 'Config' || !this.writable() || !this.config) return
558
+ const app = this.app
559
+ let key = this.rows[this.main.selected]?.key
560
+ if (isNew) key = await this.prompt('New config variable', `${app.name}\nEnter the variable name.`)
561
+ if (!key) return
562
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { this.setStatus('Invalid config variable name.', 'warning'); return }
563
+ const value = await this.prompt('Config variable · value', `${app.name} / ${key}\nEnter a new single-line value (input is masked; blank is an empty string).`, '', {secret: true})
564
+ if (value === null) return
565
+ const confirmation = await this.confirm(app, `${Object.hasOwn(this.config, key) ? 'Replace' : 'Create'} config variable ${key}.\nThis creates a release and restarts the app.`)
566
+ if (confirmation) await this.mutate(() => this.api.setConfig(app.name, key, value, confirmation))
567
+ }
568
+
569
+ async deleteConfig() {
570
+ if (TABS[this.tab] !== 'Config' || !this.writable()) return
571
+ const key = this.rows[this.main.selected]?.key
572
+ if (!key) return
573
+ const app = this.app
574
+ const confirmation = await this.confirm(app, `Delete config variable ${key}.\nThis creates a release and restarts the app.`)
575
+ if (confirmation) await this.mutate(() => this.api.setConfig(app.name, key, null, confirmation))
576
+ }
577
+
578
+ async maintenance() {
579
+ if (TABS[this.tab] !== 'Settings' || !this.writable()) return
580
+ const app = this.app
581
+ const enabled = !app.maintenance
582
+ const confirmation = await this.confirm(app, `${enabled ? 'Enable' : 'Disable'} maintenance mode.\n${enabled ? 'The app will serve the maintenance page.' : 'The app will resume serving requests.'}`)
583
+ if (confirmation) await this.mutate(() => this.api.maintenance(app.name, enabled, confirmation))
584
+ }
585
+
586
+ openBrowser() {
587
+ if (this.demo) { this.setStatus('Browser links are disabled in the offline demo.'); return }
588
+ let url
589
+ if (this.app) {
590
+ const path = ['activity', 'resources', 'resources', 'settings', 'settings', 'activity', 'metrics'][this.tab]
591
+ url = `https://dashboard.heroku.com/apps/${encodeURIComponent(this.app.name)}/${path}`
592
+ } else if (this.pipeline) url = `https://dashboard.heroku.com/pipelines/${encodeURIComponent(this.pipeline.id)}`
593
+ else if (this.team) url = `https://dashboard.heroku.com/teams/${encodeURIComponent(this.team.name)}/apps`
594
+ else url = 'https://dashboard.heroku.com/apps'
595
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open'
596
+ const child = spawn(command, process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url], {stdio: 'ignore'})
597
+ child.on('error', error => this.setStatus(`Unable to open browser: ${error.message}`, 'error'))
598
+ child.on('exit', code => this.setStatus(code === 0 ? 'Opened Heroku dashboard in your browser.' : `Browser exited with code ${code}.`, code === 0 ? 'success' : 'error'))
599
+ }
600
+
601
+ help() {
602
+ const previous = this.screen.focused
603
+ const modal = blessed.box({parent: this.screen, top: 'center', left: 'center', width: '85%', height: '85%', ...frame(), label: ` ${icons.keyboard} Keyboard shortcuts `, padding: {left: 2, top: 1}, scrollable: true, keys: true, vi: true,
604
+ content: 'NAVIGATION\n t / p / a Browse teams / pipelines / apps\n j / k, ↑ / ↓ Move selection or scroll details\n Enter Open selected team, pipeline, or app\n Tab / Shift-Tab Focus next / previous pane\n / Filter sidebar by name\n Esc Return to pipeline / workspace; clear filter\n 1–7 Select app view\n h / l, [ / ] Previous / next app view (also ← / →)\n R Refresh current app, pipeline, or workspace\n o Open current view in web dashboard\n q / Ctrl-C Quit\n\nAPP ACTIONS\n s Scale selected Resources process type\n v Reveal / hide selected config variable\n y Copy selected config value to clipboard\n e / n / d Replace / create / delete config variable\n m Toggle maintenance in Settings\n\nRemote changes require typing the exact target app name.\n--read-only disables every mutation at the API boundary.\nConfig values are masked and fetched only on opening Config.\nEach variable toggles independently; moving rows keeps values visible.\nLeaving the tab or app hides revealed values.\nCopying works while masked and in read-only mode.\n\nMetrics show dyno health and recent deployment outcomes.\nMemory / CPU / latency charts require the web dashboard.\n\nPress Esc, ?, or q to close help.'})
605
+ this.modal = modal
606
+ modal.key(['escape', '?', 'q'], () => { modal.destroy(); this.modal = null; previous?.focus(); this.render() })
607
+ modal.focus()
608
+ this.render()
609
+ }
610
+
611
+ close() {
612
+ if (this.closed) return
613
+ this.closed = true
614
+ clearInterval(this.timer)
615
+ this.loading.clear()
616
+ this.syncLoadingAnimation()
617
+ this.generation++
618
+ this.cancelPrompt?.()
619
+ this.config = null
620
+ if (!this.screen.destroyed) this.screen.destroy()
621
+ }
622
+ }
623
+
624
+ export async function runDashboard(options) {
625
+ const dashboard = new Dashboard(options)
626
+ const finished = new Promise(resolve => dashboard.screen.once('destroy', resolve))
627
+ const stop = () => dashboard.close()
628
+ process.once('SIGTERM', stop)
629
+ process.once('SIGINT', stop)
630
+ try {
631
+ await dashboard.start()
632
+ await finished
633
+ } finally {
634
+ dashboard.close()
635
+ process.removeListener('SIGTERM', stop)
636
+ process.removeListener('SIGINT', stop)
637
+ }
638
+ }
@@ -0,0 +1,34 @@
1
+ import blessed from 'blessed'
2
+ import {clean} from './text.js'
3
+ import {paint} from './theme.js'
4
+
5
+ export function detailContent(row) {
6
+ const text = clean(row?.detail ?? '')
7
+ if (!row?.valueRange) return text
8
+ const {start, end} = row.valueRange
9
+ return `${text.slice(0, start)}${paint(text.slice(start, end), 'cyan')}${text.slice(end)}`
10
+ }
11
+
12
+ export function isValueClick(detail, row, mouse) {
13
+ if (row?.kind !== 'config' || !row.valueRange || mouse.button !== 'left') return false
14
+ const pos = detail.lpos
15
+ if (!pos) return false
16
+ const left = pos.xi + detail.ileft
17
+ const top = pos.yi + detail.itop
18
+ const right = pos.xl - detail.iright - (detail.scrollbar ? 1 : 0)
19
+ const bottom = pos.yl - detail.ibottom
20
+ if (mouse.x < left || mouse.x >= right || mouse.y < top || mouse.y >= bottom) return false
21
+
22
+ // Use Blessed's rendered-line map rather than reimplementing wrapping. It
23
+ // accounts for resizing, tabs, Unicode widths, and the current scroll offset.
24
+ const wrappedLine = mouse.y - top + pos.base
25
+ const sourceLine = detail._clines.rtof[wrappedLine]
26
+ const text = clean(row.detail)
27
+ const first = text.slice(0, row.valueRange.start).split('\n').length - 1
28
+ const last = text.slice(0, row.valueRange.end).split('\n').length - 1
29
+ if (sourceLine === undefined || sourceLine < first || sourceLine > last) return false
30
+
31
+ // Exclude the unused area after short lines, plus borders and the scrollbar.
32
+ const line = clean(detail._clines[wrappedLine])
33
+ return mouse.x - left < blessed.unicode.strWidth(line)
34
+ }