@a9s/cli 1.0.6 → 1.0.8
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/dist/scripts/seed.js +202 -4
- package/dist/src/index.js +1 -0
- package/dist/src/services.js +2 -2
- package/dist/src/views/dynamodb/adapter.js +311 -8
- package/dist/src/views/dynamodb/capabilities/detailCapability.js +94 -0
- package/dist/src/views/dynamodb/capabilities/yankCapability.js +6 -0
- package/dist/src/views/dynamodb/capabilities/yankOptions.js +69 -0
- package/dist/src/views/dynamodb/schema.js +18 -0
- package/dist/src/views/dynamodb/types.js +1 -0
- package/dist/src/views/dynamodb/utils.js +175 -0
- package/dist/src/views/route53/adapter.js +165 -9
- package/dist/src/views/route53/capabilities/detailCapability.js +63 -0
- package/dist/src/views/route53/capabilities/yankCapability.js +6 -0
- package/dist/src/views/route53/capabilities/yankOptions.js +58 -0
- package/dist/src/views/route53/schema.js +18 -0
- package/dist/src/views/route53/types.js +1 -0
- package/package.json +2 -2
package/dist/scripts/seed.js
CHANGED
|
@@ -93,7 +93,7 @@ async function checkLocalStack() {
|
|
|
93
93
|
process.exit(1);
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
-
async function runAws(args) {
|
|
96
|
+
async function runAws(args, timeoutMs = 10000) {
|
|
97
97
|
const env = {
|
|
98
98
|
...process.env,
|
|
99
99
|
AWS_ACCESS_KEY_ID: "test",
|
|
@@ -102,7 +102,7 @@ async function runAws(args) {
|
|
|
102
102
|
};
|
|
103
103
|
const { stdout } = await execFileAsync("aws", ["--endpoint-url", "http://localhost:4566", ...args], {
|
|
104
104
|
env,
|
|
105
|
-
timeout:
|
|
105
|
+
timeout: timeoutMs,
|
|
106
106
|
});
|
|
107
107
|
return stdout;
|
|
108
108
|
}
|
|
@@ -195,6 +195,182 @@ async function ensureAttachedRolePolicy(roleName, policyArn) {
|
|
|
195
195
|
await runAws(["iam", "attach-role-policy", "--role-name", roleName, "--policy-arn", policyArn]);
|
|
196
196
|
console.log(` Attached managed policy to ${roleName}`);
|
|
197
197
|
}
|
|
198
|
+
async function seedDynamoDB() {
|
|
199
|
+
console.log("\nSeeding DynamoDB:");
|
|
200
|
+
const tables = [
|
|
201
|
+
{
|
|
202
|
+
name: "Users",
|
|
203
|
+
pkName: "userId",
|
|
204
|
+
skName: "timestamp",
|
|
205
|
+
items: [
|
|
206
|
+
{ userId: "user-001", timestamp: "2024-01-15T10:00:00Z", email: "alice@example.com", role: "admin" },
|
|
207
|
+
{ userId: "user-002", timestamp: "2024-01-16T14:30:00Z", email: "bob@example.com", role: "user" },
|
|
208
|
+
{ userId: "user-003", timestamp: "2024-01-17T09:45:00Z", email: "charlie@example.com", role: "user" },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: "Orders",
|
|
213
|
+
pkName: "orderId",
|
|
214
|
+
items: [
|
|
215
|
+
{ orderId: "order-001", status: "completed", total: "99.99", items: "3" },
|
|
216
|
+
{ orderId: "order-002", status: "pending", total: "149.50", items: "5" },
|
|
217
|
+
{ orderId: "order-003", status: "shipped", total: "299.00", items: "1" },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
for (const table of tables) {
|
|
222
|
+
// Try to create table
|
|
223
|
+
try {
|
|
224
|
+
const keySchema = [{ AttributeName: table.pkName, KeyType: "HASH" }];
|
|
225
|
+
const attrDefs = [{ AttributeName: table.pkName, AttributeType: "S" }];
|
|
226
|
+
if (table.skName) {
|
|
227
|
+
keySchema.push({ AttributeName: table.skName, KeyType: "RANGE" });
|
|
228
|
+
attrDefs.push({ AttributeName: table.skName, AttributeType: "S" });
|
|
229
|
+
}
|
|
230
|
+
await runAws([
|
|
231
|
+
"dynamodb",
|
|
232
|
+
"create-table",
|
|
233
|
+
"--table-name",
|
|
234
|
+
table.name,
|
|
235
|
+
"--key-schema",
|
|
236
|
+
JSON.stringify(keySchema),
|
|
237
|
+
"--attribute-definitions",
|
|
238
|
+
JSON.stringify(attrDefs),
|
|
239
|
+
"--billing-mode",
|
|
240
|
+
"PAY_PER_REQUEST",
|
|
241
|
+
"--output",
|
|
242
|
+
"json",
|
|
243
|
+
], 15000);
|
|
244
|
+
console.log(` Created table: ${table.name}`);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
const err = e;
|
|
248
|
+
if (err.message?.includes("already exists")) {
|
|
249
|
+
console.log(` Table already exists: ${table.name}`);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Add items to the table (whether newly created or existing)
|
|
256
|
+
let addedCount = 0;
|
|
257
|
+
for (const item of table.items) {
|
|
258
|
+
const itemObj = {};
|
|
259
|
+
for (const [k, v] of Object.entries(item)) {
|
|
260
|
+
itemObj[k] = { S: String(v) };
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
await runAws([
|
|
264
|
+
"dynamodb",
|
|
265
|
+
"put-item",
|
|
266
|
+
"--table-name",
|
|
267
|
+
table.name,
|
|
268
|
+
"--item",
|
|
269
|
+
JSON.stringify(itemObj),
|
|
270
|
+
], 10000);
|
|
271
|
+
addedCount++;
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
console.log(` Warning: could not add item: ${e.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
console.log(` Added ${addedCount}/${table.items.length} items`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function seedRoute53() {
|
|
281
|
+
console.log("\nSeeding Route53:");
|
|
282
|
+
const zones = [
|
|
283
|
+
{ name: "example.com.", isPrivate: false },
|
|
284
|
+
{ name: "internal.local.", isPrivate: true },
|
|
285
|
+
{ name: "staging.test.", isPrivate: false },
|
|
286
|
+
];
|
|
287
|
+
for (const zone of zones) {
|
|
288
|
+
try {
|
|
289
|
+
// First check if zone already exists
|
|
290
|
+
const listOut = await runAws([
|
|
291
|
+
"route53",
|
|
292
|
+
"list-hosted-zones",
|
|
293
|
+
"--output",
|
|
294
|
+
"json",
|
|
295
|
+
], 10000).catch(() => "");
|
|
296
|
+
let zoneId;
|
|
297
|
+
if (listOut) {
|
|
298
|
+
try {
|
|
299
|
+
const listParsed = JSON.parse(listOut);
|
|
300
|
+
const existingZone = listParsed.HostedZones?.find((z) => z.Name === zone.name);
|
|
301
|
+
if (existingZone) {
|
|
302
|
+
zoneId = existingZone.Id;
|
|
303
|
+
console.log(` Hosted zone already exists: ${zone.name}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
// Ignore parse errors
|
|
308
|
+
console.log(` List zones error: ${e.message}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// If not found, create it
|
|
312
|
+
if (!zoneId) {
|
|
313
|
+
const createOut = await runAws([
|
|
314
|
+
"route53",
|
|
315
|
+
"create-hosted-zone",
|
|
316
|
+
"--name",
|
|
317
|
+
zone.name,
|
|
318
|
+
"--caller-reference",
|
|
319
|
+
`zone-${Date.now()}-${Math.random()}`,
|
|
320
|
+
"--hosted-zone-config",
|
|
321
|
+
JSON.stringify({ PrivateZone: zone.isPrivate, Comment: `Test zone for ${zone.name}` }),
|
|
322
|
+
"--output",
|
|
323
|
+
"json",
|
|
324
|
+
], 10000);
|
|
325
|
+
const parsed = JSON.parse(createOut);
|
|
326
|
+
zoneId = parsed.HostedZone?.Id;
|
|
327
|
+
if (!zoneId)
|
|
328
|
+
throw new Error(`Failed creating hosted zone ${zone.name}`);
|
|
329
|
+
console.log(` Created hosted zone: ${zone.name} (${zoneId})`);
|
|
330
|
+
}
|
|
331
|
+
// Add some DNS records to the zone
|
|
332
|
+
const records = [
|
|
333
|
+
{ name: `www.${zone.name}`, type: "A", value: "192.0.2.1" },
|
|
334
|
+
{ name: `api.${zone.name}`, type: "A", value: "192.0.2.2" },
|
|
335
|
+
{ name: `mail.${zone.name}`, type: "A", value: "192.0.2.3" },
|
|
336
|
+
{ name: zone.name, type: "MX", value: "10 mail.example.com." },
|
|
337
|
+
];
|
|
338
|
+
for (const record of records) {
|
|
339
|
+
try {
|
|
340
|
+
await runAws([
|
|
341
|
+
"route53",
|
|
342
|
+
"change-resource-record-sets",
|
|
343
|
+
"--hosted-zone-id",
|
|
344
|
+
zoneId,
|
|
345
|
+
"--change-batch",
|
|
346
|
+
JSON.stringify({
|
|
347
|
+
Changes: [
|
|
348
|
+
{
|
|
349
|
+
Action: "UPSERT",
|
|
350
|
+
ResourceRecordSet: {
|
|
351
|
+
Name: record.name,
|
|
352
|
+
Type: record.type,
|
|
353
|
+
TTL: 300,
|
|
354
|
+
ResourceRecords: [{ Value: record.value }],
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
}),
|
|
359
|
+
], 10000);
|
|
360
|
+
console.log(` Ensured record: ${record.name} (${record.type})`);
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
const err = e;
|
|
364
|
+
console.log(` Warning adding record ${record.name}: ${err.message ?? String(e)}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (e) {
|
|
369
|
+
const err = e;
|
|
370
|
+
console.error(` Error seeding zone ${zone.name}: ${err.message ?? String(e)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
198
374
|
async function seedSecretsManager() {
|
|
199
375
|
console.log("\nSeeding Secrets Manager:");
|
|
200
376
|
const secrets = [
|
|
@@ -299,8 +475,30 @@ async function main() {
|
|
|
299
475
|
process.stdout.write(" Objects: ");
|
|
300
476
|
await seedBucket(bucket);
|
|
301
477
|
}
|
|
302
|
-
|
|
303
|
-
|
|
478
|
+
try {
|
|
479
|
+
await seedDynamoDB();
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
console.error(`\nDynamoDB seeding failed: ${e.message}`);
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
await seedRoute53();
|
|
486
|
+
}
|
|
487
|
+
catch (e) {
|
|
488
|
+
console.error(`\nRoute53 seeding failed: ${e.message}`);
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
await seedIam();
|
|
492
|
+
}
|
|
493
|
+
catch (e) {
|
|
494
|
+
console.error(`\nIAM seeding failed: ${e.message}`);
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
await seedSecretsManager();
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
console.error(`\nSecrets Manager seeding failed: ${e.message}`);
|
|
501
|
+
}
|
|
304
502
|
console.log("\nDone! LocalStack seeded with test data.");
|
|
305
503
|
console.log("Run: pnpm dev:local");
|
|
306
504
|
}
|
package/dist/src/index.js
CHANGED
package/dist/src/services.js
CHANGED
|
@@ -5,8 +5,8 @@ import { createIamServiceAdapter } from "./views/iam/adapter.js";
|
|
|
5
5
|
import { createSecretsManagerServiceAdapter } from "./views/secretsmanager/adapter.js";
|
|
6
6
|
export const SERVICE_REGISTRY = {
|
|
7
7
|
s3: (endpointUrl, region) => createS3ServiceAdapter(endpointUrl, region),
|
|
8
|
-
route53: (_endpointUrl,
|
|
9
|
-
dynamodb: (_endpointUrl,
|
|
8
|
+
route53: (_endpointUrl, region) => createRoute53ServiceAdapter(undefined, region),
|
|
9
|
+
dynamodb: (_endpointUrl, region) => createDynamoDBServiceAdapter(undefined, region),
|
|
10
10
|
iam: (_endpointUrl, _region) => createIamServiceAdapter(),
|
|
11
11
|
secretsmanager: (endpointUrl, region) => createSecretsManagerServiceAdapter(endpointUrl, region),
|
|
12
12
|
};
|
|
@@ -1,12 +1,305 @@
|
|
|
1
1
|
import { textCell } from "../../types.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { runAwsJsonAsync } from "../../utils/aws.js";
|
|
3
|
+
import { atom } from "jotai";
|
|
4
|
+
import { getDefaultStore } from "jotai";
|
|
5
|
+
import { unwrapDynamoValue, formatBillingMode, formatDynamoValue, getDynamoType, extractPkValue, extractSkValue, } from "./utils.js";
|
|
6
|
+
import { createDynamoDBDetailCapability } from "./capabilities/detailCapability.js";
|
|
7
|
+
import { createDynamoDBYankCapability } from "./capabilities/yankCapability.js";
|
|
8
|
+
export const dynamoDBLevelAtom = atom({ kind: "tables" });
|
|
9
|
+
export const dynamoDBBackStackAtom = atom([]);
|
|
10
|
+
// Cache for table descriptions to avoid repeated AWS calls
|
|
11
|
+
const tableDescriptionCache = new Map();
|
|
12
|
+
// Cache for scanned items
|
|
13
|
+
const itemsCache = new Map();
|
|
14
|
+
export function createDynamoDBServiceAdapter(endpointUrl, region) {
|
|
15
|
+
const store = getDefaultStore();
|
|
16
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
17
|
+
const getLevel = () => store.get(dynamoDBLevelAtom);
|
|
18
|
+
const setLevel = (level) => store.set(dynamoDBLevelAtom, level);
|
|
19
|
+
const getBackStack = () => store.get(dynamoDBBackStackAtom);
|
|
20
|
+
const setBackStack = (stack) => store.set(dynamoDBBackStackAtom, stack);
|
|
21
|
+
const getTableDescription = async (tableName) => {
|
|
22
|
+
const cached = tableDescriptionCache.get(tableName);
|
|
23
|
+
if (cached)
|
|
24
|
+
return cached;
|
|
25
|
+
try {
|
|
26
|
+
const data = await runAwsJsonAsync([
|
|
27
|
+
"dynamodb",
|
|
28
|
+
"describe-table",
|
|
29
|
+
"--table-name",
|
|
30
|
+
tableName,
|
|
31
|
+
...regionArgs,
|
|
32
|
+
]);
|
|
33
|
+
const table = data.Table;
|
|
34
|
+
tableDescriptionCache.set(tableName, table);
|
|
35
|
+
return table;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const getColumns = () => {
|
|
42
|
+
const level = getLevel();
|
|
43
|
+
if (level.kind === "tables") {
|
|
44
|
+
return [
|
|
45
|
+
{ key: "name", label: "Name" },
|
|
46
|
+
{ key: "status", label: "Status", width: 12 },
|
|
47
|
+
{ key: "items", label: "Items", width: 10 },
|
|
48
|
+
{ key: "billing", label: "Billing", width: 25 },
|
|
49
|
+
{ key: "gsis", label: "GSIs", width: 6 },
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
if (level.kind === "items") {
|
|
53
|
+
const table = tableDescriptionCache.get(level.tableName);
|
|
54
|
+
if (!table) {
|
|
55
|
+
// Fallback: return standard columns that will always be populated
|
|
56
|
+
return [
|
|
57
|
+
{ key: "#", label: "#", width: 4 },
|
|
58
|
+
{ key: "pk", label: "PK", width: 20 },
|
|
59
|
+
{ key: "sk", label: "SK", width: 20 },
|
|
60
|
+
{ key: "size", label: "Size", width: 10 },
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
const keys = table.KeySchema ?? [];
|
|
64
|
+
const hashKey = keys.find((k) => k.KeyType === "HASH");
|
|
65
|
+
const rangeKey = keys.find((k) => k.KeyType === "RANGE");
|
|
66
|
+
const cols = [{ key: "#", label: "#", width: 4 }];
|
|
67
|
+
if (hashKey) {
|
|
68
|
+
cols.push({ key: "pk", label: hashKey.AttributeName, width: 20 });
|
|
69
|
+
}
|
|
70
|
+
if (rangeKey) {
|
|
71
|
+
cols.push({ key: "sk", label: rangeKey.AttributeName, width: 20 });
|
|
72
|
+
}
|
|
73
|
+
cols.push({ key: "size", label: "Size", width: 10 });
|
|
74
|
+
return cols;
|
|
75
|
+
}
|
|
76
|
+
// item-fields level
|
|
77
|
+
return [
|
|
78
|
+
{ key: "attribute", label: "Attribute" },
|
|
79
|
+
{ key: "value", label: "Value", width: 50 },
|
|
80
|
+
{ key: "type", label: "Type", width: 8 },
|
|
81
|
+
];
|
|
82
|
+
};
|
|
4
83
|
const getRows = async () => {
|
|
5
|
-
|
|
84
|
+
const level = getLevel();
|
|
85
|
+
if (level.kind === "tables") {
|
|
86
|
+
try {
|
|
87
|
+
const listData = await runAwsJsonAsync([
|
|
88
|
+
"dynamodb",
|
|
89
|
+
"list-tables",
|
|
90
|
+
...regionArgs,
|
|
91
|
+
]);
|
|
92
|
+
const tableNames = listData.TableNames ?? [];
|
|
93
|
+
// Describe all tables in parallel
|
|
94
|
+
const tables = await Promise.all(tableNames.map((name) => getTableDescription(name)));
|
|
95
|
+
return tables
|
|
96
|
+
.filter((t) => t !== null)
|
|
97
|
+
.map((table) => {
|
|
98
|
+
const statusColor = table.TableStatus === "ACTIVE"
|
|
99
|
+
? "green"
|
|
100
|
+
: table.TableStatus === "CREATING" || table.TableStatus === "UPDATING"
|
|
101
|
+
? "yellow"
|
|
102
|
+
: "red";
|
|
103
|
+
return {
|
|
104
|
+
id: table.TableArn,
|
|
105
|
+
cells: {
|
|
106
|
+
name: textCell(table.TableName),
|
|
107
|
+
status: textCell(table.TableStatus),
|
|
108
|
+
items: textCell(String(table.ItemCount ?? 0)),
|
|
109
|
+
billing: textCell(formatBillingMode(table)),
|
|
110
|
+
gsis: textCell(String(table.GlobalSecondaryIndexes?.length ?? 0)),
|
|
111
|
+
},
|
|
112
|
+
meta: {
|
|
113
|
+
type: "table",
|
|
114
|
+
tableName: table.TableName,
|
|
115
|
+
tableStatus: table.TableStatus,
|
|
116
|
+
tableArn: table.TableArn,
|
|
117
|
+
billing: formatBillingMode(table),
|
|
118
|
+
gsiCount: table.GlobalSecondaryIndexes?.length ?? 0,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (level.kind === "items") {
|
|
128
|
+
const { tableName } = level;
|
|
129
|
+
try {
|
|
130
|
+
// Check cache
|
|
131
|
+
const cached = itemsCache.get(tableName);
|
|
132
|
+
if (cached) {
|
|
133
|
+
const table = cached.table;
|
|
134
|
+
const items = cached.items;
|
|
135
|
+
return items.map((item, index) => {
|
|
136
|
+
const pkValue = extractPkValue(item, table);
|
|
137
|
+
const skValue = extractSkValue(item, table);
|
|
138
|
+
const itemSize = JSON.stringify(item).length;
|
|
139
|
+
const cells = {
|
|
140
|
+
name: textCell(`Item ${index + 1}`),
|
|
141
|
+
"#": textCell(String(index + 1)),
|
|
142
|
+
pk: textCell(pkValue ?? "-"),
|
|
143
|
+
sk: textCell(skValue ?? "-"),
|
|
144
|
+
size: textCell(`${itemSize}B`),
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
id: `${tableName}-${index}`,
|
|
148
|
+
cells,
|
|
149
|
+
meta: {
|
|
150
|
+
type: "item",
|
|
151
|
+
tableName,
|
|
152
|
+
itemIndex: index,
|
|
153
|
+
itemPkValue: pkValue ?? undefined,
|
|
154
|
+
itemSkValue: skValue ?? undefined,
|
|
155
|
+
itemSize,
|
|
156
|
+
itemJson: JSON.stringify(item),
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// Fetch table description to get key schema
|
|
162
|
+
const table = await getTableDescription(tableName);
|
|
163
|
+
if (!table)
|
|
164
|
+
return [];
|
|
165
|
+
// Scan items
|
|
166
|
+
const scanData = await runAwsJsonAsync([
|
|
167
|
+
"dynamodb",
|
|
168
|
+
"scan",
|
|
169
|
+
"--table-name",
|
|
170
|
+
tableName,
|
|
171
|
+
"--limit",
|
|
172
|
+
"50",
|
|
173
|
+
...regionArgs,
|
|
174
|
+
]);
|
|
175
|
+
const items = scanData.Items ?? [];
|
|
176
|
+
itemsCache.set(tableName, { items, table });
|
|
177
|
+
return items.map((item, index) => {
|
|
178
|
+
const pkValue = extractPkValue(item, table);
|
|
179
|
+
const skValue = extractSkValue(item, table);
|
|
180
|
+
const itemSize = JSON.stringify(item).length;
|
|
181
|
+
const cells = {
|
|
182
|
+
name: textCell(`Item ${index + 1}`),
|
|
183
|
+
"#": textCell(String(index + 1)),
|
|
184
|
+
pk: textCell(pkValue ?? "-"),
|
|
185
|
+
sk: textCell(skValue ?? "-"),
|
|
186
|
+
size: textCell(`${itemSize}B`),
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
id: `${tableName}-${index}`,
|
|
190
|
+
cells,
|
|
191
|
+
meta: {
|
|
192
|
+
type: "item",
|
|
193
|
+
tableName,
|
|
194
|
+
itemIndex: index,
|
|
195
|
+
itemPkValue: pkValue ?? undefined,
|
|
196
|
+
itemSkValue: skValue ?? undefined,
|
|
197
|
+
itemSize,
|
|
198
|
+
itemJson: JSON.stringify(item),
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// item-fields level
|
|
208
|
+
if (level.kind === "item-fields") {
|
|
209
|
+
const { tableName, itemIndex } = level;
|
|
210
|
+
const cached = itemsCache.get(tableName);
|
|
211
|
+
if (!cached)
|
|
212
|
+
return [];
|
|
213
|
+
const item = cached.items[itemIndex];
|
|
214
|
+
if (!item)
|
|
215
|
+
return [];
|
|
216
|
+
return Object.entries(item).map(([attrName, attrValue]) => {
|
|
217
|
+
const displayValue = formatDynamoValue(attrValue);
|
|
218
|
+
const type = getDynamoType(attrValue);
|
|
219
|
+
return {
|
|
220
|
+
id: attrName,
|
|
221
|
+
cells: {
|
|
222
|
+
attribute: textCell(attrName),
|
|
223
|
+
value: textCell(displayValue),
|
|
224
|
+
type: textCell(type),
|
|
225
|
+
},
|
|
226
|
+
meta: {
|
|
227
|
+
type: "item-field",
|
|
228
|
+
tableName,
|
|
229
|
+
itemIndex,
|
|
230
|
+
fieldName: attrName,
|
|
231
|
+
fieldValue: displayValue,
|
|
232
|
+
fieldType: type,
|
|
233
|
+
fieldRawValue: unwrapDynamoValue(attrValue),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return [];
|
|
6
239
|
};
|
|
7
|
-
const onSelect = async (
|
|
240
|
+
const onSelect = async (row) => {
|
|
241
|
+
const level = getLevel();
|
|
242
|
+
const backStack = getBackStack();
|
|
243
|
+
const meta = row.meta;
|
|
244
|
+
if (level.kind === "tables") {
|
|
245
|
+
if (!meta || meta.type !== "table") {
|
|
246
|
+
return { action: "none" };
|
|
247
|
+
}
|
|
248
|
+
// Clear items cache when switching tables
|
|
249
|
+
itemsCache.clear();
|
|
250
|
+
const newStack = [...backStack, { level: level, selectedIndex: 0 }];
|
|
251
|
+
setBackStack(newStack);
|
|
252
|
+
setLevel({
|
|
253
|
+
kind: "items",
|
|
254
|
+
tableName: meta.tableName,
|
|
255
|
+
});
|
|
256
|
+
return { action: "navigate" };
|
|
257
|
+
}
|
|
258
|
+
if (level.kind === "items") {
|
|
259
|
+
if (!meta || meta.type !== "item") {
|
|
260
|
+
return { action: "none" };
|
|
261
|
+
}
|
|
262
|
+
const newStack = [...backStack, { level: level, selectedIndex: 0 }];
|
|
263
|
+
setBackStack(newStack);
|
|
264
|
+
setLevel({
|
|
265
|
+
kind: "item-fields",
|
|
266
|
+
tableName: meta.tableName,
|
|
267
|
+
itemIndex: meta.itemIndex,
|
|
268
|
+
});
|
|
269
|
+
return { action: "navigate" };
|
|
270
|
+
}
|
|
271
|
+
// item-fields level: leaf, no drill-down
|
|
8
272
|
return { action: "none" };
|
|
9
273
|
};
|
|
274
|
+
const canGoBack = () => getBackStack().length > 0;
|
|
275
|
+
const goBack = () => {
|
|
276
|
+
const backStack = getBackStack();
|
|
277
|
+
if (backStack.length > 0) {
|
|
278
|
+
const newStack = backStack.slice(0, -1);
|
|
279
|
+
const frame = backStack[backStack.length - 1];
|
|
280
|
+
setBackStack(newStack);
|
|
281
|
+
setLevel(frame.level);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const getPath = () => {
|
|
285
|
+
const level = getLevel();
|
|
286
|
+
if (level.kind === "tables")
|
|
287
|
+
return "dynamodb://";
|
|
288
|
+
if (level.kind === "items")
|
|
289
|
+
return `dynamodb://${level.tableName}`;
|
|
290
|
+
return `dynamodb://${level.tableName}/items`;
|
|
291
|
+
};
|
|
292
|
+
const getContextLabel = () => {
|
|
293
|
+
const level = getLevel();
|
|
294
|
+
if (level.kind === "tables")
|
|
295
|
+
return "⚡ Tables";
|
|
296
|
+
if (level.kind === "items")
|
|
297
|
+
return `⚡ ${level.tableName}`;
|
|
298
|
+
return `⚡ ${level.tableName}/items`;
|
|
299
|
+
};
|
|
300
|
+
// Compose capabilities
|
|
301
|
+
const detailCapability = createDynamoDBDetailCapability(region, getLevel);
|
|
302
|
+
const yankCapability = createDynamoDBYankCapability(region, getLevel);
|
|
10
303
|
return {
|
|
11
304
|
id: "dynamodb",
|
|
12
305
|
label: "DynamoDB",
|
|
@@ -14,9 +307,19 @@ export function createDynamoDBServiceAdapter() {
|
|
|
14
307
|
getColumns,
|
|
15
308
|
getRows,
|
|
16
309
|
onSelect,
|
|
17
|
-
canGoBack
|
|
18
|
-
goBack
|
|
19
|
-
getPath
|
|
20
|
-
getContextLabel
|
|
310
|
+
canGoBack,
|
|
311
|
+
goBack,
|
|
312
|
+
getPath,
|
|
313
|
+
getContextLabel,
|
|
314
|
+
reset() {
|
|
315
|
+
setLevel({ kind: "tables" });
|
|
316
|
+
setBackStack([]);
|
|
317
|
+
tableDescriptionCache.clear();
|
|
318
|
+
itemsCache.clear();
|
|
319
|
+
},
|
|
320
|
+
capabilities: {
|
|
321
|
+
detail: detailCapability,
|
|
322
|
+
yank: yankCapability,
|
|
323
|
+
},
|
|
21
324
|
};
|
|
22
325
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { runAwsJsonAsync } from "../../../utils/aws.js";
|
|
2
|
+
import { formatBillingMode, formatKeySchema } from "../utils.js";
|
|
3
|
+
export function createDynamoDBDetailCapability(region, getLevel) {
|
|
4
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
5
|
+
const getDetails = async (row) => {
|
|
6
|
+
const meta = row.meta;
|
|
7
|
+
if (!meta) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const level = getLevel?.();
|
|
11
|
+
// Table level
|
|
12
|
+
if (level?.kind === "tables" && meta.type === "table") {
|
|
13
|
+
try {
|
|
14
|
+
const data = await runAwsJsonAsync([
|
|
15
|
+
"dynamodb",
|
|
16
|
+
"describe-table",
|
|
17
|
+
"--table-name",
|
|
18
|
+
meta.tableName,
|
|
19
|
+
...regionArgs,
|
|
20
|
+
]);
|
|
21
|
+
const table = data.Table;
|
|
22
|
+
const fields = [
|
|
23
|
+
{ label: "Table Name", value: table.TableName },
|
|
24
|
+
{ label: "Table ARN", value: table.TableArn },
|
|
25
|
+
{ label: "Status", value: table.TableStatus },
|
|
26
|
+
{ label: "Billing Mode", value: formatBillingMode(table) },
|
|
27
|
+
{ label: "Item Count", value: String(table.ItemCount ?? 0) },
|
|
28
|
+
{ label: "Table Size", value: `${table.TableSizeBytes ?? 0} bytes` },
|
|
29
|
+
{ label: "Key Schema", value: formatKeySchema(table) },
|
|
30
|
+
];
|
|
31
|
+
// Add attributes
|
|
32
|
+
if (table.AttributeDefinitions && table.AttributeDefinitions.length > 0) {
|
|
33
|
+
const attrs = table.AttributeDefinitions.map((a) => `${a.AttributeName} (${a.AttributeType})`).join(", ");
|
|
34
|
+
fields.push({ label: "Attributes", value: attrs });
|
|
35
|
+
}
|
|
36
|
+
fields.push({
|
|
37
|
+
label: "GSI Count",
|
|
38
|
+
value: String(table.GlobalSecondaryIndexes?.length ?? 0),
|
|
39
|
+
});
|
|
40
|
+
fields.push({
|
|
41
|
+
label: "LSI Count",
|
|
42
|
+
value: String(table.LocalSecondaryIndexes?.length ?? 0),
|
|
43
|
+
});
|
|
44
|
+
if (table.CreationDateTime) {
|
|
45
|
+
fields.push({
|
|
46
|
+
label: "Created",
|
|
47
|
+
value: new Date(table.CreationDateTime).toISOString(),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return fields;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Item level
|
|
57
|
+
if (level?.kind === "items" && meta.type === "item") {
|
|
58
|
+
try {
|
|
59
|
+
const itemJson = meta.itemJson ? JSON.parse(meta.itemJson) : {};
|
|
60
|
+
const prettyJson = JSON.stringify(itemJson, null, 2);
|
|
61
|
+
const fields = [
|
|
62
|
+
{
|
|
63
|
+
label: "Item (JSON)",
|
|
64
|
+
value: prettyJson,
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
if (meta.itemSize) {
|
|
68
|
+
fields.push({
|
|
69
|
+
label: "Size",
|
|
70
|
+
value: `${meta.itemSize} bytes`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return fields;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Item-field level
|
|
80
|
+
if (level?.kind === "item-fields" && meta.type === "item-field") {
|
|
81
|
+
const fields = [
|
|
82
|
+
{ label: "Attribute", value: meta.fieldName ?? "-" },
|
|
83
|
+
{ label: "Type", value: meta.fieldType ?? "-" },
|
|
84
|
+
{
|
|
85
|
+
label: "Value",
|
|
86
|
+
value: String(meta.fieldRawValue ?? "-"),
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
return fields;
|
|
90
|
+
}
|
|
91
|
+
return [];
|
|
92
|
+
};
|
|
93
|
+
return { getDetails };
|
|
94
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createYankCapability } from "../../../adapters/capabilities/YankCapability.js";
|
|
2
|
+
import { DynamoDBRowMetaSchema } from "../schema.js";
|
|
3
|
+
import { DynamoDBYankOptions } from "./yankOptions.js";
|
|
4
|
+
export function createDynamoDBYankCapability(_region, _getLevel) {
|
|
5
|
+
return createYankCapability(DynamoDBYankOptions, DynamoDBRowMetaSchema, {});
|
|
6
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const DynamoDBYankOptions = [
|
|
2
|
+
// Table options
|
|
3
|
+
{
|
|
4
|
+
trigger: { type: "key", char: "n" },
|
|
5
|
+
label: "Copy table name",
|
|
6
|
+
feedback: "Copied table name",
|
|
7
|
+
isRelevant: (row) => row.meta.type === "table",
|
|
8
|
+
resolve: async (row) => {
|
|
9
|
+
return row.meta.tableName ?? null;
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
trigger: { type: "key", char: "a" },
|
|
14
|
+
label: "Copy table ARN",
|
|
15
|
+
feedback: "Copied table ARN",
|
|
16
|
+
isRelevant: (row) => row.meta.type === "table",
|
|
17
|
+
resolve: async (row) => {
|
|
18
|
+
return row.meta.tableArn ?? null;
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
// Item options
|
|
22
|
+
{
|
|
23
|
+
trigger: { type: "key", char: "k" },
|
|
24
|
+
label: "Copy primary key (JSON)",
|
|
25
|
+
feedback: "Copied primary key",
|
|
26
|
+
isRelevant: (row) => row.meta.type === "item",
|
|
27
|
+
resolve: async (row) => {
|
|
28
|
+
if (!row.meta.itemPkValue && !row.meta.itemSkValue) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const obj = {};
|
|
32
|
+
if (row.meta.itemPkValue) {
|
|
33
|
+
obj.pk = row.meta.itemPkValue;
|
|
34
|
+
}
|
|
35
|
+
if (row.meta.itemSkValue) {
|
|
36
|
+
obj.sk = row.meta.itemSkValue;
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(obj);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
trigger: { type: "key", char: "j" },
|
|
43
|
+
label: "Copy entire item (JSON)",
|
|
44
|
+
feedback: "Copied item",
|
|
45
|
+
isRelevant: (row) => row.meta.type === "item" && !!row.meta.itemJson,
|
|
46
|
+
resolve: async (row) => {
|
|
47
|
+
return row.meta.itemJson ?? null;
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
// Field options
|
|
51
|
+
{
|
|
52
|
+
trigger: { type: "key", char: "v" },
|
|
53
|
+
label: "Copy attribute value",
|
|
54
|
+
feedback: "Copied attribute value",
|
|
55
|
+
isRelevant: (row) => row.meta.type === "item-field",
|
|
56
|
+
resolve: async (row) => {
|
|
57
|
+
return row.meta.fieldValue ?? null;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
trigger: { type: "key", char: "k" },
|
|
62
|
+
label: "Copy attribute name",
|
|
63
|
+
feedback: "Copied attribute name",
|
|
64
|
+
isRelevant: (row) => row.meta.type === "item-field",
|
|
65
|
+
resolve: async (row) => {
|
|
66
|
+
return row.meta.fieldName ?? null;
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const DynamoDBRowMetaSchema = z.object({
|
|
3
|
+
type: z.enum(["table", "item", "item-field"]),
|
|
4
|
+
tableName: z.string().optional(),
|
|
5
|
+
tableStatus: z.string().optional(),
|
|
6
|
+
tableArn: z.string().optional(),
|
|
7
|
+
billing: z.string().optional(),
|
|
8
|
+
gsiCount: z.number().optional(),
|
|
9
|
+
itemIndex: z.number().optional(),
|
|
10
|
+
itemPkValue: z.string().optional(),
|
|
11
|
+
itemSkValue: z.string().optional(),
|
|
12
|
+
itemSize: z.number().optional(),
|
|
13
|
+
itemJson: z.string().optional(),
|
|
14
|
+
fieldName: z.string().optional(),
|
|
15
|
+
fieldValue: z.string().optional(),
|
|
16
|
+
fieldType: z.string().optional(),
|
|
17
|
+
fieldRawValue: z.unknown().optional(),
|
|
18
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unwrap a DynamoDB AttributeValue to its native JS value.
|
|
3
|
+
* E.g., { S: "hello" } → "hello", { N: "42" } → "42", { BOOL: true } → true
|
|
4
|
+
*/
|
|
5
|
+
export function unwrapDynamoValue(attr) {
|
|
6
|
+
const key = Object.keys(attr)[0];
|
|
7
|
+
const value = attr[key];
|
|
8
|
+
switch (key) {
|
|
9
|
+
case "S":
|
|
10
|
+
return value;
|
|
11
|
+
case "N":
|
|
12
|
+
return value;
|
|
13
|
+
case "B":
|
|
14
|
+
return value;
|
|
15
|
+
case "SS":
|
|
16
|
+
return value;
|
|
17
|
+
case "NS":
|
|
18
|
+
return value;
|
|
19
|
+
case "BS":
|
|
20
|
+
return value;
|
|
21
|
+
case "M": {
|
|
22
|
+
const mapVal = value;
|
|
23
|
+
const obj = {};
|
|
24
|
+
for (const [k, v] of Object.entries(mapVal)) {
|
|
25
|
+
obj[k] = unwrapDynamoValue(v);
|
|
26
|
+
}
|
|
27
|
+
return obj;
|
|
28
|
+
}
|
|
29
|
+
case "L": {
|
|
30
|
+
const listVal = value;
|
|
31
|
+
return listVal.map((v) => unwrapDynamoValue(v));
|
|
32
|
+
}
|
|
33
|
+
case "NULL":
|
|
34
|
+
return null;
|
|
35
|
+
case "BOOL":
|
|
36
|
+
return value;
|
|
37
|
+
default:
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get the DynamoDB type descriptor for an attribute value.
|
|
43
|
+
* Returns the single-letter type: S, N, B, SS, NS, BS, M, L, NULL, BOOL
|
|
44
|
+
*/
|
|
45
|
+
export function getDynamoType(attr) {
|
|
46
|
+
const key = Object.keys(attr)[0];
|
|
47
|
+
return key || "?";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Format a DynamoDB value for display.
|
|
51
|
+
* - For simple types (S, N, B, BOOL, NULL), return the value
|
|
52
|
+
* - For collections (SS, NS, BS), return comma-separated or wrapped in []
|
|
53
|
+
* - For maps/lists, return pretty JSON
|
|
54
|
+
* - For large values (>3 lines), truncate to 2 header + ... + 1 footer
|
|
55
|
+
* - For single/few line values, truncate at 50 chars with "..." suffix
|
|
56
|
+
*/
|
|
57
|
+
export function formatDynamoValue(attr) {
|
|
58
|
+
const type = getDynamoType(attr);
|
|
59
|
+
const raw = unwrapDynamoValue(attr);
|
|
60
|
+
const key = Object.keys(attr)[0];
|
|
61
|
+
const value = attr[key];
|
|
62
|
+
if (type === "NULL")
|
|
63
|
+
return "null";
|
|
64
|
+
if (type === "BOOL")
|
|
65
|
+
return String(value);
|
|
66
|
+
if (type === "N")
|
|
67
|
+
return String(value);
|
|
68
|
+
if (type === "S") {
|
|
69
|
+
const str = String(value);
|
|
70
|
+
// Escape newlines for display
|
|
71
|
+
const escaped = str.replace(/\n/g, "\\n");
|
|
72
|
+
if (escaped.length > 50)
|
|
73
|
+
return escaped.slice(0, 47) + "...";
|
|
74
|
+
return escaped;
|
|
75
|
+
}
|
|
76
|
+
if (type === "B")
|
|
77
|
+
return `<binary: ${value?.length ?? 0} bytes>`;
|
|
78
|
+
if (type === "SS") {
|
|
79
|
+
return (value ?? []).join(", ");
|
|
80
|
+
}
|
|
81
|
+
if (type === "NS") {
|
|
82
|
+
return (value ?? []).join(", ");
|
|
83
|
+
}
|
|
84
|
+
if (type === "BS") {
|
|
85
|
+
return `<${(value ?? []).length} binary values>`;
|
|
86
|
+
}
|
|
87
|
+
if (type === "M") {
|
|
88
|
+
const json = JSON.stringify(raw, null, 2);
|
|
89
|
+
return truncateLargeValue(json);
|
|
90
|
+
}
|
|
91
|
+
if (type === "L") {
|
|
92
|
+
const json = JSON.stringify(raw, null, 2);
|
|
93
|
+
return truncateLargeValue(json);
|
|
94
|
+
}
|
|
95
|
+
return String(raw);
|
|
96
|
+
}
|
|
97
|
+
function truncateLargeValue(str) {
|
|
98
|
+
const lines = str.split("\n");
|
|
99
|
+
if (lines.length > 3) {
|
|
100
|
+
return `${lines[0]}\n${lines[1]}\n...\n${lines[lines.length - 1]}`;
|
|
101
|
+
}
|
|
102
|
+
// Single or few lines
|
|
103
|
+
const compact = str.replace(/\n/g, " ");
|
|
104
|
+
if (compact.length > 50)
|
|
105
|
+
return compact.slice(0, 47) + "...";
|
|
106
|
+
return compact;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Format billing mode: "On-Demand" or "Provisioned (R:X W:Y)"
|
|
110
|
+
*/
|
|
111
|
+
export function formatBillingMode(table) {
|
|
112
|
+
const billing = table.BillingModeSummary?.BillingMode;
|
|
113
|
+
if (billing === "PAY_PER_REQUEST") {
|
|
114
|
+
return "On-Demand";
|
|
115
|
+
}
|
|
116
|
+
const r = table.ProvisionedThroughput?.ReadCapacityUnits ?? 0;
|
|
117
|
+
const w = table.ProvisionedThroughput?.WriteCapacityUnits ?? 0;
|
|
118
|
+
return `Provisioned (R:${r} W:${w})`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Format key schema: "PK: userId, SK: timestamp" or just "PK: id"
|
|
122
|
+
*/
|
|
123
|
+
export function formatKeySchema(table) {
|
|
124
|
+
const keys = table.KeySchema ?? [];
|
|
125
|
+
const hash = keys.find((k) => k.KeyType === "HASH");
|
|
126
|
+
const range = keys.find((k) => k.KeyType === "RANGE");
|
|
127
|
+
let result = "";
|
|
128
|
+
if (hash)
|
|
129
|
+
result += `PK: ${hash.AttributeName}`;
|
|
130
|
+
if (range)
|
|
131
|
+
result += (result ? ", " : "") + `SK: ${range.AttributeName}`;
|
|
132
|
+
return result || "-";
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Extract primary key value from an item.
|
|
136
|
+
* Returns a JSON string like '{"userId": "abc123"}' or '{"id": "x", "sk": "y"}'
|
|
137
|
+
*/
|
|
138
|
+
export function extractPrimaryKeyJson(item, table) {
|
|
139
|
+
const keys = table.KeySchema ?? [];
|
|
140
|
+
const pkObj = {};
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
const attrName = key.AttributeName;
|
|
143
|
+
const attr = item[attrName];
|
|
144
|
+
if (attr) {
|
|
145
|
+
pkObj[attrName] = unwrapDynamoValue(attr);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return JSON.stringify(pkObj);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Get the sort key value from an item for display.
|
|
152
|
+
* Returns unwrapped value or undefined.
|
|
153
|
+
*/
|
|
154
|
+
export function extractSkValue(item, table) {
|
|
155
|
+
const rangeKey = table.KeySchema?.find((k) => k.KeyType === "RANGE");
|
|
156
|
+
if (!rangeKey)
|
|
157
|
+
return undefined;
|
|
158
|
+
const attr = item[rangeKey.AttributeName];
|
|
159
|
+
if (!attr)
|
|
160
|
+
return undefined;
|
|
161
|
+
return String(unwrapDynamoValue(attr));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get the partition key value from an item for display.
|
|
165
|
+
* Returns unwrapped value or undefined.
|
|
166
|
+
*/
|
|
167
|
+
export function extractPkValue(item, table) {
|
|
168
|
+
const hashKey = table.KeySchema?.find((k) => k.KeyType === "HASH");
|
|
169
|
+
if (!hashKey)
|
|
170
|
+
return undefined;
|
|
171
|
+
const attr = item[hashKey.AttributeName];
|
|
172
|
+
if (!attr)
|
|
173
|
+
return undefined;
|
|
174
|
+
return String(unwrapDynamoValue(attr));
|
|
175
|
+
}
|
|
@@ -1,22 +1,178 @@
|
|
|
1
1
|
import { textCell } from "../../types.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { runAwsJsonAsync } from "../../utils/aws.js";
|
|
3
|
+
import { atom } from "jotai";
|
|
4
|
+
import { getDefaultStore } from "jotai";
|
|
5
|
+
import { createRoute53DetailCapability } from "./capabilities/detailCapability.js";
|
|
6
|
+
import { createRoute53YankCapability } from "./capabilities/yankCapability.js";
|
|
7
|
+
export const route53LevelAtom = atom({ kind: "zones" });
|
|
8
|
+
export const route53BackStackAtom = atom([]);
|
|
9
|
+
export function createRoute53ServiceAdapter(endpointUrl, region) {
|
|
10
|
+
const store = getDefaultStore();
|
|
11
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
12
|
+
const getLevel = () => store.get(route53LevelAtom);
|
|
13
|
+
const setLevel = (level) => store.set(route53LevelAtom, level);
|
|
14
|
+
const getBackStack = () => store.get(route53BackStackAtom);
|
|
15
|
+
const setBackStack = (stack) => store.set(route53BackStackAtom, stack);
|
|
16
|
+
const getColumns = () => {
|
|
17
|
+
const level = getLevel();
|
|
18
|
+
if (level.kind === "zones") {
|
|
19
|
+
return [
|
|
20
|
+
{ key: "name", label: "Name" },
|
|
21
|
+
{ key: "zoneId", label: "Zone ID" },
|
|
22
|
+
{ key: "recordCount", label: "Records" },
|
|
23
|
+
{ key: "type", label: "Type", width: 10 },
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
// records level
|
|
27
|
+
return [
|
|
28
|
+
{ key: "name", label: "Name" },
|
|
29
|
+
{ key: "type", label: "Type", width: 10 },
|
|
30
|
+
{ key: "ttl", label: "TTL", width: 10 },
|
|
31
|
+
{ key: "values", label: "Value(s)", width: 50 },
|
|
32
|
+
];
|
|
33
|
+
};
|
|
4
34
|
const getRows = async () => {
|
|
5
|
-
|
|
35
|
+
const level = getLevel();
|
|
36
|
+
if (level.kind === "zones") {
|
|
37
|
+
const data = await runAwsJsonAsync([
|
|
38
|
+
"route53",
|
|
39
|
+
"list-hosted-zones",
|
|
40
|
+
...regionArgs,
|
|
41
|
+
]);
|
|
42
|
+
return (data.HostedZones ?? []).map((zone) => {
|
|
43
|
+
const isPrivate = zone.Config?.PrivateZone ?? zone.HostedZoneConfig?.PrivateZone ?? false;
|
|
44
|
+
const shortZoneId = zone.Id.replace(/^\/hostedzone\//, "");
|
|
45
|
+
return {
|
|
46
|
+
id: zone.Id,
|
|
47
|
+
cells: {
|
|
48
|
+
name: textCell(zone.Name),
|
|
49
|
+
zoneId: textCell(shortZoneId),
|
|
50
|
+
recordCount: textCell(String(zone.ResourceRecordSetCount ?? 0)),
|
|
51
|
+
type: textCell(isPrivate ? "Private" : "Public"),
|
|
52
|
+
},
|
|
53
|
+
meta: {
|
|
54
|
+
type: "zone",
|
|
55
|
+
zoneId: shortZoneId,
|
|
56
|
+
zoneName: zone.Name,
|
|
57
|
+
isPrivate,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// records level
|
|
63
|
+
const { zoneId, zoneName } = level;
|
|
64
|
+
try {
|
|
65
|
+
const fullZoneId = zoneId.startsWith("/hostedzone/") ? zoneId : `/hostedzone/${zoneId}`;
|
|
66
|
+
const data = await runAwsJsonAsync([
|
|
67
|
+
"route53",
|
|
68
|
+
"list-resource-record-sets",
|
|
69
|
+
"--hosted-zone-id",
|
|
70
|
+
fullZoneId,
|
|
71
|
+
...regionArgs,
|
|
72
|
+
]);
|
|
73
|
+
return (data.ResourceRecordSets ?? []).map((record) => {
|
|
74
|
+
const values = [];
|
|
75
|
+
let valuesDisplay = "";
|
|
76
|
+
if (record.AliasTarget) {
|
|
77
|
+
valuesDisplay = `ALIAS → ${record.AliasTarget.DNSName}`;
|
|
78
|
+
}
|
|
79
|
+
else if (record.ResourceRecords && record.ResourceRecords.length > 0) {
|
|
80
|
+
record.ResourceRecords.forEach((r) => values.push(r.Value));
|
|
81
|
+
if (values.length <= 3) {
|
|
82
|
+
valuesDisplay = values.join(", ");
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
valuesDisplay = `<${values.length} values>`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
id: `${record.Name}-${record.Type}`,
|
|
90
|
+
cells: {
|
|
91
|
+
name: textCell(record.Name),
|
|
92
|
+
type: textCell(record.Type),
|
|
93
|
+
ttl: textCell(record.AliasTarget ? "-" : String(record.TTL ?? "-")),
|
|
94
|
+
values: textCell(valuesDisplay),
|
|
95
|
+
},
|
|
96
|
+
meta: {
|
|
97
|
+
type: "record",
|
|
98
|
+
zoneId,
|
|
99
|
+
zoneName,
|
|
100
|
+
recordName: record.Name,
|
|
101
|
+
recordType: record.Type,
|
|
102
|
+
recordTtl: record.TTL,
|
|
103
|
+
recordValues: values,
|
|
104
|
+
recordAliasTarget: record.AliasTarget,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
6
112
|
};
|
|
7
|
-
const onSelect = async (
|
|
113
|
+
const onSelect = async (row) => {
|
|
114
|
+
const level = getLevel();
|
|
115
|
+
const backStack = getBackStack();
|
|
116
|
+
const meta = row.meta;
|
|
117
|
+
if (level.kind === "zones") {
|
|
118
|
+
if (!meta || meta.type !== "zone") {
|
|
119
|
+
return { action: "none" };
|
|
120
|
+
}
|
|
121
|
+
const newStack = [...backStack, { level: level, selectedIndex: 0 }];
|
|
122
|
+
setBackStack(newStack);
|
|
123
|
+
setLevel({
|
|
124
|
+
kind: "records",
|
|
125
|
+
zoneId: meta.zoneId,
|
|
126
|
+
zoneName: meta.zoneName,
|
|
127
|
+
});
|
|
128
|
+
return { action: "navigate" };
|
|
129
|
+
}
|
|
130
|
+
// records level: leaf, no drill-down
|
|
8
131
|
return { action: "none" };
|
|
9
132
|
};
|
|
133
|
+
const canGoBack = () => getBackStack().length > 0;
|
|
134
|
+
const goBack = () => {
|
|
135
|
+
const backStack = getBackStack();
|
|
136
|
+
if (backStack.length > 0) {
|
|
137
|
+
const newStack = backStack.slice(0, -1);
|
|
138
|
+
const frame = backStack[backStack.length - 1];
|
|
139
|
+
setBackStack(newStack);
|
|
140
|
+
setLevel(frame.level);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const getPath = () => {
|
|
144
|
+
const level = getLevel();
|
|
145
|
+
if (level.kind === "zones")
|
|
146
|
+
return "route53://";
|
|
147
|
+
return `route53://${level.zoneName}`;
|
|
148
|
+
};
|
|
149
|
+
const getContextLabel = () => {
|
|
150
|
+
const level = getLevel();
|
|
151
|
+
if (level.kind === "zones")
|
|
152
|
+
return "🌐 Hosted Zones";
|
|
153
|
+
return `🌐 ${level.zoneName}`;
|
|
154
|
+
};
|
|
155
|
+
// Compose capabilities
|
|
156
|
+
const detailCapability = createRoute53DetailCapability(region, getLevel);
|
|
157
|
+
const yankCapability = createRoute53YankCapability(region, getLevel);
|
|
10
158
|
return {
|
|
11
159
|
id: "route53",
|
|
12
160
|
label: "Route53",
|
|
13
|
-
hudColor: { bg: "
|
|
161
|
+
hudColor: { bg: "cyan", fg: "black" },
|
|
14
162
|
getColumns,
|
|
15
163
|
getRows,
|
|
16
164
|
onSelect,
|
|
17
|
-
canGoBack
|
|
18
|
-
goBack
|
|
19
|
-
getPath
|
|
20
|
-
getContextLabel
|
|
165
|
+
canGoBack,
|
|
166
|
+
goBack,
|
|
167
|
+
getPath,
|
|
168
|
+
getContextLabel,
|
|
169
|
+
reset() {
|
|
170
|
+
setLevel({ kind: "zones" });
|
|
171
|
+
setBackStack([]);
|
|
172
|
+
},
|
|
173
|
+
capabilities: {
|
|
174
|
+
detail: detailCapability,
|
|
175
|
+
yank: yankCapability,
|
|
176
|
+
},
|
|
21
177
|
};
|
|
22
178
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { runAwsJsonAsync } from "../../../utils/aws.js";
|
|
2
|
+
export function createRoute53DetailCapability(region, getLevel) {
|
|
3
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
4
|
+
const getDetails = async (row) => {
|
|
5
|
+
const meta = row.meta;
|
|
6
|
+
if (!meta) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
const level = getLevel?.();
|
|
10
|
+
// Zone level
|
|
11
|
+
if (level?.kind === "zones" && meta.type === "zone") {
|
|
12
|
+
const fullZoneId = meta.zoneId.startsWith("/hostedzone/")
|
|
13
|
+
? meta.zoneId
|
|
14
|
+
: `/hostedzone/${meta.zoneId}`;
|
|
15
|
+
const data = await runAwsJsonAsync([
|
|
16
|
+
"route53",
|
|
17
|
+
"get-hosted-zone",
|
|
18
|
+
"--id",
|
|
19
|
+
fullZoneId,
|
|
20
|
+
...regionArgs,
|
|
21
|
+
]);
|
|
22
|
+
const fields = [
|
|
23
|
+
{ label: "Zone Name", value: data.Name ?? "-" },
|
|
24
|
+
{ label: "Zone ID (Full)", value: data.Id ?? "-" },
|
|
25
|
+
{ label: "Zone ID (Short)", value: meta.zoneId },
|
|
26
|
+
{ label: "Record Count", value: String(data.ResourceRecordSetCount ?? 0) },
|
|
27
|
+
{
|
|
28
|
+
label: "Type",
|
|
29
|
+
value: meta.isPrivate ? "Private" : "Public",
|
|
30
|
+
},
|
|
31
|
+
{ label: "Caller Reference", value: data.CallerReference ?? "-" },
|
|
32
|
+
];
|
|
33
|
+
return fields;
|
|
34
|
+
}
|
|
35
|
+
// Record level
|
|
36
|
+
if (level?.kind === "records" && meta.type === "record") {
|
|
37
|
+
const fields = [
|
|
38
|
+
{ label: "Record Name", value: meta.recordName ?? "-" },
|
|
39
|
+
{ label: "Record Type", value: meta.recordType ?? "-" },
|
|
40
|
+
{ label: "TTL", value: meta.recordAliasTarget ? "(Alias - no TTL)" : String(meta.recordTtl ?? "-") },
|
|
41
|
+
];
|
|
42
|
+
if (meta.recordValues && meta.recordValues.length > 0) {
|
|
43
|
+
fields.push({
|
|
44
|
+
label: "Values",
|
|
45
|
+
value: meta.recordValues.join("\n"),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (meta.recordAliasTarget) {
|
|
49
|
+
fields.push({
|
|
50
|
+
label: "Alias Target",
|
|
51
|
+
value: meta.recordAliasTarget.DNSName,
|
|
52
|
+
});
|
|
53
|
+
fields.push({
|
|
54
|
+
label: "Hosted Zone ID",
|
|
55
|
+
value: meta.recordAliasTarget.HostedZoneId,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return fields;
|
|
59
|
+
}
|
|
60
|
+
return [];
|
|
61
|
+
};
|
|
62
|
+
return { getDetails };
|
|
63
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createYankCapability } from "../../../adapters/capabilities/YankCapability.js";
|
|
2
|
+
import { Route53RowMetaSchema } from "../schema.js";
|
|
3
|
+
import { Route53YankOptions } from "./yankOptions.js";
|
|
4
|
+
export function createRoute53YankCapability(_region, _getLevel) {
|
|
5
|
+
return createYankCapability(Route53YankOptions, Route53RowMetaSchema, {});
|
|
6
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const Route53YankOptions = [
|
|
2
|
+
// Zone options
|
|
3
|
+
{
|
|
4
|
+
trigger: { type: "key", char: "n" },
|
|
5
|
+
label: "Copy zone name",
|
|
6
|
+
feedback: "Copied zone name",
|
|
7
|
+
isRelevant: (row) => row.meta.type === "zone",
|
|
8
|
+
resolve: async (row) => {
|
|
9
|
+
return row.meta.zoneName ?? null;
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
trigger: { type: "key", char: "i" },
|
|
14
|
+
label: "Copy zone ID",
|
|
15
|
+
feedback: "Copied zone ID",
|
|
16
|
+
isRelevant: (row) => row.meta.type === "zone",
|
|
17
|
+
resolve: async (row) => {
|
|
18
|
+
return row.meta.zoneId ?? null;
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
// Record options
|
|
22
|
+
{
|
|
23
|
+
trigger: { type: "key", char: "n" },
|
|
24
|
+
label: "Copy record name",
|
|
25
|
+
feedback: "Copied record name",
|
|
26
|
+
isRelevant: (row) => row.meta.type === "record",
|
|
27
|
+
resolve: async (row) => {
|
|
28
|
+
return row.meta.recordName ?? null;
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
trigger: { type: "key", char: "v" },
|
|
33
|
+
label: "Copy first value",
|
|
34
|
+
feedback: "Copied first value",
|
|
35
|
+
isRelevant: (row) => {
|
|
36
|
+
return row.meta.type === "record" && (row.meta.recordValues?.length ?? 0) > 0;
|
|
37
|
+
},
|
|
38
|
+
resolve: async (row) => {
|
|
39
|
+
return row.meta.recordValues?.[0] ?? null;
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
trigger: { type: "key", char: "b" },
|
|
44
|
+
label: "Copy in BIND format",
|
|
45
|
+
feedback: "Copied BIND format",
|
|
46
|
+
isRelevant: (row) => row.meta.type === "record",
|
|
47
|
+
resolve: async (row) => {
|
|
48
|
+
const name = row.meta.recordName ?? "";
|
|
49
|
+
const type = row.meta.recordType ?? "";
|
|
50
|
+
const ttl = row.meta.recordTtl ?? 3600;
|
|
51
|
+
const value = row.meta.recordValues?.[0] ?? "";
|
|
52
|
+
if (row.meta.recordAliasTarget) {
|
|
53
|
+
return `${name} ALIAS ${row.meta.recordAliasTarget.DNSName}`;
|
|
54
|
+
}
|
|
55
|
+
return `${name} ${ttl} IN ${type} ${value}`;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const Route53RowMetaSchema = z.object({
|
|
3
|
+
type: z.enum(["zone", "record"]),
|
|
4
|
+
zoneId: z.string().optional(),
|
|
5
|
+
zoneName: z.string().optional(),
|
|
6
|
+
isPrivate: z.boolean().optional(),
|
|
7
|
+
recordName: z.string().optional(),
|
|
8
|
+
recordType: z.string().optional(),
|
|
9
|
+
recordTtl: z.number().optional(),
|
|
10
|
+
recordValues: z.array(z.string()).optional(),
|
|
11
|
+
recordAliasTarget: z
|
|
12
|
+
.object({
|
|
13
|
+
HostedZoneId: z.string(),
|
|
14
|
+
DNSName: z.string(),
|
|
15
|
+
EvaluateTargetHealth: z.boolean(),
|
|
16
|
+
})
|
|
17
|
+
.optional(),
|
|
18
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a9s/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "k9s-style TUI navigator for AWS services",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aws",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"localstack:setup": "pnpm localstack:up && sleep 3 && pnpm localstack:seed",
|
|
36
36
|
"dev:local": "AWS_ENDPOINT_URL=http://localhost:4566 AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test tsx --watch src/index.tsx",
|
|
37
37
|
"dev": "tsx --watch src/index.tsx",
|
|
38
|
-
"build": "tsc",
|
|
38
|
+
"build": "tsc && node scripts/add-shebang.js",
|
|
39
39
|
"lint": "oxlint",
|
|
40
40
|
"format": "oxfmt --write",
|
|
41
41
|
"test": "vitest run",
|