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