heroku-dash 0.1.0 → 0.2.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
@@ -77,8 +77,8 @@ While data is loading, an OpenCode-inspired purple scanner (`■` / `⬝`) sweep
77
77
  | View | What you can do |
78
78
  | --- | --- |
79
79
  | **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 |
80
+ | **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 |
81
+ | **3 Add-ons** | Inspect services, plans, provisioning state, billing app, and local/shared attachments; optionally view billed costs and capacity limits |
82
82
  | **4 Config** | View config keys; reveal or copy a selected value; create, replace, or delete variables |
83
83
  | **5 Settings** | Inspect domains, ACM state, buildpacks, region, stack, and space; toggle maintenance mode |
84
84
  | **6 Releases** | Inspect the latest 20 releases, including status, author, description, and timestamp |
@@ -128,6 +128,25 @@ Press **`y`** to copy the selected variable's value without revealing it. Reveal
128
128
 
129
129
  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
130
 
131
+ ## Optional costs and limits with heroku-resources
132
+
133
+ Install [heroku-resources](https://github.com/rmm5t/heroku-resources) alongside dash, then restart the dashboard:
134
+
135
+ ```sh
136
+ heroku plugins:install heroku-resources
137
+ heroku dash
138
+ ```
139
+
140
+ 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.
141
+
142
+ - **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.
143
+ - **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.
144
+ - **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.
145
+
146
+ 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`.
147
+
148
+ 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.
149
+
131
150
  ## Metrics and current scope
132
151
 
133
152
  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 +199,7 @@ npm publish
180
199
 
181
200
  `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
201
 
183
- The initial release is **`heroku-dash@0.1.0`**. For subsequent releases, increment the version before publishing, for example:
202
+ The current package version is **`heroku-dash@0.2.0`**. For subsequent releases, increment the version before publishing, for example:
184
203
 
185
204
  ```sh
186
205
  npm version patch --no-git-tag-version
@@ -198,16 +217,27 @@ npm run test:live -- ~/work/hermod ~/work/heimdall
198
217
 
199
218
  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
219
 
220
+ To verify cost/limit enrichment with the installed `heroku-resources` plugin against specific apps:
221
+
222
+ ```sh
223
+ npm run test:resources -- hermod-staging heimdall-staging
224
+ ```
225
+
226
+ 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.
227
+
201
228
  After linking the plugin, macOS/Linux users with Python 3 can exercise the actual CLI in a pseudo-terminal:
202
229
 
203
230
  ```sh
204
231
  python3 scripts/terminal-check.py
205
232
  python3 scripts/terminal-check.py --repo ~/work/hermod
206
233
  python3 scripts/terminal-check.py --repo ~/work/heimdall
234
+ python3 scripts/terminal-check.py --repo ~/work/heimdall --resources
207
235
  ```
208
236
 
209
237
  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
238
 
239
+ `--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.
240
+
211
241
  ### Layout
212
242
 
213
243
  ```text
@@ -215,8 +245,10 @@ src/commands/dash.js Command flags, authentication, startup
215
245
  src/project.js Git context and pipeline resolution
216
246
  src/hierarchy.js Team and pipeline parents for resource breadcrumbs
217
247
  src/api.js Platform API reads, pagination, guarded writes
248
+ src/resources.js Optional adapter to the installed heroku-resources plugin
218
249
  src/ui/dashboard.js Terminal navigation, prompts, refresh, lifecycle
219
250
  src/ui/views.js View models, config masking, operational metrics
251
+ src/ui/resource-details.js Cost and capacity details and billing annotations
220
252
  src/ui/details.js Highlighted values and scroll-aware click targets
221
253
  src/ui/theme.js Nerd Font icons, semantic colors, styled labels
222
254
  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.2.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.2.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
+ }
@@ -11,8 +11,8 @@ const SIDEBAR_WIDTH = '22%'
11
11
  const frame = () => ({border: {type: 'line'}, style: {fg: palette.fg, bg: palette.bg, border: {fg: palette.border}, focus: {border: {fg: palette.accent}}}})
12
12
 
13
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})
14
+ constructor({api, catalog, context, resources = null, refresh = 30, demo = false, screen, writeClipboard = clipboard.write}) {
15
+ Object.assign(this, {api, catalog, context, resources, refresh, demo, writeClipboard})
16
16
  this.screen = screen ?? blessed.screen({smartCSR: true, fullUnicode: true, title: 'heroku dash', dockBorders: true, autoPadding: true})
17
17
  this.tab = 0
18
18
  this.mode = 'pipelines'
@@ -26,6 +26,9 @@ export class Dashboard {
26
26
  this.generation = 0
27
27
  this.navGeneration = 0
28
28
  this.config = null
29
+ this.resourceData = {}
30
+ this.resourceErrors = {}
31
+ this.resourceRequests = new Map()
29
32
  this.revealed = new Set()
30
33
  this.copying = false
31
34
  this.busy = false
@@ -107,7 +110,7 @@ export class Dashboard {
107
110
  key(['a'], () => this.setMode('apps'))
108
111
  key(['/'], () => void this.filterNav())
109
112
  key(['escape'], () => void this.back())
110
- key(['R'], () => void this.reload())
113
+ key(['R', 'S-r'], () => void this.reload())
111
114
  key(['[', 'left', 'h'], () => this.changeTab((this.tab + TABS.length - 1) % TABS.length))
112
115
  key([']', 'right', 'l'], () => this.changeTab((this.tab + 1) % TABS.length))
113
116
  for (let i = 0; i < TABS.length; i++) key([String(i + 1)], () => this.changeTab(i))
@@ -243,6 +246,7 @@ export class Dashboard {
243
246
 
244
247
  clearApp() {
245
248
  this.generation++
249
+ this.resetResourceDetails()
246
250
  for (const key of ['app', 'pipeline', 'config']) this.loading.delete(key)
247
251
  this.syncLoadingAnimation()
248
252
  this.app = null
@@ -314,7 +318,7 @@ export class Dashboard {
314
318
  await this.loadApp()
315
319
  }
316
320
 
317
- async loadApp(automatic = false) {
321
+ async loadApp(automatic = false, {forceResources = false} = {}) {
318
322
  if (!this.app || this.busy || this.closed) return false
319
323
  const generation = this.generation
320
324
  const app = this.app
@@ -328,11 +332,13 @@ export class Dashboard {
328
332
  this.pipeline = hierarchy.pipeline
329
333
  this.breadcrumbTeam = hierarchy.team
330
334
  Object.assign(data.errors, hierarchy.errors)
335
+ this.resetResourceDetails()
331
336
  this.data = data
332
337
  this.app = data.app
333
338
  this.message = `${automatic ? 'Auto-refreshed' : 'Updated'} ${new Date(data.fetchedAt).toLocaleTimeString()}${Object.keys(data.errors).length ? ' · Some sections unavailable; see Overview.' : ''}`
334
339
  this.messageTone = Object.keys(data.errors).length ? 'warning' : 'success'
335
340
  this.drawApp()
341
+ void this.loadResourceDetails({force: forceResources})
336
342
  return true
337
343
  } catch (error) {
338
344
  if (generation === this.generation) {
@@ -352,7 +358,52 @@ export class Dashboard {
352
358
  const {app, formation, errors} = this.data
353
359
  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
360
  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)
361
+ this.setRows(appRows(TABS[this.tab], this.data, {
362
+ config: this.config, configError: this.configError, revealed: this.revealed,
363
+ resources: {provider: this.resources, data: this.resourceData, errors: this.resourceErrors},
364
+ }), true)
365
+ }
366
+
367
+ resetResourceDetails() {
368
+ this.resourceData = {}
369
+ this.resourceErrors = {}
370
+ this.resourceRequests.clear()
371
+ for (const kind of ['dynos', 'addons']) this.loading.delete(`resources-${kind}`)
372
+ this.syncLoadingAnimation()
373
+ }
374
+
375
+ async loadResourceDetails({force = false} = {}) {
376
+ const kind = {Resources: 'dynos', 'Add-ons': 'addons'}[TABS[this.tab]]
377
+ if (!kind || !this.resources?.available || !this.data || this.closed) return
378
+ if (!force && (this.resourceData[kind] || this.resourceErrors[kind] || this.resourceRequests.has(kind))) return
379
+ const data = this.data
380
+ const generation = this.generation
381
+ const request = {}
382
+ this.resourceRequests.set(kind, request)
383
+ const current = () => !this.closed && generation === this.generation && this.data === data && this.resourceRequests.get(kind) === request
384
+ const finishLoading = this.beginLoading(`resources-${kind}`, `Loading ${kind === 'dynos' ? 'dyno costs and allocations' : 'add-on costs and limits'}…`)
385
+ try {
386
+ const result = await this.resources[kind](data, {force})
387
+ if (!current()) return
388
+ this.resourceData[kind] = result
389
+ delete this.resourceErrors[kind]
390
+ } catch (error) {
391
+ if (!current()) return
392
+ this.resourceErrors[kind] = errorMessage(error)
393
+ } finally {
394
+ if (current()) {
395
+ this.resourceRequests.delete(kind)
396
+ if ({Resources: 'dynos', 'Add-ons': 'addons'}[TABS[this.tab]] === kind) {
397
+ // getScroll() includes Blessed's cursor offset; childBase is the
398
+ // actual first visible line that should survive this redraw.
399
+ const scroll = this.detail.childBase
400
+ this.drawApp()
401
+ this.detail.setScroll(scroll)
402
+ this.render()
403
+ }
404
+ }
405
+ finishLoading()
406
+ }
356
407
  }
357
408
 
358
409
  setRows(rows, preserve = false) {
@@ -386,6 +437,7 @@ export class Dashboard {
386
437
  this.main.select(0)
387
438
  this.drawApp()
388
439
  if (TABS[index] === 'Config' && !this.config) void this.loadConfig()
440
+ void this.loadResourceDetails()
389
441
  }
390
442
 
391
443
  async loadConfig() {
@@ -426,7 +478,7 @@ export class Dashboard {
426
478
  if (this.busy) return
427
479
  if (this.app) {
428
480
  this.revealed.clear()
429
- await this.loadApp()
481
+ await this.loadApp(false, {forceResources: true})
430
482
  if (TABS[this.tab] === 'Config') await this.loadConfig()
431
483
  } else if (this.pipeline) await this.openPipeline(this.pipeline)
432
484
  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
@@ -64,7 +64,7 @@ export function scannerFrame(frame) {
64
64
  export function stateStyle(state) {
65
65
  if (['up', 'idle', 'succeeded', 'provisioned', 'active'].includes(state)) return {icon: 'success', tone: 'success'}
66
66
  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'}
67
+ if (['starting', 'pending', 'provisioning', 'deprovisioning', 'maintenance', 'upgrade pending', 'plan change pending'].includes(state)) return {icon: 'clock', tone: 'warning'}
68
68
  return {icon: 'stopped', tone: 'muted'}
69
69
  }
70
70
 
package/src/ui/views.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {clean, single} from './text.js'
2
2
  import {stateStyle} from './theme.js'
3
+ import {addonDetails, dynoDetails} from './resource-details.js'
3
4
 
4
5
  export {clean, single} from './text.js'
5
6
  export function age(date, now = Date.now()) {
@@ -30,7 +31,7 @@ export function operationalMetrics(data) {
30
31
  }
31
32
  }
32
33
 
33
- export function appRows(tab, data, {config, configError, revealed = new Set()} = {}) {
34
+ export function appRows(tab, data, {config, configError, revealed = new Set(), resources} = {}) {
34
35
  const {app, formation, dynos, addons, attachments, releases, domains, buildpacks, errors} = data
35
36
  const rows = []
36
37
  const error = section => {
@@ -53,22 +54,25 @@ export function appRows(tab, data, {config, configError, revealed = new Set()} =
53
54
  for (const f of formation) rows.push(row(`${f.type.padEnd(16)} ${String(f.quantity).padStart(3)} × ${f.size} [s] scale`, lines([
54
55
  ['Process', f.type], ['Quantity', f.quantity], ['Size', f.size], ['Command', f.command],
55
56
  ['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}))
57
+ ]) + dynoDetails(resources, 'formations', f.type), {kind: 'formation', value: f, icon: 'resources', tone: f.quantity ? 'cyan' : 'muted', emphasis: f.type}))
57
58
  for (const d of dynos) rows.push(row(` ${d.name.padEnd(20)} ${d.state.padEnd(10)} ${d.size} · ${age(d.created_at)}`, lines([
58
59
  ['Dyno', d.name], ['State', d.state], ['Size', d.size], ['Release', d.release ? `v${d.release.version}` : '—'],
59
60
  ['Created', d.created_at], ['Command', d.command],
60
- ]), {...stateStyle(d.state), emphasis: d.state}))
61
+ ]) + dynoDetails(resources, 'instances', d.name), {...stateStyle(d.state), emphasis: d.state}))
61
62
  }
62
63
  if (tab === 'Add-ons') {
63
64
  error('addons'); error('attachments')
64
65
  const all = new Map(addons.map(addon => [addon.id, addon]))
65
66
  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}))
67
+ for (const addon of all.values()) {
68
+ const state = resources?.data?.addons?.byId?.[addon.id]?.state ?? addon.state
69
+ 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],
71
+ ['State', state], ['Billing app', addon.app?.name], ['Created', addon.created_at],
72
+ ['Attachments', attachments.filter(a => a.addon.id === addon.id).map(a => a.name).join(', ') || '—'],
73
+ ['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}))
75
+ }
72
76
  }
73
77
  if (tab === 'Config') {
74
78
  if (configError) rows.push(row('Config vars unavailable', configError, {icon: 'error', tone: 'error', emphasis: 'unavailable'}))