Skip to content

Commit 985f829

Browse files
vladfranguB4nan
andauthored
refactor: more responsive output for actors ls (#724)
Co-authored-by: Martin Adámek <[email protected]>
1 parent bf959b9 commit 985f829

File tree

4 files changed

+169
-43
lines changed

4 files changed

+169
-43
lines changed

src/commands/actors/ls.ts

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,49 @@
1+
import type { ACTOR_JOB_STATUSES } from '@apify/consts';
12
import { Flags } from '@oclif/core';
3+
import { Time } from '@sapphire/duration';
24
import type { Actor, ActorRunListItem, ActorTaggedBuild, PaginatedList } from 'apify-client';
35
import chalk from 'chalk';
46

57
import { ApifyCommand } from '../../lib/apify_command.js';
68
import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js';
7-
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
9+
import { CompactMode, kSkipColumn, ResponsiveTable } from '../../lib/commands/responsive-table.js';
810
import { info, simpleLog } from '../../lib/outputs.js';
9-
import { getLoggedClientOrThrow, ShortDurationFormatter, TimestampFormatter } from '../../lib/utils.js';
11+
import {
12+
DateOnlyTimestampFormatter,
13+
getLoggedClientOrThrow,
14+
MultilineTimestampFormatter,
15+
ShortDurationFormatter,
16+
} from '../../lib/utils.js';
17+
18+
const statusMap: Record<(typeof ACTOR_JOB_STATUSES)[keyof typeof ACTOR_JOB_STATUSES], string> = {
19+
'TIMED-OUT': chalk.gray('after'),
20+
'TIMING-OUT': chalk.gray('after'),
21+
ABORTED: chalk.gray('after'),
22+
ABORTING: chalk.gray('after'),
23+
FAILED: chalk.gray('after'),
24+
READY: chalk.gray('for'),
25+
RUNNING: chalk.gray('for'),
26+
SUCCEEDED: chalk.gray('after'),
27+
};
1028

1129
const recentlyUsedTable = new ResponsiveTable({
12-
allColumns: ['Name', 'Runs', 'Last run started at', 'Last run status', 'Last run duration'],
13-
mandatoryColumns: ['Name', 'Runs', 'Last run started at', 'Last run status', 'Last run duration'],
30+
allColumns: ['Name', 'Runs', 'Last run started at', 'Last run status', 'Last run duration', '_Small_LastRunText'],
31+
mandatoryColumns: ['Name', 'Runs', 'Last run status', 'Last run duration'],
1432
columnAlignments: {
1533
'Runs': 'right',
1634
'Last run duration': 'right',
1735
Name: 'left',
1836
'Last run status': 'center',
1937
},
38+
hiddenColumns: ['_Small_LastRunText'],
39+
breakpointOverrides: {
40+
small: {
41+
'Last run status': {
42+
label: 'Last run',
43+
valueFrom: '_Small_LastRunText',
44+
},
45+
},
46+
},
2047
});
2148

2249
const myRecentlyUsedTable = new ResponsiveTable({
@@ -29,24 +56,25 @@ const myRecentlyUsedTable = new ResponsiveTable({
2956
'Last run',
3057
'Last run status',
3158
'Last run duration',
59+
'_Small_LastRunText',
3260
],
33-
mandatoryColumns: [
34-
'Name',
35-
'Modified at',
36-
'Builds',
37-
'Default build',
38-
'Runs',
39-
'Last run',
40-
'Last run status',
41-
'Last run duration',
42-
],
61+
mandatoryColumns: ['Name', 'Runs', 'Last run', 'Last run duration'],
62+
hiddenColumns: ['_Small_LastRunText'],
4363
columnAlignments: {
4464
'Builds': 'right',
4565
'Runs': 'right',
4666
'Last run duration': 'right',
4767
Name: 'left',
4868
'Last run status': 'center',
4969
},
70+
breakpointOverrides: {
71+
small: {
72+
'Last run': {
73+
label: 'Last run',
74+
valueFrom: '_Small_LastRunText',
75+
},
76+
},
77+
},
5078
});
5179

5280
interface HydratedListData {
@@ -148,24 +176,37 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
148176

149177
const table = my ? myRecentlyUsedTable : recentlyUsedTable;
150178

179+
const longestActorTitleLength =
180+
actorList.items.reduce((acc, curr) => {
181+
const title = `${curr.username}/${curr.name}`;
182+
183+
if (title.length > acc) {
184+
return title.length;
185+
}
186+
187+
return acc;
188+
}, 0) +
189+
// Padding left right of the name column
190+
2 +
191+
// Runs column minimum size with padding
192+
6;
193+
151194
for (const item of actorList.items) {
152195
const lastRunDisplayedTimestamp = item.stats.lastRunStartedAt
153-
? TimestampFormatter.display(item.stats.lastRunStartedAt)
196+
? MultilineTimestampFormatter.display(item.stats.lastRunStartedAt)
154197
: '';
155198

156199
const lastRunDuration = item.lastRun
157200
? (() => {
158201
if (item.lastRun.finishedAt) {
159-
return chalk.gray(
160-
ShortDurationFormatter.format(
161-
item.lastRun.finishedAt.getTime() - item.lastRun.startedAt.getTime(),
162-
),
202+
return ShortDurationFormatter.format(
203+
item.lastRun.finishedAt.getTime() - item.lastRun.startedAt.getTime(),
163204
);
164205
}
165206

166207
const duration = Date.now() - item.lastRun.startedAt.getTime();
167208

168-
return chalk.gray(`${ShortDurationFormatter.format(duration)} ...`);
209+
return `${ShortDurationFormatter.format(duration)}…`;
169210
})()
170211
: '';
171212

@@ -187,16 +228,50 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {
187228
})()
188229
: chalk.gray('Unknown');
189230

231+
const runStatus = (() => {
232+
if (item.lastRun) {
233+
const status = prettyPrintStatus(item.lastRun.status);
234+
235+
const stringParts = [status];
236+
237+
if (lastRunDuration) {
238+
stringParts.push(statusMap[item.lastRun.status], chalk.cyan(lastRunDuration));
239+
}
240+
241+
if (item.lastRun.finishedAt) {
242+
const diff = Date.now() - item.lastRun.finishedAt.getTime();
243+
244+
if (diff < Time.Week) {
245+
stringParts.push('\n', chalk.gray(`${ShortDurationFormatter.format(diff)} ago`));
246+
} else {
247+
stringParts.push(
248+
'\n',
249+
chalk.gray('On', DateOnlyTimestampFormatter.display(item.lastRun.finishedAt)),
250+
);
251+
}
252+
}
253+
254+
return stringParts.join(' ');
255+
}
256+
257+
return '';
258+
})();
259+
190260
table.pushRow({
191261
Name: `${item.title}\n${chalk.gray(`${item.username}/${item.name}`)}`,
192-
Runs: chalk.cyan(`${item.stats?.totalRuns ?? 0}`),
262+
// Completely arbitrary number, but its enough for a very specific edge case where a full actor identifier could be very long, but only on small terminals
263+
Runs:
264+
ResponsiveTable.isSmallTerminal() && longestActorTitleLength >= 56
265+
? kSkipColumn
266+
: chalk.cyan(`${item.stats?.totalRuns ?? 0}`),
193267
'Last run started at': lastRunDisplayedTimestamp,
194268
'Last run': lastRunDisplayedTimestamp,
195269
'Last run status': item.lastRun ? prettyPrintStatus(item.lastRun.status) : '',
196-
'Modified at': TimestampFormatter.display(item.modifiedAt),
270+
'Modified at': MultilineTimestampFormatter.display(item.modifiedAt),
197271
Builds: item.actor ? chalk.cyan(item.actor.stats.totalBuilds) : chalk.gray('Unknown'),
198-
'Last run duration': lastRunDuration,
272+
'Last run duration': ResponsiveTable.isSmallTerminal() ? kSkipColumn : chalk.cyan(lastRunDuration),
199273
'Default build': defaultBuild,
274+
_Small_LastRunText: runStatus,
200275
});
201276
}
202277

src/commands/runs/ls.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { Args, Flags } from '@oclif/core';
2-
import { Timestamp } from '@sapphire/timestamp';
32
import chalk from 'chalk';
43

54
import { ApifyCommand } from '../../lib/apify_command.js';
65
import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js';
76
import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js';
87
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
98
import { error, simpleLog } from '../../lib/outputs.js';
10-
import { getLoggedClientOrThrow, ShortDurationFormatter } from '../../lib/utils.js';
11-
12-
const multilineTimestampFormatter = new Timestamp(`YYYY-MM-DD[\n]HH:mm:ss`);
9+
import { getLoggedClientOrThrow, MultilineTimestampFormatter, ShortDurationFormatter } from '../../lib/utils.js';
1310

1411
const table = new ResponsiveTable({
1512
allColumns: ['ID', 'Status', 'Results', 'Usage', 'Started At', 'Took', 'Build No.', 'Origin'],
@@ -121,14 +118,14 @@ export class RunsLsCommand extends ApifyCommand<typeof RunsLsCommand> {
121118
Status: prettyPrintStatus(run.status),
122119
Results: datasetInfos.get(run.id) || chalk.gray('N/A'),
123120
Usage: chalk.cyan(`$${(run.usageTotalUsd ?? 0).toFixed(3)}`),
124-
'Started At': multilineTimestampFormatter.display(run.startedAt),
121+
'Started At': MultilineTimestampFormatter.display(run.startedAt),
125122
Took: tookString,
126123
'Build No.': run.buildNumber,
127124
Origin: run.meta.origin ?? 'UNKNOWN',
128125
});
129126
}
130127

131-
message.push(table.render(compact ? CompactMode.VeryCompact : CompactMode.None));
128+
message.push(table.render(compact ? CompactMode.VeryCompact : CompactMode.WebLikeCompact));
132129

133130
simpleLog({
134131
message: message.join('\n'),

src/lib/commands/responsive-table.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ function generateHeaderColors(length: number): string[] {
5050

5151
const terminalColumns = process.stdout.columns ?? 100;
5252

53-
export interface ResponsiveTableOptions<
54-
AllColumns extends string,
55-
MandatoryColumns extends NoInfer<AllColumns> = AllColumns,
56-
> {
53+
/** @internal */
54+
export const kSkipColumn = Symbol.for('@apify/cli:responsive-table:skip-column');
55+
56+
export interface ResponsiveTableOptions<AllColumns extends string> {
5757
/**
5858
* Represents all the columns the that this table should show, and their order
5959
*/
@@ -62,37 +62,81 @@ export interface ResponsiveTableOptions<
6262
* Represents the columns that are mandatory for the user to see, even if the terminal size is less than adequate (<100).
6363
* Make sure this field includes columns that provide enough context AND that will fit in an 80-column terminal.
6464
*/
65-
mandatoryColumns: MandatoryColumns[];
65+
mandatoryColumns: NoInfer<AllColumns>[];
6666
/**
6767
* By default, all columns are left-aligned. You can specify columns that should be aligned in the middle or right
6868
*/
6969
columnAlignments?: Partial<Record<AllColumns, 'left' | 'center' | 'right'>>;
70+
/**
71+
* An array of hidden columns, that can be used to store extra data in a table row, to then render differently in the table based on size constraints
72+
*/
73+
hiddenColumns?: NoInfer<AllColumns>[];
74+
/**
75+
* A set of different column and value overrides for specific columns based on size constraints
76+
*/
77+
breakpointOverrides?: {
78+
small: {
79+
[column in NoInfer<AllColumns>]?: {
80+
label?: string;
81+
/**
82+
* The actual column to fetch the value from
83+
*/
84+
valueFrom?: NoInfer<AllColumns>;
85+
};
86+
};
87+
};
7088
}
7189

72-
export class ResponsiveTable<AllColumns extends string, MandatoryColumns extends NoInfer<AllColumns> = AllColumns> {
73-
private options: ResponsiveTableOptions<AllColumns, MandatoryColumns>;
90+
export class ResponsiveTable<AllColumns extends string> {
91+
private options: ResponsiveTableOptions<AllColumns>;
7492

75-
private rows: Record<AllColumns, string>[] = [];
93+
private rows: Record<AllColumns, string | typeof kSkipColumn>[] = [];
7694

77-
constructor(options: ResponsiveTableOptions<AllColumns, MandatoryColumns>) {
95+
constructor(options: ResponsiveTableOptions<AllColumns>) {
7896
this.options = options;
7997
}
8098

81-
pushRow(item: Record<AllColumns, string>) {
99+
pushRow(item: Record<AllColumns, string | typeof kSkipColumn>) {
82100
this.rows.push(item);
83101
}
84102

85103
render(compactMode: CompactMode): string {
86-
const head = terminalColumns < 100 ? this.options.mandatoryColumns : this.options.allColumns;
87-
const headColors = generateHeaderColors(head.length);
104+
const rawHead = ResponsiveTable.isSmallTerminal() ? this.options.mandatoryColumns : this.options.allColumns;
105+
const headColors = generateHeaderColors(rawHead.length);
88106

89107
const compact = compactMode === CompactMode.VeryCompact;
90108
const chars = charMap[compactMode];
91109

92110
const colAligns: ('left' | 'right' | 'center')[] = [];
93111

94-
for (const column of head) {
112+
const head: string[] = [];
113+
const headKeys: NoInfer<AllColumns>[] = [];
114+
115+
for (const column of rawHead) {
116+
// Skip all hidden columns
117+
if (this.options.hiddenColumns?.includes(column)) {
118+
continue;
119+
}
120+
121+
// If there's even one row that is set to have a skipped column value, skip it
122+
if (this.rows.some((row) => row[column] === kSkipColumn)) {
123+
continue;
124+
}
125+
126+
// Column alignment
95127
colAligns.push(this.options.columnAlignments?.[column] || 'left');
128+
129+
if (ResponsiveTable.isSmallTerminal()) {
130+
// Header titles
131+
head.push(this.options.breakpointOverrides?.small?.[column]?.label ?? column);
132+
133+
// Actual key to get the value from
134+
headKeys.push(this.options.breakpointOverrides?.small?.[column]?.valueFrom ?? column);
135+
} else {
136+
// Always use full values
137+
head.push(column);
138+
headKeys.push(column);
139+
}
96140
}
97141

98142
const table = new Table({
@@ -106,10 +150,14 @@ export class ResponsiveTable<AllColumns extends string, MandatoryColumns extends
106150
});
107151

108152
for (const rowData of this.rows) {
109-
const row = head.map((col) => rowData[col]);
153+
const row = headKeys.map((col) => rowData[col] as string);
110154
table.push(row);
111155
}
112156

113157
return table.toString();
114158
}
159+
160+
static isSmallTerminal() {
161+
return terminalColumns < 100;
162+
}
115163
}

src/lib/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ const getTokenWithAuthFileFallback = (existingToken?: string) => {
151151
return existingToken;
152152
};
153153

154+
// biome-ignore format: off
154155
type CJSAxiosHeaders = import('axios', { with: { 'resolution-mode': 'require' } }).AxiosRequestConfig['headers'];
155156

156157
/**
@@ -758,6 +759,11 @@ export const ensureApifyDirectory = (file: string) => {
758759
};
759760

760761
export const TimestampFormatter = new Timestamp('YYYY-MM-DD [at] HH:mm:ss');
762+
763+
export const MultilineTimestampFormatter = new Timestamp(`YYYY-MM-DD[\n]HH:mm:ss`);
764+
765+
export const DateOnlyTimestampFormatter = new Timestamp('YYYY-MM-DD');
766+
761767
export const DurationFormatter = new SapphireDurationFormatter();
762768

763769
export const ShortDurationFormatter = new SapphireDurationFormatter({

0 commit comments

Comments
 (0)