Skip to content

Commit 56e8ffa

Browse files
authored
feat: datasets info / key-value-stores info (#726)
1 parent b9efbb2 commit 56e8ffa

File tree

5 files changed

+364
-3
lines changed

5 files changed

+364
-3
lines changed

src/commands/datasets/info.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Args } from '@oclif/core';
2+
import type { Task } from 'apify-client';
3+
import chalk from 'chalk';
4+
5+
import { ApifyCommand } from '../../lib/apify_command.js';
6+
import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js';
7+
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
8+
import { getUserPlanPricing } from '../../lib/commands/storage-size.js';
9+
import { tryToGetDataset } from '../../lib/commands/storages.js';
10+
import { error, simpleLog } from '../../lib/outputs.js';
11+
import { getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js';
12+
13+
const consoleLikeTable = new ResponsiveTable({
14+
allColumns: ['Row1', 'Row2'],
15+
mandatoryColumns: ['Row1', 'Row2'],
16+
});
17+
18+
export class DatasetsInfoCommand extends ApifyCommand<typeof DatasetsInfoCommand> {
19+
static override description = 'Shows information about a dataset.';
20+
21+
static override args = {
22+
storeId: Args.string({
23+
description: 'The dataset store ID to print information about.',
24+
required: true,
25+
}),
26+
};
27+
28+
static override enableJsonFlag = true;
29+
30+
async run() {
31+
const { storeId } = this.args;
32+
33+
const apifyClient = await getLoggedClientOrThrow();
34+
const maybeStore = await tryToGetDataset(apifyClient, storeId);
35+
36+
if (!maybeStore) {
37+
error({
38+
message: `Key-value store with ID or name "${storeId}" not found.`,
39+
});
40+
41+
return;
42+
}
43+
44+
const { dataset: info } = maybeStore;
45+
46+
const [user, actor, run] = await Promise.all([
47+
apifyClient
48+
.user(info.userId)
49+
.get()
50+
.then((u) => u!),
51+
info.actId ? apifyClient.actor(info.actId).get() : Promise.resolve(undefined),
52+
info.actRunId ? apifyClient.run(info.actRunId).get() : Promise.resolve(undefined),
53+
]);
54+
55+
let task: Task | undefined;
56+
57+
if (run?.actorTaskId) {
58+
task = await apifyClient
59+
.task(run.actorTaskId)
60+
.get()
61+
.catch(() => undefined);
62+
}
63+
64+
if (this.flags.json) {
65+
return {
66+
...info,
67+
user,
68+
actor: actor || null,
69+
run: run || null,
70+
task: task || null,
71+
};
72+
}
73+
74+
const fullSizeInBytes = info.stats?.storageBytes || 0;
75+
const readCount = info.stats?.readCount || 0;
76+
const writeCount = info.stats?.writeCount || 0;
77+
const cleanCount = (info.cleanItemCount || 0).toLocaleString('en-US');
78+
const totalCount = (info.itemCount || 0).toLocaleString('en-US');
79+
80+
const operationsParts = [
81+
`${chalk.bold(readCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(readCount, 'read', 'reads'))}`,
82+
`${chalk.bold(writeCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(writeCount, 'write', 'writes'))}`,
83+
];
84+
85+
let row3 = `Items: ${chalk.bold(cleanCount)} ${chalk.gray('clean')} / ${chalk.bold(totalCount)} ${chalk.gray('total')}\nOperations: ${operationsParts.join(' / ')}`;
86+
87+
if (user.plan) {
88+
const pricing = getUserPlanPricing(user.plan);
89+
90+
if (pricing) {
91+
const storeCostPerHour =
92+
pricing.KEY_VALUE_STORE_TIMED_STORAGE_GBYTE_HOURS * (fullSizeInBytes / 1000 ** 3);
93+
const storeCostPerMonth = storeCostPerHour * 24 * 30;
94+
95+
const usdAmountString =
96+
storeCostPerMonth > 1 ? `$${storeCostPerMonth.toFixed(2)}` : `$${storeCostPerHour.toFixed(3)}`;
97+
98+
row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })} / ${chalk.gray(`${usdAmountString} per month`)}`;
99+
}
100+
} else {
101+
row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })}`;
102+
}
103+
104+
const row1 = [
105+
`Dataset ID: ${chalk.bgGray(info.id)}`,
106+
`Name: ${info.name ? chalk.bgGray(info.name) : chalk.bold(chalk.italic('Unnamed'))}`,
107+
`Created: ${chalk.bold(TimestampFormatter.display(info.createdAt))}`,
108+
`Modified: ${chalk.bold(TimestampFormatter.display(info.modifiedAt))}`,
109+
].join('\n');
110+
111+
let runInfo = chalk.bold('—');
112+
113+
if (info.actRunId) {
114+
if (run) {
115+
runInfo = chalk.bgBlue(run.id);
116+
} else {
117+
runInfo = chalk.italic(chalk.gray('Run removed'));
118+
}
119+
}
120+
121+
let actorInfo = chalk.bold('—');
122+
123+
if (actor) {
124+
actorInfo = chalk.blue(actor.title || actor.name);
125+
}
126+
127+
let taskInfo = chalk.bold('—');
128+
129+
if (task) {
130+
taskInfo = chalk.blue(task.title || task.name);
131+
}
132+
133+
const row2 = [`Run: ${runInfo}`, `Actor: ${actorInfo}`, `Task: ${taskInfo}`].join('\n');
134+
135+
consoleLikeTable.pushRow({
136+
Row1: row1,
137+
Row2: row2,
138+
});
139+
140+
const rendered = consoleLikeTable.render(CompactMode.NoLines);
141+
142+
const rows = rendered.split('\n').map((row) => row.trim());
143+
144+
// Remove the first row
145+
rows.shift();
146+
147+
const message = [
148+
`${chalk.bold(info.name || chalk.italic('Unnamed'))}`,
149+
`${chalk.gray(info.name ? `${user.username}/${info.name}` : info.id)} ${chalk.gray('Owned by')} ${chalk.blue(user.username)}`,
150+
'',
151+
rows.join('\n'),
152+
'',
153+
row3,
154+
].join('\n');
155+
156+
simpleLog({ message, stdout: true });
157+
158+
return undefined;
159+
}
160+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Args } from '@oclif/core';
2+
import type { Task } from 'apify-client';
3+
import chalk from 'chalk';
4+
5+
import { ApifyCommand } from '../../lib/apify_command.js';
6+
import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js';
7+
import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js';
8+
import { getUserPlanPricing } from '../../lib/commands/storage-size.js';
9+
import { tryToGetKeyValueStore } from '../../lib/commands/storages.js';
10+
import { error, simpleLog } from '../../lib/outputs.js';
11+
import { getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js';
12+
13+
const consoleLikeTable = new ResponsiveTable({
14+
allColumns: ['Row1', 'Row2'],
15+
mandatoryColumns: ['Row1', 'Row2'],
16+
});
17+
18+
export class KeyValueStoresInfoCommand extends ApifyCommand<typeof KeyValueStoresInfoCommand> {
19+
static override description = 'Shows information about a key-value store.';
20+
21+
static override hiddenAliases = ['kvs:info'];
22+
23+
static override args = {
24+
storeId: Args.string({
25+
description: 'The key-value store ID to print information about.',
26+
required: true,
27+
}),
28+
};
29+
30+
static override enableJsonFlag = true;
31+
32+
async run() {
33+
const { storeId } = this.args;
34+
35+
const apifyClient = await getLoggedClientOrThrow();
36+
const maybeStore = await tryToGetKeyValueStore(apifyClient, storeId);
37+
38+
if (!maybeStore) {
39+
error({
40+
message: `Key-value store with ID or name "${storeId}" not found.`,
41+
});
42+
43+
return;
44+
}
45+
46+
const { keyValueStore: info } = maybeStore;
47+
48+
const [user, actor, run] = await Promise.all([
49+
apifyClient
50+
.user(info.userId)
51+
.get()
52+
.then((u) => u!),
53+
info.actId ? apifyClient.actor(info.actId).get() : Promise.resolve(undefined),
54+
info.actRunId ? apifyClient.run(info.actRunId).get() : Promise.resolve(undefined),
55+
]);
56+
57+
let task: Task | undefined;
58+
59+
if (run?.actorTaskId) {
60+
task = await apifyClient
61+
.task(run.actorTaskId)
62+
.get()
63+
.catch(() => undefined);
64+
}
65+
66+
if (this.flags.json) {
67+
return {
68+
...info,
69+
user,
70+
actor: actor || null,
71+
run: run || null,
72+
task: task || null,
73+
};
74+
}
75+
76+
const fullSizeInBytes = info.stats?.storageBytes || 0;
77+
const readCount = info.stats?.readCount || 0;
78+
const writeCount = info.stats?.writeCount || 0;
79+
const deleteCount = info.stats?.deleteCount || 0;
80+
const listCount = info.stats?.listCount || 0;
81+
82+
const operationsParts = [
83+
`${chalk.bold(readCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(readCount, 'read', 'reads'))}`,
84+
`${chalk.bold(writeCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(writeCount, 'write', 'writes'))}`,
85+
`${chalk.bold(deleteCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(deleteCount, 'delete', 'deletes'))}`,
86+
`${chalk.bold(listCount.toLocaleString('en-US'))} ${chalk.gray(this.pluralString(listCount, 'list', 'lists'))}`,
87+
];
88+
89+
let row3 = `Operations: ${operationsParts.join(' / ')}`;
90+
91+
if (user.plan) {
92+
const pricing = getUserPlanPricing(user.plan);
93+
94+
if (pricing) {
95+
const storeCostPerHour =
96+
pricing.KEY_VALUE_STORE_TIMED_STORAGE_GBYTE_HOURS * (fullSizeInBytes / 1000 ** 3);
97+
const storeCostPerMonth = storeCostPerHour * 24 * 30;
98+
99+
const usdAmountString =
100+
storeCostPerMonth > 1 ? `$${storeCostPerMonth.toFixed(2)}` : `$${storeCostPerHour.toFixed(3)}`;
101+
102+
row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })} / ${chalk.gray(`${usdAmountString} per month`)}`;
103+
}
104+
} else {
105+
row3 += `\nStorage size: ${prettyPrintBytes({ bytes: fullSizeInBytes, shortBytes: true, precision: 1 })} / ${chalk.gray('$unknown per month')}`;
106+
}
107+
108+
const row1 = [
109+
`Store ID: ${chalk.bgGray(info.id)}`,
110+
`Name: ${info.name ? chalk.bgGray(info.name) : chalk.bold(chalk.italic('Unnamed'))}`,
111+
`Created: ${chalk.bold(TimestampFormatter.display(info.createdAt))}`,
112+
`Modified: ${chalk.bold(TimestampFormatter.display(info.modifiedAt))}`,
113+
].join('\n');
114+
115+
let runInfo = chalk.bold('—');
116+
117+
if (info.actRunId) {
118+
if (run) {
119+
runInfo = chalk.bgBlue(run.id);
120+
} else {
121+
runInfo = chalk.italic(chalk.gray('Run removed'));
122+
}
123+
}
124+
125+
let actorInfo = chalk.bold('—');
126+
127+
if (actor) {
128+
actorInfo = chalk.blue(actor.title || actor.name);
129+
}
130+
131+
let taskInfo = chalk.bold('—');
132+
133+
if (task) {
134+
taskInfo = chalk.blue(task.title || task.name);
135+
}
136+
137+
const row2 = [`Run: ${runInfo}`, `Actor: ${actorInfo}`, `Task: ${taskInfo}`].join('\n');
138+
139+
consoleLikeTable.pushRow({
140+
Row1: row1,
141+
Row2: row2,
142+
});
143+
144+
const rendered = consoleLikeTable.render(CompactMode.NoLines);
145+
146+
const rows = rendered.split('\n').map((row) => row.trim());
147+
148+
// Remove the first row
149+
rows.shift();
150+
151+
const message = [
152+
`${chalk.bold(info.name || chalk.italic('Unnamed'))}`,
153+
`${chalk.gray(info.name ? `${user.username}/${info.name}` : info.id)} ${chalk.gray('Owned by')} ${chalk.blue(user.username)}`,
154+
'',
155+
rows.join('\n'),
156+
'',
157+
row3,
158+
].join('\n');
159+
160+
simpleLog({ message, stdout: true });
161+
162+
return undefined;
163+
}
164+
}

src/lib/commands/pretty-print-bytes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function prettyPrintBytes({
1616
return `${(0).toPrecision(precision)} Byte`;
1717
}
1818

19-
const i = Math.floor(Math.log(bytes) / Math.log(1024));
19+
const i = Math.floor(Math.log(bytes) / Math.log(1000));
2020

21-
return `${(bytes / 1024 ** i).toFixed(precision)} ${colorFunc(sizes[i])}`;
21+
return `${(bytes / 1000 ** i).toFixed(precision)} ${colorFunc(sizes[i])}`;
2222
}

src/lib/commands/responsive-table.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,24 @@ const compactModeCharsWithLineSeparator: Partial<Record<CharName, string>> = {
2323
'right-mid': '┤',
2424
};
2525

26+
const noSeparators: Partial<Record<CharName, string>> = {
27+
left: '',
28+
right: '',
29+
mid: '',
30+
'bottom-left': '',
31+
'bottom-mid': '',
32+
'bottom-right': '',
33+
top: '',
34+
'top-left': '',
35+
'top-mid': '',
36+
'top-right': '',
37+
'left-mid': '',
38+
'mid-mid': '',
39+
'right-mid': '',
40+
bottom: '',
41+
middle: ' ',
42+
};
43+
2644
export enum CompactMode {
2745
/**
2846
* Print the table as is
@@ -36,12 +54,17 @@ export enum CompactMode {
3654
* A version of the compact table that looks akin to the web console (fewer separators, but with lines between rows)
3755
*/
3856
WebLikeCompact = 1,
57+
/**
58+
* Straight up no lines, just two spaces in the middle of columns
59+
*/
60+
NoLines = 2,
3961
}
4062

4163
const charMap = {
4264
[CompactMode.None]: undefined,
4365
[CompactMode.VeryCompact]: compactModeChars,
4466
[CompactMode.WebLikeCompact]: compactModeCharsWithLineSeparator,
67+
[CompactMode.NoLines]: noSeparators,
4568
} satisfies Record<CompactMode, Partial<Record<CharName, string>> | undefined>;
4669

4770
function generateHeaderColors(length: number): string[] {
@@ -104,7 +127,7 @@ export class ResponsiveTable<AllColumns extends string> {
104127
const rawHead = ResponsiveTable.isSmallTerminal() ? this.options.mandatoryColumns : this.options.allColumns;
105128
const headColors = generateHeaderColors(rawHead.length);
106129

107-
const compact = compactMode === CompactMode.VeryCompact;
130+
const compact = compactMode === CompactMode.VeryCompact || compactMode === CompactMode.NoLines;
108131
const chars = charMap[compactMode];
109132

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

0 commit comments

Comments
 (0)