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 +35 -3
- package/oclif.manifest.json +1 -1
- package/package.json +2 -1
- package/src/commands/dash.js +3 -1
- package/src/demo.js +1 -1
- package/src/resources.js +92 -0
- package/src/ui/dashboard.js +58 -6
- package/src/ui/resource-details.js +49 -0
- package/src/ui/theme.js +1 -1
- package/src/ui/views.js +13 -9
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
|
|
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
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heroku-dash",
|
|
3
|
-
"version": "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
|
},
|
package/src/commands/dash.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/resources.js
ADDED
|
@@ -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
|
+
}
|
package/src/ui/dashboard.js
CHANGED
|
@@ -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, {
|
|
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())
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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'}))
|