heroku-dash 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -66,6 +66,8 @@ When remotes span multiple pipelines, the browser asks you to choose one. Use `-
66
66
 
67
67
  The left sidebar browses teams, pipelines, or apps. Choosing a team scopes its pipelines and apps; **All teams / personal** clears the scope. Pipelines list apps ordered by stage. Open an app to see its seven views, with a selectable resource list above a scrollable details pane.
68
68
 
69
+ Overview, Resources, Add-ons, Settings, and Metrics use aligned tables with fixed column headers and right-aligned quantities. Columns adapt to the terminal width; narrow layouts hide the Resources age and Add-ons service columns. Full values, including shortened names and hidden columns, remain available in Details.
70
+
69
71
  The heading shows the resource hierarchy: **team › pipeline › app**, including when you open an app or pipeline directly. Personal resources use **Personal**, and apps without a pipeline use **No pipeline**. Opening a resource resolves its parents without changing the sidebar's team filter.
70
72
 
71
73
  Nerd Font icons identify teams, pipelines, apps, process types, databases, and the app views. **Green** indicates healthy/successful states, **amber** indicates pending states or maintenance, **red** indicates failures, and **gray** indicates inactive or unknown states. Config rows use a lock for masked values and an amber eye for revealed values. Status text remains visible alongside icons and colors.
@@ -199,7 +201,7 @@ npm publish
199
201
 
200
202
  `npm publish` runs lint and tests through `prepublishOnly`, then generates the command manifest through `prepack`. The package includes the runtime source, `oclif.manifest.json`, README, and MIT license. Development dependencies are needed to publish, but aren't required when installing the published plugin. Package access is explicitly public.
201
203
 
202
- The current package version is **`heroku-dash@0.2.0`**. For subsequent releases, increment the version before publishing, for example:
204
+ The current package version is **`heroku-dash@0.3.0`**. For subsequent releases, increment the version before publishing, for example:
203
205
 
204
206
  ```sh
205
207
  npm version patch --no-git-tag-version
@@ -101,5 +101,5 @@
101
101
  ]
102
102
  }
103
103
  },
104
- "version": "0.2.0"
104
+ "version": "0.3.0"
105
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heroku-dash",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A keyboard-driven terminal dashboard for Heroku",
5
5
  "author": "Ryan McGeary <ryan@mcgeary.org>",
6
6
  "homepage": "https://github.com/rmm5t/heroku-dash",
