@cloudbeat/atlas-cli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -19
- package/dist/api.js +70 -18
- package/dist/cli.js +34 -6
- package/dist/commands/analyze.js +118 -0
- package/dist/commands/capacity.js +99 -0
- package/dist/commands/docs.js +57 -0
- package/dist/commands/files.js +18 -4
- package/dist/commands/plan.js +100 -0
- package/dist/commands/record.js +11 -1
- package/dist/commands/results.js +13 -7
- package/dist/commands/schema.js +86 -0
- package/dist/commands/skills.js +111 -0
- package/dist/commands/tests.js +73 -0
- package/dist/docs/agent-setup.md +109 -0
- package/dist/docs/agent-tasks.md +169 -0
- package/dist/docs/ai-agents.md +90 -0
- package/dist/docs/auth.md +74 -0
- package/dist/docs/automation.md +150 -0
- package/dist/docs/ci.md +105 -0
- package/dist/docs/clusters.md +48 -0
- package/dist/docs/commands.md +155 -0
- package/dist/docs/files.md +86 -0
- package/dist/docs/generators.md +52 -0
- package/dist/docs/getting-started.md +85 -0
- package/dist/docs/index.json +110 -0
- package/dist/docs/projects.md +50 -0
- package/dist/docs/recorder.md +143 -0
- package/dist/docs/results.md +79 -0
- package/dist/docs/running.md +80 -0
- package/dist/docs/skills.md +88 -0
- package/dist/docs/test-plans.md +92 -0
- package/dist/docs/troubleshooting.md +79 -0
- package/dist/plan.js +250 -0
- package/dist/skills/AGENTS.md +70 -0
- package/dist/skills/atlas-analyze/SKILL.md +104 -0
- package/dist/skills/atlas-correlate/SKILL.md +89 -0
- package/dist/skills/atlas-load-test/SKILL.md +154 -0
- package/dist/skills/atlas-record/SKILL.md +167 -0
- package/dist/skills/atlas-run/SKILL.md +109 -0
- package/dist/skills/atlas-test-plan/SKILL.md +281 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -43,15 +43,15 @@ whenever they change — this is independent of running a test, and uploaded
|
|
|
43
43
|
files stay in the project until you replace or delete them.
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
atlas files upload -p
|
|
47
|
-
atlas files list -p
|
|
46
|
+
atlas files upload -p SHOP ./checkout.jmx ./data/users.csv ./data/products.csv
|
|
47
|
+
atlas files list -p SHOP
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
Uploading a file with a name that already exists replaces it.
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
atlas files download -p
|
|
54
|
-
atlas files rm -p
|
|
53
|
+
atlas files download -p SHOP checkout.jmx # fetch a copy back
|
|
54
|
+
atlas files rm -p SHOP old-data.csv # delete one
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
### How files are laid out — and how to reference them
|
|
@@ -64,16 +64,16 @@ generator.
|
|
|
64
64
|
So in your test plan, reference data files by a plain relative path:
|
|
65
65
|
|
|
66
66
|
```
|
|
67
|
-
./
|
|
68
|
-
|
|
67
|
+
./users.csv ✅ recommended
|
|
68
|
+
users.csv ✅ also works
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
Not by an absolute path, and not through a folder:
|
|
72
72
|
|
|
73
73
|
```
|
|
74
|
-
C:\Users\me\Data\
|
|
75
|
-
/home/me/data/
|
|
76
|
-
./data/
|
|
74
|
+
C:\Users\me\Data\users.csv ❌ a Windows path cannot be found on a Linux generator
|
|
75
|
+
/home/me/data/users.csv ❌ absolute paths won't exist there either
|
|
76
|
+
./data/users.csv ❌ the folder structure is not preserved
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
This catches people out most often with test plans authored in JMeter on
|
|
@@ -93,7 +93,7 @@ Two related things worth checking before your first run:
|
|
|
93
93
|
## Running a test
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
|
-
atlas run -p
|
|
96
|
+
atlas run -p SHOP --jmx checkout.jmx --servers 5 --wait
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
`--wait` follows the run to completion and prints the aggregation report.
|
|
@@ -101,9 +101,9 @@ Without it, the command returns as soon as the test has been submitted, and you
|
|
|
101
101
|
can check on it later:
|
|
102
102
|
|
|
103
103
|
```bash
|
|
104
|
-
atlas status
|
|
105
|
-
atlas results
|
|
106
|
-
atlas stop
|
|
104
|
+
atlas status SHOP-20260818-194201
|
|
105
|
+
atlas results SHOP-20260818-194201
|
|
106
|
+
atlas stop SHOP-20260818-194201
|
|
107
107
|
```
|
|
108
108
|
|
|
109
109
|
`--jmx` is only needed when a project holds more than one test plan.
|
|
@@ -139,9 +139,9 @@ export ATLAS_URL=https://atlas.cloudbeat.io
|
|
|
139
139
|
export ATLAS_TOKEN=atlas_pat_...
|
|
140
140
|
|
|
141
141
|
# Only if the plan or its data live in this repository and may have changed.
|
|
142
|
-
atlas files upload -p
|
|
142
|
+
atlas files upload -p SHOP ./checkout.jmx ./data/users.csv
|
|
143
143
|
|
|
144
|
-
atlas run -p
|
|
144
|
+
atlas run -p SHOP --jmx checkout.jmx --servers 5 \
|
|
145
145
|
--gate "error_rate<1,p95<2000" \
|
|
146
146
|
--junit results.xml
|
|
147
147
|
```
|
|
@@ -191,7 +191,7 @@ plan:
|
|
|
191
191
|
atlas record https://example.com -o flow.jmx
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
Add `--upload -p
|
|
194
|
+
Add `--upload -p SHOP` to put the finished plan straight into a project, ready
|
|
195
195
|
to run.
|
|
196
196
|
|
|
197
197
|
| | |
|
|
@@ -221,11 +221,38 @@ it if it isn't present.
|
|
|
221
221
|
| `atlas status <testId>` | check whether a test is still running |
|
|
222
222
|
| `atlas results <testId>` | show the aggregation report for a test |
|
|
223
223
|
| `atlas stop <testId>` | stop a running test |
|
|
224
|
+
| `atlas tests` | list a project's recent runs |
|
|
225
|
+
| `atlas timeseries <testId>` | active users, response time and error rate over time |
|
|
226
|
+
| `atlas errors <testId>` | the distinct errors a test produced |
|
|
227
|
+
| `atlas capacity` | what you can run on: machines, clusters and usage limits |
|
|
228
|
+
| `atlas plan check [file]` | check a test plan will run and report before you use it |
|
|
224
229
|
| `atlas record [url]` | record a browser session |
|
|
230
|
+
| `atlas schema` | describe every command and option (for scripts and AI agents) |
|
|
231
|
+
| `atlas skills install` | set up an AI assistant to drive Atlas properly |
|
|
232
|
+
| `atlas docs [topic]` | read the documentation without leaving the terminal |
|
|
225
233
|
| `atlas config get` / `set` | default instance URL and project |
|
|
226
234
|
|
|
227
|
-
|
|
228
|
-
`atlas <command> --help` for the full list of options.
|
|
235
|
+
Every command takes `--json`, and `-p, --project <name>` where a project
|
|
236
|
+
applies. Run `atlas <command> --help` for the full list of options.
|
|
237
|
+
|
|
238
|
+
When a command fails with `--json`, the error is printed as JSON on standard
|
|
239
|
+
error, so a script can read clean output on standard out and still see why a
|
|
240
|
+
run stopped.
|
|
241
|
+
|
|
242
|
+
## Working with an AI assistant
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
atlas skills install
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Writes task-specific guidance into the current project — `.claude/skills/` for
|
|
249
|
+
Claude Code, and `AGENTS.md` for Copilot, Cursor and anything else that reads it
|
|
250
|
+
— covering the end-to-end flow, recording, dynamic values, sizing a run and
|
|
251
|
+
reading results. `--user` installs for every project, `--force` replaces files
|
|
252
|
+
you have edited. Start a new assistant session afterwards.
|
|
253
|
+
|
|
254
|
+
`atlas schema --json` describes the whole command surface, and `atlas docs`
|
|
255
|
+
prints the documentation without a network connection.
|
|
229
256
|
|
|
230
257
|
## Settings
|
|
231
258
|
|
|
@@ -242,6 +269,12 @@ configuration file.
|
|
|
242
269
|
To avoid repeating yourself:
|
|
243
270
|
|
|
244
271
|
```bash
|
|
245
|
-
atlas config set project
|
|
272
|
+
atlas config set project SHOP
|
|
246
273
|
atlas config set url https://atlas.cloudbeat.io
|
|
247
274
|
```
|
|
275
|
+
|
|
276
|
+
## Compatibility
|
|
277
|
+
|
|
278
|
+
Works with any Atlas instance. From 0.3.0 the CLI talks to a new API path, and
|
|
279
|
+
falls back automatically when it finds an older instance that does not serve it —
|
|
280
|
+
so you do not need to match versions between the CLI and your Atlas server.
|
package/dist/api.js
CHANGED
|
@@ -8,6 +8,9 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const config_1 = require("./config");
|
|
10
10
|
const ui_1 = require("./ui");
|
|
11
|
+
/** Where the control-plane API lives today, and where it used to live. */
|
|
12
|
+
const API_PREFIX = '/app/api';
|
|
13
|
+
const LEGACY_API_PREFIX = '/admin/api';
|
|
11
14
|
// ── Client ───────────────────────────────────────────────────────────────────
|
|
12
15
|
class AtlasApi {
|
|
13
16
|
url;
|
|
@@ -22,6 +25,33 @@ class AtlasApi {
|
|
|
22
25
|
get baseUrl() {
|
|
23
26
|
return this.url;
|
|
24
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The control-plane API prefix.
|
|
30
|
+
*
|
|
31
|
+
* It moved from /admin/api to /app/api when the browser UI moved to /app.
|
|
32
|
+
* Servers keep serving the old prefix so CLIs released before that keep
|
|
33
|
+
* working; this handles the other direction — a current CLI pointed at an
|
|
34
|
+
* Atlas that predates the move. The first 404 under the new prefix switches
|
|
35
|
+
* to the old one for the life of the process, so there is no extra round
|
|
36
|
+
* trip against an up-to-date server.
|
|
37
|
+
*/
|
|
38
|
+
prefix = API_PREFIX;
|
|
39
|
+
prefixResolved = false;
|
|
40
|
+
get api() {
|
|
41
|
+
return this.prefix;
|
|
42
|
+
}
|
|
43
|
+
/** Fetch that retries once under the legacy prefix if the route is absent. */
|
|
44
|
+
async fetchApi(routePath, init) {
|
|
45
|
+
const res = await fetch(`${this.url}${routePath}`, init);
|
|
46
|
+
if (res.status === 404 && !this.prefixResolved && routePath.startsWith(API_PREFIX)) {
|
|
47
|
+
this.prefix = LEGACY_API_PREFIX;
|
|
48
|
+
this.prefixResolved = true;
|
|
49
|
+
const legacy = routePath.replace(API_PREFIX, LEGACY_API_PREFIX);
|
|
50
|
+
return { res: await fetch(`${this.url}${legacy}`, init), routePath: legacy };
|
|
51
|
+
}
|
|
52
|
+
this.prefixResolved = true;
|
|
53
|
+
return { res, routePath };
|
|
54
|
+
}
|
|
25
55
|
authHeaders() {
|
|
26
56
|
if (!this.token) {
|
|
27
57
|
throw new ui_1.CliError('Not authenticated. Run `atlas login`, or set ATLAS_TOKEN to an API token.', ui_1.EXIT.USAGE);
|
|
@@ -34,9 +64,9 @@ class AtlasApi {
|
|
|
34
64
|
const headers = auth ? this.authHeaders() : {};
|
|
35
65
|
if (opts.body !== undefined)
|
|
36
66
|
headers['Content-Type'] = 'application/json';
|
|
37
|
-
let
|
|
67
|
+
let attempt;
|
|
38
68
|
try {
|
|
39
|
-
|
|
69
|
+
attempt = await this.fetchApi(routePath, {
|
|
40
70
|
method,
|
|
41
71
|
headers,
|
|
42
72
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
@@ -45,7 +75,7 @@ class AtlasApi {
|
|
|
45
75
|
catch (err) {
|
|
46
76
|
throw new ui_1.CliError(`Cannot reach ${this.url}: ${err.message}`, ui_1.EXIT.ERROR);
|
|
47
77
|
}
|
|
48
|
-
return this.parse(method, routePath, res);
|
|
78
|
+
return this.parse(method, attempt.routePath, attempt.res);
|
|
49
79
|
}
|
|
50
80
|
async parse(method, routePath, res) {
|
|
51
81
|
const text = await res.text();
|
|
@@ -67,9 +97,11 @@ class AtlasApi {
|
|
|
67
97
|
}
|
|
68
98
|
// ── auth ──────────────────────────────────────────────────────────────────
|
|
69
99
|
async login(username, password) {
|
|
70
|
-
|
|
100
|
+
// Usually the very first call a CLI makes, so this is where the prefix
|
|
101
|
+
// normally gets resolved against an older server.
|
|
102
|
+
let attempt;
|
|
71
103
|
try {
|
|
72
|
-
|
|
104
|
+
attempt = await this.fetchApi(`${this.api}/auth/login`, {
|
|
73
105
|
method: 'POST',
|
|
74
106
|
headers: { 'Content-Type': 'application/json' },
|
|
75
107
|
body: JSON.stringify({ username, password }),
|
|
@@ -78,14 +110,14 @@ class AtlasApi {
|
|
|
78
110
|
catch (err) {
|
|
79
111
|
throw new ui_1.CliError(`Cannot reach ${this.url}: ${err.message}`, ui_1.EXIT.ERROR);
|
|
80
112
|
}
|
|
81
|
-
return this.parse('POST',
|
|
113
|
+
return this.parse('POST', attempt.routePath, attempt.res);
|
|
82
114
|
}
|
|
83
115
|
async me() {
|
|
84
|
-
return this.request('GET',
|
|
116
|
+
return this.request('GET', `${this.api}/auth/me`);
|
|
85
117
|
}
|
|
86
118
|
// ── projects & files ──────────────────────────────────────────────────────
|
|
87
119
|
async projects() {
|
|
88
|
-
return this.request('GET',
|
|
120
|
+
return this.request('GET', `${this.api}/projects`);
|
|
89
121
|
}
|
|
90
122
|
/** Projects are addressed by name on the CLI; the API wants the id. */
|
|
91
123
|
async projectByName(name) {
|
|
@@ -98,7 +130,7 @@ class AtlasApi {
|
|
|
98
130
|
return hit;
|
|
99
131
|
}
|
|
100
132
|
async files(projectId) {
|
|
101
|
-
return this.request('GET',
|
|
133
|
+
return this.request('GET', `${this.api}/projects/${projectId}/files`);
|
|
102
134
|
}
|
|
103
135
|
async uploadFiles(projectId, paths) {
|
|
104
136
|
const form = new FormData();
|
|
@@ -110,7 +142,7 @@ class AtlasApi {
|
|
|
110
142
|
}
|
|
111
143
|
let res;
|
|
112
144
|
try {
|
|
113
|
-
res = await fetch(`${this.url}
|
|
145
|
+
res = await fetch(`${this.url}${this.api}/projects/${projectId}/files`, {
|
|
114
146
|
method: 'POST',
|
|
115
147
|
headers: this.authHeaders(), // no Content-Type: fetch sets the multipart boundary
|
|
116
148
|
body: form,
|
|
@@ -119,33 +151,39 @@ class AtlasApi {
|
|
|
119
151
|
catch (err) {
|
|
120
152
|
throw new ui_1.CliError(`Upload failed: ${err.message}`, ui_1.EXIT.ERROR);
|
|
121
153
|
}
|
|
122
|
-
return this.parse('POST',
|
|
154
|
+
return this.parse('POST', `${this.api}/projects/${projectId}/files`, res);
|
|
123
155
|
}
|
|
124
156
|
async downloadFile(projectId, filename) {
|
|
125
|
-
const res = await fetch(`${this.url}
|
|
157
|
+
const res = await fetch(`${this.url}${this.api}/projects/${projectId}/files/${encodeURIComponent(filename)}/download`, { headers: this.authHeaders() });
|
|
126
158
|
if (!res.ok)
|
|
127
159
|
throw new ui_1.CliError(`Download failed: ${res.status} ${res.statusText}`, ui_1.EXIT.ERROR);
|
|
128
160
|
return Buffer.from(await res.arrayBuffer());
|
|
129
161
|
}
|
|
130
162
|
async deleteFile(projectId, filename) {
|
|
131
|
-
await this.request('DELETE',
|
|
163
|
+
await this.request('DELETE', `${this.api}/projects/${projectId}/files/${encodeURIComponent(filename)}`);
|
|
132
164
|
}
|
|
133
165
|
// ── fleet ─────────────────────────────────────────────────────────────────
|
|
134
166
|
async servers() {
|
|
135
|
-
return this.request('GET',
|
|
167
|
+
return this.request('GET', `${this.api}/servers`);
|
|
136
168
|
}
|
|
137
169
|
async clusters() {
|
|
138
|
-
return this.request('GET',
|
|
170
|
+
return this.request('GET', `${this.api}/clusters`);
|
|
171
|
+
}
|
|
172
|
+
async quota() {
|
|
173
|
+
return this.request('GET', `${this.api}/auth/quota`);
|
|
174
|
+
}
|
|
175
|
+
async fleet() {
|
|
176
|
+
return this.request('GET', `${this.api}/auth/fleet`);
|
|
139
177
|
}
|
|
140
178
|
// ── runs ──────────────────────────────────────────────────────────────────
|
|
141
179
|
async run(projectId, body) {
|
|
142
|
-
return this.request('POST',
|
|
180
|
+
return this.request('POST', `${this.api}/projects/${projectId}/run`, { body });
|
|
143
181
|
}
|
|
144
182
|
async queueEntry(queueId) {
|
|
145
|
-
return this.request('GET',
|
|
183
|
+
return this.request('GET', `${this.api}/tests/queue/${queueId}`);
|
|
146
184
|
}
|
|
147
185
|
async stop(testId) {
|
|
148
|
-
return this.request('POST',
|
|
186
|
+
return this.request('POST', `${this.api}/tests/${encodeURIComponent(testId)}/stop`, { body: {} });
|
|
149
187
|
}
|
|
150
188
|
// ── results (public dashboard API) ─────────────────────────────────────────
|
|
151
189
|
async summary(testId) {
|
|
@@ -154,6 +192,20 @@ class AtlasApi {
|
|
|
154
192
|
async testStatus(testId) {
|
|
155
193
|
return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/status`, { auth: false });
|
|
156
194
|
}
|
|
195
|
+
/** Runs for a project, most recent first (the dashboard's test list). */
|
|
196
|
+
async tests(project) {
|
|
197
|
+
return this.request('GET', `/api/projects/${encodeURIComponent(project)}/tests`, { auth: false });
|
|
198
|
+
}
|
|
199
|
+
/** 30s-bucketed points: active users, average response time, error rate. */
|
|
200
|
+
async timeseries(testId, label) {
|
|
201
|
+
const q = label ? `?label=${encodeURIComponent(label)}` : '';
|
|
202
|
+
return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/timeseries${q}`, { auth: false });
|
|
203
|
+
}
|
|
204
|
+
/** One document per distinct error type, with the full request/response. */
|
|
205
|
+
async errors(testId, label) {
|
|
206
|
+
const q = label ? `?label=${encodeURIComponent(label)}` : '';
|
|
207
|
+
return this.request('GET', `/api/tests/${encodeURIComponent(testId)}/errors${q}`, { auth: false });
|
|
208
|
+
}
|
|
157
209
|
}
|
|
158
210
|
exports.AtlasApi = AtlasApi;
|
|
159
211
|
//# sourceMappingURL=api.js.map
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,13 @@ const files_1 = require("./commands/files");
|
|
|
14
14
|
const run_1 = require("./commands/run");
|
|
15
15
|
const results_1 = require("./commands/results");
|
|
16
16
|
const record_1 = require("./commands/record");
|
|
17
|
+
const tests_1 = require("./commands/tests");
|
|
18
|
+
const analyze_1 = require("./commands/analyze");
|
|
19
|
+
const capacity_1 = require("./commands/capacity");
|
|
20
|
+
const schema_1 = require("./commands/schema");
|
|
21
|
+
const docs_1 = require("./commands/docs");
|
|
22
|
+
const skills_1 = require("./commands/skills");
|
|
23
|
+
const plan_1 = require("./commands/plan");
|
|
17
24
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
18
25
|
const { version } = require('../package.json');
|
|
19
26
|
const program = new commander_1.Command();
|
|
@@ -37,13 +44,26 @@ Examples:
|
|
|
37
44
|
atlas run -p SKT --jmx SKT-2026.jmx --servers 5 --wait \\
|
|
38
45
|
--gate "error_rate<1,p95<2000" --junit results.xml
|
|
39
46
|
atlas record https://example.com -o flow.jmx --upload -p SKT
|
|
47
|
+
|
|
48
|
+
For scripts and AI agents:
|
|
49
|
+
atlas schema --json every command, argument and option
|
|
50
|
+
atlas docs the documentation, offline
|
|
51
|
+
atlas skills install teach an AI assistant to drive Atlas properly
|
|
52
|
+
--json on every command; failures print JSON on stderr
|
|
40
53
|
`);
|
|
41
54
|
(0, auth_1.registerAuth)(program);
|
|
42
55
|
(0, projects_1.registerProjects)(program);
|
|
43
56
|
(0, files_1.registerFiles)(program);
|
|
44
57
|
(0, run_1.registerRun)(program);
|
|
45
58
|
(0, results_1.registerResults)(program);
|
|
59
|
+
(0, tests_1.registerTests)(program);
|
|
60
|
+
(0, analyze_1.registerAnalyze)(program);
|
|
61
|
+
(0, capacity_1.registerCapacity)(program);
|
|
62
|
+
(0, plan_1.registerPlan)(program);
|
|
46
63
|
(0, record_1.registerRecord)(program);
|
|
64
|
+
(0, schema_1.registerSchema)(program);
|
|
65
|
+
(0, docs_1.registerDocs)(program);
|
|
66
|
+
(0, skills_1.registerSkills)(program);
|
|
47
67
|
// Small config helper so CI/dev can pin a default project or URL without env.
|
|
48
68
|
const config = program.command('config').description('Read or write CLI settings');
|
|
49
69
|
config
|
|
@@ -69,15 +89,23 @@ config
|
|
|
69
89
|
async function main() {
|
|
70
90
|
await program.parseAsync(process.argv);
|
|
71
91
|
}
|
|
92
|
+
/** A caller that asked for JSON gets JSON on the way out too — on stderr, so a
|
|
93
|
+
* pipeline parsing stdout never has to tell output from failure. */
|
|
94
|
+
function failed(message, code) {
|
|
95
|
+
if (process.argv.includes('--json')) {
|
|
96
|
+
console.error(JSON.stringify({ error: { message, code } }, null, 2));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
ui_1.log.error(message);
|
|
100
|
+
}
|
|
101
|
+
process.exit(code);
|
|
102
|
+
}
|
|
72
103
|
main().catch((err) => {
|
|
73
104
|
// Expected failures print a message; anything else is a bug and keeps its stack.
|
|
74
|
-
if (err instanceof ui_1.CliError)
|
|
75
|
-
|
|
76
|
-
process.exit(err.code);
|
|
77
|
-
}
|
|
78
|
-
ui_1.log.error(err.message ?? String(err));
|
|
105
|
+
if (err instanceof ui_1.CliError)
|
|
106
|
+
failed(err.message, err.code);
|
|
79
107
|
if (process.env.ATLAS_DEBUG)
|
|
80
108
|
console.error(err);
|
|
81
|
-
|
|
109
|
+
failed(err.message ?? String(err), ui_1.EXIT.ERROR);
|
|
82
110
|
});
|
|
83
111
|
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerAnalyze = registerAnalyze;
|
|
7
|
+
const cli_table3_1 = __importDefault(require("cli-table3"));
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const api_1 = require("../api");
|
|
10
|
+
const ui_1 = require("../ui");
|
|
11
|
+
/** Rows a human can scan; --json always carries every point. */
|
|
12
|
+
const MAX_HUMAN_ROWS = 24;
|
|
13
|
+
/** Keep the first, the last and an even spread between — a run is 175 buckets
|
|
14
|
+
* at 30s, and the shape is what a person is reading the table for. */
|
|
15
|
+
function sample(items, max) {
|
|
16
|
+
if (items.length <= max)
|
|
17
|
+
return items;
|
|
18
|
+
const step = (items.length - 1) / (max - 1);
|
|
19
|
+
return Array.from({ length: max }, (_, i) => items[Math.round(i * step)]);
|
|
20
|
+
}
|
|
21
|
+
function clock(iso) {
|
|
22
|
+
return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
23
|
+
}
|
|
24
|
+
function printPoints(points) {
|
|
25
|
+
const table = new cli_table3_1.default({
|
|
26
|
+
head: ['TIME', 'USERS', 'AVG MS', 'ERR%'].map(h => chalk_1.default.dim(h)),
|
|
27
|
+
colAligns: ['left', 'right', 'right', 'right'],
|
|
28
|
+
style: { head: [], border: [] },
|
|
29
|
+
});
|
|
30
|
+
for (const p of sample(points, MAX_HUMAN_ROWS)) {
|
|
31
|
+
table.push([
|
|
32
|
+
clock(p.timestamp),
|
|
33
|
+
String(p.activeUsers),
|
|
34
|
+
String(p.avgMs),
|
|
35
|
+
p.errorRate === 0 ? chalk_1.default.green('0%') : chalk_1.default.red(`${p.errorRate}%`),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
console.log(table.toString());
|
|
39
|
+
}
|
|
40
|
+
function truncate(s, n) {
|
|
41
|
+
const one = (s ?? '').replace(/\s+/g, ' ').trim();
|
|
42
|
+
return one.length > n ? one.slice(0, n - 1) + '…' : one;
|
|
43
|
+
}
|
|
44
|
+
function printErrors(errors) {
|
|
45
|
+
const table = new cli_table3_1.default({
|
|
46
|
+
head: ['CODE', 'COUNT', 'SHARE', 'LABEL', 'MESSAGE'].map(h => chalk_1.default.dim(h)),
|
|
47
|
+
colAligns: ['left', 'right', 'right', 'left', 'left'],
|
|
48
|
+
style: { head: [], border: [] },
|
|
49
|
+
wordWrap: false,
|
|
50
|
+
});
|
|
51
|
+
for (const e of errors) {
|
|
52
|
+
table.push([
|
|
53
|
+
truncate(e.responseCode, 24) || chalk_1.default.dim('—'),
|
|
54
|
+
String(e.count),
|
|
55
|
+
`${e.errorPct}%`,
|
|
56
|
+
truncate(e.label, 32),
|
|
57
|
+
truncate(e.failureMessage, 60),
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
console.log(table.toString());
|
|
61
|
+
}
|
|
62
|
+
function registerAnalyze(program) {
|
|
63
|
+
program
|
|
64
|
+
.command('timeseries <testId>')
|
|
65
|
+
.description('Active users, response time and error rate in 30s buckets')
|
|
66
|
+
.option('--label <label>', 'one sampler/transaction instead of the whole test')
|
|
67
|
+
.option('--json', 'machine-readable output — every point, not a sample')
|
|
68
|
+
.action(async (testId, opts) => {
|
|
69
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
70
|
+
const { points } = await api.timeseries(testId, opts.label);
|
|
71
|
+
if (opts.json) {
|
|
72
|
+
(0, ui_1.json)({ testId, label: opts.label ?? '_all', points });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (points.length === 0) {
|
|
76
|
+
throw new ui_1.CliError(`No metrics found for "${testId}".`, ui_1.EXIT.ERROR);
|
|
77
|
+
}
|
|
78
|
+
const peakUsers = Math.max(...points.map(p => p.activeUsers));
|
|
79
|
+
const peakAvg = Math.max(...points.map(p => p.avgMs));
|
|
80
|
+
printPoints(points);
|
|
81
|
+
if (points.length > MAX_HUMAN_ROWS) {
|
|
82
|
+
ui_1.log.dim(`${points.length} points, ${MAX_HUMAN_ROWS} shown — use --json for all of them.`);
|
|
83
|
+
}
|
|
84
|
+
ui_1.log.info(`peak users ${chalk_1.default.bold(String(peakUsers))} · peak avg ${chalk_1.default.bold(`${peakAvg}ms`)}`);
|
|
85
|
+
});
|
|
86
|
+
program
|
|
87
|
+
.command('errors <testId>')
|
|
88
|
+
.description('Distinct error types for a run, with request and response detail')
|
|
89
|
+
.option('--label <label>', 'errors for one sampler/transaction only')
|
|
90
|
+
.option('--full', 'print the full request/response of each error')
|
|
91
|
+
.option('--json', 'machine-readable output')
|
|
92
|
+
.action(async (testId, opts) => {
|
|
93
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
94
|
+
const { errors, totalErrors } = await api.errors(testId, opts.label);
|
|
95
|
+
if (opts.json) {
|
|
96
|
+
(0, ui_1.json)({ testId, label: opts.label ?? '_all', totalErrors, errors });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (errors.length === 0) {
|
|
100
|
+
ui_1.log.ok(`No errors recorded for ${testId}${opts.label ? ` on "${opts.label}"` : ''}.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
printErrors(errors);
|
|
104
|
+
ui_1.log.info(`${errors.length} distinct error type(s), ${totalErrors} occurrence(s).`);
|
|
105
|
+
if (opts.full) {
|
|
106
|
+
for (const e of errors) {
|
|
107
|
+
console.log();
|
|
108
|
+
ui_1.log.info(chalk_1.default.bold(`${e.responseCode} ${e.label}`));
|
|
109
|
+
ui_1.log.dim(e.requestUrl);
|
|
110
|
+
if (e.requestBody)
|
|
111
|
+
ui_1.log.dim(`request: ${truncate(e.requestBody, 500)}`);
|
|
112
|
+
if (e.responseBody)
|
|
113
|
+
ui_1.log.dim(`response: ${truncate(e.responseBody, 500)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=analyze.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerCapacity = registerCapacity;
|
|
7
|
+
const cli_table3_1 = __importDefault(require("cli-table3"));
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const api_1 = require("../api");
|
|
10
|
+
const ui_1 = require("../ui");
|
|
11
|
+
/** An agent sizing a run needs to know what it is allowed to ask for before it
|
|
12
|
+
* asks the user. This is fleet + clusters + caps + quota in one call. */
|
|
13
|
+
function printServers(servers) {
|
|
14
|
+
const table = new cli_table3_1.default({
|
|
15
|
+
head: ['SERVER', 'STATUS', 'PROVIDER', 'BUSY'].map(h => chalk_1.default.dim(h)),
|
|
16
|
+
style: { head: [], border: [] },
|
|
17
|
+
});
|
|
18
|
+
for (const s of servers) {
|
|
19
|
+
table.push([
|
|
20
|
+
s.name,
|
|
21
|
+
s.status === 'on' ? chalk_1.default.green('on') : s.status === 'off' ? chalk_1.default.dim('off') : chalk_1.default.yellow('unknown'),
|
|
22
|
+
s.provider,
|
|
23
|
+
s.jmeter_running ? chalk_1.default.yellow('running a test') : chalk_1.default.dim('free'),
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
console.log(table.toString());
|
|
27
|
+
}
|
|
28
|
+
function printClusters(clusters) {
|
|
29
|
+
const table = new cli_table3_1.default({
|
|
30
|
+
head: ['CLUSTER', 'TYPE', 'CONTROLLERS'].map(h => chalk_1.default.dim(h)),
|
|
31
|
+
style: { head: [], border: [] },
|
|
32
|
+
});
|
|
33
|
+
for (const c of clusters) {
|
|
34
|
+
const connected = `${c.connectedControllers}/${c.controllers}`;
|
|
35
|
+
table.push([
|
|
36
|
+
c.name,
|
|
37
|
+
c.provider_type,
|
|
38
|
+
c.connectedControllers > 0 ? chalk_1.default.green(connected) : chalk_1.default.red(connected),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
console.log(table.toString());
|
|
42
|
+
}
|
|
43
|
+
function registerCapacity(program) {
|
|
44
|
+
program
|
|
45
|
+
.command('capacity')
|
|
46
|
+
.description('What this credential can run on: servers, clusters, fleet caps and quota')
|
|
47
|
+
.option('--json', 'machine-readable output')
|
|
48
|
+
.action(async (opts) => {
|
|
49
|
+
const api = api_1.AtlasApi.fromConfig();
|
|
50
|
+
const [servers, clusters] = await Promise.all([api.servers(), api.clusters()]);
|
|
51
|
+
// Caps and quota are newer than the fleet endpoints; an Atlas that predates
|
|
52
|
+
// them should still answer the main question rather than failing outright.
|
|
53
|
+
const optional = async (call) => call.catch(() => null);
|
|
54
|
+
const [fleet, quota] = await Promise.all([
|
|
55
|
+
optional(api.fleet()),
|
|
56
|
+
optional(api.quota()),
|
|
57
|
+
]);
|
|
58
|
+
const free = servers.filter(s => !s.jmeter_running);
|
|
59
|
+
if (opts.json) {
|
|
60
|
+
(0, ui_1.json)({
|
|
61
|
+
servers,
|
|
62
|
+
clusters,
|
|
63
|
+
fleet,
|
|
64
|
+
quota,
|
|
65
|
+
available: { servers: free.length, of: servers.length },
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (servers.length) {
|
|
70
|
+
printServers(servers);
|
|
71
|
+
ui_1.log.info(`${free.length} of ${servers.length} server(s) free.`);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
ui_1.log.warn('No fixed servers available to this credential.');
|
|
75
|
+
}
|
|
76
|
+
if (clusters.length) {
|
|
77
|
+
console.log();
|
|
78
|
+
printClusters(clusters);
|
|
79
|
+
ui_1.log.dim('Clusters allocate containers per run — size them with `atlas run --cluster <name> --units <n>`.');
|
|
80
|
+
}
|
|
81
|
+
if (fleet) {
|
|
82
|
+
console.log();
|
|
83
|
+
const cap = (used, max) => (max == null ? `${used} (no cap)` : `${used} of ${max}`);
|
|
84
|
+
ui_1.log.info(`nodes ${cap(fleet.nodes, fleet.maxNodes)} · concurrent agents ${cap(fleet.agents, fleet.maxAgents)}`);
|
|
85
|
+
}
|
|
86
|
+
if (quota?.limited) {
|
|
87
|
+
const used = quota.usedHours ?? 0;
|
|
88
|
+
const limit = quota.limitHours ?? 0;
|
|
89
|
+
const line = `usage ${used}h of ${limit}h${quota.pct != null ? ` (${quota.pct}%)` : ''}`;
|
|
90
|
+
if (quota.blocked)
|
|
91
|
+
ui_1.log.error(`${line} — runs are blocked until the limit is raised or the period resets.`);
|
|
92
|
+
else if ((quota.pct ?? 0) >= 80)
|
|
93
|
+
ui_1.log.warn(line);
|
|
94
|
+
else
|
|
95
|
+
ui_1.log.info(line);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=capacity.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerDocs = registerDocs;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
const ui_1 = require("../ui");
|
|
11
|
+
/** Written by scripts/bundle-docs.js at build time. */
|
|
12
|
+
const DOCS_DIR = path_1.default.join(__dirname, '..', 'docs');
|
|
13
|
+
function loadIndex() {
|
|
14
|
+
const file = path_1.default.join(DOCS_DIR, 'index.json');
|
|
15
|
+
if (!fs_1.default.existsSync(file))
|
|
16
|
+
return [];
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fs_1.default.readFileSync(file, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function registerDocs(program) {
|
|
25
|
+
program
|
|
26
|
+
.command('docs [topic]')
|
|
27
|
+
.description('Print the documentation for a topic (offline); no topic lists them')
|
|
28
|
+
.option('--json', 'machine-readable output')
|
|
29
|
+
.action((topic, opts) => {
|
|
30
|
+
const index = loadIndex();
|
|
31
|
+
if (index.length === 0) {
|
|
32
|
+
throw new ui_1.CliError('No documentation is bundled with this build. Read it at https://atlas.cloudbeat.io/docs', ui_1.EXIT.ERROR);
|
|
33
|
+
}
|
|
34
|
+
if (!topic) {
|
|
35
|
+
if (opts.json) {
|
|
36
|
+
(0, ui_1.json)({ topics: index });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
ui_1.log.info(chalk_1.default.bold('Topics'));
|
|
40
|
+
for (const t of index) {
|
|
41
|
+
console.log(` ${chalk_1.default.cyan(t.topic.padEnd(16))}${t.summary || t.title}`);
|
|
42
|
+
}
|
|
43
|
+
ui_1.log.dim('atlas docs <topic> · https://atlas.cloudbeat.io/docs');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const hit = index.find(t => t.topic === topic.toLowerCase());
|
|
47
|
+
if (!hit) {
|
|
48
|
+
throw new ui_1.CliError(`No topic "${topic}". Known: ${index.map(t => t.topic).join(', ')}`, ui_1.EXIT.USAGE);
|
|
49
|
+
}
|
|
50
|
+
const body = fs_1.default.readFileSync(path_1.default.join(DOCS_DIR, `${hit.topic}.md`), 'utf8');
|
|
51
|
+
if (opts.json)
|
|
52
|
+
(0, ui_1.json)({ topic: hit.topic, title: hit.title, markdown: body });
|
|
53
|
+
else
|
|
54
|
+
console.log(body);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=docs.js.map
|