@a9s/cli 1.0.7 → 1.0.9
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/App.js +35 -3
- package/dist/src/components/AdvancedTextInput.js +3 -1
- package/dist/src/components/AutocompleteInput.js +3 -1
- package/dist/src/components/DetailPanel.js +3 -1
- package/dist/src/components/DiffViewer.js +3 -1
- package/dist/src/components/ErrorStatePanel.js +3 -1
- package/dist/src/components/HUD.js +3 -1
- package/dist/src/components/HelpPanel.js +6 -4
- package/dist/src/components/ModeBar.js +5 -8
- package/dist/src/components/Table/index.js +19 -26
- package/dist/src/components/TableSkeleton.js +3 -1
- package/dist/src/components/YankHelpPanel.js +3 -1
- package/dist/src/constants/commands.js +2 -1
- package/dist/src/constants/theme.js +608 -0
- package/dist/src/contexts/ThemeContext.js +13 -0
- package/dist/src/features/AppMainView.integration.test.js +1 -0
- package/dist/src/features/AppMainView.js +6 -4
- package/dist/src/hooks/useCommandRouter.js +5 -0
- package/dist/src/hooks/usePickerManager.js +35 -1
- package/dist/src/index.js +2 -1
- package/dist/src/services.js +2 -2
- package/dist/src/state/atoms.js +3 -0
- package/dist/src/utils/config.js +36 -0
- package/dist/src/views/dynamodb/adapter.js +313 -9
- 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/iam/adapter.js +2 -1
- package/dist/src/views/route53/adapter.js +166 -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/dist/src/views/s3/adapter.js +2 -1
- package/dist/src/views/secretsmanager/adapter.js +2 -1
- package/package.json +2 -1
|
@@ -13,7 +13,9 @@ import { debugLog } from "../utils/debugLogger.js";
|
|
|
13
13
|
import { revealSecretsAtom } from "../state/atoms.js";
|
|
14
14
|
import { truncateSecretForTable } from "../utils/secretDisplay.js";
|
|
15
15
|
import { getCellValue } from "../types.js";
|
|
16
|
+
import { useTheme } from "../contexts/ThemeContext.js";
|
|
16
17
|
export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState, isLoading, filteredRows, uploadPending, uploadPreview, columns, selectedIndex, scrollOffset, filterText, adapter, termCols, tableHeight, headerMarkers, yankHelpOpen, yankOptions, yankHelpRow, panelScrollOffset, }) {
|
|
18
|
+
const THEME = useTheme();
|
|
17
19
|
const revealSecrets = useAtomValue(revealSecretsAtom);
|
|
18
20
|
// Format secret values for display ONLY - original rows (via filteredRows) stay unchanged for editing
|
|
19
21
|
const displayRows = useMemo(() => {
|
|
@@ -65,7 +67,7 @@ export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState
|
|
|
65
67
|
error,
|
|
66
68
|
]);
|
|
67
69
|
if (helpPanel.helpOpen) {
|
|
68
|
-
return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor:
|
|
70
|
+
return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.helpPanelBorderText, children: _jsx(HelpPanel, { title: "Keyboard Help", scopeLabel: "All modes reference", tabs: helpTabs, activeTab: helpPanel.helpTabIndex, terminalWidth: termCols, maxRows: helpPanel.helpVisibleRows, scrollOffset: helpPanel.helpScrollOffset }) }));
|
|
69
71
|
}
|
|
70
72
|
if (pickers.activePicker) {
|
|
71
73
|
const ap = pickers.activePicker;
|
|
@@ -73,17 +75,17 @@ export function AppMainView({ helpPanel, helpTabs, pickers, error, describeState
|
|
|
73
75
|
return (_jsx(Table, { rows: ap.filteredRows, columns: ap.columns, selectedIndex: ap.selectedIndex, filterText: ap.filter, terminalWidth: termCols, maxHeight: tableHeight, scrollOffset: ap.scrollOffset, contextLabel: ap.contextLabel }));
|
|
74
76
|
}
|
|
75
77
|
if (yankHelpOpen) {
|
|
76
|
-
return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor:
|
|
78
|
+
return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.yankPanelBorderText, children: _jsx(YankHelpPanel, { options: yankOptions, row: yankHelpRow }) }));
|
|
77
79
|
}
|
|
78
80
|
if (uploadPending) {
|
|
79
81
|
// Overhead: border 2 + header 4 + separators 2 + DiffViewer header+divider 2 = 10
|
|
80
82
|
const diffVisibleLines = Math.max(1, tableHeight - 10);
|
|
81
|
-
return (_jsxs(Box, { width: "100%", borderStyle: "round", borderColor:
|
|
83
|
+
return (_jsxs(Box, { width: "100%", borderStyle: "round", borderColor: THEME.upload.uploadBorderText, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, paddingY: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: THEME.upload.uploadTitleText, children: "\u26A0 Overwrite Secret on AWS?" }), _jsx(Text, { color: THEME.upload.uploadSubtitleText, children: "This will update the secret permanently." })] }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: THEME.upload.uploadDiffDividerText, children: uploadPreview ? (_jsx(DiffViewer, { oldValue: uploadPreview.old, newValue: uploadPreview.new, scrollOffset: panelScrollOffset, visibleLines: diffVisibleLines })) : (_jsx(Text, { color: THEME.upload.uploadLoadingText, children: "Loading preview..." })) }), _jsx(Box, { paddingX: 1, paddingY: 1, borderTop: true, borderColor: THEME.upload.uploadConfirmPromptText, children: _jsxs(Text, { children: ["Press", " ", _jsx(Text, { bold: true, color: THEME.upload.uploadConfirmKeyText, children: "y" }), " ", "to confirm or", " ", _jsx(Text, { bold: true, color: THEME.upload.uploadCancelKeyText, children: "n" }), " ", "to cancel"] }) })] }));
|
|
82
84
|
}
|
|
83
85
|
if (describeState) {
|
|
84
86
|
// Overhead: border 2 + title 1 + separator 1 + footer 2 = 6
|
|
85
87
|
const detailVisibleLines = Math.max(1, tableHeight - 6);
|
|
86
|
-
return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor:
|
|
88
|
+
return (_jsx(Box, { width: "100%", borderStyle: "round", borderColor: THEME.panel.detailPanelBorderText, children: _jsx(DetailPanel, { title: getCellValue(describeState.row.cells.name) ?? describeState.row.id, fields: describeState.fields ?? [], isLoading: describeState.loading, scrollOffset: panelScrollOffset, visibleLines: detailVisibleLines }) }));
|
|
87
89
|
}
|
|
88
90
|
if (isLoading) {
|
|
89
91
|
return (_jsx(TableSkeleton, { columns: columns, terminalWidth: termCols, rows: 1, contextLabel: adapter.getContextLabel?.() ?? "" }));
|
|
@@ -8,6 +8,8 @@ export function parseCommand(input) {
|
|
|
8
8
|
return { type: "openRegions" };
|
|
9
9
|
if (command === "resources")
|
|
10
10
|
return { type: "openResources" };
|
|
11
|
+
if (command === "theme")
|
|
12
|
+
return { type: "openThemePicker" };
|
|
11
13
|
const regionMatch = command.match(/^(region|use-region)\s+([a-z0-9-]+)$/i);
|
|
12
14
|
if (regionMatch?.[2]) {
|
|
13
15
|
return { type: "setRegion", region: regionMatch[2].toLowerCase() };
|
|
@@ -37,6 +39,9 @@ export function useCommandRouter(args) {
|
|
|
37
39
|
case "openResources":
|
|
38
40
|
args.openResourcePicker();
|
|
39
41
|
return;
|
|
42
|
+
case "openThemePicker":
|
|
43
|
+
args.openThemePicker();
|
|
44
|
+
return;
|
|
40
45
|
case "setRegion":
|
|
41
46
|
args.setSelectedRegion(parsed.region);
|
|
42
47
|
return;
|
|
@@ -3,10 +3,12 @@ import { textCell } from "../types.js";
|
|
|
3
3
|
import { usePickerState } from "./usePickerState.js";
|
|
4
4
|
import { usePickerTable } from "./usePickerTable.js";
|
|
5
5
|
import { SERVICE_REGISTRY } from "../services.js";
|
|
6
|
+
import { THEMES, THEME_LABELS } from "../constants/theme.js";
|
|
6
7
|
export function usePickerManager({ tableHeight, availableRegions, availableProfiles, }) {
|
|
7
8
|
const region = usePickerState();
|
|
8
9
|
const profile = usePickerState();
|
|
9
10
|
const resource = usePickerState();
|
|
11
|
+
const theme = usePickerState();
|
|
10
12
|
const regionRows = useMemo(() => availableRegions.map((r) => ({
|
|
11
13
|
id: r.name,
|
|
12
14
|
cells: { region: textCell(r.name), description: textCell(r.description) },
|
|
@@ -25,6 +27,14 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
|
|
|
25
27
|
},
|
|
26
28
|
meta: {},
|
|
27
29
|
})), []);
|
|
30
|
+
const themeRows = useMemo(() => Object.keys(THEMES).map((themeName) => ({
|
|
31
|
+
id: themeName,
|
|
32
|
+
cells: {
|
|
33
|
+
theme: textCell(THEME_LABELS[themeName]),
|
|
34
|
+
id: textCell(themeName),
|
|
35
|
+
},
|
|
36
|
+
meta: {},
|
|
37
|
+
})), []);
|
|
28
38
|
const regionTable = usePickerTable({
|
|
29
39
|
rows: regionRows,
|
|
30
40
|
filterText: region.filter,
|
|
@@ -40,6 +50,11 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
|
|
|
40
50
|
filterText: resource.filter,
|
|
41
51
|
maxHeight: tableHeight,
|
|
42
52
|
});
|
|
53
|
+
const themeTable = usePickerTable({
|
|
54
|
+
rows: themeRows,
|
|
55
|
+
filterText: theme.filter,
|
|
56
|
+
maxHeight: tableHeight,
|
|
57
|
+
});
|
|
43
58
|
const regionColumns = [
|
|
44
59
|
{ key: "region", label: "Region" },
|
|
45
60
|
{ key: "description", label: "Description" },
|
|
@@ -52,6 +67,10 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
|
|
|
52
67
|
{ key: "resource", label: "Resource" },
|
|
53
68
|
{ key: "description", label: "Description" },
|
|
54
69
|
];
|
|
70
|
+
const themeColumns = [
|
|
71
|
+
{ key: "theme", label: "Theme" },
|
|
72
|
+
{ key: "id", label: "ID" },
|
|
73
|
+
];
|
|
55
74
|
const regionEntry = {
|
|
56
75
|
id: "region",
|
|
57
76
|
columns: regionColumns,
|
|
@@ -73,13 +92,22 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
|
|
|
73
92
|
...resource,
|
|
74
93
|
...resourceTable,
|
|
75
94
|
};
|
|
95
|
+
const themeEntry = {
|
|
96
|
+
id: "theme",
|
|
97
|
+
columns: themeColumns,
|
|
98
|
+
contextLabel: "Select Theme",
|
|
99
|
+
...theme,
|
|
100
|
+
...themeTable,
|
|
101
|
+
};
|
|
76
102
|
const activePicker = regionEntry.open
|
|
77
103
|
? regionEntry
|
|
78
104
|
: profileEntry.open
|
|
79
105
|
? profileEntry
|
|
80
106
|
: resourceEntry.open
|
|
81
107
|
? resourceEntry
|
|
82
|
-
:
|
|
108
|
+
: themeEntry.open
|
|
109
|
+
? themeEntry
|
|
110
|
+
: null;
|
|
83
111
|
const getEntry = (id) => {
|
|
84
112
|
switch (id) {
|
|
85
113
|
case "region":
|
|
@@ -88,6 +116,8 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
|
|
|
88
116
|
return profileEntry;
|
|
89
117
|
case "resource":
|
|
90
118
|
return resourceEntry;
|
|
119
|
+
case "theme":
|
|
120
|
+
return themeEntry;
|
|
91
121
|
}
|
|
92
122
|
};
|
|
93
123
|
const openPicker = (id) => {
|
|
@@ -114,6 +144,9 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
|
|
|
114
144
|
case "profile":
|
|
115
145
|
handlers.onSelectProfile(activePicker.selectedRow.id);
|
|
116
146
|
break;
|
|
147
|
+
case "theme":
|
|
148
|
+
handlers.onSelectTheme(activePicker.selectedRow.id);
|
|
149
|
+
break;
|
|
117
150
|
}
|
|
118
151
|
activePicker.closePicker();
|
|
119
152
|
};
|
|
@@ -121,6 +154,7 @@ export function usePickerManager({ tableHeight, availableRegions, availableProfi
|
|
|
121
154
|
region: regionEntry,
|
|
122
155
|
profile: profileEntry,
|
|
123
156
|
resource: resourceEntry,
|
|
157
|
+
theme: themeEntry,
|
|
124
158
|
activePicker,
|
|
125
159
|
openPicker,
|
|
126
160
|
closeActivePicker,
|
package/dist/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Command, Option } from "@commander-js/extra-typings";
|
|
|
4
4
|
import { App } from "./App.js";
|
|
5
5
|
import { SERVICE_REGISTRY } from "./services.js";
|
|
6
6
|
import { withFullscreen } from "./utils/withFullscreen.js";
|
|
7
|
+
import { ThemeProvider } from "./contexts/ThemeContext.js";
|
|
7
8
|
const SERVICE_IDS = Object.keys(SERVICE_REGISTRY);
|
|
8
9
|
const program = new Command()
|
|
9
10
|
.name("a9s")
|
|
@@ -17,7 +18,7 @@ program.parse();
|
|
|
17
18
|
// opts() return type is fully inferred from addOption() calls via extra-typings
|
|
18
19
|
const options = program.opts();
|
|
19
20
|
void (async () => {
|
|
20
|
-
const { instance, cleanup } = withFullscreen(_jsx(App, { initialService: options.service, endpointUrl: options.endpointUrl }));
|
|
21
|
+
const { instance, cleanup } = withFullscreen(_jsx(ThemeProvider, { children: _jsx(App, { initialService: options.service, endpointUrl: options.endpointUrl }) }));
|
|
21
22
|
process.on("SIGINT", () => {
|
|
22
23
|
cleanup();
|
|
23
24
|
process.exit(0);
|
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
|
};
|
package/dist/src/state/atoms.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { atom } from "jotai";
|
|
2
|
+
import { loadConfig } from "../utils/config.js";
|
|
2
3
|
/** Persists across HMR / re-renders. Currently selected AWS service. */
|
|
3
4
|
export const currentlySelectedServiceAtom = atom("s3");
|
|
4
5
|
/** Current UI mode (navigate / search / command). */
|
|
@@ -25,3 +26,5 @@ export const adapterSessionAtom = atom((get) => {
|
|
|
25
26
|
});
|
|
26
27
|
/** Toggle state for revealing/hiding secret values. Persists across HMR. */
|
|
27
28
|
export const revealSecretsAtom = atom(false);
|
|
29
|
+
/** Active UI theme name — initialized from ~/.config/a9s/config.json on startup. */
|
|
30
|
+
export const themeNameAtom = atom(loadConfig().theme ?? "monokai");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import * as YAML from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { THEMES } from "../constants/theme.js";
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), ".config", "a9s");
|
|
8
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config.yaml");
|
|
9
|
+
const THEME_NAMES = Object.keys(THEMES);
|
|
10
|
+
const A9sConfigSchema = z.object({
|
|
11
|
+
theme: z.enum(THEME_NAMES).optional(),
|
|
12
|
+
});
|
|
13
|
+
export function loadConfig() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = YAML.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
16
|
+
return A9sConfigSchema.parse(raw);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function saveConfig(update) {
|
|
23
|
+
try {
|
|
24
|
+
let existing = {};
|
|
25
|
+
try {
|
|
26
|
+
const raw = YAML.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
27
|
+
existing = A9sConfigSchema.parse(raw);
|
|
28
|
+
}
|
|
29
|
+
catch { }
|
|
30
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
31
|
+
fs.writeFileSync(CONFIG_PATH, YAML.stringify({ ...existing, ...update }));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Best-effort — silently ignore write failures
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,22 +1,326 @@
|
|
|
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
|
+
import { SERVICE_COLORS } from "../../constants/theme.js";
|
|
9
|
+
export const dynamoDBLevelAtom = atom({ kind: "tables" });
|
|
10
|
+
export const dynamoDBBackStackAtom = atom([]);
|
|
11
|
+
// Cache for table descriptions to avoid repeated AWS calls
|
|
12
|
+
const tableDescriptionCache = new Map();
|
|
13
|
+
// Cache for scanned items
|
|
14
|
+
const itemsCache = new Map();
|
|
15
|
+
export function createDynamoDBServiceAdapter(endpointUrl, region) {
|
|
16
|
+
const store = getDefaultStore();
|
|
17
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
18
|
+
const getLevel = () => store.get(dynamoDBLevelAtom);
|
|
19
|
+
const setLevel = (level) => store.set(dynamoDBLevelAtom, level);
|
|
20
|
+
const getBackStack = () => store.get(dynamoDBBackStackAtom);
|
|
21
|
+
const setBackStack = (stack) => store.set(dynamoDBBackStackAtom, stack);
|
|
22
|
+
const getTableDescription = async (tableName) => {
|
|
23
|
+
const cached = tableDescriptionCache.get(tableName);
|
|
24
|
+
if (cached)
|
|
25
|
+
return cached;
|
|
26
|
+
try {
|
|
27
|
+
const data = await runAwsJsonAsync([
|
|
28
|
+
"dynamodb",
|
|
29
|
+
"describe-table",
|
|
30
|
+
"--table-name",
|
|
31
|
+
tableName,
|
|
32
|
+
...regionArgs,
|
|
33
|
+
]);
|
|
34
|
+
const table = data.Table;
|
|
35
|
+
tableDescriptionCache.set(tableName, table);
|
|
36
|
+
return table;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const getColumns = () => {
|
|
43
|
+
const level = getLevel();
|
|
44
|
+
if (level.kind === "tables") {
|
|
45
|
+
return [
|
|
46
|
+
{ key: "name", label: "Name" },
|
|
47
|
+
{ key: "status", label: "Status", width: 12 },
|
|
48
|
+
{ key: "items", label: "Items", width: 10 },
|
|
49
|
+
{ key: "billing", label: "Billing", width: 25 },
|
|
50
|
+
{ key: "gsis", label: "GSIs", width: 6 },
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
if (level.kind === "items") {
|
|
54
|
+
const table = tableDescriptionCache.get(level.tableName);
|
|
55
|
+
if (!table) {
|
|
56
|
+
// Fallback: return standard columns that will always be populated
|
|
57
|
+
return [
|
|
58
|
+
{ key: "#", label: "#", width: 4 },
|
|
59
|
+
{ key: "pk", label: "PK", width: 20 },
|
|
60
|
+
{ key: "sk", label: "SK", width: 20 },
|
|
61
|
+
{ key: "size", label: "Size", width: 10 },
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
const keys = table.KeySchema ?? [];
|
|
65
|
+
const hashKey = keys.find((k) => k.KeyType === "HASH");
|
|
66
|
+
const rangeKey = keys.find((k) => k.KeyType === "RANGE");
|
|
67
|
+
const cols = [{ key: "#", label: "#", width: 4 }];
|
|
68
|
+
if (hashKey) {
|
|
69
|
+
cols.push({ key: "pk", label: hashKey.AttributeName, width: 20 });
|
|
70
|
+
}
|
|
71
|
+
if (rangeKey) {
|
|
72
|
+
cols.push({ key: "sk", label: rangeKey.AttributeName, width: 20 });
|
|
73
|
+
}
|
|
74
|
+
cols.push({ key: "size", label: "Size", width: 10 });
|
|
75
|
+
return cols;
|
|
76
|
+
}
|
|
77
|
+
// item-fields level
|
|
78
|
+
return [
|
|
79
|
+
{ key: "attribute", label: "Attribute" },
|
|
80
|
+
{ key: "value", label: "Value", width: 50 },
|
|
81
|
+
{ key: "type", label: "Type", width: 8 },
|
|
82
|
+
];
|
|
83
|
+
};
|
|
4
84
|
const getRows = async () => {
|
|
5
|
-
|
|
85
|
+
const level = getLevel();
|
|
86
|
+
if (level.kind === "tables") {
|
|
87
|
+
try {
|
|
88
|
+
const listData = await runAwsJsonAsync([
|
|
89
|
+
"dynamodb",
|
|
90
|
+
"list-tables",
|
|
91
|
+
...regionArgs,
|
|
92
|
+
]);
|
|
93
|
+
const tableNames = listData.TableNames ?? [];
|
|
94
|
+
// Describe all tables in parallel
|
|
95
|
+
const tables = await Promise.all(tableNames.map((name) => getTableDescription(name)));
|
|
96
|
+
return tables
|
|
97
|
+
.filter((t) => t !== null)
|
|
98
|
+
.map((table) => {
|
|
99
|
+
const statusColor = table.TableStatus === "ACTIVE"
|
|
100
|
+
? "green"
|
|
101
|
+
: table.TableStatus === "CREATING" || table.TableStatus === "UPDATING"
|
|
102
|
+
? "yellow"
|
|
103
|
+
: "red";
|
|
104
|
+
return {
|
|
105
|
+
id: table.TableArn,
|
|
106
|
+
cells: {
|
|
107
|
+
name: textCell(table.TableName),
|
|
108
|
+
status: textCell(table.TableStatus),
|
|
109
|
+
items: textCell(String(table.ItemCount ?? 0)),
|
|
110
|
+
billing: textCell(formatBillingMode(table)),
|
|
111
|
+
gsis: textCell(String(table.GlobalSecondaryIndexes?.length ?? 0)),
|
|
112
|
+
},
|
|
113
|
+
meta: {
|
|
114
|
+
type: "table",
|
|
115
|
+
tableName: table.TableName,
|
|
116
|
+
tableStatus: table.TableStatus,
|
|
117
|
+
tableArn: table.TableArn,
|
|
118
|
+
billing: formatBillingMode(table),
|
|
119
|
+
gsiCount: table.GlobalSecondaryIndexes?.length ?? 0,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (level.kind === "items") {
|
|
129
|
+
const { tableName } = level;
|
|
130
|
+
try {
|
|
131
|
+
// Check cache
|
|
132
|
+
const cached = itemsCache.get(tableName);
|
|
133
|
+
if (cached) {
|
|
134
|
+
const table = cached.table;
|
|
135
|
+
const items = cached.items;
|
|
136
|
+
return items.map((item, index) => {
|
|
137
|
+
const pkValue = extractPkValue(item, table);
|
|
138
|
+
const skValue = extractSkValue(item, table);
|
|
139
|
+
const itemSize = JSON.stringify(item).length;
|
|
140
|
+
const cells = {
|
|
141
|
+
name: textCell(`Item ${index + 1}`),
|
|
142
|
+
"#": textCell(String(index + 1)),
|
|
143
|
+
pk: textCell(pkValue ?? "-"),
|
|
144
|
+
sk: textCell(skValue ?? "-"),
|
|
145
|
+
size: textCell(`${itemSize}B`),
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
id: `${tableName}-${index}`,
|
|
149
|
+
cells,
|
|
150
|
+
meta: {
|
|
151
|
+
type: "item",
|
|
152
|
+
tableName,
|
|
153
|
+
itemIndex: index,
|
|
154
|
+
itemPkValue: pkValue ?? undefined,
|
|
155
|
+
itemSkValue: skValue ?? undefined,
|
|
156
|
+
itemSize,
|
|
157
|
+
itemJson: JSON.stringify(item),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Fetch table description to get key schema
|
|
163
|
+
const table = await getTableDescription(tableName);
|
|
164
|
+
if (!table)
|
|
165
|
+
return [];
|
|
166
|
+
// Scan items
|
|
167
|
+
const scanData = await runAwsJsonAsync([
|
|
168
|
+
"dynamodb",
|
|
169
|
+
"scan",
|
|
170
|
+
"--table-name",
|
|
171
|
+
tableName,
|
|
172
|
+
"--limit",
|
|
173
|
+
"50",
|
|
174
|
+
...regionArgs,
|
|
175
|
+
]);
|
|
176
|
+
const items = scanData.Items ?? [];
|
|
177
|
+
itemsCache.set(tableName, { items, table });
|
|
178
|
+
return items.map((item, index) => {
|
|
179
|
+
const pkValue = extractPkValue(item, table);
|
|
180
|
+
const skValue = extractSkValue(item, table);
|
|
181
|
+
const itemSize = JSON.stringify(item).length;
|
|
182
|
+
const cells = {
|
|
183
|
+
name: textCell(`Item ${index + 1}`),
|
|
184
|
+
"#": textCell(String(index + 1)),
|
|
185
|
+
pk: textCell(pkValue ?? "-"),
|
|
186
|
+
sk: textCell(skValue ?? "-"),
|
|
187
|
+
size: textCell(`${itemSize}B`),
|
|
188
|
+
};
|
|
189
|
+
return {
|
|
190
|
+
id: `${tableName}-${index}`,
|
|
191
|
+
cells,
|
|
192
|
+
meta: {
|
|
193
|
+
type: "item",
|
|
194
|
+
tableName,
|
|
195
|
+
itemIndex: index,
|
|
196
|
+
itemPkValue: pkValue ?? undefined,
|
|
197
|
+
itemSkValue: skValue ?? undefined,
|
|
198
|
+
itemSize,
|
|
199
|
+
itemJson: JSON.stringify(item),
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// item-fields level
|
|
209
|
+
if (level.kind === "item-fields") {
|
|
210
|
+
const { tableName, itemIndex } = level;
|
|
211
|
+
const cached = itemsCache.get(tableName);
|
|
212
|
+
if (!cached)
|
|
213
|
+
return [];
|
|
214
|
+
const item = cached.items[itemIndex];
|
|
215
|
+
if (!item)
|
|
216
|
+
return [];
|
|
217
|
+
return Object.entries(item).map(([attrName, attrValue]) => {
|
|
218
|
+
const displayValue = formatDynamoValue(attrValue);
|
|
219
|
+
const type = getDynamoType(attrValue);
|
|
220
|
+
return {
|
|
221
|
+
id: attrName,
|
|
222
|
+
cells: {
|
|
223
|
+
attribute: textCell(attrName),
|
|
224
|
+
value: textCell(displayValue),
|
|
225
|
+
type: textCell(type),
|
|
226
|
+
},
|
|
227
|
+
meta: {
|
|
228
|
+
type: "item-field",
|
|
229
|
+
tableName,
|
|
230
|
+
itemIndex,
|
|
231
|
+
fieldName: attrName,
|
|
232
|
+
fieldValue: displayValue,
|
|
233
|
+
fieldType: type,
|
|
234
|
+
fieldRawValue: unwrapDynamoValue(attrValue),
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return [];
|
|
6
240
|
};
|
|
7
|
-
const onSelect = async (
|
|
241
|
+
const onSelect = async (row) => {
|
|
242
|
+
const level = getLevel();
|
|
243
|
+
const backStack = getBackStack();
|
|
244
|
+
const meta = row.meta;
|
|
245
|
+
if (level.kind === "tables") {
|
|
246
|
+
if (!meta || meta.type !== "table") {
|
|
247
|
+
return { action: "none" };
|
|
248
|
+
}
|
|
249
|
+
// Clear items cache when switching tables
|
|
250
|
+
itemsCache.clear();
|
|
251
|
+
const newStack = [...backStack, { level: level, selectedIndex: 0 }];
|
|
252
|
+
setBackStack(newStack);
|
|
253
|
+
setLevel({
|
|
254
|
+
kind: "items",
|
|
255
|
+
tableName: meta.tableName,
|
|
256
|
+
});
|
|
257
|
+
return { action: "navigate" };
|
|
258
|
+
}
|
|
259
|
+
if (level.kind === "items") {
|
|
260
|
+
if (!meta || meta.type !== "item") {
|
|
261
|
+
return { action: "none" };
|
|
262
|
+
}
|
|
263
|
+
const newStack = [...backStack, { level: level, selectedIndex: 0 }];
|
|
264
|
+
setBackStack(newStack);
|
|
265
|
+
setLevel({
|
|
266
|
+
kind: "item-fields",
|
|
267
|
+
tableName: meta.tableName,
|
|
268
|
+
itemIndex: meta.itemIndex,
|
|
269
|
+
});
|
|
270
|
+
return { action: "navigate" };
|
|
271
|
+
}
|
|
272
|
+
// item-fields level: leaf, no drill-down
|
|
8
273
|
return { action: "none" };
|
|
9
274
|
};
|
|
275
|
+
const canGoBack = () => getBackStack().length > 0;
|
|
276
|
+
const goBack = () => {
|
|
277
|
+
const backStack = getBackStack();
|
|
278
|
+
if (backStack.length > 0) {
|
|
279
|
+
const newStack = backStack.slice(0, -1);
|
|
280
|
+
const frame = backStack[backStack.length - 1];
|
|
281
|
+
setBackStack(newStack);
|
|
282
|
+
setLevel(frame.level);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const getPath = () => {
|
|
286
|
+
const level = getLevel();
|
|
287
|
+
if (level.kind === "tables")
|
|
288
|
+
return "dynamodb://";
|
|
289
|
+
if (level.kind === "items")
|
|
290
|
+
return `dynamodb://${level.tableName}`;
|
|
291
|
+
return `dynamodb://${level.tableName}/items`;
|
|
292
|
+
};
|
|
293
|
+
const getContextLabel = () => {
|
|
294
|
+
const level = getLevel();
|
|
295
|
+
if (level.kind === "tables")
|
|
296
|
+
return "⚡ Tables";
|
|
297
|
+
if (level.kind === "items")
|
|
298
|
+
return `⚡ ${level.tableName}`;
|
|
299
|
+
return `⚡ ${level.tableName}/items`;
|
|
300
|
+
};
|
|
301
|
+
// Compose capabilities
|
|
302
|
+
const detailCapability = createDynamoDBDetailCapability(region, getLevel);
|
|
303
|
+
const yankCapability = createDynamoDBYankCapability(region, getLevel);
|
|
10
304
|
return {
|
|
11
305
|
id: "dynamodb",
|
|
12
306
|
label: "DynamoDB",
|
|
13
|
-
hudColor:
|
|
307
|
+
hudColor: SERVICE_COLORS.dynamodb,
|
|
14
308
|
getColumns,
|
|
15
309
|
getRows,
|
|
16
310
|
onSelect,
|
|
17
|
-
canGoBack
|
|
18
|
-
goBack
|
|
19
|
-
getPath
|
|
20
|
-
getContextLabel
|
|
311
|
+
canGoBack,
|
|
312
|
+
goBack,
|
|
313
|
+
getPath,
|
|
314
|
+
getContextLabel,
|
|
315
|
+
reset() {
|
|
316
|
+
setLevel({ kind: "tables" });
|
|
317
|
+
setBackStack([]);
|
|
318
|
+
tableDescriptionCache.clear();
|
|
319
|
+
itemsCache.clear();
|
|
320
|
+
},
|
|
321
|
+
capabilities: {
|
|
322
|
+
detail: detailCapability,
|
|
323
|
+
yank: yankCapability,
|
|
324
|
+
},
|
|
21
325
|
};
|
|
22
326
|
}
|