@@ -0,0 +1,75 @@
1
+ import blessed from 'blessed'
2
+ import {single} from './text.js'
3
+
4
+ function cell(value, width, right = false) {
5
+ let text = single(value)
6
+ if (blessed.unicode.strWidth(text) > width) {
7
+ let clipped = ''
8
+ let length = 0
9
+ for (const character of text) {
10
+ const size = blessed.unicode.strWidth(character)
11
+ if (length + size > width - 1) break
12
+ clipped += character
13
+ length += size
14
+ }
15
+ text = width > 0 ? `${clipped}…` : ''
16
+ }
17
+ const padding = ' '.repeat(Math.max(0, width - blessed.unicode.strWidth(text)))
18
+ return right ? padding + text : text + padding
19
+ }
20
+
21
+ export const TABLE_COLUMNS = {
22
+ Overview: [
23
+ {label: 'Item / Process', min: 14, weight: 1, max: 24},
24
+ {label: 'Size / Value', min: 10, weight: 3},
25
+ {label: 'Qty', width: 4, right: true},
26
+ {label: 'Status', width: 13},
27
+ ],
28
+ Resources: [
29
+ {label: 'Process / Dyno', min: 14, weight: 1, max: 26},
30
+ {label: 'Size', min: 12, weight: 2},
31
+ {label: 'Qty', width: 4, right: true},
32
+ {label: 'State / Action', width: 14},
33
+ {label: 'Age', width: 9, hideBelow: 72},
34
+ ],
35
+ 'Add-ons': [
36
+ {label: 'Add-on', min: 16, weight: 2},
37
+ {label: 'Service', min: 14, weight: 1, hideBelow: 78},
38
+ {label: 'Plan', min: 12, weight: 1},
39
+ {label: 'State', width: 19},
40
+ ],
41
+ Settings: [
42
+ {label: 'Setting / Type', min: 14, weight: 1, max: 24},
43
+ {label: 'Value', min: 16, weight: 4},
44
+ {label: 'Status / Action', width: 16},
45
+ ],
46
+ Metrics: [
47
+ {label: 'Metric / Process', compact: 'Metric', min: 14, weight: 3},
48
+ {label: 'Target / Total', compact: 'Total/Goal', min: 11, weight: 1, max: 16, right: true},
49
+ {label: 'Current', min: 11, weight: 1, max: 16, right: true},
50
+ {label: 'Status', width: 13},
51
+ ],
52
+ }
53
+
54
+ export function tableColumns(values, width, layout = 'Overview') {
55
+ width = Math.max(0, Math.floor(width))
56
+ const columns = TABLE_COLUMNS[layout].map((column, index) => ({...column, index}))
57
+ .filter(column => width >= (column.hideBelow ?? 0))
58
+ const widths = columns.map(column => column.width ?? column.min)
59
+ let extra = width - widths.reduce((sum, size) => sum + size, 0) - (columns.length - 1) * 2
60
+ const value = column => values ? values[column.index] : width < 72 ? column.compact ?? column.label : column.label
61
+ if (extra < 0) return cell(columns.map(column => single(value(column))).join(' '), width)
62
+ while (extra > 0) {
63
+ const flexible = columns.map((column, index) => ({...column, position: index}))
64
+ .filter(column => column.weight && widths[column.position] < (column.max ?? Infinity))
65
+ if (!flexible.length) break
66
+ const weight = flexible.reduce((sum, column) => sum + column.weight, 0)
67
+ const remaining = extra
68
+ for (const column of flexible) {
69
+ const share = Math.min(extra, Math.max(1, Math.floor(remaining * column.weight / weight)), (column.max ?? Infinity) - widths[column.position])
70
+ widths[column.position] += share
71
+ extra -= share
72
+ }
73
+ }
74
+ return columns.map((column, index) => cell(value(column), widths[index], column.right)).join(' ')
75
+ }
@@ -5,6 +5,7 @@ import {errorMessage} from '../api.js'
5
5
  import {resolveHierarchy} from '../hierarchy.js'
6
6
  import {appRows, clean, single, sortApps, STAGES, TABS} from './views.js'
7
7
  import {detailContent, isValueClick} from './details.js'
8
+ import {tableColumns} from './columns.js'
8
9
  import {badge, icons, paint, palette, rowLabel, SCANNER_INTERVAL, scannerFrame, shortcut, stageStyles, tabIcons} from './theme.js'
9
10
 
10
11
  const SIDEBAR_WIDTH = '22%'
@@ -52,6 +53,13 @@ export class Dashboard {
52
53
  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}})
53
54
  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,
54
55
  scrollbar: {ch: '│', style: {bg: palette.border}}, style: {...frame().style, selected: {bg: palette.selected, fg: 'white'}, item: {fg: palette.fg}}})
56
+ this.columnHeader = blessed.box({parent: this.main, top: -1, left: 0, right: 1, height: 1, fixed: true, hidden: true, tags: false, autoFocus: false,
57
+ style: {fg: palette.muted, bg: palette.panel, bold: true}})
58
+ this.columnHeader.on('click', () => {
59
+ if (this.modal || this.closed) return
60
+ this.main.focus()
61
+ this.render()
62
+ })
55
63
  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,
56
64
  scrollbar: {ch: '│', style: {bg: palette.border}}})
57
65
  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}})
