From d4c4a0a2a30e832f954ae7346a87aecd4c55fbe7 Mon Sep 17 00:00:00 2001 From: Tim Carey-Smith Date: Thu, 27 Aug 2026 16:12:06 -0700 Subject: [PATCH] fix(ps): render ps --extended as vertical detail blocks The extended (ps -x) output used a wide 14-column hux.table. On any terminal narrower than the full table, cells wrapped down to a few characters each, shredding dyno UUIDs, timestamps, and IPs vertically and making the output unreadable. Render one vertical detail block per dyno via hux.styledObject under a styled header, matching the style of `releases:info` and other detail views. Nothing wraps at any width, the full command is shown rather than truncated, and null fields (e.g. Private Space dynos without an AZ/route) are omitted instead of printing empty columns. The --no-wrap flag existed only to mitigate this table's wrapping and was passed solely to the extended renderer, so it is removed here along with its now-unused truncate helper. The flag remains available on the other commands that use it. --- docs/ps.md | 1 - src/commands/ps/index.ts | 54 +++++------ test/unit/commands/ps/index.unit.test.ts | 118 +++++++++++++---------- 3 files changed, 93 insertions(+), 80 deletions(-) diff --git a/docs/ps.md b/docs/ps.md index 3ab79d7e81..604115c837 100644 --- a/docs/ps.md +++ b/docs/ps.md @@ -30,7 +30,6 @@ FLAGS -a, --app= (required) [env: HEROKU_APP] app to run command against -r, --remote= git remote of app to use --json display as json - --no-wrap disable wrapped table cells for easier copy/paste GLOBAL FLAGS --prompt interactively prompt for command arguments and flags diff --git a/src/commands/ps/index.ts b/src/commands/ps/index.ts index 58575259c4..54cc5e0244 100644 --- a/src/commands/ps/index.ts +++ b/src/commands/ps/index.ts @@ -8,7 +8,6 @@ import {AccountQuota} from '../../lib/types/account-quota.js' import {AppProcessTier} from '../../lib/types/app-process-tier.js' import {DynoExtended} from '../../lib/types/dyno-extended.js' import {Account} from '../../lib/types/fir.js' -import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' const heredoc = tsheredoc.default @@ -29,7 +28,6 @@ export default class Index extends Command { app: flags.app({required: true}), extended: flags.boolean({char: 'x', hidden: true}), // only works with sudo privileges json: flags.boolean({description: 'display as json'}), - 'no-wrap': flags.noWrap(), remote: flags.remote(), } static strict = false @@ -77,7 +75,7 @@ export default class Index extends Command { if (json) hux.styledJSON(selectedDynos) else if (extended) - printExtended(selectedDynos, flags['no-wrap']) + printExtended(selectedDynos) else { await printAccountQuota(this.heroku, appInfo, accountInfo) if (selectedDynos.length === 0) @@ -205,35 +203,31 @@ function printDynos(dynos: DynoExtended[]) : void { } } -function printExtended(dynos: DynoExtended[], noWrap = false) { +function printExtended(dynos: DynoExtended[]) { const sortedDynos = dynos.sort(byProcessTypeAndNumber) - /* eslint-disable perfectionist/sort-objects */ - hux.table( - sortedDynos, - { - ID: {get: (dyno: DynoExtended) => dyno.id}, - Process: {get: (dyno: DynoExtended) => dyno.name}, - State: {get: (dyno: DynoExtended) => `${dyno.state} ${ago(new Date(dyno.updated_at))}`}, - Region: {get: (dyno: DynoExtended) => dyno.extended?.region ?? ''}, - 'Execution Plane': {get: (dyno: DynoExtended) => dyno.extended?.execution_plane ?? ''}, - Fleet: {get: (dyno: DynoExtended) => dyno.extended?.fleet ?? ''}, - Instance: {get: (dyno: DynoExtended) => dyno.extended?.instance ?? ''}, - IP: {get: (dyno: DynoExtended) => dyno.extended?.ip ?? ''}, - Port: {get: (dyno: DynoExtended) => dyno.extended?.port?.toString() ?? ''}, - AZ: {get: (dyno: DynoExtended) => dyno.extended?.az ?? ''}, - Release: {get: (dyno: DynoExtended) => dyno.release.version}, - Command: {get: (dyno: DynoExtended) => truncate(dyno.command)}, - Route: {get: (dyno: DynoExtended) => dyno.extended?.route ?? ''}, - Size: {get: (dyno: DynoExtended) => dyno.size}, - }, - huxTableNoWrapOptions(noWrap), - ) - /* eslint-enable perfectionist/sort-objects */ -} - -function truncate(s: string) { - return s.length > 35 ? `${s.slice(0, 34)}…` : s + for (const dyno of sortedDynos) { + const ext = dyno.extended ?? {} + + hux.styledHeader(`${color.label(dyno.name)} (${color.info(dyno.size)})`) + /* eslint-disable perfectionist/sort-objects */ + hux.styledObject({ + ID: dyno.id, + State: `${dyno.state} ${ago(new Date(dyno.updated_at))}`, + Release: dyno.release.version, + Command: dyno.command, + Region: ext.region, + 'Execution Plane': ext.execution_plane, + Fleet: ext.fleet, + Instance: ext.instance, + IP: ext.ip, + Port: ext.port, + AZ: ext.az, + Route: ext.route, + }) + /* eslint-enable perfectionist/sort-objects */ + ux.stdout() + } } function uniqueValues(value: string, index: number, self: string[]) : boolean { diff --git a/test/unit/commands/ps/index.unit.test.ts b/test/unit/commands/ps/index.unit.test.ts index a10eed9b17..854ce44b01 100644 --- a/test/unit/commands/ps/index.unit.test.ts +++ b/test/unit/commands/ps/index.unit.test.ts @@ -1,9 +1,8 @@ import {runCommand} from '@heroku-cli/test-utils' -import {hux} from '@heroku/heroku-cli-util' import ansis from 'ansis' import {expect} from 'chai' import nock from 'nock' -import {restore, stub} from 'sinon' +import {restore} from 'sinon' import strftime from 'strftime' import tsheredoc from 'tsheredoc' @@ -269,50 +268,37 @@ describe('ps', function () { api.done() expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` - Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size - ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ─────── ─────── ───────── ──────── ──── - 101 run.1 up ${hourAgoStr} (~ 1h ago) us execution_plane fleet instance 10.0.0.2 8000 us-east 40 bash da route Eco - 100 web.1 up ${hourAgoStr} (~ 1h ago) us execution_plane fleet instance 10.0.0.1 8000 us-east 40 npm start da route Eco + === run.1 (Eco) + ID: 101 + State: up ${hourAgoStr} (~ 1h ago) + Release: 40 + Command: bash + Region: us + Execution Plane: execution_plane + Fleet: fleet + Instance: instance + IP: 10.0.0.2 + Port: 8000 + AZ: us-east + Route: da route + === web.1 (Eco) + ID: 100 + State: up ${hourAgoStr} (~ 1h ago) + Release: 40 + Command: npm start + Region: us + Execution Plane: execution_plane + Fleet: fleet + Instance: instance + IP: 10.0.0.1 + Port: 8000 + AZ: us-east + Route: da route `)) expect(stderr).to.equal('') }) - it('passes no-wrap option through to extended table rendering', async function () { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) - .get('/apps/myapp/dynos?extended=true') - .reply(200, [{ - command: 'npm start', - extended: { - az: 'us-east', - execution_plane: 'execution_plane', - fleet: 'fleet', - instance: 'instance', - ip: '10.0.0.1', - port: 8000, - region: 'us', - route: 'da route', - }, - id: '100', - name: 'web.1', - release: {id: '10', version: '40'}, - size: 'Eco', - state: 'up', - type: 'web', - updated_at: hourAgo, - }]) - - const tableStub = stub(hux, 'table') - await runCommand(Cmd, ['--app', 'myapp', '--extended', '--no-wrap']) - - const callArgs = tableStub.firstCall.args - expect(callArgs[2]).to.include({maxWidth: 'none', overflow: 'truncate'}) - }) - it('shows extended info for Private Space app', async function () { const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/account') @@ -369,10 +355,22 @@ describe('ps', function () { api.done() expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` - Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size - ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ── ─────── ───────── ───── ──── - 101 run.1 up ${hourAgoStr} (~ 1h ago) us instance 10.0.0.1 40 bash Eco - 100 web.1 up ${hourAgoStr} (~ 1h ago) us instance 10.0.0.1 40 npm start Eco + === run.1 (Eco) + ID: 101 + State: up ${hourAgoStr} (~ 1h ago) + Release: 40 + Command: bash + Region: us + Instance: instance + IP: 10.0.0.1 + === web.1 (Eco) + ID: 100 + State: up ${hourAgoStr} (~ 1h ago) + Release: 40 + Command: npm start + Region: us + Instance: instance + IP: 10.0.0.1 `)) expect(stderr).to.equal('') }) @@ -419,10 +417,32 @@ describe('ps', function () { api.done() expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` - Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size - ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ─────── ─────── ───────── ──────── ──────── - 101 run.1 up ${hourAgoStr} (~ 1h ago) us execution_plane fleet instance 10.0.0.2 8000 us-east 40 bash da route Shield-L - 100 web.1 up ${hourAgoStr} (~ 1h ago) us execution_plane fleet instance 10.0.0.1 8000 us-east 40 npm start da route Shield-M + === run.1 (Shield-L) + ID: 101 + State: up ${hourAgoStr} (~ 1h ago) + Release: 40 + Command: bash + Region: us + Execution Plane: execution_plane + Fleet: fleet + Instance: instance + IP: 10.0.0.2 + Port: 8000 + AZ: us-east + Route: da route + === web.1 (Shield-M) + ID: 100 + State: up ${hourAgoStr} (~ 1h ago) + Release: 40 + Command: npm start + Region: us + Execution Plane: execution_plane + Fleet: fleet + Instance: instance + IP: 10.0.0.1 + Port: 8000 + AZ: us-east + Route: da route `)) expect(stderr).to.equal('') })