Skip to content

Commit

Permalink
refactor: more responsive output for actors ls (#724)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Adámek <banan23@gmail.com>
  • Loading branch information
vladfrangu and B4nan authored Jan 13, 2025
1 parent bf959b9 commit 985f829
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 43 deletions.
121 changes: 98 additions & 23 deletions src/commands/actors/ls.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,49 @@
import type { ACTOR_JOB_STATUSES } from '@apify/consts';
import { Flags } from '@oclif/core';
import { Time } from '@sapphire/duration';
import type { Actor, ActorRunListItem, ActorTaggedBuild, PaginatedList } from 'apify-client';
import chalk from 'chalk';

import { ApifyCommand } from '../../lib/apify_command.js';
import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js';
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
import { CompactMode, kSkipColumn, ResponsiveTable } from '../../lib/commands/responsive-table.js';
import { info, simpleLog } from '../../lib/outputs.js';
import { getLoggedClientOrThrow, ShortDurationFormatter, TimestampFormatter } from '../../lib/utils.js';
import {
DateOnlyTimestampFormatter,
getLoggedClientOrThrow,
MultilineTimestampFormatter,
ShortDurationFormatter,
} from '../../lib/utils.js';

const statusMap: Record<(typeof ACTOR_JOB_STATUSES)[keyof typeof ACTOR_JOB_STATUSES], string> = {
'TIMED-OUT': chalk.gray('after'),
'TIMING-OUT': chalk.gray('after'),
ABORTED: chalk.gray('after'),
ABORTING: chalk.gray('after'),
FAILED: chalk.gray('after'),
READY: chalk.gray('for'),
RUNNING: chalk.gray('for'),
SUCCEEDED: chalk.gray('after'),
};

const recentlyUsedTable = new ResponsiveTable({
allColumns: ['Name', 'Runs', 'Last run started at', 'Last run status', 'Last run duration'],
mandatoryColumns: ['Name', 'Runs', 'Last run started at', 'Last run status', 'Last run duration'],
allColumns: ['Name', 'Runs', 'Last run started at', 'Last run status', 'Last run duration', '_Small_LastRunText'],
mandatoryColumns: ['Name', 'Runs', 'Last run status', 'Last run duration'],
columnAlignments: {
'Runs': 'right',
'Last run duration': 'right',
Name: 'left',
'Last run status': 'center',
},
hiddenColumns: ['_Small_LastRunText'],
breakpointOverrides: {
small: {
'Last run status': {
label: 'Last run',
valueFrom: '_Small_LastRunText',
},
},
},
});

const myRecentlyUsedTable = new ResponsiveTable({
Expand All @@ -29,24 +56,25 @@ const myRecentlyUsedTable = new ResponsiveTable({
'Last run',
'Last run status',
'Last run duration',
'_Small_LastRunText',
],
mandatoryColumns: [
'Name',
'Modified at',
'Builds',
'Default build',
'Runs',
'Last run',
'Last run status',
'Last run duration',
],
mandatoryColumns: ['Name', 'Runs', 'Last run', 'Last run duration'],
hiddenColumns: ['_Small_LastRunText'],
columnAlignments: {
'Builds': 'right',
'Runs': 'right',
'Last run duration': 'right',
Name: 'left',
'Last run status': 'center',
},
breakpointOverrides: {
small: {
'Last run': {
label: 'Last run',
valueFrom: '_Small_LastRunText',
},
},
},
});

interface HydratedListData {
Expand Down Expand Up @@ -148,24 +176,37 @@ export class ActorsLsCommand extends ApifyCommand<typeof ActorsLsCommand> {

const table = my ? myRecentlyUsedTable : recentlyUsedTable;

const longestActorTitleLength =
actorList.items.reduce((acc, curr) => {
const title = `${curr.username}/${curr.name}`;

if (title.length > acc) {
return title.length;
}

return acc;
}, 0) +
// Padding left right of the name column
2 +
// Runs column minimum size with padding
6;

for (const item of actorList.items) {
const lastRunDisplayedTimestamp = item.stats.lastRunStartedAt
? TimestampFormatter.display(item.stats.lastRunStartedAt)
? MultilineTimestampFormatter.display(item.stats.lastRunStartedAt)
: '';

const lastRunDuration = item.lastRun
? (() => {
if (item.lastRun.finishedAt) {
return chalk.gray(
ShortDurationFormatter.format(
item.lastRun.finishedAt.getTime() - item.lastRun.startedAt.getTime(),
),
return ShortDurationFormatter.format(
item.lastRun.finishedAt.getTime() - item.lastRun.startedAt.getTime(),
);
}

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

return chalk.gray(`${ShortDurationFormatter.format(duration)} ...`);
return `${ShortDurationFormatter.format(duration)}…`;
})()
: '';

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

const runStatus = (() => {
if (item.lastRun) {
const status = prettyPrintStatus(item.lastRun.status);

const stringParts = [status];

if (lastRunDuration) {
stringParts.push(statusMap[item.lastRun.status], chalk.cyan(lastRunDuration));
}

if (item.lastRun.finishedAt) {
const diff = Date.now() - item.lastRun.finishedAt.getTime();

if (diff < Time.Week) {
stringParts.push('\n', chalk.gray(`${ShortDurationFormatter.format(diff)} ago`));
} else {
stringParts.push(
'\n',
chalk.gray('On', DateOnlyTimestampFormatter.display(item.lastRun.finishedAt)),
);
}
}

return stringParts.join(' ');
}

return '';
})();

table.pushRow({
Name: `${item.title}\n${chalk.gray(`${item.username}/${item.name}`)}`,
Runs: chalk.cyan(`${item.stats?.totalRuns ?? 0}`),
// 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
Runs:
ResponsiveTable.isSmallTerminal() && longestActorTitleLength >= 56
? kSkipColumn
: chalk.cyan(`${item.stats?.totalRuns ?? 0}`),
'Last run started at': lastRunDisplayedTimestamp,
'Last run': lastRunDisplayedTimestamp,
'Last run status': item.lastRun ? prettyPrintStatus(item.lastRun.status) : '',
'Modified at': TimestampFormatter.display(item.modifiedAt),
'Modified at': MultilineTimestampFormatter.display(item.modifiedAt),
Builds: item.actor ? chalk.cyan(item.actor.stats.totalBuilds) : chalk.gray('Unknown'),
'Last run duration': lastRunDuration,
'Last run duration': ResponsiveTable.isSmallTerminal() ? kSkipColumn : chalk.cyan(lastRunDuration),
'Default build': defaultBuild,
_Small_LastRunText: runStatus,
});
}

Expand Down
9 changes: 3 additions & 6 deletions src/commands/runs/ls.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { Args, Flags } from '@oclif/core';
import { Timestamp } from '@sapphire/timestamp';
import chalk from 'chalk';

import { ApifyCommand } from '../../lib/apify_command.js';
import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js';
import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js';
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
import { error, simpleLog } from '../../lib/outputs.js';
import { getLoggedClientOrThrow, ShortDurationFormatter } from '../../lib/utils.js';

const multilineTimestampFormatter = new Timestamp(`YYYY-MM-DD[\n]HH:mm:ss`);
import { getLoggedClientOrThrow, MultilineTimestampFormatter, ShortDurationFormatter } from '../../lib/utils.js';

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

message.push(table.render(compact ? CompactMode.VeryCompact : CompactMode.None));
message.push(table.render(compact ? CompactMode.VeryCompact : CompactMode.WebLikeCompact));

simpleLog({
message: message.join('\n'),
Expand Down
76 changes: 62 additions & 14 deletions src/lib/commands/responsive-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ function generateHeaderColors(length: number): string[] {

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

export interface ResponsiveTableOptions<
AllColumns extends string,
MandatoryColumns extends NoInfer<AllColumns> = AllColumns,
> {
/** @internal */
export const kSkipColumn = Symbol.for('@apify/cli:responsive-table:skip-column');

export interface ResponsiveTableOptions<AllColumns extends string> {
/**
* Represents all the columns the that this table should show, and their order
*/
Expand All @@ -62,37 +62,81 @@ export interface ResponsiveTableOptions<
* Represents the columns that are mandatory for the user to see, even if the terminal size is less than adequate (<100).
* Make sure this field includes columns that provide enough context AND that will fit in an 80-column terminal.
*/
mandatoryColumns: MandatoryColumns[];
mandatoryColumns: NoInfer<AllColumns>[];
/**
* By default, all columns are left-aligned. You can specify columns that should be aligned in the middle or right
*/
columnAlignments?: Partial<Record<AllColumns, 'left' | 'center' | 'right'>>;
/**
* 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
*/
hiddenColumns?: NoInfer<AllColumns>[];
/**
* A set of different column and value overrides for specific columns based on size constraints
*/
breakpointOverrides?: {
small: {
[column in NoInfer<AllColumns>]?: {
label?: string;
/**
* The actual column to fetch the value from
*/
valueFrom?: NoInfer<AllColumns>;
};
};
};
}

export class ResponsiveTable<AllColumns extends string, MandatoryColumns extends NoInfer<AllColumns> = AllColumns> {
private options: ResponsiveTableOptions<AllColumns, MandatoryColumns>;
export class ResponsiveTable<AllColumns extends string> {
private options: ResponsiveTableOptions<AllColumns>;

private rows: Record<AllColumns, string>[] = [];
private rows: Record<AllColumns, string | typeof kSkipColumn>[] = [];

constructor(options: ResponsiveTableOptions<AllColumns, MandatoryColumns>) {
constructor(options: ResponsiveTableOptions<AllColumns>) {
this.options = options;
}

pushRow(item: Record<AllColumns, string>) {
pushRow(item: Record<AllColumns, string | typeof kSkipColumn>) {
this.rows.push(item);
}

render(compactMode: CompactMode): string {
const head = terminalColumns < 100 ? this.options.mandatoryColumns : this.options.allColumns;
const headColors = generateHeaderColors(head.length);
const rawHead = ResponsiveTable.isSmallTerminal() ? this.options.mandatoryColumns : this.options.allColumns;
const headColors = generateHeaderColors(rawHead.length);

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

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

for (const column of head) {
const head: string[] = [];
const headKeys: NoInfer<AllColumns>[] = [];

for (const column of rawHead) {
// Skip all hidden columns
if (this.options.hiddenColumns?.includes(column)) {
continue;
}

// If there's even one row that is set to have a skipped column value, skip it
if (this.rows.some((row) => row[column] === kSkipColumn)) {
continue;
}

// Column alignment
colAligns.push(this.options.columnAlignments?.[column] || 'left');

if (ResponsiveTable.isSmallTerminal()) {
// Header titles
head.push(this.options.breakpointOverrides?.small?.[column]?.label ?? column);

// Actual key to get the value from
headKeys.push(this.options.breakpointOverrides?.small?.[column]?.valueFrom ?? column);
} else {
// Always use full values
head.push(column);
headKeys.push(column);
}
}

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

for (const rowData of this.rows) {
const row = head.map((col) => rowData[col]);
const row = headKeys.map((col) => rowData[col] as string);
table.push(row);
}

return table.toString();
}

static isSmallTerminal() {
return terminalColumns < 100;
}
}
6 changes: 6 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ const getTokenWithAuthFileFallback = (existingToken?: string) => {
return existingToken;
};

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

/**
Expand Down Expand Up @@ -758,6 +759,11 @@ export const ensureApifyDirectory = (file: string) => {
};

export const TimestampFormatter = new Timestamp('YYYY-MM-DD [at] HH:mm:ss');

export const MultilineTimestampFormatter = new Timestamp(`YYYY-MM-DD[\n]HH:mm:ss`);

export const DateOnlyTimestampFormatter = new Timestamp('YYYY-MM-DD');

export const DurationFormatter = new SapphireDurationFormatter();

export const ShortDurationFormatter = new SapphireDurationFormatter({
Expand Down

0 comments on commit 985f829

Please sign in to comment.