@@ -192,6 +200,7 @@ export class Dashboard {
192
200
 
193
201
  render() {
194
202
  if (this.closed) return
203
+ this.layoutColumns()
195
204
  const team = this.app || this.pipeline ? this.breadcrumbTeam?.name ?? 'Loading team…' : this.team?.name
196
205
  const pipeline = this.pipeline?.name ?? (this.app ? this.data ? this.data.errors.coupling ? 'Pipeline unavailable' : 'No pipeline' : 'Loading pipeline…' : null)
197
206
  const scope = [['teams', team], ['pipelines', pipeline], ['apps', this.app?.name]]
@@ -414,7 +423,7 @@ export class Dashboard {
414
423
  this.updatingRows = true
415
424
  try {
416
425
  this.rows = rows
417
- this.main.setItems(rows.map(rowLabel))
426
+ this.main.setItems(rows.map(row => rowLabel(row, this.main.width - this.main.iwidth - 1)))
418
427
  this.main.select(Math.min(selected, Math.max(0, rows.length - 1)))
419
428
  } finally {
420
429
  this.updatingRows = false
@@ -422,6 +431,24 @@ export class Dashboard {
422
431
  this.drawDetail()
423
432
  }
424
433
 
434
+ layoutColumns() {
435
+ const columnar = this.rows.find(row => row.columns)
436
+ this.main.padding.top = columnar ? 1 : 0
437
+ // Keep the border label above the new header padding (Blessed normally
438
+ // repositions labels only after scrolling or resizing).
439
+ if (this.main._label) this.main._label.rtop = this.main.childBase - this.main.itop
440
+ if (!columnar) { this.columnHeader.hide(); return }
441
+ const width = this.main.width - this.main.iwidth - 1
442
+ this.columnHeader.setContent(` ${tableColumns(null, width - 4, columnar.columnLayout)}`)
443
+ this.columnHeader.show()
444
+ this.columnHeader.setFront()
445
+ // Reflow on resize without rebuilding the list or changing its selection.
446
+ for (const [index, row] of this.rows.entries()) {
447
+ const label = rowLabel(row, width)
448
+ if (this.main.ritems[index] !== label) this.main.setItem(index, label)
449
+ }
450
+ }
451
+
425
452
  drawDetail() {
426
453
  const row = this.rows[this.main.selected]
427
454
  this.detail.setLabel(` ${icons[row?.icon] ?? icons.overview} Details `)
package/src/ui/theme.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import blessed from 'blessed'
2
2
  import {clean, single} from './text.js'
3
+ import {tableColumns} from './columns.js'
3
4
 
4
5
  export const palette = {
5
6
  bg: '#161b22', panel: '#1c212b', fg: '#c9d1d9', muted: '#8b949e',
@@ -68,8 +69,8 @@ export function stateStyle(state) {
68
69
  return {icon: 'stopped', tone: 'muted'}
69
70
  }
70
71
 
71
- export function rowLabel(row) {
72
- const text = single(row.label)
72
+ export function rowLabel(row, width = 90) {
73
+ const text = row.columns ? tableColumns(row.columns, Math.max(0, width - 4), row.columnLayout) : single(row.label)
73
74
  const emphasis = row.emphasis ? single(row.emphasis) : ''
74
75
  let offset = emphasis ? text.indexOf(emphasis) : -1
75
76
  // A state such as "up" must highlight the state column, not "backup.1".
package/src/ui/views.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import {clean, single} from './text.js'
2
2
  import {stateStyle} from './theme.js'
3
3
  import {addonDetails, dynoDetails} from './resource-details.js'
4
+ import {TABLE_COLUMNS} from './columns.js'
4
5
 
5
6
  export {clean, single} from './text.js'
6
7
  export function age(date, now = Date.now()) {
@@ -34,19 +35,30 @@ export function operationalMetrics(data) {
34
35
  export function appRows(tab, data, {config, configError, revealed = new Set(), resources} = {}) {
35
36
  const {app, formation, dynos, addons, attachments, releases, domains, buildpacks, errors} = data
36
37
  const rows = []
38
+ const noticeColumns = (label, status) => TABLE_COLUMNS[tab]?.map((column, index) => index === 0 ? label : /^(State|Status)/.test(column.label) ? status : '—')
37
39
  const error = section => {
38
- if (errors[section]) rows.push(row(`${section} unavailable`, errors[section], {icon: 'error', tone: 'error', emphasis: 'unavailable'}))
40
+ if (errors[section]) rows.push(row(`${section} unavailable`, errors[section], {
41
+ icon: 'error', tone: 'error', emphasis: 'Unavailable',
42
+ columns: noticeColumns(section, 'Unavailable'),
43
+ }))
39
44
  }
40
45
  if (tab === 'Overview') {
41
46
  rows.push(row(`${app.name} · ${app.maintenance ? 'MAINTENANCE' : 'ACTIVE'}`, lines([
42
47
  ['App', app.name], ['Team', app.team?.name ?? 'Personal / shared'], ['Region', app.region?.name],
43
48
  ['Stack', app.stack?.name], ['Generation', app.generation?.name], ['Web URL', app.web_url],
44
49
  ['Git URL', app.git_url], ['Created', app.created_at], ['Updated', app.updated_at], ['ID', app.id],
45
- ]), {...stateStyle(app.maintenance ? 'maintenance' : 'active'), emphasis: app.maintenance ? 'MAINTENANCE' : 'ACTIVE'}))
50
+ ]), {...stateStyle(app.maintenance ? 'maintenance' : 'active'), emphasis: app.maintenance ? 'MAINTENANCE' : 'ACTIVE',
51
+ columns: ['App', app.name, '—', app.maintenance ? 'MAINTENANCE' : 'ACTIVE']}))
46
52
  rows.push(row(`${formation.reduce((n, f) => n + f.quantity, 0)} configured dynos · ${addons.length} add-ons`,
47
- 'Use Resources to inspect and scale process types.\nUse Add-ons to inspect plans and attachments.\nConfig values are masked until explicitly revealed.', {icon: 'resources'}))
48
- for (const f of formation) rows.push(row(`${f.type} · ${f.quantity} × ${f.size}`, f.command, {icon: 'resources', tone: f.quantity ? 'cyan' : 'muted'}))
49
- if (releases[0]) rows.push(row(`Latest release: v${releases[0].version} · ${releases[0].status}`, releases[0].description, {...stateStyle(releases[0].status), emphasis: releases[0].status}))
53
+ 'Use Resources to inspect and scale process types.\nUse Add-ons to inspect plans and attachments.\nConfig values are masked until explicitly revealed.', {
54
+ icon: 'resources', columns: ['Total dynos', errors.addons ? 'Add-ons unavailable' : `${addons.length} add-ons`, errors.formation ? '—' : formation.reduce((n, f) => n + f.quantity, 0), errors.formation ? 'Unavailable' : 'Configured'],
55
+ }))
56
+ for (const f of formation) rows.push(row(`${f.type} · ${f.quantity} × ${f.size}`, lines([
57
+ ['Process', f.type], ['Quantity', f.quantity], ['Size', f.size], ['Command', f.command],
58
+ ]), {icon: 'resources', tone: f.quantity ? 'cyan' : 'muted', columns: [f.type, f.size, f.quantity, f.quantity ? 'Configured' : 'Scaled to 0']}))
59
+ if (releases[0]) rows.push(row(`Latest release: v${releases[0].version} · ${releases[0].status}`, releases[0].description, {
60
+ ...stateStyle(releases[0].status), emphasis: releases[0].status, columns: ['Latest release', `v${releases[0].version}`, '—', releases[0].status],
61
+ }))
50
62
  for (const section of Object.keys(errors)) error(section)
51
63
  }
52
64
  if (tab === 'Resources') {
@@ -54,24 +66,30 @@ export function appRows(tab, data, {config, configError, revealed = new Set(), r
54
66
  for (const f of formation) rows.push(row(`${f.type.padEnd(16)} ${String(f.quantity).padStart(3)} × ${f.size} [s] scale`, lines([
55
67
  ['Process', f.type], ['Quantity', f.quantity], ['Size', f.size], ['Command', f.command],
56
68
  ['Updated', f.updated_at], ['Action', 'Press s to change quantity / size. Scaling may change billing.'],
57
- ]) + dynoDetails(resources, 'formations', f.type), {kind: 'formation', value: f, icon: 'resources', tone: f.quantity ? 'cyan' : 'muted', emphasis: f.type}))
69
+ ]) + dynoDetails(resources, 'formations', f.type), {kind: 'formation', value: f, icon: 'resources', tone: f.quantity ? 'cyan' : 'muted', emphasis: f.type,
70
+ columns: [f.type, f.size, f.quantity, '[s] scale', '—']}))
58
71
  for (const d of dynos) rows.push(row(` ${d.name.padEnd(20)} ${d.state.padEnd(10)} ${d.size} · ${age(d.created_at)}`, lines([
59
72
  ['Dyno', d.name], ['State', d.state], ['Size', d.size], ['Release', d.release ? `v${d.release.version}` : '—'],
60
- ['Created', d.created_at], ['Command', d.command],
61
- ]) + dynoDetails(resources, 'instances', d.name), {...stateStyle(d.state), emphasis: d.state}))
73
+ ['Age', age(d.created_at)], ['Created', d.created_at], ['Command', d.command],
74
+ ]) + dynoDetails(resources, 'instances', d.name), {...stateStyle(d.state), emphasis: d.state,
75
+ columns: [d.name, d.size, '—', d.state, age(d.created_at)]}))
62
76
  }
