From 98c4d5169756280e60ec2c990010ac721dd56259 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 13 Jan 2025 21:47:52 +0200 Subject: [PATCH] feat: `datasets info` / `key-value-stores info` --- src/commands/datasets/info.ts | 160 ++++++++++++++++++++++++ src/commands/key-value-stores/info.ts | 164 +++++++++++++++++++++++++ src/lib/commands/pretty-print-bytes.ts | 4 +- src/lib/commands/responsive-table.ts | 25 +++- src/lib/commands/storage-size.ts | 14 +++ 5 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 src/commands/datasets/info.ts create mode 100644 src/commands/key-value-stores/info.ts create mode 100644 src/lib/commands/storage-size.ts diff --git a/src/commands/datasets/info.ts b/src/commands/datasets/info.ts new file mode 100644 index 00000000..222f4335 --- /dev/null +++ b/src/commands/datasets/info.ts @@ -0,0 +1,160 @@ +import { Args } from '@oclif/core'; +import type { Task } from 'apify-client'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/apify_command.js'; +import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; +import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; +import { getUserPlanPricing } from '../../lib/commands/storage-size.js'; +import { tryToGetDataset } from '../../lib/commands/storages.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; + +const consoleLikeTable = new ResponsiveTable({ + allColumns: ['Row1', 'Row2'], + mandatoryColumns: ['Row1', 'Row2'], +}); + +export class DatasetsInfoCommand extends ApifyCommand { + static override description = 'Shows information about a dataset.'; + + static override args = { + storeId: Args.string({ + description: 'The dataset store ID to print information about.', + required: true, + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { storeId } = this.args; + + const apifyClient = await getLoggedClientOrThrow(); + const maybeStore = await tryToGetDataset(apifyClient, storeId); + + if (!maybeStore) { + error({ + message: `Key-value store with ID or name "${storeId}" not found.`, + }); + + return; + } + + const { dataset: info } = maybeStore; + + const [user, actor, run] = await Promise.all([ + apifyClient + .user(info.userId) + .get() + .then((u) => u!), + info.actId ? apifyClient.actor(info.actId).get() : Promise.resolve(undefined), + info.actRunId ? apifyClient.run(info.actRunId).get() : Promise.resolve(undefined), + ]); + + let task: Task | undefined; + + if (run?.actorTaskId) { + task = await apifyClient + .task(run.actorTaskId) + .get() + .catch(() => undefined); + } + + if (this.flags.json) { + return { + ...info, + user, + actor: actor || null, + run: run || null, + task: task || null, + }; + } + + const fullSizeInBytes = info.stats?.storageBytes || 0; + const readCount = info.stats?.readCount || 0; + const writeCount = info.stats?.writeCount || 0; + const cleanCount = (info.cleanItemCount || 0).toLocaleString('en-US'); + const totalCount = (info.itemCount || 0).toLocaleString('en-US'); + + const operationsParts = [ + `${chalk.bold(readCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(readCount, 'read', 'reads'))}`, + `${chalk.bold(writeCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(writeCount, 'write', 'writes'))}`, + ]; + + let row3 = `Items: ${chalk.bold(cleanCount)} ${chalk.gray('clean')} / ${chalk.bold(totalCount)} ${chalk.gray('total')}\nOperations: ${operationsParts.join(' / ')}`; + + if (user.plan) { + const pricing = getUserPlanPricing(user.plan); + + if (pricing) { + const storeCostPerHour = + pricing.KEY_VALUE_STORE_TIMED_STORAGE_GBYTE_HOURS * (fullSizeInBytes / 1000 ** 3); + const storeCostPerMonth = storeCostPerHour * 24 * 30; + + const usdAmountString = + storeCostPerMonth > 1 ? `$${storeCostPerMonth.toFixed(2)}` : `$${storeCostPerHour.toFixed(3)}`; + + row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })} / ${chalk.gray(`${usdAmountString} per month`)}`; + } + } else { + row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })}`; + } + + const row1 = [ + `Dataset ID: ${chalk.bgGray(info.id)}`, + `Name: ${info.name ? chalk.bgGray(info.name) : chalk.bold(chalk.italic('Unnamed'))}`, + `Created: ${chalk.bold(TimestampFormatter.display(info.createdAt))}`, + `Modified: ${chalk.bold(TimestampFormatter.display(info.modifiedAt))}`, + ].join('\n'); + + let runInfo = chalk.bold('—'); + + if (info.actRunId) { + if (run) { + runInfo = chalk.bgBlue(run.id); + } else { + runInfo = chalk.italic(chalk.gray('Run removed')); + } + } + + let actorInfo = chalk.bold('—'); + + if (actor) { + actorInfo = chalk.blue(actor.title || actor.name); + } + + let taskInfo = chalk.bold('—'); + + if (task) { + taskInfo = chalk.blue(task.title || task.name); + } + + const row2 = [`Run: ${runInfo}`, `Actor: ${actorInfo}`, `Task: ${taskInfo}`].join('\n'); + + consoleLikeTable.pushRow({ + Row1: row1, + Row2: row2, + }); + + const rendered = consoleLikeTable.render(CompactMode.NoLines); + + const rows = rendered.split('\n').map((row) => row.trim()); + + // Remove the first row + rows.shift(); + + const message = [ + `${chalk.bold(info.name || chalk.italic('Unnamed'))}`, + `${chalk.gray(info.name ? `${user.username}/${info.name}` : info.id)} ${chalk.gray('Owned by')} ${chalk.blue(user.username)}`, + '', + rows.join('\n'), + '', + row3, + ].join('\n'); + + simpleLog({ message, stdout: true }); + + return undefined; + } +} diff --git a/src/commands/key-value-stores/info.ts b/src/commands/key-value-stores/info.ts new file mode 100644 index 00000000..0387e820 --- /dev/null +++ b/src/commands/key-value-stores/info.ts @@ -0,0 +1,164 @@ +import { Args } from '@oclif/core'; +import type { Task } from 'apify-client'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/apify_command.js'; +import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; +import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; +import { getUserPlanPricing } from '../../lib/commands/storage-size.js'; +import { tryToGetKeyValueStore } from '../../lib/commands/storages.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; + +const consoleLikeTable = new ResponsiveTable({ + allColumns: ['Row1', 'Row2'], + mandatoryColumns: ['Row1', 'Row2'], +}); + +export class KeyValueStoresInfoCommand extends ApifyCommand { + static override description = 'Shows information about a key-value store.'; + + static override hiddenAliases = ['kvs:info']; + + static override args = { + storeId: Args.string({ + description: 'The key-value store ID to print information about.', + required: true, + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { storeId } = this.args; + + const apifyClient = await getLoggedClientOrThrow(); + const maybeStore = await tryToGetKeyValueStore(apifyClient, storeId); + + if (!maybeStore) { + error({ + message: `Key-value store with ID or name "${storeId}" not found.`, + }); + + return; + } + + const { keyValueStore: info } = maybeStore; + + const [user, actor, run] = await Promise.all([ + apifyClient + .user(info.userId) + .get() + .then((u) => u!), + info.actId ? apifyClient.actor(info.actId).get() : Promise.resolve(undefined), + info.actRunId ? apifyClient.run(info.actRunId).get() : Promise.resolve(undefined), + ]); + + let task: Task | undefined; + + if (run?.actorTaskId) { + task = await apifyClient + .task(run.actorTaskId) + .get() + .catch(() => undefined); + } + + if (this.flags.json) { + return { + ...info, + user, + actor: actor || null, + run: run || null, + task: task || null, + }; + } + + const fullSizeInBytes = info.stats?.storageBytes || 0; + const readCount = info.stats?.readCount || 0; + const writeCount = info.stats?.writeCount || 0; + const deleteCount = info.stats?.deleteCount || 0; + const listCount = info.stats?.listCount || 0; + + const operationsParts = [ + `${chalk.bold(readCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(readCount, 'read', 'reads'))}`, + `${chalk.bold(writeCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(writeCount, 'write', 'writes'))}`, + `${chalk.bold(deleteCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(deleteCount, 'delete', 'deletes'))}`, + `${chalk.bold(listCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(listCount, 'list', 'lists'))}`, + ]; + + let row3 = `Operations: ${operationsParts.join(' / ')}`; + + if (user.plan) { + const pricing = getUserPlanPricing(user.plan); + + if (pricing) { + const storeCostPerHour = + pricing.KEY_VALUE_STORE_TIMED_STORAGE_GBYTE_HOURS * (fullSizeInBytes / 1000 ** 3); + const storeCostPerMonth = storeCostPerHour * 24 * 30; + + const usdAmountString = + storeCostPerMonth > 1 ? `$${storeCostPerMonth.toFixed(2)}` : `$${storeCostPerHour.toFixed(3)}`; + + row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })} / ${chalk.gray(`${usdAmountString} per month`)}`; + } + } else { + row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })} / ${chalk.gray('$unknown per month')}`; + } + + const row1 = [ + `Store ID: ${chalk.bgGray(info.id)}`, + `Name: ${info.name ? chalk.bgGray(info.name) : chalk.bold(chalk.italic('Unnamed'))}`, + `Created: ${chalk.bold(TimestampFormatter.display(info.createdAt))}`, + `Modified: ${chalk.bold(TimestampFormatter.display(info.modifiedAt))}`, + ].join('\n'); + + let runInfo = chalk.bold('—'); + + if (info.actRunId) { + if (run) { + runInfo = chalk.bgBlue(run.id); + } else { + runInfo = chalk.italic(chalk.gray('Run removed')); + } + } + + let actorInfo = chalk.bold('—'); + + if (actor) { + actorInfo = chalk.blue(actor.title || actor.name); + } + + let taskInfo = chalk.bold('—'); + + if (task) { + taskInfo = chalk.blue(task.title || task.name); + } + + const row2 = [`Run: ${runInfo}`, `Actor: ${actorInfo}`, `Task: ${taskInfo}`].join('\n'); + + consoleLikeTable.pushRow({ + Row1: row1, + Row2: row2, + }); + + const rendered = consoleLikeTable.render(CompactMode.NoLines); + + const rows = rendered.split('\n').map((row) => row.trim()); + + // Remove the first row + rows.shift(); + + const message = [ + `${chalk.bold(info.name || chalk.italic('Unnamed'))}`, + `${chalk.gray(info.name ? `${user.username}/${info.name}` : info.id)} ${chalk.gray('Owned by')} ${chalk.blue(user.username)}`, + '', + rows.join('\n'), + '', + row3, + ].join('\n'); + + simpleLog({ message, stdout: true }); + + return undefined; + } +} diff --git a/src/lib/commands/pretty-print-bytes.ts b/src/lib/commands/pretty-print-bytes.ts index c5137c76..f6e376f7 100644 --- a/src/lib/commands/pretty-print-bytes.ts +++ b/src/lib/commands/pretty-print-bytes.ts @@ -16,7 +16,7 @@ export function prettyPrintBytes({ return `${(0).toPrecision(precision)} Byte`; } - const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const i = Math.floor(Math.log(bytes) / Math.log(1000)); - return `${(bytes / 1024 ** i).toFixed(precision)} ${colorFunc(sizes[i])}`; + return `${(bytes / 1000 ** i).toFixed(precision)} ${colorFunc(sizes[i])}`; } diff --git a/src/lib/commands/responsive-table.ts b/src/lib/commands/responsive-table.ts index af911cff..c8281dd1 100644 --- a/src/lib/commands/responsive-table.ts +++ b/src/lib/commands/responsive-table.ts @@ -23,6 +23,24 @@ const compactModeCharsWithLineSeparator: Partial> = { 'right-mid': '┤', }; +const noSeparators: Partial> = { + left: '', + right: '', + mid: '', + 'bottom-left': '', + 'bottom-mid': '', + 'bottom-right': '', + top: '', + 'top-left': '', + 'top-mid': '', + 'top-right': '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '', + bottom: '', + middle: ' ', +}; + export enum CompactMode { /** * Print the table as is @@ -36,12 +54,17 @@ export enum CompactMode { * A version of the compact table that looks akin to the web console (fewer separators, but with lines between rows) */ WebLikeCompact = 1, + /** + * Straight up no lines, just two spaces in the middle of columns + */ + NoLines = 2, } const charMap = { [CompactMode.None]: undefined, [CompactMode.VeryCompact]: compactModeChars, [CompactMode.WebLikeCompact]: compactModeCharsWithLineSeparator, + [CompactMode.NoLines]: noSeparators, } satisfies Record> | undefined>; function generateHeaderColors(length: number): string[] { @@ -104,7 +127,7 @@ export class ResponsiveTable { const rawHead = ResponsiveTable.isSmallTerminal() ? this.options.mandatoryColumns : this.options.allColumns; const headColors = generateHeaderColors(rawHead.length); - const compact = compactMode === CompactMode.VeryCompact; + const compact = compactMode === CompactMode.VeryCompact || compactMode === CompactMode.NoLines; const chars = charMap[compactMode]; const colAligns: ('left' | 'right' | 'center')[] = []; diff --git a/src/lib/commands/storage-size.ts b/src/lib/commands/storage-size.ts new file mode 100644 index 00000000..90c9f76f --- /dev/null +++ b/src/lib/commands/storage-size.ts @@ -0,0 +1,14 @@ +import type { UserPlan } from 'apify-client'; + +export function getUserPlanPricing(userPlan: UserPlan) { + const planPricing = Reflect.get(userPlan, 'planPricing'); + + if (!planPricing) { + return null; + } + + return Reflect.get(planPricing, 'chargeableServiceUnitPricesUsd') as { + DATASET_TIMED_STORAGE_GBYTE_HOURS: number; + KEY_VALUE_STORE_TIMED_STORAGE_GBYTE_HOURS: number; + } | null; +}