heroku-dash 0.1.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/src/ui/text.js ADDED
@@ -0,0 +1,8 @@
1
+ // Remote text is never allowed to supply terminal control sequences. Widgets
2
+ // keep Blessed tag parsing disabled, so literal {tags} remain ordinary text.
3
+ export function clean(value) {
4
+ return String(value ?? '—').replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
5
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '')
6
+ }
7
+
8
+ export function single(value) { return clean(value).replace(/[\r\n\t]/g, ' ') }
@@ -0,0 +1,86 @@
1
+ import blessed from 'blessed'
2
+ import {clean, single} from './text.js'
3
+
4
+ export const palette = {
5
+ bg: '#161b22', panel: '#1c212b', fg: '#c9d1d9', muted: '#8b949e',
6
+ accent: '#bc8cff', border: '#484f58', selected: '#30304b',
7
+ success: '#7ee787', warning: '#e3b341', error: '#ff7b72', info: '#79c0ff', cyan: '#76e3ea',
8
+ loadingTrail: '#916bbb', loadingFade: '#5e467e', loadingDim: '#362b48',
9
+ }
10
+
11
+ // Nerd Fonts' BMP glyphs stay one terminal cell wide with a Nerd Font Mono.
12
+ export const icons = {
13
+ heroku: '\ue77b', teams: '\uf0c0', pipelines: '\uf0e8', apps: '\uf1b2',
14
+ overview: '\uf05a', resources: '\uf233', addons: '\uf12e', config: '\uf084',
15
+ settings: '\uf013', releases: '\uf135', metrics: '\uf080',
16
+ success: '\uf058', warning: '\uf071', error: '\uf057', stopped: '\uf28d',
17
+ refresh: '\uf021', lock: '\uf023', eye: '\uf06e', globe: '\uf0ac',
18
+ stack: '\uf1b3', clock: '\uf017', search: '\uf002', code: '\uf121',
19
+ review: '\uf126', staging: '\uf0c3', database: '\uf1c0', help: '\uf059',
20
+ keyboard: '\uf11c', chevron: '\uf105',
21
+ }
22
+
23
+ export const tabIcons = ['overview', 'resources', 'addons', 'config', 'settings', 'releases', 'metrics']
24
+ export const stageStyles = {
25
+ development: {icon: 'code', tone: 'info'},
26
+ review: {icon: 'review', tone: 'accent'},
27
+ staging: {icon: 'staging', tone: 'warning'},
28
+ production: {icon: 'releases', tone: 'success'},
29
+ }
30
+
31
+ // Only these helpers introduce ANSI styles, after sanitizing their payloads.
32
+ // Reset foreground alone so row selection backgrounds remain intact.
33
+ export function paint(value, tone = 'fg', bold = false) {
34
+ const color = blessed.colors.convert(palette[tone] ?? palette.fg)
35
+ return `\x1b[38;5;${color}m${bold ? '\x1b[1m' : ''}${clean(value)}${bold ? '\x1b[22m' : ''}\x1b[39m`
36
+ }
37
+
38
+ export function badge(icon, value, tone = 'accent') {
39
+ return `${paint(icons[icon] ?? icons.overview, tone)} ${paint(single(value), tone)}`
40
+ }
41
+
42
+ export const SCANNER_INTERVAL = 40
43
+
44
+ export function scannerFrame(frame) {
45
+ // OpenCode-inspired square/dot scanner: light trails behind the moving head,
46
+ // then fades during a brief hold before the direction reverses.
47
+ const width = 8
48
+ const halfCycle = width - 1 + 4
49
+ const phase = frame % (halfCycle * 2)
50
+ const forward = phase < halfCycle
51
+ const step = phase % halfCycle
52
+ const position = Math.min(step, width - 1)
53
+ const head = forward ? position : width - 1 - position
54
+ const fade = Math.max(0, step - (width - 1))
55
+ const trail = ['accent', 'loadingTrail', 'loadingFade', 'loadingDim']
56
+ return Array.from({length: width}, (_, index) => {
57
+ const distance = forward ? head - index : index - head
58
+ if (distance === 0) return paint('■', 'accent', true)
59
+ if (distance > 0 && distance + fade < trail.length) return paint('■', trail[distance + fade])
60
+ return paint('⬝', 'loadingDim')
61
+ }).join('')
62
+ }
63
+
64
+ export function stateStyle(state) {
65
+ if (['up', 'idle', 'succeeded', 'provisioned', 'active'].includes(state)) return {icon: 'success', tone: 'success'}
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'}
68
+ return {icon: 'stopped', tone: 'muted'}
69
+ }
70
+
71
+ export function rowLabel(row) {
72
+ const text = single(row.label)
73
+ const emphasis = row.emphasis ? single(row.emphasis) : ''
74
+ let offset = emphasis ? text.indexOf(emphasis) : -1
75
+ // A state such as "up" must highlight the state column, not "backup.1".
76
+ while (offset >= 0 && ((offset > 0 && !/\s/.test(text[offset - 1]))
77
+ || (offset + emphasis.length < text.length && !/\s/.test(text[offset + emphasis.length])))) {
78
+ offset = text.indexOf(emphasis, offset + 1)
79
+ }
80
+ const label = offset < 0 ? text : `${text.slice(0, offset)}${paint(emphasis, row.tone)}${text.slice(offset + emphasis.length)}`
81
+ return ` ${paint(icons[row.icon] ?? icons.overview, row.tone ?? 'accent')} ${label}`
82
+ }
83
+
84
+ export function shortcut(key, description) {
85
+ return `${paint(key, 'accent', true)} ${paint(description, 'muted')}`
86
+ }
@@ -0,0 +1,127 @@
1
+ import {clean, single} from './text.js'
2
+ import {stateStyle} from './theme.js'
3
+
4
+ export {clean, single} from './text.js'
5
+ export function age(date, now = Date.now()) {
6
+ const seconds = Math.max(0, Math.floor((now - Date.parse(date)) / 1000))
7
+ if (!Number.isFinite(seconds)) return '—'
8
+ if (seconds < 60) return `${seconds}s`
9
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
10
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor(seconds % 3600 / 60)}m`
11
+ return `${Math.floor(seconds / 86400)}d ${Math.floor(seconds % 86400 / 3600)}h`
12
+ }
13
+ const lines = entries => entries.map(([key, value]) => `${key.padEnd(17)} ${clean(value)}`).join('\n')
14
+ const row = (label, detail, extra = {}) => ({label: single(label), detail: clean(detail), ...extra})
15
+ export const TABS = ['Overview', 'Resources', 'Add-ons', 'Config', 'Settings', 'Releases', 'Metrics']
16
+ export const STAGES = ['development', 'review', 'staging', 'production']
17
+
18
+ export function sortApps(apps) {
19
+ return [...apps].sort((a, b) => (STAGES.indexOf(a.stage) - STAGES.indexOf(b.stage)) || a.name.localeCompare(b.name))
20
+ }
21
+
22
+ export function operationalMetrics(data) {
23
+ const persistent = data.dynos.filter(d => data.formation.some(f => f.type === d.type))
24
+ const desired = data.formation.reduce((sum, f) => sum + f.quantity, 0)
25
+ const healthy = persistent.filter(d => d.state === 'up' || d.state === 'idle').length
26
+ return {desired, healthy, total: data.dynos.length,
27
+ crashed: data.dynos.filter(d => d.state === 'crashed').length,
28
+ starting: data.dynos.filter(d => d.state === 'starting').length,
29
+ coverage: desired ? Math.round(healthy / desired * 100) : null,
30
+ }
31
+ }
32
+
33
+ export function appRows(tab, data, {config, configError, revealed = new Set()} = {}) {
34
+ const {app, formation, dynos, addons, attachments, releases, domains, buildpacks, errors} = data
35
+ const rows = []
36
+ const error = section => {
37
+ if (errors[section]) rows.push(row(`${section} unavailable`, errors[section], {icon: 'error', tone: 'error', emphasis: 'unavailable'}))
38
+ }
39
+ if (tab === 'Overview') {
40
+ rows.push(row(`${app.name} · ${app.maintenance ? 'MAINTENANCE' : 'ACTIVE'}`, lines([
41
+ ['App', app.name], ['Team', app.team?.name ?? 'Personal / shared'], ['Region', app.region?.name],
42
+ ['Stack', app.stack?.name], ['Generation', app.generation?.name], ['Web URL', app.web_url],
43
+ ['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'}))
45
+ rows.push(row(`${formation.reduce((n, f) => n + f.quantity, 0)} configured dynos · ${addons.length} add-ons`,
46
+ 'Use Resources to inspect and scale process types.\nUse Add-ons to inspect plans and attachments.\nConfig values are masked until explicitly revealed.', {icon: 'resources'}))
47
+ for (const f of formation) rows.push(row(`${f.type} · ${f.quantity} × ${f.size}`, f.command, {icon: 'resources', tone: f.quantity ? 'cyan' : 'muted'}))
48
+ if (releases[0]) rows.push(row(`Latest release: v${releases[0].version} · ${releases[0].status}`, releases[0].description, {...stateStyle(releases[0].status), emphasis: releases[0].status}))
49
+ for (const section of Object.keys(errors)) error(section)
50
+ }
51
+ if (tab === 'Resources') {
52
+ error('formation'); error('dynos')
53
+ for (const f of formation) rows.push(row(`${f.type.padEnd(16)} ${String(f.quantity).padStart(3)} × ${f.size} [s] scale`, lines([
54
+ ['Process', f.type], ['Quantity', f.quantity], ['Size', f.size], ['Command', f.command],
55
+ ['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
+ for (const d of dynos) rows.push(row(` ${d.name.padEnd(20)} ${d.state.padEnd(10)} ${d.size} · ${age(d.created_at)}`, lines([
58
+ ['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}))
61
+ }
62
+ if (tab === 'Add-ons') {
63
+ error('addons'); error('attachments')
64
+ const all = new Map(addons.map(addon => [addon.id, addon]))
65
+ for (const attachment of attachments) if (!all.has(attachment.addon.id)) all.set(attachment.addon.id, attachment.addon)
66
+ for (const addon of all.values()) rows.push(row(`${addon.name} · ${addon.plan?.name ?? 'shared attachment'} · ${addon.state ?? '—'}`, lines([
67
+ ['Name', addon.name], ['Service', addon.addon_service?.name], ['Plan', addon.plan?.name],
68
+ ['State', addon.state], ['Billing app', addon.app?.name], ['Created', addon.created_at],
69
+ ['Attachments', attachments.filter(a => a.addon.id === addon.id).map(a => a.name).join(', ') || '—'],
70
+ ['Config keys', addon.config_vars?.join(', ')], ['ID', addon.id],
71
+ ]), {...stateStyle(addon.state), icon: /postgres|redis|mysql|mongo|key-value/i.test(addon.addon_service?.name ?? addon.plan?.name ?? '') ? 'database' : 'addons', emphasis: addon.state}))
72
+ }
73
+ if (tab === 'Config') {
74
+ if (configError) rows.push(row('Config vars unavailable', configError, {icon: 'error', tone: 'error', emphasis: 'unavailable'}))
75
+ else if (!config) rows.push(row('Loading config vars…', 'Config vars are fetched only when you open this tab.', {icon: 'refresh', tone: 'info'}))
76
+ else for (const key of Object.keys(config).sort()) {
77
+ const visible = revealed.has(key)
78
+ const prefix = `${single(key)}\n\n`
79
+ const value = visible ? clean(config[key]) || '(empty value)' : 'Value hidden. Press v to reveal this variable.'
80
+ rows.push(row(`${key} = ${visible ? single(config[key]) : '••••••••'}`,
81
+ `${prefix}${value}\n\n${visible ? 'Click the highlighted value to copy it.\n' : ''}[y] copy value [v] reveal / hide (this variable)\n[e] replace value [n] new variable [d] delete\nConfig changes create a release and restart the app.`, {
82
+ kind: 'config', key, icon: visible ? 'eye' : 'lock', tone: visible ? 'warning' : 'cyan', emphasis: key,
83
+ valueRange: visible ? {start: prefix.length, end: prefix.length + value.length} : undefined,
84
+ }))
85
+ }
86
+ }
87
+ 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'}))
89
+ rows.push(row(`Region: ${app.region?.name} · Stack: ${app.stack?.name}`, lines([
90
+ ['Region', app.region?.name], ['Stack', app.stack?.name], ['Build stack', app.build_stack?.name],
91
+ ['Space', app.space?.name ?? 'Common Runtime'], ['ACM', app.acm ? 'Enabled' : 'Disabled'],
92
+ ]), {icon: 'globe', tone: 'info'}))
93
+ error('domains'); error('buildpacks')
94
+ for (const domain of domains) rows.push(row(`Domain ${domain.hostname}`, lines([
95
+ ['Hostname', domain.hostname], ['Kind', domain.kind], ['CNAME', domain.cname],
96
+ ['Status', domain.status], ['ACM status', domain.acm_status], ['ACM reason', domain.acm_status_reason],
97
+ ]), {icon: 'globe', tone: 'info'}))
98
+ for (const item of buildpacks) rows.push(row(`Buildpack ${item.ordinal}. ${item.buildpack?.name ?? item.buildpack?.url}`, item.buildpack?.url, {icon: 'code', tone: 'accent'}))
99
+ }
100
+ if (tab === 'Releases') {
101
+ error('releases')
102
+ for (const release of releases) rows.push(row(`v${String(release.version).padEnd(5)} ${release.status.padEnd(10)} ${age(release.created_at).padEnd(7)} ${release.description}`, lines([
103
+ ['Version', `v${release.version}`], ['Status', release.status], ['Description', release.description],
104
+ ['User', release.user?.email], ['Created', release.created_at], ['ID', release.id],
105
+ ]), {...stateStyle(release.status), emphasis: release.status}))
106
+ }
107
+ if (tab === 'Metrics') {
108
+ error('dynos'); error('formation'); error('releases')
109
+ const m = operationalMetrics(data)
110
+ const available = !errors.dynos && !errors.formation
111
+ rows.push(row(`Dyno health ${available ? `${m.healthy} / ${m.desired} configured dynos up or idle` : 'unavailable'}`, available
112
+ ? `${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
+ : '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'}))
115
+ for (const f of formation) {
116
+ const members = dynos.filter(d => d.type === f.type)
117
+ const running = members.filter(d => ['up', 'idle'].includes(d.state)).length
118
+ rows.push(row(`${f.type} · desired ${f.quantity} · running ${running}`, members.map(d => `${d.name.padEnd(22)} ${d.state.padEnd(10)} age ${age(d.created_at)}`).join('\n') || 'No dynos currently running.',
119
+ {icon: 'resources', tone: errors.dynos ? 'muted' : members.some(d => d.state === 'crashed') ? 'error' : running < f.quantity ? 'warning' : f.quantity ? 'success' : 'muted', emphasis: `running ${running}`}))
120
+ }
121
+ if (!errors.releases) rows.push(row(`Deployments ${releases.filter(r => r.status === 'succeeded').length} succeeded / ${releases.length} recent releases`,
122
+ `Latest ${releases.length} releases (up to 20).\n${releases.filter(r => r.status === 'failed').length} failed releases.\nLatest release: ${releases[0] ? `v${releases[0].version}, ${age(releases[0].created_at)} ago` : 'none'}.`, {icon: 'releases', tone: releases.some(r => r.status === 'failed') ? 'warning' : 'info'}))
123
+ rows.push(row('Telemetry availability', 'CPU, memory, throughput, and latency charts are not exposed by the public Heroku Platform API.\n\nThis view shows live operational snapshots, not APM time-series metrics.\nPress o to open the app’s metrics page in the web dashboard.', {icon: 'overview', tone: 'muted'}))
124
+ rows.push(row(`Snapshot: ${new Date(data.fetchedAt).toLocaleTimeString()}`, 'Press R to refresh. Automatic refresh follows --refresh (default: 30 seconds).', {icon: 'clock', tone: 'muted'}))
125
+ }
126
+ return rows.length ? rows : [row('No items', `No ${tab.toLowerCase()} to display.`, {icon: 'search', tone: 'muted'})]
127
+ }