63
77
  if (tab === 'Add-ons') {
64
78
  error('addons'); error('attachments')
65
79
  const all = new Map(addons.map(addon => [addon.id, addon]))
66
80
  for (const attachment of attachments) if (!all.has(attachment.addon.id)) all.set(attachment.addon.id, attachment.addon)
67
81
  for (const addon of all.values()) {
68
- const state = resources?.data?.addons?.byId?.[addon.id]?.state ?? addon.state
82
+ const enriched = resources?.data?.addons?.byId?.[addon.id]
83
+ const state = enriched?.state ?? addon.state
84
+ const service = addon.addon_service?.human_name ?? enriched?.service ?? addon.addon_service?.name ?? '—'
85
+ const plan = enriched?.plan ?? addon.plan?.human_name ?? addon.plan?.name?.replace(/^[^:]+:/, '')
69
86
  rows.push(row(`${addon.name} · ${addon.plan?.name ?? 'shared attachment'} · ${state ?? '—'}`, lines([
70
- ['Name', addon.name], ['Service', addon.addon_service?.name], ['Plan', addon.plan?.name],
87
+ ['Name', addon.name], ['Service', addon.addon_service?.name ?? enriched?.service], ['Plan', addon.plan?.name ?? enriched?.plan],
71
88
  ['State', state], ['Billing app', addon.app?.name], ['Created', addon.created_at],
72
89
  ['Attachments', attachments.filter(a => a.addon.id === addon.id).map(a => a.name).join(', ') || '—'],
73
90
  ['Config keys', addon.config_vars?.join(', ')], ['ID', addon.id],
74
- ]) + addonDetails(resources, addon.id), {...stateStyle(state), icon: /postgres|redis|mysql|mongo|key-value/i.test(addon.addon_service?.name ?? addon.plan?.name ?? '') ? 'database' : 'addons', emphasis: state}))
91
+ ]) + addonDetails(resources, addon.id), {...stateStyle(state), icon: /postgres|redis|mysql|mongo|key-value/i.test(addon.addon_service?.name ?? addon.plan?.name ?? '') ? 'database' : 'addons', emphasis: state,
92
+ columns: [addon.name, service, plan, state]}))
75
93
  }
