@rulebricks/cli 2.0.0 → 2.0.1
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 +3 -3
- package/dist/commands/benchmark.d.ts +11 -0
- package/dist/commands/benchmark.js +173 -0
- package/dist/commands/deploy.js +3 -3
- package/dist/commands/destroy.js +2 -2
- package/dist/commands/logs.js +1 -0
- package/dist/components/Wizard/steps/BenchmarkSteps.d.ts +31 -0
- package/dist/components/Wizard/steps/BenchmarkSteps.js +304 -0
- package/dist/components/Wizard/steps/DatabaseStep.js +49 -35
- package/dist/index.js +42 -6
- package/dist/lib/benchmark.d.ts +63 -0
- package/dist/lib/benchmark.js +466 -0
- package/dist/lib/dns.d.ts +3 -1
- package/dist/lib/dns.js +138 -56
- package/dist/lib/helm.d.ts +14 -1
- package/dist/lib/helm.js +36 -1
- package/dist/lib/kubernetes.js +2 -0
- package/dist/types/index.d.ts +90 -0
- package/dist/types/index.js +51 -0
- package/package.json +5 -5
- package/terraform/aws/main.tf +22 -0
- package/terraform/azure/main.tf +45 -0
- package/terraform/gcp/main.tf +34 -0
- /package/{email-templates → templates}/email_change.html +0 -0
- /package/{email-templates → templates}/invite.html +0 -0
- /package/{email-templates → templates}/password_change.html +0 -0
- /package/{email-templates → templates}/verify.html +0 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark utilities for running k6 load tests against Rulebricks deployments
|
|
3
|
+
*/
|
|
4
|
+
import { execa } from "execa";
|
|
5
|
+
import { promises as fs } from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import os from "os";
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import { BLOCKED_BENCHMARK_DOMAINS, } from "../types/index.js";
|
|
10
|
+
// Directory for benchmark scripts and results
|
|
11
|
+
const RULEBRICKS_DIR = path.join(os.homedir(), ".rulebricks");
|
|
12
|
+
const BENCHMARKS_DIR = path.join(RULEBRICKS_DIR, "benchmarks");
|
|
13
|
+
/**
|
|
14
|
+
* Extracts meaningful error message from execa error
|
|
15
|
+
*/
|
|
16
|
+
function extractExecaError(error) {
|
|
17
|
+
const execaError = error;
|
|
18
|
+
const output = execaError.stderr || execaError.stdout || "";
|
|
19
|
+
if (output) {
|
|
20
|
+
const lines = output.split("\n").filter((l) => l.trim());
|
|
21
|
+
if (lines.length > 0)
|
|
22
|
+
return lines[0];
|
|
23
|
+
}
|
|
24
|
+
return execaError.shortMessage || execaError.message || "Unknown error";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Check if k6 is installed and available
|
|
28
|
+
*/
|
|
29
|
+
export async function isK6Installed() {
|
|
30
|
+
try {
|
|
31
|
+
await execa("k6", ["version"]);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Get k6 version string
|
|
40
|
+
*/
|
|
41
|
+
export async function getK6Version() {
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = await execa("k6", ["version"]);
|
|
44
|
+
return stdout.trim();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get k6 installation instructions based on OS
|
|
52
|
+
*/
|
|
53
|
+
export function getK6InstallInstructions() {
|
|
54
|
+
const platform = process.platform;
|
|
55
|
+
switch (platform) {
|
|
56
|
+
case "darwin":
|
|
57
|
+
return "Install k6 with: brew install k6";
|
|
58
|
+
case "linux":
|
|
59
|
+
return "Install k6: https://k6.io/docs/get-started/installation/#linux";
|
|
60
|
+
case "win32":
|
|
61
|
+
return "Install k6 with: choco install k6 or winget install k6";
|
|
62
|
+
default:
|
|
63
|
+
return "Install k6: https://k6.io/docs/get-started/installation/";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Validate that a URL is a valid benchmark target (not a cloud URL)
|
|
68
|
+
*/
|
|
69
|
+
export function isValidBenchmarkTarget(url) {
|
|
70
|
+
try {
|
|
71
|
+
const parsed = new URL(url);
|
|
72
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
73
|
+
// Check against blocked domains
|
|
74
|
+
for (const blocked of BLOCKED_BENCHMARK_DOMAINS) {
|
|
75
|
+
if (hostname === blocked || hostname.endsWith(`.${blocked}`)) {
|
|
76
|
+
return {
|
|
77
|
+
valid: false,
|
|
78
|
+
reason: `Cannot benchmark against Rulebricks Cloud (${blocked}). Only private deployments managed by this CLI are allowed.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Must be HTTPS for production deployments
|
|
83
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
84
|
+
return {
|
|
85
|
+
valid: false,
|
|
86
|
+
reason: "URL must use http:// or https:// protocol",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { valid: true };
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return {
|
|
93
|
+
valid: false,
|
|
94
|
+
reason: "Invalid URL format",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Check if a deployment is healthy by calling the /api/health endpoint
|
|
100
|
+
* Returns true if the deployment responds with {"status":"OK"}
|
|
101
|
+
*/
|
|
102
|
+
export async function checkDeploymentHealth(deploymentUrl) {
|
|
103
|
+
try {
|
|
104
|
+
// Ensure URL has protocol
|
|
105
|
+
const baseUrl = deploymentUrl.startsWith("http")
|
|
106
|
+
? deploymentUrl
|
|
107
|
+
: `https://${deploymentUrl}`;
|
|
108
|
+
const cleanUrl = baseUrl.replace(/\/$/, "");
|
|
109
|
+
const healthUrl = `${cleanUrl}/api/health`;
|
|
110
|
+
// Use native fetch with a timeout
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(healthUrl, {
|
|
115
|
+
method: "GET",
|
|
116
|
+
signal: controller.signal,
|
|
117
|
+
headers: {
|
|
118
|
+
Accept: "application/json",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
clearTimeout(timeoutId);
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const data = (await response.json());
|
|
126
|
+
// Check for the expected health response
|
|
127
|
+
return data.status === "OK";
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
clearTimeout(timeoutId);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build the full API URL from deployment domain and flow slug
|
|
140
|
+
*/
|
|
141
|
+
export function buildApiUrl(domain, flowSlug) {
|
|
142
|
+
// Ensure domain has protocol
|
|
143
|
+
const baseUrl = domain.startsWith("http") ? domain : `https://${domain}`;
|
|
144
|
+
// Remove trailing slash if present
|
|
145
|
+
const cleanUrl = baseUrl.replace(/\/$/, "");
|
|
146
|
+
// Build the flows API endpoint
|
|
147
|
+
return `${cleanUrl}/api/v1/flows/${flowSlug}`;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Ensure benchmark scripts directory exists and contains the test scripts
|
|
151
|
+
*/
|
|
152
|
+
export async function ensureBenchmarkScripts() {
|
|
153
|
+
await fs.mkdir(BENCHMARKS_DIR, { recursive: true });
|
|
154
|
+
// Check if scripts exist, if not write them
|
|
155
|
+
const qpsScript = path.join(BENCHMARKS_DIR, "qps-test.js");
|
|
156
|
+
const throughputScript = path.join(BENCHMARKS_DIR, "throughput-test.js");
|
|
157
|
+
const libDir = path.join(BENCHMARKS_DIR, "lib");
|
|
158
|
+
const payloadScript = path.join(libDir, "payload.js");
|
|
159
|
+
const reportScript = path.join(libDir, "report.js");
|
|
160
|
+
// Create lib directory
|
|
161
|
+
await fs.mkdir(libDir, { recursive: true });
|
|
162
|
+
// Try to copy from package first (during development or installed package)
|
|
163
|
+
const packageBenchmarksDir = await findPackageBenchmarksDir();
|
|
164
|
+
if (packageBenchmarksDir) {
|
|
165
|
+
// Copy from package
|
|
166
|
+
await copyFile(path.join(packageBenchmarksDir, "qps-test.js"), qpsScript);
|
|
167
|
+
await copyFile(path.join(packageBenchmarksDir, "throughput-test.js"), throughputScript);
|
|
168
|
+
await copyFile(path.join(packageBenchmarksDir, "lib", "payload.js"), payloadScript);
|
|
169
|
+
await copyFile(path.join(packageBenchmarksDir, "lib", "report.js"), reportScript);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Scripts not found - this shouldn't happen in a properly installed package
|
|
173
|
+
throw new Error("Benchmark scripts not found. Please reinstall the CLI package.");
|
|
174
|
+
}
|
|
175
|
+
return BENCHMARKS_DIR;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Find the benchmarks directory in the package
|
|
179
|
+
*/
|
|
180
|
+
async function findPackageBenchmarksDir() {
|
|
181
|
+
// Try relative to the current file (development)
|
|
182
|
+
const possiblePaths = [
|
|
183
|
+
// Development: src/lib/benchmark.ts -> benchmarks/
|
|
184
|
+
path.resolve(import.meta.dirname, "..", "..", "benchmarks"),
|
|
185
|
+
// Installed: dist/lib/benchmark.js -> benchmarks/
|
|
186
|
+
path.resolve(import.meta.dirname, "..", "..", "..", "benchmarks"),
|
|
187
|
+
];
|
|
188
|
+
for (const p of possiblePaths) {
|
|
189
|
+
try {
|
|
190
|
+
await fs.access(path.join(p, "qps-test.js"));
|
|
191
|
+
return p;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// Continue to next path
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Copy a file, creating destination directory if needed
|
|
201
|
+
*/
|
|
202
|
+
async function copyFile(src, dest) {
|
|
203
|
+
try {
|
|
204
|
+
const content = await fs.readFile(src, "utf-8");
|
|
205
|
+
await fs.writeFile(dest, content, "utf-8");
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
throw new Error(`Failed to copy ${src} to ${dest}: ${error}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Create the output directory for benchmark results
|
|
213
|
+
*/
|
|
214
|
+
export async function createOutputDirectory(deploymentName) {
|
|
215
|
+
const timestamp = new Date()
|
|
216
|
+
.toISOString()
|
|
217
|
+
.replace(/[:.]/g, "-")
|
|
218
|
+
.replace("T", "_")
|
|
219
|
+
.slice(0, 19);
|
|
220
|
+
const dirName = `rulebricks-${deploymentName}-${timestamp}`;
|
|
221
|
+
const outputDir = path.join(process.cwd(), dirName);
|
|
222
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
223
|
+
return outputDir;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Save benchmark configuration to output directory
|
|
227
|
+
*/
|
|
228
|
+
export async function saveBenchmarkConfig(outputDir, config) {
|
|
229
|
+
const configPath = path.join(outputDir, "config.json");
|
|
230
|
+
// Redact API key for security
|
|
231
|
+
const safeConfig = {
|
|
232
|
+
...config,
|
|
233
|
+
apiKey: config.apiKey.slice(0, 8) + "..." + config.apiKey.slice(-4),
|
|
234
|
+
};
|
|
235
|
+
await fs.writeFile(configPath, JSON.stringify(safeConfig, null, 2), "utf-8");
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Run a benchmark test
|
|
239
|
+
*/
|
|
240
|
+
export async function runBenchmark(config, options = {}) {
|
|
241
|
+
// Ensure k6 is installed
|
|
242
|
+
const k6Installed = await isK6Installed();
|
|
243
|
+
if (!k6Installed) {
|
|
244
|
+
return {
|
|
245
|
+
success: false,
|
|
246
|
+
outputDir: "",
|
|
247
|
+
reportPath: "",
|
|
248
|
+
resultsPath: "",
|
|
249
|
+
error: `k6 is not installed. ${getK6InstallInstructions()}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// Validate target URL
|
|
253
|
+
const urlValidation = isValidBenchmarkTarget(config.apiUrl);
|
|
254
|
+
if (!urlValidation.valid) {
|
|
255
|
+
return {
|
|
256
|
+
success: false,
|
|
257
|
+
outputDir: "",
|
|
258
|
+
reportPath: "",
|
|
259
|
+
resultsPath: "",
|
|
260
|
+
error: urlValidation.reason,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// Ensure benchmark scripts exist
|
|
264
|
+
let scriptsDir;
|
|
265
|
+
try {
|
|
266
|
+
scriptsDir = await ensureBenchmarkScripts();
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
outputDir: "",
|
|
272
|
+
reportPath: "",
|
|
273
|
+
resultsPath: "",
|
|
274
|
+
error: `Failed to set up benchmark scripts: ${error}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Create output directory
|
|
278
|
+
const outputDir = await createOutputDirectory(config.deploymentName);
|
|
279
|
+
// Save config for reference
|
|
280
|
+
await saveBenchmarkConfig(outputDir, config);
|
|
281
|
+
// Determine which test script to run
|
|
282
|
+
const testScript = config.testMode === "qps"
|
|
283
|
+
? path.join(scriptsDir, "qps-test.js")
|
|
284
|
+
: path.join(scriptsDir, "throughput-test.js");
|
|
285
|
+
const reportName = config.testMode === "qps" ? "qps-report.html" : "throughput-report.html";
|
|
286
|
+
const resultsName = config.testMode === "qps" ? "qps-results.json" : "throughput-results.json";
|
|
287
|
+
// Build environment variables for k6
|
|
288
|
+
const env = {
|
|
289
|
+
...process.env,
|
|
290
|
+
API_URL: config.apiUrl,
|
|
291
|
+
API_KEY: config.apiKey,
|
|
292
|
+
TEST_DURATION: config.testDuration,
|
|
293
|
+
TARGET_RPS: config.targetRps.toString(),
|
|
294
|
+
};
|
|
295
|
+
if (config.testMode === "throughput" && config.bulkSize) {
|
|
296
|
+
env.BULK_SIZE = config.bulkSize.toString();
|
|
297
|
+
}
|
|
298
|
+
// Run k6
|
|
299
|
+
try {
|
|
300
|
+
const k6Process = spawn("k6", ["run", testScript], {
|
|
301
|
+
cwd: outputDir,
|
|
302
|
+
env,
|
|
303
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
304
|
+
});
|
|
305
|
+
let stdout = "";
|
|
306
|
+
let stderr = "";
|
|
307
|
+
k6Process.stdout?.on("data", (data) => {
|
|
308
|
+
const text = data.toString();
|
|
309
|
+
stdout += text;
|
|
310
|
+
if (options.onOutput) {
|
|
311
|
+
text.split("\n").forEach((line) => {
|
|
312
|
+
if (line.trim())
|
|
313
|
+
options.onOutput(line);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
k6Process.stderr?.on("data", (data) => {
|
|
318
|
+
const text = data.toString();
|
|
319
|
+
stderr += text;
|
|
320
|
+
if (options.onOutput) {
|
|
321
|
+
text.split("\n").forEach((line) => {
|
|
322
|
+
if (line.trim())
|
|
323
|
+
options.onOutput(line);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
// Wait for process to complete
|
|
328
|
+
const exitCode = await new Promise((resolve) => {
|
|
329
|
+
k6Process.on("close", (code) => {
|
|
330
|
+
resolve(code ?? 1);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
const reportPath = path.join(outputDir, reportName);
|
|
334
|
+
const resultsPath = path.join(outputDir, resultsName);
|
|
335
|
+
// Check if report was generated
|
|
336
|
+
let reportExists = false;
|
|
337
|
+
try {
|
|
338
|
+
await fs.access(reportPath);
|
|
339
|
+
reportExists = true;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
reportExists = false;
|
|
343
|
+
}
|
|
344
|
+
// Parse metrics from results JSON if available
|
|
345
|
+
let metrics;
|
|
346
|
+
try {
|
|
347
|
+
const resultsContent = await fs.readFile(resultsPath, "utf-8");
|
|
348
|
+
metrics = parseK6Results(resultsContent, config);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Results file might not exist or be parseable
|
|
352
|
+
}
|
|
353
|
+
// k6 returns non-zero if thresholds fail, but we still consider it a success
|
|
354
|
+
// if the report was generated
|
|
355
|
+
if (reportExists) {
|
|
356
|
+
return {
|
|
357
|
+
success: true,
|
|
358
|
+
outputDir,
|
|
359
|
+
reportPath,
|
|
360
|
+
resultsPath,
|
|
361
|
+
metrics,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
return {
|
|
366
|
+
success: false,
|
|
367
|
+
outputDir,
|
|
368
|
+
reportPath,
|
|
369
|
+
resultsPath,
|
|
370
|
+
error: `k6 test failed (exit code ${exitCode}). ${stderr || stdout}`.trim(),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
outputDir,
|
|
378
|
+
reportPath: path.join(outputDir, reportName),
|
|
379
|
+
resultsPath: path.join(outputDir, resultsName),
|
|
380
|
+
error: extractExecaError(error),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Parse k6 JSON results into BenchmarkMetrics
|
|
386
|
+
*/
|
|
387
|
+
function parseK6Results(jsonContent, config) {
|
|
388
|
+
const data = JSON.parse(jsonContent);
|
|
389
|
+
const metrics = data.metrics || {};
|
|
390
|
+
const totalRequests = metrics.http_reqs?.values?.count || 0;
|
|
391
|
+
const testDuration = (data.state?.testRunDurationMs || 0) / 1000;
|
|
392
|
+
const actualRps = testDuration > 0 ? totalRequests / testDuration : 0;
|
|
393
|
+
const result = {
|
|
394
|
+
actualRps,
|
|
395
|
+
successRate: (metrics.successes?.values?.rate || 0) * 100,
|
|
396
|
+
p50Latency: metrics.http_req_duration?.values?.med || 0,
|
|
397
|
+
p90Latency: metrics.http_req_duration?.values?.["p(90)"] || 0,
|
|
398
|
+
p95Latency: metrics.http_req_duration?.values?.["p(95)"] || 0,
|
|
399
|
+
p99Latency: metrics.http_req_duration?.values?.["p(99)"] || 0,
|
|
400
|
+
minLatency: metrics.http_req_duration?.values?.min || 0,
|
|
401
|
+
maxLatency: metrics.http_req_duration?.values?.max || 0,
|
|
402
|
+
avgLatency: metrics.http_req_duration?.values?.avg || 0,
|
|
403
|
+
totalRequests,
|
|
404
|
+
failedRequests: metrics.dropped_requests?.values?.count || 0,
|
|
405
|
+
testDuration,
|
|
406
|
+
dataSent: metrics.data_sent?.values?.count || 0,
|
|
407
|
+
dataReceived: metrics.data_received?.values?.count || 0,
|
|
408
|
+
maxVUs: metrics.vus_max?.values?.max || metrics.vus?.values?.max || 0,
|
|
409
|
+
};
|
|
410
|
+
// Add throughput-specific metrics
|
|
411
|
+
if (config.testMode === "throughput" && config.bulkSize) {
|
|
412
|
+
result.actualThroughput = actualRps * config.bulkSize;
|
|
413
|
+
result.totalPayloads =
|
|
414
|
+
metrics.total_payloads?.values?.count || totalRequests * config.bulkSize;
|
|
415
|
+
}
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Open a file in the default browser/application
|
|
420
|
+
*/
|
|
421
|
+
export async function openInBrowser(filePath) {
|
|
422
|
+
const platform = process.platform;
|
|
423
|
+
try {
|
|
424
|
+
switch (platform) {
|
|
425
|
+
case "darwin":
|
|
426
|
+
await execa("open", [filePath]);
|
|
427
|
+
break;
|
|
428
|
+
case "win32":
|
|
429
|
+
await execa("cmd", ["/c", "start", '""', filePath]);
|
|
430
|
+
break;
|
|
431
|
+
case "linux":
|
|
432
|
+
default:
|
|
433
|
+
await execa("xdg-open", [filePath]);
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
// Silently fail if browser can't be opened
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Format duration string (e.g., "4m") to human readable
|
|
443
|
+
*/
|
|
444
|
+
export function formatDuration(duration) {
|
|
445
|
+
const match = duration.match(/^(\d+)(m|s|h)$/);
|
|
446
|
+
if (!match)
|
|
447
|
+
return duration;
|
|
448
|
+
const [, value, unit] = match;
|
|
449
|
+
const num = parseInt(value, 10);
|
|
450
|
+
switch (unit) {
|
|
451
|
+
case "s":
|
|
452
|
+
return `${num} second${num !== 1 ? "s" : ""}`;
|
|
453
|
+
case "m":
|
|
454
|
+
return `${num} minute${num !== 1 ? "s" : ""}`;
|
|
455
|
+
case "h":
|
|
456
|
+
return `${num} hour${num !== 1 ? "s" : ""}`;
|
|
457
|
+
default:
|
|
458
|
+
return duration;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Calculate expected throughput for display
|
|
463
|
+
*/
|
|
464
|
+
export function calculateExpectedThroughput(targetRps, bulkSize) {
|
|
465
|
+
return bulkSize ? targetRps * bulkSize : targetRps;
|
|
466
|
+
}
|
package/dist/lib/dns.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { DNSRecord } from "../types/index.js";
|
|
2
2
|
/**
|
|
3
|
-
* Checks if a DNS record resolves
|
|
3
|
+
* Checks if a DNS record resolves using multiple DNS resolvers for reliability.
|
|
4
|
+
* Tries system DNS first, then Google (8.8.8.8), then Cloudflare (1.1.1.1).
|
|
5
|
+
* Returns success if ANY resolver confirms the record matches the target.
|
|
4
6
|
*/
|
|
5
7
|
export declare function checkDNSRecord(hostname: string, expectedTarget?: string): Promise<{
|
|
6
8
|
resolved: boolean;
|