heroku-dash 0.1.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.
@@ -77,8 +79,8 @@ While data is loading, an OpenCode-inspired purple scanner (`■` / `⬝`) sweep
77
79
  | View | What you can do |
78
80
  | --- | --- |
79
81
  | **1 Overview** | Inspect app identity, team, region, stack, URLs, formation, and latest release |
80
- | **2 Resources** | Inspect process commands, desired quantity, dyno size, individual dyno states and ages; scale quantity and size |
81
- | **3 Add-ons** | Inspect services, plans, provisioning state, billing app, and local/shared attachments |
82
+ | **2 Resources** | Inspect process commands, desired quantity, dyno size, individual dyno states and ages; scale quantity and size; optionally view costs and CPU/RAM allocations |
83
+ | **3 Add-ons** | Inspect services, plans, provisioning state, billing app, and local/shared attachments; optionally view billed costs and capacity limits |
82
84
  | **4 Config** | View config keys; reveal or copy a selected value; create, replace, or delete variables |
83
85
  | **5 Settings** | Inspect domains, ACM state, buildpacks, region, stack, and space; toggle maintenance mode |
84
86
  | **6 Releases** | Inspect the latest 20 releases, including status, author, description, and timestamp |
@@ -128,6 +130,25 @@ Press **`y`** to copy the selected variable's value without revealing it. Reveal
128
130
 
129
131
  Clipboard access uses the system clipboard on the machine running `dash` (macOS, Windows, or a Linux desktop). On Wayland, install `wl-clipboard`; X11 uses `xsel`, with a bundled fallback. A desktop clipboard must be accessible to the terminal; headless/SSH sessions without one show a copy error instead.
130
132
 