76
94
  }
77
95
  if (tab === 'Config') {
@@ -89,17 +107,20 @@ export function appRows(tab, data, {config, configError, revealed = new Set(), r
89
107
  }
90
108
  }
91
109
  if (tab === 'Settings') {
92
- rows.push(row(`Maintenance mode: ${app.maintenance ? 'ON' : 'OFF'} [m] toggle`, 'Press m to toggle maintenance mode. This changes how the app serves requests.', {icon: 'settings', tone: app.maintenance ? 'warning' : 'success', emphasis: app.maintenance ? 'ON' : 'OFF'}))
110
+ rows.push(row(`Maintenance mode: ${app.maintenance ? 'ON' : 'OFF'} [m] toggle`, 'Press m to toggle maintenance mode. This changes how the app serves requests.', {icon: 'settings', tone: app.maintenance ? 'warning' : 'success', emphasis: app.maintenance ? 'ON' : 'OFF',
111
+ columns: ['Maintenance', app.maintenance ? 'ON' : 'OFF', '[m] toggle']}))
93
112
  rows.push(row(`Region: ${app.region?.name} · Stack: ${app.stack?.name}`, lines([
94
113
  ['Region', app.region?.name], ['Stack', app.stack?.name], ['Build stack', app.build_stack?.name],
95
114
  ['Space', app.space?.name ?? 'Common Runtime'], ['ACM', app.acm ? 'Enabled' : 'Disabled'],
96
- ]), {icon: 'globe', tone: 'info'}))
115
+ ]), {icon: 'globe', tone: 'info', columns: ['Region / stack', `${app.region?.name ?? '—'} / ${app.stack?.name ?? '—'}`, '—']}))
97
116
  error('domains'); error('buildpacks')
