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 +37 -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/columns.js +75 -0
- package/src/ui/dashboard.js +86 -7
- package/src/ui/resource-details.js +49 -0
- package/src/ui/theme.js +4 -3
- package/src/ui/views.js +61 -26
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
|
|
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
|
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.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
|
},
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/ui/dashboard.js
CHANGED
|
@@ -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, {
|
|
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], {
|
|
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.', {
|
|
47
|
-
|
|
48
|
-
|
|
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())
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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}`,
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
rows.push(row(
|
|
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
|
-
|
|
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
|
}
|