133
+ ## Optional costs and limits with heroku-resources
134
+
135
+ Install [heroku-resources](https://github.com/rmm5t/heroku-resources) alongside dash, then restart the dashboard:
136
+
137
+ ```sh
138
+ heroku plugins:install heroku-resources
139
+ heroku dash
140
+ ```
141
+
142
+ Dash detects the installed plugin automatically, including a locally linked checkout. It reuses the pricing, dyno specification, add-on limit, and pending-plan-change helpers from **heroku-resources 0.5.1**. The integration is optional: if the plugin is absent or its helpers are incompatible, the details pane explains why enrichment is unavailable.
143
+
144
+ - **Resources details:** RAM per dyno, total allocated RAM for a process, CPU allocation, estimated monthly process cost, and the per-dyno size rate. Scaled-to-zero processes are included. One-off dynos show a full-month size rate, not a claim about their actual charge.
145
+ - **Add-ons details:** billed price and billing app, active/billed plans, provider status, connection limit, RAM allocation, and disk capacity where supported. Postgres and Key-Value Store limits come from the companion plugin's service lookups; other services may have a price but no available limits.
146
+ - **Billing semantics:** prices are USD estimates, not invoices. Eco uses the shared account-level $5/month plan. Contract and metered prices are identified explicitly. Shared attachments identify their billing app. During plan changes, limits describe the active allocation while price reflects the billed plan.
147
+
148
+ Enrichment loads when you open **Resources** or **Add-ons**, using the current Heroku account and GET requests only, including in `--read-only` mode. It works for apps outside pipelines too. Direct helper reuse avoids fetching an entire pipeline stage via `heroku resources --json`.
149
+
150
+ Switching between views reuses the current app's fetched details. App refreshes refresh enrichment for the active resource view; dyno-size metadata is cached for five minutes. Press **`R`** to refresh immediately, including the size cache. Individual unavailable add-ons or limits don't block the rest of the dashboard. The offline demo does not perform these lookups.
151
+
131
152
  ## Metrics and current scope
132
153
 
133
154
  The Metrics view uses real snapshots from the public Heroku Platform API. It counts `up` and `idle` formation dynos as healthy, excludes one-off processes from desired-formation health, and shows recent release outcomes. Dyno age is time since creation, not a historical uptime guarantee. During deploys, overlapping dynos can exceed the desired count.
@@ -180,7 +201,7 @@ npm publish
180
201
 
181
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.
182
203
 
183
- The initial release is **`heroku-dash@0.1.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:
184
205
 
185
206
  ```sh
186
207
  npm version patch --no-git-tag-version
@@ -198,16 +219,27 @@ npm run test:live -- ~/work/hermod ~/work/heimdall
198
219
 
199
220
  The live-check transport **rejects every method except GET**. It verifies repository-to-pipeline resolution and renders all seven app views, printing counts rather than config values. It reads every app in the detected pipelines.
200
221
 
222
+ To verify cost/limit enrichment with the installed `heroku-resources` plugin against specific apps:
223
+
224
+ ```sh
225
+ npm run test:resources -- hermod-staging heimdall-staging
226
+ ```
227
+
228
+ This check also enforces GET-only access, including calls to Heroku's Postgres and Key-Value Store service APIs. It prints resource counts, without fetching config vars.
229
+
201
230
  After linking the plugin, macOS/Linux users with Python 3 can exercise the actual CLI in a pseudo-terminal:
202
231
 
203
232
  ```sh
204
233
  python3 scripts/terminal-check.py
205
234
  python3 scripts/terminal-check.py --repo ~/work/hermod
206
235
  python3 scripts/terminal-check.py --repo ~/work/heimdall
236
+ python3 scripts/terminal-check.py --repo ~/work/heimdall --resources
207
237
  ```
208
238
 
209
239
  Without `--repo`, this uses the offline demo. Live terminal checks always pass `--read-only --refresh 0`; mutation behavior is tested only with mocked APIs.
210
240
 
241
+ `--resources` also checks Resources/Add-ons cost details in the actual terminal UI; use a pipeline whose first app has dynos and add-ons, with `heroku-resources` installed.
242
+
211
243
  ### Layout
212
244
 
213
245
  ```text
@@ -215,8 +247,10 @@ src/commands/dash.js Command flags, authentication, startup
215
247
  src/project.js Git context and pipeline resolution
216
248
  src/hierarchy.js Team and pipeline parents for resource breadcrumbs
217
249
  src/api.js Platform API reads, pagination, guarded writes
250
+ src/resources.js Optional adapter to the installed heroku-resources plugin
218
251
  src/ui/dashboard.js Terminal navigation, prompts, refresh, lifecycle
219
252
  src/ui/views.js View models, config masking, operational metrics
253
+ src/ui/resource-details.js Cost and capacity details and billing annotations
220
254
  src/ui/details.js Highlighted values and scroll-aware click targets
221
255
  src/ui/theme.js Nerd Font icons, semantic colors, styled labels
222
256
  src/ui/text.js Terminal-safe text sanitization
@@ -101,5 +101,5 @@
101
101
  ]
102
102
  }
103
103
  },
104
- "version": "0.1.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.1.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",
@@ -19,6 +19,7 @@
19
19
  "test": "node --test",
20
20
  "check": "npm run lint && npm test && npm run build",
21
21
  "test:live": "node scripts/live-check.js",
22
+ "test:resources": "node scripts/resources-check.js",
22
23
  "prepack": "npm run build",
23
24
  "prepublishOnly": "npm run lint && npm test"
24
25
  },
@@ -2,6 +2,7 @@ import {Command} from '@heroku-cli/command'
2
2
  import {Flags} from '@oclif/core'
3
3
  import {HerokuAPI} from '../api.js'
4
4
  import {inspectProject, resolveContext} from '../project.js'
5
+ import {loadResourcesIntegration} from '../resources.js'
5
6
 
6
7
  export default class Dash extends Command {
7
8
  static promptFlagActive = false
@@ -42,6 +43,7 @@ export default class Dash extends Command {
42
43
  context.team = catalog.teams.find(t => t.id === flags.team || t.name === flags.team)
43
44
  if (!context.team) this.error(`Team not found: ${flags.team}`)
44
45
  }
45
- await runDashboard({api, catalog, context, refresh: flags.refresh})
46
+ const resources = await loadResourcesIntegration(this.config, api)
47
+ await runDashboard({api, catalog, context, resources, refresh: flags.refresh})
46
48
  }
47
49
  }
package/src/demo.js CHANGED
@@ -27,5 +27,5 @@ export function createDemo() {
27
27
  api.pipelineApps = async () => structuredClone(apps)
28
28
  api.appData = async id => data(apps.find(a => a.id === id || a.name === id))
29
29
  api.config = async () => ({NODE_ENV: 'production', EXAMPLE_SECRET: 'demo-only-value', WEB_CONCURRENCY: '2'})
30
- return {api, catalog, context: {pipeline, reason: 'Offline demo'}}
30
+ return {api, catalog, resources: {available: false, message: 'Cost and limit lookup is disabled in the offline demo.'}, context: {pipeline, reason: 'Offline demo'}}
31
31
  }
@@ -0,0 +1,92 @@
1
+ import {join} from 'node:path'
2
+ import {pathToFileURL} from 'node:url'
3
+ import {errorMessage} from './api.js'
4
+
5
+ const SDK_HEADERS = {Accept: 'application/vnd.heroku+json; version=3.sdk', 'Accept-Expansion': 'addon_service,plan'}
6
+ const SIZE_CACHE_MS = 5 * 60_000
7
+
8
+ export async function loadResourcesIntegration(config, api, importModule = url => import(url)) {
9
+ const plugin = config.plugins?.get('heroku-resources')
10
+ if (!plugin) return {available: false, message: 'Install heroku-resources and restart dash to show costs and limits.'}
11
+ try {
12
+ const [specs, limits, report] = await Promise.all(['specs', 'addon-limits', 'report'].map(name =>
13
+ importModule(pathToFileURL(join(plugin.root, 'src', `${name}.js`)).href),
14
+ ))
15
+ for (const name of ['memoryForSize', 'cpuForSize', 'monthlyCostForSize']) {
16
+ if (typeof specs[name] !== 'function') throw new Error('Incompatible dyno helpers')
17
+ }
18
+ if (typeof limits.fetchAddonDetails !== 'function' || typeof report.buildReport !== 'function') throw new Error('Incompatible add-on helpers')
19
+ return new ResourcesIntegration(api, {specs, ...limits, ...report}, plugin.version)
20
+ } catch {
21
+ return {available: false, message: `Cost/limit integration is unavailable with this installation of heroku-resources (${plugin.version ?? 'unknown version'}). Check or update the plugin, then restart dash.`}
22
+ }
23
+ }
24
+
25
+ export class ResourcesIntegration {
26
+ constructor(api, helpers, version) {
27
+ Object.assign(this, {api, helpers, version, available: true})
28
+ // The companion helpers receive only GET access, using dash's authenticated
29
+ // client. They cannot prompt for login or perform writes through this adapter.
30
+ this.reader = {get: async (path, options = {}) => ({body: await api.get(path, {...options, method: 'GET'})})}
31
+ }
32
+
33
+ async sizes(force) {
34
+ if (!this.sizeRequest || (this.sizeLoadedAt && (force || Date.now() - this.sizeLoadedAt > SIZE_CACHE_MS))) {
35
+ this.sizeLoadedAt = 0
36
+ const request = this.api.list('/dyno-sizes').then(sizes => {
37
+ this.sizeLoadedAt = Date.now()
38
+ return sizes
39
+ }).catch(error => {
40
+ this.sizeRequest = null
41
+ throw error
42
+ })
43
+ this.sizeRequest = request
44
+ }
45
+ return this.sizeRequest
46
+ }
47
+
48
+ async dynos(data, {force = false} = {}) {
49
+ const sizes = await this.sizes(force)
50
+ const {memoryForSize, cpuForSize, monthlyCostForSize} = this.helpers.specs
51
+ const shielded = data.app.space?.shield === true
52
+ const allocation = (size, quantity) => {
53
+ const ramPerDynoMb = memoryForSize(size, shielded, sizes)
54
+ return {
55
+ ramPerDynoMb,
56
+ allocatedRamMb: ramPerDynoMb === null ? null : ramPerDynoMb * quantity,
57
+ cpuPerDyno: cpuForSize(size, 1, shielded, sizes),
58
+ cpu: cpuForSize(size, quantity, shielded, sizes),
59
+ ...monthlyCostForSize(size, quantity, shielded, sizes),
60
+ unitMonthlyCost: monthlyCostForSize(size, 1, shielded, sizes).monthlyCost,
61
+ }
62
+ }
63
+ return {
64
+ formations: Object.fromEntries(data.formation.map(f => [f.type, allocation(f.size, f.quantity)])),
65
+ instances: Object.fromEntries(data.dynos.map(d => [d.name, allocation(d.size, 1)])),
66
+ }
67
+ }
68
+
69
+ async addons(data) {
70
+ const owned = await this.api.list(`/apps/${encodeURIComponent(data.app.id)}/addons`, {headers: SDK_HEADERS})
71
+ const all = new Map(owned.map(addon => [addon.id, addon]))
72
+ for (const attachment of data.attachments) {
73
+ if (!all.has(attachment.addon.id)) all.set(attachment.addon.id, null)
74
+ }
75
+ const entries = await Promise.all([...all].map(async ([id, resource]) => {
76
+ try {
77
+ const addon = resource ?? await this.api.get(`/addons/${encodeURIComponent(id)}`, {headers: SDK_HEADERS})
78
+ const details = await this.helpers.fetchAddonDetails(this.reader, addon)
79
+ const report = this.helpers.buildReport(null, null, [{app: data.app, dynos: [], formation: [], addons: [{...addon, ...details}]}], [])
80
+ if (!report.addons?.[0]) throw new Error('Unexpected heroku-resources add-on report')
81
+ return [id, {
82
+ ...report.addons[0],
83
+ billingApp: addon.app?.name ?? (resource ? data.app.name : null),
84
+ shared: addon.app?.id ? addon.app.id !== data.app.id : !resource,
85
+ }]
86
+ } catch (error) {
87
+ return [id, {error: errorMessage(error)}]
88
+ }
89
+ }))
90
+ return {byId: Object.fromEntries(entries)}
91
+ }
92
+ }
@@ -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,14 +5,15 @@ 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%'
11
12
  const frame = () => ({border: {type: 'line'}, style: {fg: palette.fg, bg: palette.bg, border: {fg: palette.border}, focus: {border: {fg: palette.accent}}}})
12
13
 
13
14
  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})
15
+ constructor({api, catalog, context, resources = null, refresh = 30, demo = false, screen, writeClipboard = clipboard.write}) {
16
+ Object.assign(this, {api, catalog, context, resources, refresh, demo, writeClipboard})
16
17
  this.screen = screen ?? blessed.screen({smartCSR: true, fullUnicode: true, title: 'heroku dash', dockBorders: true, autoPadding: true})
17
18
  this.tab = 0
18
19
  this.mode = 'pipelines'
@@ -26,6 +27,9 @@ export class Dashboard {
26
27
  this.generation = 0
27
28
  this.navGeneration = 0
28
29
  this.config = null
30
+ this.resourceData = {}
31
+ this.resourceErrors = {}
32
+ this.resourceRequests = new Map()
29
33
  this.revealed = new Set()
30
34
  this.copying = false
31
35
  this.busy = false
@@ -49,6 +53,13 @@ export class Dashboard {
49
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}})
50
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,
51
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
+ })
52
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,
53
64
  scrollbar: {ch: '│', style: {bg: palette.border}}})
54
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}})
@@ -107,7 +118,7 @@ export class Dashboard {
107
118
  key(['a'], () => this.setMode('apps'))
108
119
  key(['/'], () => void this.filterNav())
109
120
  key(['escape'], () => void this.back())
110
- key(['R'], () => void this.reload())
121
+ key(['R', 'S-r'], () => void this.reload())
111
122
  key(['[', 'left', 'h'], () => this.changeTab((this.tab + TABS.length - 1) % TABS.length))
112
123
  key([']', 'right', 'l'], () => this.changeTab((this.tab + 1) % TABS.length))
113
124
  for (let i = 0; i < TABS.length; i++) key([String(i + 1)], () => this.changeTab(i))
@@ -189,6 +200,7 @@ export class Dashboard {
189
200
 
190
201
  render() {
191
202
  if (this.closed) return
203
+ this.layoutColumns()
192
204
  const team = this.app || this.pipeline ? this.breadcrumbTeam?.name ?? 'Loading team…' : this.team?.name
193
205
  const pipeline = this.pipeline?.name ?? (this.app ? this.data ? this.data.errors.coupling ? 'Pipeline unavailable' : 'No pipeline' : 'Loading pipeline…' : null)
194
206
  const scope = [['teams', team], ['pipelines', pipeline], ['apps', this.app?.name]]
@@ -243,6 +255,7 @@ export class Dashboard {
243
255
 
244
256
  clearApp() {
245
257
  this.generation++
258
+ this.resetResourceDetails()
246
259
  for (const key of ['app', 'pipeline', 'config']) this.loading.delete(key)
247
260
  this.syncLoadingAnimation()
248
261
  this.app = null
@@ -314,7 +327,7 @@ export class Dashboard {
314
327
  await this.loadApp()
315
328
  }
316
329
 
317
- async loadApp(automatic = false) {
330
+ async loadApp(automatic = false, {forceResources = false} = {}) {
318
331
  if (!this.app || this.busy || this.closed) return false
319
332
  const generation = this.generation
320
333
  const app = this.app
@@ -328,11 +341,13 @@ export class Dashboard {
328
341
  this.pipeline = hierarchy.pipeline
329
342
  this.breadcrumbTeam = hierarchy.team
330
343
  Object.assign(data.errors, hierarchy.errors)
344
+ this.resetResourceDetails()
331
345
  this.data = data
332
346
  this.app = data.app
333
347
  this.message = `${automatic ? 'Auto-refreshed' : 'Updated'} ${new Date(data.fetchedAt).toLocaleTimeString()}${Object.keys(data.errors).length ? ' · Some sections unavailable; see Overview.' : ''}`
334
348
  this.messageTone = Object.keys(data.errors).length ? 'warning' : 'success'
335
349
  this.drawApp()
350
+ void this.loadResourceDetails({force: forceResources})
336
351
  return true
337
352
  } catch (error) {
338
353
  if (generation === this.generation) {
@@ -352,7 +367,52 @@ export class Dashboard {
352
367
  const {app, formation, errors} = this.data
353
368
  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
369
  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)
370
+ this.setRows(appRows(TABS[this.tab], this.data, {
371
+ config: this.config, configError: this.configError, revealed: this.revealed,
372
+ resources: {provider: this.resources, data: this.resourceData, errors: this.resourceErrors},
373
+ }), true)
374
+ }
375
+
376
+ resetResourceDetails() {
377
+ this.resourceData = {}
378
+ this.resourceErrors = {}
379
+ this.resourceRequests.clear()
380
+ for (const kind of ['dynos', 'addons']) this.loading.delete(`resources-${kind}`)
381
+ this.syncLoadingAnimation()
382
+ }
383
+
384
+ async loadResourceDetails({force = false} = {}) {
385
+ const kind = {Resources: 'dynos', 'Add-ons': 'addons'}[TABS[this.tab]]
386
+ if (!kind || !this.resources?.available || !this.data || this.closed) return
387
+ if (!force && (this.resourceData[kind] || this.resourceErrors[kind] || this.resourceRequests.has(kind))) return
388
+ const data = this.data
389
+ const generation = this.generation
390
+ const request = {}
391
+ this.resourceRequests.set(kind, request)
392
+ const current = () => !this.closed && generation === this.generation && this.data === data && this.resourceRequests.get(kind) === request
393
+ const finishLoading = this.beginLoading(`resources-${kind}`, `Loading ${kind === 'dynos' ? 'dyno costs and allocations' : 'add-on costs and limits'}…`)
394
+ try {
395
+ const result = await this.resources[kind](data, {force})
396
+ if (!current()) return
397
+ this.resourceData[kind] = result
398
+ delete this.resourceErrors[kind]
399
+ } catch (error) {
400
+ if (!current()) return
401
+ this.resourceErrors[kind] = errorMessage(error)
402
+ } finally {
403
+ if (current()) {
404
+ this.resourceRequests.delete(kind)
405
+ if ({Resources: 'dynos', 'Add-ons': 'addons'}[TABS[this.tab]] === kind) {
406
+ // getScroll() includes Blessed's cursor offset; childBase is the
407
+ // actual first visible line that should survive this redraw.
408
+ const scroll = this.detail.childBase
409
+ this.drawApp()
410
+ this.detail.setScroll(scroll)
411
+ this.render()
412
+ }
413
+ }
414
+ finishLoading()
415
+ }
356
416
  }
357
417
 
358
418
  setRows(rows, preserve = false) {
@@ -363,7 +423,7 @@ export class Dashboard {
363
423
  this.updatingRows = true
364
424
  try {
365
425
  this.rows = rows
366
- this.main.setItems(rows.map(rowLabel))
426
+ this.main.setItems(rows.map(row => rowLabel(row, this.main.width - this.main.iwidth - 1)))
367
427
  this.main.select(Math.min(selected, Math.max(0, rows.length - 1)))
368
428
  } finally {
369
429
  this.updatingRows = false
@@ -371,6 +431,24 @@ export class Dashboard {
371
431
  this.drawDetail()
372
432
  }
373
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
+
374
452
  drawDetail() {
375
453
  const row = this.rows[this.main.selected]
376
454
  this.detail.setLabel(` ${icons[row?.icon] ?? icons.overview} Details `)
@@ -386,6 +464,7 @@ export class Dashboard {
386
464
  this.main.select(0)
387
465
  this.drawApp()
388
466
  if (TABS[index] === 'Config' && !this.config) void this.loadConfig()
467
+ void this.loadResourceDetails()
389
468
  }
390
469
 
391
470
  async loadConfig() {
@@ -426,7 +505,7 @@ export class Dashboard {
426
505
  if (this.busy) return
427
506
  if (this.app) {
428
507
  this.revealed.clear()
429
- await this.loadApp()
508
+ await this.loadApp(false, {forceResources: true})
430
509
  if (TABS[this.tab] === 'Config') await this.loadConfig()
431
510
  } else if (this.pipeline) await this.openPipeline(this.pipeline)
432
511
  else {
@@ -0,0 +1,49 @@
1
+ const money = value => Number.isFinite(value) ? new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value) : 'Unavailable'
2
+ const ram = value => Number.isFinite(value) ? `${value >= 1024 ? value / 1024 : value} ${value >= 1024 ? 'GB' : 'MB'}` : 'Unavailable'
3
+ const field = (name, value) => `${name.padEnd(17)} ${value ?? 'Unavailable'}`
4
+
5
+ function unavailable(resources, kind) {
6
+ if (!resources?.provider?.available) return resources?.provider?.message ?? 'Install heroku-resources and restart dash to show costs and limits.'
7
+ if (resources.errors?.[kind]) return `Costs / limits unavailable: ${resources.errors[kind]}`
8
+ if (!resources.data?.[kind]) return 'Loading costs and limits from heroku-resources…'
9
+ return null
10
+ }
11
+
12
+ export function dynoDetails(resources, kind, name) {
13
+ const note = unavailable(resources, 'dynos')
14
+ if (note) return `\n\nCOST & ALLOCATION\n${note}`
15
+ const item = resources.data.dynos[kind]?.[name]
16
+ if (!item) return '\n\nCOST & ALLOCATION\nNo matching dyno specification available.'
17
+ const process = kind === 'formations'
18
+ const price = item.ecoPlan ? 'Shared $5/month account plan (not per dyno)'
19
+ : Number.isFinite(item.monthlyCost) ? `${money(item.monthlyCost)}/month${process ? ' for this process' : ' per dyno (full-month rate)'}` : 'Unavailable'
20
+ return `\n\nCOST & ALLOCATION\n${[
21
+ field('RAM / dyno', ram(item.ramPerDynoMb)),
22
+ ...(process ? [field('Allocated RAM', ram(item.allocatedRamMb))] : []),
23
+ field('CPU / dyno', item.cpuPerDyno),
24
+ ...(process ? [field('CPU allocation', item.cpu)] : []),
25
+ field('Estimated cost', price),
26
+ ...(process && !item.ecoPlan && Number.isFinite(item.unitMonthlyCost) ? [field('Size rate', `${money(item.unitMonthlyCost)}/dyno/month`)] : []),
27
+ field('Source', `heroku-resources ${resources.provider.version ?? ''}`),
28
+ ].join('\n')}\n\nAllocations are size limits, not live usage.\nCosts are USD full-month estimates; actual billing is prorated.`
29
+ }
30
+
31
+ export function addonDetails(resources, id) {
32
+ const note = unavailable(resources, 'addons')
33
+ if (note) return `\n\nCOST & LIMITS\n${note}`
34
+ const item = resources.data.addons.byId[id]
35
+ if (!item || item.error) return `\n\nCOST & LIMITS\n${item?.error ?? 'No matching add-on information available.'}`
36
+ const cost = item.contract ? 'Contract pricing (amount unknown)' : item.metered ? 'Metered / usage-based pricing'
37
+ : Number.isFinite(item.costCents) && item.costUnit ? `${money(item.costCents / 100)}/${item.costUnit}` : 'Unavailable'
38
+ return `\n\nCOST & LIMITS\n${[
39
+ field('Billed cost', cost),
40
+ field('Billing app', item.billingApp),
41
+ field('Billed plan', item.plan),
42
+ field('Active plan', item.activePlan),
43
+ field('Provider status', item.providerStatus),
44
+ field('Connection limit', item.maxConnections),
45
+ field('RAM limit', item.ram),
46
+ field('Disk capacity', item.diskSize),
47
+ field('Source', `heroku-resources ${resources.provider.version ?? ''}`),
48
+ ].join('\n')}${item.shared ? '\n\nShared attachment: the add-on is billed to its owning app.' : ''}${item.planChangePending ? '\n\nPlan change pending: limits describe the active plan; cost reflects the billed plan.' : ''}\n\nLimits are capacity, not current usage. Prices are USD; unavailable limits are not zero.`
49
+ }
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',
@@ -64,12 +65,12 @@ export function scannerFrame(frame) {
64
65
  export function stateStyle(state) {
65
66
  if (['up', 'idle', 'succeeded', 'provisioned', 'active'].includes(state)) return {icon: 'success', tone: 'success'}
66
67
  if (['crashed', 'failed', 'error'].includes(state)) return {icon: 'error', tone: 'error'}
67
- if (['starting', 'pending', 'provisioning', 'deprovisioning', 'maintenance'].includes(state)) return {icon: 'clock', tone: 'warning'}
68
+ if (['starting', 'pending', 'provisioning', 'deprovisioning', 'maintenance', 'upgrade pending', 'plan change pending'].includes(state)) return {icon: 'clock', tone: 'warning'}
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,5 +1,7 @@
1
1
  import {clean, single} from './text.js'
2
2
  import {stateStyle} from './theme.js'
3
+ import {addonDetails, dynoDetails} from './resource-details.js'
4
+ import {TABLE_COLUMNS} from './columns.js'
3
5
 
4
6
  export {clean, single} from './text.js'
5
7
  export function age(date, now = Date.now()) {
@@ -30,22 +32,33 @@ export function operationalMetrics(data) {
30
32
  }
31
33
  }
32
34
 
33
- export function appRows(tab, data, {config, configError, revealed = new Set()} = {}) {
35
+ export function appRows(tab, data, {config, configError, revealed = new Set(), resources} = {}) {
34
36
  const {app, formation, dynos, addons, attachments, releases, domains, buildpacks, errors} = data
35
37
  const rows = []
38
+ const noticeColumns = (label, status) => TABLE_COLUMNS[tab]?.map((column, index) => index === 0 ? label : /^(State|Status)/.test(column.label) ? status : '—')
36
39
  const error = section => {
37
- 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
+ }))
38
44
  }
39
45
  if (tab === 'Overview') {
40
46
  rows.push(row(`${app.name} · ${app.maintenance ? 'MAINTENANCE' : 'ACTIVE'}`, lines([
41
47
  ['App', app.name], ['Team', app.team?.name ?? 'Personal / shared'], ['Region', app.region?.name],
42
48
  ['Stack', app.stack?.name], ['Generation', app.generation?.name], ['Web URL', app.web_url],
43
49
  ['Git URL', app.git_url], ['Created', app.created_at], ['Updated', app.updated_at], ['ID', app.id],
44
- ]), {...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']}))
45
52
  rows.push(row(`${formation.reduce((n, f) => n + f.quantity, 0)} configured dynos · ${addons.length} add-ons`,
46
- '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'}))
47
- for (const f of formation) rows.push(row(`${f.type} · ${f.quantity} × ${f.size}`, f.command, {icon: 'resources', tone: f.quantity ? 'cyan' : 'muted'}))
48
- 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
+ }))
49
62
  for (const section of Object.keys(errors)) error(section)
50
63
  }
51
64
  if (tab === 'Resources') {
@@ -53,22 +66,31 @@ export function appRows(tab, data, {config, configError, revealed = new Set()} =
53
66
  for (const f of formation) rows.push(row(`${f.type.padEnd(16)} ${String(f.quantity).padStart(3)} × ${f.size} [s] scale`, lines([
54
67
  ['Process', f.type], ['Quantity', f.quantity], ['Size', f.size], ['Command', f.command],
55
68
  ['Updated', f.updated_at], ['Action', 'Press s to change quantity / size. Scaling may change billing.'],
56
- ]), {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', '—']}))
57
71
  for (const d of dynos) rows.push(row(` ${d.name.padEnd(20)} ${d.state.padEnd(10)} ${d.size} · ${age(d.created_at)}`, lines([
58
72
  ['Dyno', d.name], ['State', d.state], ['Size', d.size], ['Release', d.release ? `v${d.release.version}` : '—'],
59
- ['Created', d.created_at], ['Command', d.command],
60
- ]), {...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)]}))
61
76
  }
62
77
  if (tab === 'Add-ons') {
63
78
  error('addons'); error('attachments')
64
79
  const all = new Map(addons.map(addon => [addon.id, addon]))
65
80
  for (const attachment of attachments) if (!all.has(attachment.addon.id)) all.set(attachment.addon.id, attachment.addon)
66
- for (const addon of all.values()) rows.push(row(`${addon.name} · ${addon.plan?.name ?? 'shared attachment'} · ${addon.state ?? '—'}`, lines([
67
- ['Name', addon.name], ['Service', addon.addon_service?.name], ['Plan', addon.plan?.name],
68
- ['State', addon.state], ['Billing app', addon.app?.name], ['Created', addon.created_at],
69
- ['Attachments', attachments.filter(a => a.addon.id === addon.id).map(a => a.name).join(', ') || '—'],
70
- ['Config keys', addon.config_vars?.join(', ')], ['ID', addon.id],
71
- ]), {...stateStyle(addon.state), icon: /postgres|redis|mysql|mongo|key-value/i.test(addon.addon_service?.name ?? addon.plan?.name ?? '') ? 'database' : 'addons', emphasis: addon.state}))
81
+ for (const addon of all.values()) {
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(/^[^:]+:/, '')
86
+ rows.push(row(`${addon.name} · ${addon.plan?.name ?? 'shared attachment'} · ${state ?? '—'}`, lines([
87
+ ['Name', addon.name], ['Service', addon.addon_service?.name ?? enriched?.service], ['Plan', addon.plan?.name ?? enriched?.plan],
88
+ ['State', state], ['Billing app', addon.app?.name], ['Created', addon.created_at],
89
+ ['Attachments', attachments.filter(a => a.addon.id === addon.id).map(a => a.name).join(', ') || '—'],
90
+ ['Config keys', addon.config_vars?.join(', ')], ['ID', addon.id],
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]}))
93
+ }
72
94
  }
73
95
  if (tab === 'Config') {
74
96
  if (configError) rows.push(row('Config vars unavailable', configError, {icon: 'error', tone: 'error', emphasis: 'unavailable'}))
@@ -85,17 +107,20 @@ export function appRows(tab, data, {config, configError, revealed = new Set()} =
85
107
  }
86
108
  }
87
109
  if (tab === 'Settings') {
88
- 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']}))
89
112
  rows.push(row(`Region: ${app.region?.name} · Stack: ${app.stack?.name}`, lines([
90
113
  ['Region', app.region?.name], ['Stack', app.stack?.name], ['Build stack', app.build_stack?.name],
91
114
  ['Space', app.space?.name ?? 'Common Runtime'], ['ACM', app.acm ? 'Enabled' : 'Disabled'],
92
- ]), {icon: 'globe', tone: 'info'}))
115
+ ]), {icon: 'globe', tone: 'info', columns: ['Region / stack', `${app.region?.name ?? '—'} / ${app.stack?.name ?? '—'}`, '—']}))
93
116
  error('domains'); error('buildpacks')
94
117
  for (const domain of domains) rows.push(row(`Domain ${domain.hostname}`, lines([
95
118
  ['Hostname', domain.hostname], ['Kind', domain.kind], ['CNAME', domain.cname],
96
119
  ['Status', domain.status], ['ACM status', domain.acm_status], ['ACM reason', domain.acm_status_reason],
97
- ]), {icon: 'globe', tone: 'info'}))
98
- 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, '—']}))
99
124
  }
100
125
  if (tab === 'Releases') {
101
126
  error('releases')
@@ -111,17 +136,27 @@ export function appRows(tab, data, {config, configError, revealed = new Set()} =
111
136
  rows.push(row(`Dyno health ${available ? `${m.healthy} / ${m.desired} configured dynos up or idle` : 'unavailable'}`, available
112
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.`
113
138
  : 'Dyno health cannot be computed because formation or dyno data is unavailable.',
114
- {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']}))
115
141
  for (const f of formation) {
116
142
  const members = dynos.filter(d => d.type === f.type)
117
143
  const running = members.filter(d => ['up', 'idle'].includes(d.state)).length
118
- 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.',
119
- {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]}))
120
151
  }
121
152
  if (!errors.releases) rows.push(row(`Deployments ${releases.filter(r => r.status === 'succeeded').length} succeeded / ${releases.length} recent releases`,
122
- `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'}))
123
- 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'}))
124
- 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']}))
125
158
  }
126
- 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
127
162
  }