98
117
  for (const domain of domains) rows.push(row(`Domain ${domain.hostname}`, lines([
99
118
  ['Hostname', domain.hostname], ['Kind', domain.kind], ['CNAME', domain.cname],
100
119
  ['Status', domain.status], ['ACM status', domain.acm_status], ['ACM reason', domain.acm_status_reason],
101
- ]), {icon: 'globe', tone: 'info'}))
102
- for (const item of buildpacks) rows.push(row(`Buildpack ${item.ordinal}. ${item.buildpack?.name ?? item.buildpack?.url}`, item.buildpack?.url, {icon: 'code', tone: 'accent'}))
120
+ ]), {icon: 'globe', tone: 'info', columns: ['Domain', domain.hostname, domain.status]}))
121
+ for (const item of buildpacks) rows.push(row(`Buildpack ${item.ordinal}. ${item.buildpack?.name ?? item.buildpack?.url}`, lines([
122
+ ['Buildpack', item.buildpack?.name], ['Order', item.ordinal], ['URL', item.buildpack?.url],
123
+ ]), {icon: 'code', tone: 'accent', columns: [`Buildpack ${item.ordinal}`, item.buildpack?.name ?? item.buildpack?.url, '—']}))
103
124
  }
104
125
  if (tab === 'Releases') {
105
126
  error('releases')
@@ -115,17 +136,27 @@ export function appRows(tab, data, {config, configError, revealed = new Set(), r
115
136
  rows.push(row(`Dyno health ${available ? `${m.healthy} / ${m.desired} configured dynos up or idle` : 'unavailable'}`, available
116
137
  ? `${m.healthy} up / idle ${m.starting} starting ${m.crashed} crashed\n${m.total} total dynos, including one-off processes.\n\nBased on current dyno states, not historical availability.\nEco dynos in the idle state are counted as healthy.\nDuring a deploy, overlapping dynos can exceed the desired count.`
117
138
  : 'Dyno health cannot be computed because formation or dyno data is unavailable.',
118
- {icon: 'metrics', tone: !available ? 'muted' : m.crashed ? 'error' : m.healthy < m.desired ? 'warning' : 'success', emphasis: 'Dyno health'}))
139
+ {icon: 'metrics', tone: !available ? 'muted' : m.crashed ? 'error' : m.healthy < m.desired ? 'warning' : 'success', emphasis: 'Dyno health',
140
+ columns: ['Dyno health', available ? m.desired : '—', available ? m.healthy : '—', available ? 'Up / idle' : 'Unavailable']}))
119
141
  for (const f of formation) {
120
142
  const members = dynos.filter(d => d.type === f.type)
121
143
  const running = members.filter(d => ['up', 'idle'].includes(d.state)).length
122
- rows.push(row(`${f.type} · desired ${f.quantity} · running ${running}`, members.map(d => `${d.name.padEnd(22)} ${d.state.padEnd(10)} age ${age(d.created_at)}`).join('\n') || 'No dynos currently running.',
123
- {icon: 'resources', tone: errors.dynos ? 'muted' : members.some(d => d.state === 'crashed') ? 'error' : running < f.quantity ? 'warning' : f.quantity ? 'success' : 'muted', emphasis: `running ${running}`}))
144
+ const crashed = members.some(d => d.state === 'crashed')
145
+ const status = errors.dynos ? 'Unavailable' : crashed ? 'Crashed' : running < f.quantity ? 'Below target' : !f.quantity && !running ? 'Scaled to 0' : 'Up / idle'
146
+ const detail = lines([['Process', f.type], ['Desired', f.quantity], ['Running', errors.dynos ? 'Unavailable' : running]])
147
+ + '\n\n' + (errors.dynos ? `Dyno data unavailable: ${errors.dynos}` : members.map(d => `${d.name.padEnd(22)} ${d.state.padEnd(10)} age ${age(d.created_at)}`).join('\n') || 'No dynos currently running.')
148
+ rows.push(row(`${f.type} · desired ${f.quantity} · running ${running}`, detail,
149
+ {icon: 'resources', tone: errors.dynos ? 'muted' : crashed ? 'error' : running < f.quantity ? 'warning' : f.quantity ? 'success' : 'muted', emphasis: status,
150
+ columns: [f.type, f.quantity, errors.dynos ? '—' : running, status]}))
124
151
  }
125
152
  if (!errors.releases) rows.push(row(`Deployments ${releases.filter(r => r.status === 'succeeded').length} succeeded / ${releases.length} recent releases`,
126
- `Latest ${releases.length} releases (up to 20).\n${releases.filter(r => r.status === 'failed').length} failed releases.\nLatest release: ${releases[0] ? `v${releases[0].version}, ${age(releases[0].created_at)} ago` : 'none'}.`, {icon: 'releases', tone: releases.some(r => r.status === 'failed') ? 'warning' : 'info'}))
127
- rows.push(row('Telemetry availability', 'CPU, memory, throughput, and latency charts are not exposed by the public Heroku Platform API.\n\nThis view shows live operational snapshots, not APM time-series metrics.\nPress o to open the app’s metrics page in the web dashboard.', {icon: 'overview', tone: 'muted'}))
128
- rows.push(row(`Snapshot: ${new Date(data.fetchedAt).toLocaleTimeString()}`, 'Press R to refresh. Automatic refresh follows --refresh (default: 30 seconds).', {icon: 'clock', tone: 'muted'}))
153
+ `Latest ${releases.length} releases (up to 20).\n${releases.filter(r => r.status === 'failed').length} failed releases.\nLatest release: ${releases[0] ? `v${releases[0].version}, ${age(releases[0].created_at)} ago` : 'none'}.`, {icon: 'releases', tone: releases.some(r => r.status === 'failed') ? 'warning' : 'info',
154
+ columns: ['Releases OK', releases.length, releases.filter(r => r.status === 'succeeded').length, !releases.length ? 'No releases' : releases.some(r => r.status === 'failed') ? `${releases.filter(r => r.status === 'failed').length} failed` : 'Succeeded']}))
155
+ rows.push(row('Telemetry availability', 'CPU, memory, throughput, and latency charts are not exposed by the public Heroku Platform API.\n\nThis view shows live operational snapshots, not APM time-series metrics.\nPress o to open the app’s metrics page in the web dashboard.', {icon: 'overview', tone: 'muted', columns: ['Telemetry', '—', '—', 'Web only']}))
156
+ rows.push(row(`Snapshot: ${new Date(data.fetchedAt).toLocaleTimeString()}`, `Snapshot: ${data.fetchedAt}\n\nPress R to refresh. Automatic refresh follows --refresh (default: 30 seconds).`, {icon: 'clock', tone: 'muted',
157
+ columns: ['Snapshot', '—', new Date(data.fetchedAt).toLocaleTimeString(), 'Fetched']}))
129
158
  }
130
- return rows.length ? rows : [row('No items', `No ${tab.toLowerCase()} to display.`, {icon: 'search', tone: 'muted'})]
159
+ const result = rows.length ? rows : [row('No items', `No ${tab.toLowerCase()} to display.`, {icon: 'search', tone: 'muted', columns: noticeColumns('No items', 'Empty')})]
160
+ for (const item of result) if (item.columns) item.columnLayout = tab
161
+ return result
131
162
  }