├── .gitignore ├── Makefile ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── popup.html ├── public ├── awsdash-dark.svg ├── awsdash-light.svg ├── awsdash-logo-dark.png ├── awsdash-logo-dark.svg ├── awsdash-logo-light.png ├── awsdash-logo-light.svg ├── awsdash-logo.png └── awsdash-logo.svg ├── src ├── actions.ts ├── background.ts ├── components │ └── PopupApp.tsx ├── constants.ts ├── content-script.ts ├── ec2-transforms.ts ├── indexdb.ts ├── migrations │ └── 1-default-aws-profile.ts ├── popup.ts ├── popup.tsx ├── style.css ├── types.ts └── vite-env.d.ts ├── tsconfig.json ├── vite.config.chrome.content.ts ├── vite.config.chrome.ts ├── vite.config.firefox.content.ts ├── vite.config.firefox.ts └── xmanifests ├── chrome └── manifest.json └── firefox └── manifest.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | *.zip 27 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build build-chrome build-firefox chrome firefox 2 | 3 | VERSION := 1.0.8 4 | 5 | # Detect OS for sed command 6 | UNAME_S := $(shell uname -s) 7 | ifeq ($(UNAME_S),Darwin) 8 | SED_INPLACE := sed -i '' 9 | else 10 | SED_INPLACE := sed -i 11 | endif 12 | 13 | build-chrome: 14 | @npm run build:chrome 15 | @$(SED_INPLACE) 's/"version": ".*"/"version": "$(VERSION)"/' dist/chrome/manifest.json 16 | 17 | build-firefox: 18 | @npm run build:firefox 19 | @$(SED_INPLACE) 's/"version": ".*"/"version": "$(VERSION)"/' dist/firefox/manifest.json 20 | 21 | chrome: build-chrome 22 | @DATETIME=$(shell date +%Y%m%d%H%M%S) && \ 23 | (cd dist/chrome && zip -r ../../awsdash-chrome-extension-$(VERSION)-$$DATETIME.zip .) 24 | 25 | firefox: build-firefox 26 | @DATETIME=$(shell date +%Y%m%d%H%M%S) && \ 27 | (cd dist/firefox && zip -r ../../awsdash-firefox-extension-$(VERSION)-$$DATETIME.zip .) 28 | 29 | build: 30 | @npm run build:all 31 | @$(SED_INPLACE) 's/"version": ".*"/"version": "$(VERSION)"/' dist/chrome/manifest.json 32 | @$(SED_INPLACE) 's/"version": ".*"/"version": "$(VERSION)"/' dist/firefox/manifest.json 33 | 34 | release: build 35 | @(cd dist/chrome && zip -r ../../awsdash-chrome-extension-$(VERSION)-$$DATETIME.zip .) 36 | @(cd dist/firefox && zip -r ../../awsdash-firefox-extension-$(VERSION)-$$DATETIME.zip .) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### What is this? 2 | Companion browser extension for https://awsdash.com - A simple alternative UI to manage AWS resources. 3 | 4 | 5 | ![AwsDash.com](./public/awsdash-light.svg) 6 | 7 | ### How to build the extenion 8 | 9 | - OS requirement: Ubuntu 22.04 / Mac Sonoma (M1) 10 | - Node version: 20.8.1 11 | 12 | ``` 13 | $ npm install 14 | $ make firefox 15 | $ make chrome 16 | ``` 17 | 18 | ### In firefox go to: 19 | 20 | ``` 21 | about:debugging#/runtime/this-firefox 22 | ``` 23 | 24 | To test the extension, click "Load Temporary Add-on" and select the `manifest.json` file in the `dist/firefox` directory. 25 | 26 | 27 | ### To inspect IndexDB stored inside background script 28 | 29 | chrome-extension:///manifest.json 30 | 31 | Open developer tool 32 | 33 | https://stackoverflow.com/questions/72910185/how-to-inspect-indexeddb-data-for-chrome-extension-manifest-v3 34 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browser-extension", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build:chrome:general": "vite build --config vite.config.chrome.ts", 9 | "build:chrome:content": "vite build --config vite.config.chrome.content.ts", 10 | "build:chrome": "npm run build:chrome:general && npm run build:chrome:content", 11 | "build:firefox:general": "vite build --config vite.config.firefox.ts", 12 | "build:firefox:content": "vite build --config vite.config.firefox.content.ts", 13 | "build:firefox": "npm run build:firefox:general && npm run build:firefox:content", 14 | "build:all": "npm-run-all --parallel build:chrome build:firefox", 15 | "preview": "vite preview", 16 | "watch": "vite build --watch" 17 | }, 18 | "devDependencies": { 19 | "npm-run-all": "^4.1.5", 20 | "typescript": "^5.5.3", 21 | "vite": "^5.4.1" 22 | }, 23 | "dependencies": { 24 | "@aws-sdk/client-s3": "^3.637.0", 25 | "@aws-sdk/s3-request-presigner": "^3.651.1", 26 | "@tanstack/react-query-devtools": "^5.53.1", 27 | "@types/webextension-polyfill": "^0.12.1", 28 | "@vitejs/plugin-react": "^4.3.1", 29 | "flexsearch": "^0.7.43", 30 | "idb": "^8.0.0", 31 | "localforage": "^1.10.0", 32 | "nanoid": "^5.0.7", 33 | "vite-plugin-static-copy": "^1.0.6", 34 | "webextension-polyfill": "^0.12.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AwsDash Extension 7 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /public/awsdash-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/awsdash-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/awsdash-logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptgamr/awsdash-browser-extension/d1c964b9f055d831b4e77b772c9b16b6d2c249cf/public/awsdash-logo-dark.png -------------------------------------------------------------------------------- /public/awsdash-logo-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/awsdash-logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptgamr/awsdash-browser-extension/d1c964b9f055d831b4e77b772c9b16b6d2c249cf/public/awsdash-logo-light.png -------------------------------------------------------------------------------- /public/awsdash-logo-light.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/awsdash-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ptgamr/awsdash-browser-extension/d1c964b9f055d831b4e77b772c9b16b6d2c249cf/public/awsdash-logo.png -------------------------------------------------------------------------------- /public/awsdash-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { BucketItem } from "./types"; 2 | import { IN_MESSAGE_TYPES, NOTIFY_MESSAGE_TYPES } from "./constants"; 3 | 4 | export type ExtIncomingActions = 5 | | { 6 | type: typeof IN_MESSAGE_TYPES.PING; 7 | } 8 | | { 9 | type: typeof IN_MESSAGE_TYPES.GET_AWS_PROFILES; 10 | } 11 | | { 12 | type: typeof IN_MESSAGE_TYPES.LIST_BUCKETS; 13 | profiles: string[]; 14 | } 15 | | { 16 | type: typeof IN_MESSAGE_TYPES.GET_S3_OBJECT_URL; 17 | profileName: string; 18 | bucketName: string; 19 | bucketRegion: string; 20 | key: string; 21 | } 22 | | { 23 | type: typeof IN_MESSAGE_TYPES.LIST_EC2_INSTANCES; 24 | profiles: string[]; 25 | region: string; 26 | instanceStateFilter: string[]; 27 | } 28 | | { 29 | type: typeof IN_MESSAGE_TYPES.SET_CREDENTIALS; 30 | credentials: { 31 | accessKeyId: string; 32 | secretAccessKey: string; 33 | region?: string; 34 | }; 35 | } 36 | | { 37 | type: typeof IN_MESSAGE_TYPES.LIST_BUCKET_CONTENTS; 38 | awsProfile: string; 39 | bucketName: string; 40 | bucketRegion: string; 41 | prefix?: string; 42 | } 43 | | { 44 | type: typeof IN_MESSAGE_TYPES.LIST_ALL_BUCKET_CONTENTS; 45 | awsProfile: string; 46 | bucketName: string; 47 | bucketRegion: string; 48 | } 49 | | { 50 | type: typeof IN_MESSAGE_TYPES.LOAD_INDEX; 51 | bucketNames: string[]; 52 | } 53 | | { 54 | type: typeof IN_MESSAGE_TYPES.START_INDEX_BUCKET; 55 | awsProfile: string; 56 | bucketName: string; 57 | bucketRegion: string; 58 | } 59 | | { 60 | type: typeof IN_MESSAGE_TYPES.STOP_INDEX_BUCKET; 61 | indexingProcess: IndexingProcess; 62 | } 63 | | { 64 | type: typeof IN_MESSAGE_TYPES.GET_BUCKET_SEARCH_RESULT; 65 | bucketName: string; 66 | id: number; 67 | }; 68 | 69 | export interface IndexingProcess { 70 | pid: string; 71 | documentCount: number; 72 | status: "indexing" | "stopping" | "done"; 73 | bucketName: string; 74 | } 75 | 76 | export type ExtNotificationActions = 77 | | { 78 | type: typeof NOTIFY_MESSAGE_TYPES.EXTENSION_READY; 79 | profiles: string[]; 80 | version: string; 81 | } 82 | | { 83 | type: typeof NOTIFY_MESSAGE_TYPES.INDEXING_START; 84 | indexingProcess: IndexingProcess; 85 | } 86 | | { 87 | type: typeof NOTIFY_MESSAGE_TYPES.INDEXING_PROGRESS; 88 | indexingProcess: IndexingProcess; 89 | } 90 | | { 91 | type: typeof NOTIFY_MESSAGE_TYPES.INDEXING_STOPPED; 92 | indexingProcess: IndexingProcess; 93 | } 94 | | { 95 | type: typeof NOTIFY_MESSAGE_TYPES.INDEXING_DONE; 96 | indexingProcess: IndexingProcess; 97 | } 98 | | { 99 | type: typeof NOTIFY_MESSAGE_TYPES.INITIAL_INDEX_LOADING; 100 | } 101 | | { 102 | type: typeof NOTIFY_MESSAGE_TYPES.INITIAL_INDEX_DONE; 103 | } 104 | | { 105 | type: typeof NOTIFY_MESSAGE_TYPES.DOCUMENT_FOR_INDEXING; 106 | item: BucketItem; 107 | } 108 | | { 109 | type: typeof NOTIFY_MESSAGE_TYPES.INDEX_AVAILABLE; 110 | bucketName: string; 111 | documentCount: number; 112 | lastIndexed: number; 113 | } 114 | | { 115 | type: typeof NOTIFY_MESSAGE_TYPES.AWS_FETCH_ERROR; 116 | profileName: string; 117 | errorMessage: string; 118 | errorDetail: Error; 119 | }; 120 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import { 3 | S3Client, 4 | ListBucketsCommand, 5 | GetBucketLocationCommand, 6 | ListObjectsV2Command, 7 | ListObjectsV2Output, 8 | GetObjectCommand, 9 | } from "@aws-sdk/client-s3"; 10 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 11 | import { DescribeInstancesCommand, EC2Client } from "@aws-sdk/client-ec2"; 12 | import { 13 | IN_MESSAGE_TYPES, 14 | OUT_MESSAGE_TYPES, 15 | NOTIFY_MESSAGE_TYPES, 16 | } from "./constants"; 17 | import { AWSProfile, BucketInfo, BucketItem, Ec2Instance } from "./types"; 18 | import localForage from "localforage"; 19 | import { nanoid } from "nanoid"; 20 | import { 21 | ExtIncomingActions, 22 | ExtNotificationActions, 23 | IndexingProcess, 24 | } from "./actions"; 25 | import { bucketItemStore, dbWrapper } from "./indexdb"; 26 | import { parseReservationResponse } from "./ec2-transforms"; 27 | import { migrate as migrate1 } from "./migrations/1-default-aws-profile"; 28 | 29 | async function migrate() { 30 | await migrate1(browser); 31 | } 32 | 33 | localForage.setDriver(localForage.INDEXEDDB); 34 | 35 | console.log("AwsDash - Background script loaded"); 36 | 37 | browser.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => { 38 | console.log("Background script received message:", rawMessage); 39 | 40 | const message = rawMessage as ExtIncomingActions; 41 | 42 | switch (message.type) { 43 | case IN_MESSAGE_TYPES.PING: 44 | sendResponse({ type: OUT_MESSAGE_TYPES.PONG }); 45 | return true; 46 | 47 | case IN_MESSAGE_TYPES.GET_AWS_PROFILES: 48 | getCredentials().then((profiles) => { 49 | sendResponse(profiles.map((p) => p.name)); 50 | }); 51 | return true; 52 | 53 | case IN_MESSAGE_TYPES.GET_S3_OBJECT_URL: 54 | getS3ObjectUrl( 55 | message.profileName, 56 | message.bucketName, 57 | message.key, 58 | message.bucketRegion 59 | ) 60 | .then((url) => { 61 | sendResponse({ url }); 62 | }) 63 | .catch((error) => { 64 | sendResponse({ error: error.message }); 65 | }); 66 | return true; 67 | 68 | case IN_MESSAGE_TYPES.LIST_EC2_INSTANCES: 69 | listEc2Instances( 70 | message.profiles, 71 | message.region, 72 | message.instanceStateFilter 73 | ) 74 | .then((instances) => { 75 | sendResponse({ instances }); 76 | }) 77 | .catch((error) => { 78 | sendResponse({ error: error.message }); 79 | }); 80 | return true; 81 | case IN_MESSAGE_TYPES.LIST_BUCKETS: 82 | listBuckets(message.profiles) 83 | .then((buckets) => { 84 | console.log("Buckets listed:", buckets); 85 | sendResponse({ 86 | type: OUT_MESSAGE_TYPES.LIST_BUCKETS_RESPONSE, 87 | buckets: buckets, 88 | }); 89 | }) 90 | .catch((error) => { 91 | console.error("Error listing buckets:", error); 92 | sendResponse({ 93 | type: OUT_MESSAGE_TYPES.LIST_BUCKETS_ERROR, 94 | error: error.message, 95 | }); 96 | }); 97 | return true; 98 | 99 | case IN_MESSAGE_TYPES.LIST_BUCKET_CONTENTS: 100 | listBucketContents( 101 | message.awsProfile, 102 | message.bucketName, 103 | message.bucketRegion, 104 | message.prefix 105 | ) 106 | .then((contents) => { 107 | console.log("Bucket contents listed:", contents); 108 | sendResponse({ 109 | type: OUT_MESSAGE_TYPES.LIST_BUCKET_CONTENTS_RESPONSE, 110 | contents: contents, 111 | }); 112 | }) 113 | .catch((error) => { 114 | console.error("Error listing bucket contents:", error); 115 | sendResponse({ 116 | type: OUT_MESSAGE_TYPES.LIST_BUCKET_CONTENTS_ERROR, 117 | error: error.message, 118 | }); 119 | }); 120 | return true; 121 | 122 | case IN_MESSAGE_TYPES.LIST_ALL_BUCKET_CONTENTS: 123 | listAllBucketContents( 124 | message.awsProfile, 125 | message.bucketName, 126 | message.bucketRegion 127 | ) 128 | .then((contents) => { 129 | sendResponse({ contents }); 130 | }) 131 | .catch((error) => { 132 | sendResponse({ error: error.message }); 133 | }); 134 | return true; 135 | 136 | case IN_MESSAGE_TYPES.LOAD_INDEX: 137 | void requestBucketIndices(message.bucketNames); 138 | sendResponse({ ack: true }); 139 | return true; 140 | 141 | case IN_MESSAGE_TYPES.GET_BUCKET_SEARCH_RESULT: 142 | getBucketSearchResult(message.bucketName, message.id).then((item) => { 143 | sendResponse({ item: item || null }); 144 | }); 145 | return true; 146 | 147 | case IN_MESSAGE_TYPES.START_INDEX_BUCKET: 148 | void startIndexBucket({ 149 | name: message.bucketName, 150 | location: message.bucketRegion, 151 | awsProfile: message.awsProfile, 152 | }); 153 | sendResponse({ ack: true }); 154 | return true; 155 | 156 | case IN_MESSAGE_TYPES.STOP_INDEX_BUCKET: 157 | stopIndexBucket(message.indexingProcess); 158 | sendResponse({ ack: true }); 159 | return true; 160 | } 161 | }); 162 | 163 | async function getCredentials(profiles?: string[]): Promise { 164 | const result = await browser.storage.local.get("awsProfiles"); 165 | const credentials = (result.awsProfiles || []) as AWSProfile[]; 166 | return credentials.filter( 167 | (profile) => 168 | profile.accessKeyId && 169 | profile.secretAccessKey && 170 | (profiles ? profiles.includes(profile.name) : true) 171 | ); 172 | } 173 | 174 | async function listEc2Instances( 175 | profiles: string[], 176 | region: string, 177 | instanceStateFilter: string[] 178 | ): Promise { 179 | const credentials = await getCredentials(profiles); 180 | const instancesMap: Map = new Map(); 181 | 182 | for (const profile of credentials) { 183 | try { 184 | const ec2Client = new EC2Client({ 185 | credentials: { 186 | accessKeyId: profile.accessKeyId, 187 | secretAccessKey: profile.secretAccessKey, 188 | }, 189 | region, 190 | }); 191 | 192 | const command = new DescribeInstancesCommand({ 193 | Filters: [ 194 | { 195 | Name: "instance-state-name", 196 | Values: instanceStateFilter, 197 | }, 198 | ], 199 | }); 200 | 201 | const { Reservations } = await ec2Client.send(command); 202 | const instances = parseReservationResponse(Reservations, profile.name); 203 | 204 | for (const instance of instances) { 205 | if (!instancesMap.has(instance.id)) { 206 | instancesMap.set(instance.id, instance); 207 | } 208 | } 209 | } catch (error: unknown) { 210 | notifyAwsComWeb({ 211 | type: NOTIFY_MESSAGE_TYPES.AWS_FETCH_ERROR, 212 | profileName: profile.name, 213 | errorMessage: `Error loading EC2 instances using profile ${profile.name}. Please double check your AWS credentials.`, 214 | errorDetail: error as Error, 215 | }); 216 | } 217 | } 218 | 219 | return Array.from(instancesMap.values()); 220 | } 221 | 222 | async function listBuckets(profiles: string[]): Promise { 223 | const credentials = await getCredentials(profiles); 224 | const bucketsMap: Map = new Map(); 225 | 226 | for (const profile of credentials) { 227 | try { 228 | const s3Client = new S3Client({ 229 | credentials: { 230 | accessKeyId: profile.accessKeyId, 231 | secretAccessKey: profile.secretAccessKey, 232 | }, 233 | region: "us-east-1", 234 | }); 235 | 236 | const command = new ListBucketsCommand({}); 237 | 238 | const { Buckets } = await s3Client.send(command); 239 | 240 | if (Buckets && Buckets.length > 0) { 241 | // a bunches of calls to get the location of each bucket 242 | const buckets: { name: string; location: string }[] = await Promise.all( 243 | Buckets.map(async (bucket) => { 244 | const locationCommand = new GetBucketLocationCommand({ 245 | Bucket: bucket.Name, 246 | }); 247 | try { 248 | const { LocationConstraint } = 249 | await s3Client!.send(locationCommand); 250 | return { 251 | name: bucket.Name!, 252 | location: LocationConstraint || "us-east-1", 253 | }; 254 | } catch (error) { 255 | console.error( 256 | `Error getting location for bucket ${bucket.Name}:`, 257 | error 258 | ); 259 | return { 260 | name: bucket.Name!, 261 | location: "Unknown", 262 | }; 263 | } 264 | }) 265 | ); 266 | for (const bucket of buckets) { 267 | if (!bucketsMap.has(bucket.name)) { 268 | bucketsMap.set(bucket.name, { 269 | ...bucket, 270 | awsProfile: profile.name, 271 | }); 272 | } 273 | } 274 | } 275 | } catch (error: unknown) { 276 | notifyAwsComWeb({ 277 | type: NOTIFY_MESSAGE_TYPES.AWS_FETCH_ERROR, 278 | profileName: profile.name, 279 | errorMessage: `Error listing S3 buckets using profile ${profile.name}. Please double check your AWS credentials`, 280 | errorDetail: error as Error, 281 | }); 282 | } 283 | } 284 | 285 | const buckets = Array.from(bucketsMap.values()); 286 | 287 | const store = await getBucketsIndexStore(); 288 | for (const bucket of buckets) { 289 | const documentCount = await store.getItem(`index_count_${bucket.name}`); 290 | const lastIndexed = await store.getItem(`index_time_${bucket.name}`); 291 | 292 | if (lastIndexed) { 293 | bucket.documentCount = documentCount as number; 294 | bucket.lastIndexed = lastIndexed as number; 295 | } 296 | } 297 | 298 | return buckets; 299 | } 300 | 301 | async function listBucketContents( 302 | profileName: string, 303 | bucketName: string, 304 | bucketRegion: string, 305 | prefix: string = "" 306 | ): Promise { 307 | const credentials = await getCredentials(); 308 | const profile = credentials.find((p) => p.name === profileName); 309 | if (!profile) { 310 | throw new Error("AWS credentials not set for profile: " + profileName); 311 | } 312 | 313 | // Create a new S3 client with the correct region 314 | const regionSpecificS3Client = new S3Client({ 315 | credentials: { 316 | accessKeyId: profile.accessKeyId, 317 | secretAccessKey: profile.secretAccessKey, 318 | }, 319 | region: bucketRegion, 320 | }); 321 | 322 | const command = new ListObjectsV2Command({ 323 | Bucket: bucketName, 324 | Delimiter: "/", 325 | Prefix: prefix, 326 | }); 327 | 328 | const response = await regionSpecificS3Client.send(command); 329 | 330 | const contents = [ 331 | ...(response.CommonPrefixes || []).map((commonPrefix) => ({ 332 | bucket: bucketName, 333 | name: commonPrefix.Prefix!.slice(prefix.length, -1), // Remove prefix and trailing slash 334 | type: "folder" as const, 335 | key: commonPrefix.Prefix!, 336 | syncTimestamp: Date.now(), 337 | awsProfile: profileName, 338 | bucketRegion: bucketRegion, 339 | })), 340 | ...(response.Contents || []) 341 | .filter((item) => item.Key !== prefix) // Exclude the current prefix itself 342 | .map((item) => toBucketItem(profileName, bucketRegion, bucketName, item)), 343 | ]; 344 | 345 | return contents; 346 | } 347 | 348 | async function getS3ObjectUrl( 349 | profileName: string, 350 | bucketName: string, 351 | key: string, 352 | bucketRegion: string 353 | ): Promise { 354 | const credentials = await getCredentials(); 355 | const profile = credentials.find((p) => p.name === profileName); 356 | if (!profile) { 357 | throw new Error("AWS credentials not set for profile: " + profileName); 358 | } 359 | 360 | const s3Client = new S3Client({ 361 | credentials: { 362 | accessKeyId: profile.accessKeyId, 363 | secretAccessKey: profile.secretAccessKey, 364 | }, 365 | region: bucketRegion, 366 | }); 367 | 368 | const command = new GetObjectCommand({ 369 | Bucket: bucketName, 370 | Key: key, 371 | }); 372 | 373 | const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); // URL expires in 1 hour 374 | return url; 375 | } 376 | 377 | async function listAllBucketContents( 378 | profileName: string, 379 | bucketName: string, 380 | bucketRegion: string 381 | ): Promise { 382 | const credentials = await getCredentials(); 383 | const profile = credentials.find((p) => p.name === profileName); 384 | if (!profile) { 385 | throw new Error("AWS credentials not set for profile: " + profileName); 386 | } 387 | 388 | // Create a new S3 client with the correct region 389 | const regionSpecificS3Client = new S3Client({ 390 | credentials: { 391 | accessKeyId: profile.accessKeyId, 392 | secretAccessKey: profile.secretAccessKey, 393 | }, 394 | region: bucketRegion, 395 | }); 396 | let contents: BucketItem[] = []; 397 | let continuationToken: string | undefined; 398 | 399 | do { 400 | const command = new ListObjectsV2Command({ 401 | Bucket: bucketName, 402 | MaxKeys: 1000, 403 | ContinuationToken: continuationToken, 404 | }); 405 | 406 | try { 407 | const response = await regionSpecificS3Client.send(command); 408 | if (response.Contents) { 409 | contents = contents.concat( 410 | response.Contents.map((item) => 411 | toBucketItem(profileName, bucketRegion, bucketName, item) 412 | ) 413 | ); 414 | } 415 | console.log("listAllBucketContents: ", contents); 416 | continuationToken = response.NextContinuationToken; 417 | } catch (error) { 418 | console.error("Error listing bucket contents:", error); 419 | throw error; 420 | } 421 | } while (continuationToken); 422 | 423 | return contents; 424 | } 425 | 426 | // Create a single store for all bucket indexes 427 | const getBucketsIndexStore = () => { 428 | return localForage.createInstance({ 429 | name: "AwsDashComFlex", 430 | storeName: "buckets_index", 431 | }); 432 | }; 433 | 434 | async function idbPromise() { 435 | await dbWrapper.connect(); 436 | } 437 | 438 | migrate(); 439 | 440 | idbPromise().then(() => { 441 | console.log("IDB READY!"); 442 | }); 443 | 444 | let contentScriptPorts: browser.Runtime.Port[] = []; 445 | 446 | // Listen for connection attempts from content scripts 447 | browser.runtime.onConnect.addListener((port) => { 448 | if (port.name === "awsdashcom-background-to-content") { 449 | console.log("Content script connected", port); 450 | contentScriptPorts.push(port); 451 | console.log("contentScriptPorts", contentScriptPorts); 452 | 453 | port.onDisconnect.addListener(() => { 454 | contentScriptPorts = contentScriptPorts.filter((p) => p !== port); 455 | console.log("Content script disconnected"); 456 | }); 457 | } 458 | }); 459 | 460 | // Modify the notifyAwsComWeb function to use the established connections 461 | function notifyAwsComWeb(notif: ExtNotificationActions) { 462 | contentScriptPorts.forEach((port) => { 463 | port.postMessage({ type: "NOTIFICATION", payload: notif }); 464 | }); 465 | } 466 | 467 | async function requestBucketIndices(bucketNames: string[]) { 468 | notifyAwsComWeb({ 469 | type: NOTIFY_MESSAGE_TYPES.INITIAL_INDEX_LOADING, 470 | }); 471 | 472 | await bucketItemStore.iterate(bucketNames, (item: BucketItem) => { 473 | notifyAwsComWeb({ 474 | type: NOTIFY_MESSAGE_TYPES.DOCUMENT_FOR_INDEXING, 475 | item, 476 | }); 477 | }); 478 | 479 | notifyAwsComWeb({ 480 | type: NOTIFY_MESSAGE_TYPES.INITIAL_INDEX_DONE, 481 | }); 482 | } 483 | 484 | const indexingProcesses: Record = {}; 485 | 486 | async function stopIndexBucket(p: IndexingProcess) { 487 | if (indexingProcesses[p.bucketName]?.pid === p.pid) { 488 | indexingProcesses[p.bucketName].status = "stopping"; 489 | notifyAwsComWeb({ 490 | type: NOTIFY_MESSAGE_TYPES.INDEXING_PROGRESS, 491 | indexingProcess: indexingProcesses[p.bucketName], 492 | }); 493 | } 494 | } 495 | async function startIndexBucket(bucket: BucketInfo) { 496 | const credentials = await getCredentials(); 497 | const profile = credentials.find((p) => p.name === bucket.awsProfile); 498 | if (!profile) { 499 | throw new Error( 500 | "AWS credentials not set for profile: " + bucket.awsProfile 501 | ); 502 | } 503 | // Create a new S3 client with the correct region 504 | const regionSpecificS3Client = new S3Client({ 505 | credentials: { 506 | accessKeyId: profile.accessKeyId, 507 | secretAccessKey: profile.secretAccessKey, 508 | }, 509 | region: bucket.location, 510 | }); 511 | 512 | indexingProcesses[bucket.name] = { 513 | pid: nanoid(), 514 | documentCount: 0, 515 | status: "indexing", 516 | bucketName: bucket.name, 517 | }; 518 | 519 | notifyAwsComWeb({ 520 | type: NOTIFY_MESSAGE_TYPES.INDEXING_START, 521 | indexingProcess: indexingProcesses[bucket.name], 522 | }); 523 | 524 | const syncTimestamp = Date.now(); 525 | 526 | await idbPromise(); 527 | 528 | try { 529 | let continuationToken: string | undefined; 530 | 531 | do { 532 | const command = new ListObjectsV2Command({ 533 | Bucket: bucket.name, 534 | MaxKeys: 1000, 535 | ContinuationToken: continuationToken, 536 | }); 537 | 538 | try { 539 | if ( 540 | !indexingProcesses[bucket.name] || 541 | indexingProcesses[bucket.name].status === "stopping" 542 | ) { 543 | notifyAwsComWeb({ 544 | type: NOTIFY_MESSAGE_TYPES.INDEXING_STOPPED, 545 | indexingProcess: indexingProcesses[bucket.name], 546 | }); 547 | throw new Error("Indexing process is interuptted!"); 548 | } 549 | 550 | const response = await regionSpecificS3Client.send(command); 551 | if (response.Contents) { 552 | for (const item of response.Contents) { 553 | indexingProcesses[bucket.name].documentCount++; 554 | const doc = toBucketItem( 555 | bucket.awsProfile, 556 | bucket.location, 557 | bucket.name, 558 | item, 559 | syncTimestamp 560 | ); 561 | notifyAwsComWeb({ 562 | type: NOTIFY_MESSAGE_TYPES.DOCUMENT_FOR_INDEXING, 563 | item: await bucketItemStore.upsert(doc), 564 | }); 565 | } 566 | } 567 | console.log( 568 | "Bucket index progress, num docs = ", 569 | indexingProcesses[bucket.name].documentCount 570 | ); 571 | notifyAwsComWeb({ 572 | type: NOTIFY_MESSAGE_TYPES.INDEXING_PROGRESS, 573 | indexingProcess: indexingProcesses[bucket.name], 574 | }); 575 | continuationToken = response.NextContinuationToken; 576 | } catch (error) { 577 | console.error("Error indexing bucket:", bucket.name, error); 578 | throw error; 579 | } 580 | } while (continuationToken); 581 | 582 | await bucketItemStore.deleteNonCurrentVersion(bucket.name, syncTimestamp); 583 | 584 | const documentCount = indexingProcesses[bucket.name].documentCount; 585 | const lastIndexedAt = Date.now(); 586 | 587 | const store = getBucketsIndexStore(); 588 | await store.setItem(`index_count_${bucket.name}`, documentCount); 589 | await store.setItem(`index_time_${bucket.name}`, lastIndexedAt); 590 | 591 | console.log(`Index for ${bucket.name} saved successfully`); 592 | 593 | indexingProcesses[bucket.name].status = "done"; 594 | 595 | notifyAwsComWeb({ 596 | type: NOTIFY_MESSAGE_TYPES.INDEXING_DONE, 597 | indexingProcess: indexingProcesses[bucket.name], 598 | }); 599 | 600 | notifyAwsComWeb({ 601 | type: NOTIFY_MESSAGE_TYPES.INDEX_AVAILABLE, 602 | bucketName: bucket.name, 603 | documentCount: documentCount, 604 | lastIndexed: lastIndexedAt, 605 | }); 606 | } catch (error) { 607 | console.error(`Error indexing ${bucket.name}:`, error); 608 | } finally { 609 | delete indexingProcesses[bucket.name]; 610 | } 611 | } 612 | 613 | async function getBucketSearchResult(bucketName: string, id: number) { 614 | return bucketItemStore.getByBucketAndId(bucketName, id); 615 | } 616 | 617 | function toBucketItem( 618 | awsProfile: string, 619 | bucketRegion: string, 620 | bucket: string, 621 | item: NonNullable[0], 622 | syncTimestamp?: number 623 | ): BucketItem { 624 | return { 625 | bucket: bucket, 626 | bucketRegion: bucketRegion, 627 | key: item.Key!, 628 | type: item.Key!.endsWith("/") ? "folder" : "file", 629 | size: item.Size, 630 | lastModified: item.LastModified?.toISOString(), 631 | storageClass: item.StorageClass, 632 | etag: item.ETag, 633 | restoreStatus: item.RestoreStatus 634 | ? { 635 | isRestoreInProgress: item.RestoreStatus.IsRestoreInProgress, 636 | restoreExpiryDate: 637 | item.RestoreStatus.RestoreExpiryDate?.toISOString(), 638 | } 639 | : undefined, 640 | syncTimestamp: syncTimestamp || Date.now(), 641 | awsProfile, 642 | }; 643 | } 644 | -------------------------------------------------------------------------------- /src/components/PopupApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | 3 | interface AWSProfile { 4 | name: string; 5 | accessKeyId: string; 6 | secretAccessKey: string; 7 | } 8 | 9 | export const PopupApp: React.FC = () => { 10 | const [profiles, setProfiles] = useState([]); 11 | const [error, setError] = useState(null); 12 | const [newProfileName, setNewProfileName] = useState(""); 13 | 14 | const [profileToDelete, setProfileToDelete] = useState(null); 15 | 16 | useEffect(() => { 17 | // Load saved profiles when component mounts 18 | chrome.storage.local.get(["awsProfiles"], (result) => { 19 | if (result.awsProfiles) { 20 | setProfiles(result.awsProfiles); 21 | } 22 | }); 23 | }, []); 24 | 25 | // New useEffect for auto-saving profiles 26 | useEffect(() => { 27 | const saveProfiles = () => { 28 | chrome.storage.local.set({ awsProfiles: profiles }, () => { 29 | console.log("Profiles auto-saved"); 30 | }); 31 | }; 32 | 33 | const debounceTimer = setTimeout(saveProfiles, 200); 34 | 35 | return () => clearTimeout(debounceTimer); 36 | }, [profiles]); 37 | 38 | const addProfile = () => { 39 | if (newProfileName) { 40 | if (profiles.some((p) => p.name === newProfileName)) { 41 | setError(`Profile name "${newProfileName}" is already taken.`); 42 | } else { 43 | const newProfile: AWSProfile = { 44 | name: newProfileName, 45 | accessKeyId: "", 46 | secretAccessKey: "", 47 | }; 48 | setProfiles([...profiles, newProfile]); 49 | setNewProfileName(""); 50 | setError(null); 51 | } 52 | } 53 | }; 54 | 55 | const updateProfile = ( 56 | oldName: string, 57 | field: keyof AWSProfile, 58 | value: string 59 | ) => { 60 | setProfiles( 61 | profiles.map((profile) => { 62 | if (profile.name === oldName) { 63 | if (field === "name") { 64 | if (profiles.some((p) => p.name === value && p.name !== oldName)) { 65 | setError(`Profile name "${value}" is already taken.`); 66 | return profile; 67 | } 68 | setError(null); 69 | } 70 | return { ...profile, [field]: value }; 71 | } 72 | return profile; 73 | }) 74 | ); 75 | }; 76 | 77 | const ensureUniqueName = (name: string, currentName?: string): string => { 78 | let uniqueName = name; 79 | let counter = 1; 80 | while ( 81 | profiles.some((p) => p.name === uniqueName && p.name !== currentName) 82 | ) { 83 | uniqueName = `${name} (${counter})`; 84 | counter++; 85 | } 86 | return uniqueName; 87 | }; 88 | 89 | // Update the deleteProfile function 90 | const deleteProfile = (profileName: string) => { 91 | if (profileName === profileToDelete) { 92 | setProfiles(profiles.filter((profile) => profile.name !== profileName)); 93 | setProfileToDelete(null); 94 | setError(null); 95 | } else { 96 | setProfileToDelete(profileName); 97 | } 98 | }; 99 | 100 | // Add a new function to cancel deletion 101 | const cancelDelete = () => { 102 | setProfileToDelete(null); 103 | }; 104 | 105 | return ( 106 |
107 |
108 |
109 | 114 | AwsDash Logo 115 | 116 |
117 |

118 | 124 | Visit AWS Dash 125 | 126 |

127 | 128 | {profiles.map((profile) => ( 129 |
133 |
134 |
135 | 141 | 142 | {profile.name} 143 | 144 |
145 | {profileToDelete === profile.name ? ( 146 |
147 | 153 | 159 |
160 | ) : ( 161 | 167 | )} 168 |
169 |
170 | 176 | 181 | updateProfile(profile.name, "accessKeyId", e.target.value) 182 | } 183 | className="mt-1 block w-full px-3 py-2 bg-gray-700 text-gray-300 border border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" 184 | /> 185 |
186 |
187 | 193 | 198 | updateProfile(profile.name, "secretAccessKey", e.target.value) 199 | } 200 | className="mt-1 block w-full px-3 py-2 bg-gray-700 text-gray-300 border border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" 201 | /> 202 |
203 |
204 | ))} 205 | 206 | {error &&
{error}
} 207 | 208 |
209 | 215 | setNewProfileName(e.target.value)} 220 | className="mt-1 block w-full px-3 py-2 bg-gray-700 text-gray-300 border border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" 221 | /> 222 |
223 | 229 |
230 |
231 | ); 232 | }; 233 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { IndexOptionsForDocumentSearch } from "flexsearch"; 2 | import { BucketItem } from "./types"; 3 | 4 | export const IN_MESSAGE_TYPES = { 5 | PING: "PING", 6 | GET_AWS_PROFILES: "GET_AWS_PROFILES", 7 | LIST_BUCKETS: "LIST_BUCKETS", 8 | GET_S3_OBJECT_URL: "GET_S3_OBJECT_URL", 9 | LIST_EC2_INSTANCES: "LIST_EC2_INSTANCES", 10 | SET_CREDENTIALS: "SET_CREDENTIALS", 11 | LIST_BUCKET_CONTENTS: "LIST_BUCKET_CONTENTS", 12 | LIST_ALL_BUCKET_CONTENTS: "LIST_ALL_BUCKET_CONTENTS", 13 | START_INDEX_BUCKET: "START_INDEX_BUCKET", 14 | STOP_INDEX_BUCKET: "STOP_INDEX_BUCKET", 15 | LOAD_INDEX: "LOAD_INDEX", 16 | GET_BUCKET_SEARCH_RESULT: "GET_BUCKET_SEARCH_RESULT", 17 | } as const; 18 | 19 | export const OUT_MESSAGE_TYPES = { 20 | PONG: "PONG", 21 | LIST_BUCKETS_RESPONSE: "LIST_BUCKETS_RESPONSE", 22 | LIST_BUCKETS_ERROR: "LIST_BUCKETS_ERROR", 23 | SET_CREDENTIALS_OK: "SET_CREDENTIALS_OK", 24 | SET_CREDENTIALS_ERROR: "SET_CREDENTIALS_ERROR", 25 | LIST_BUCKET_CONTENTS_RESPONSE: "LIST_BUCKET_CONTENTS_RESPONSE", 26 | LIST_BUCKET_CONTENTS_ERROR: "LIST_BUCKET_CONTENTS_ERROR", 27 | } as const; 28 | 29 | export const NOTIFY_MESSAGE_TYPES = { 30 | EXTENSION_READY: "EXTENSION_READY", 31 | AWS_FETCH_ERROR: "AWS_FETCH_ERROR", 32 | INDEXING_START: "INDEXING_START", 33 | INDEXING_PROGRESS: "INDEXING_PROGRESS", 34 | INDEXING_DONE: "INDEXING_DONE", 35 | INDEXING_STOPPED: "INDEXING_STOPPED", 36 | INDEXING_ERROR: "INDEXING_ERROR", 37 | INDEX_AVAILABLE: "INDEX_AVAILABLE", 38 | DOCUMENT_FOR_INDEXING: "DOCUMENT_FOR_INDEXING", 39 | INITIAL_INDEX_LOADING: "INITIAL_INDEX_LOADING", 40 | INITIAL_INDEX_DONE: "INITIAL_INDEX_DONE", 41 | } as const; 42 | 43 | export const INDEX_OPTIONS: IndexOptionsForDocumentSearch = { 44 | document: { 45 | id: "id", 46 | index: ["key"], 47 | }, 48 | tokenize: "full", // Tokenize the data for partial matches 49 | cache: true, 50 | }; 51 | -------------------------------------------------------------------------------- /src/content-script.ts: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | 3 | console.log("AwsDash - Content script loaded"); 4 | 5 | const AWSDASHCOM_WEB_URLS = ["http://localhost:5173", "https://awsdash.com"]; 6 | 7 | // Listen for messages from the web page 8 | window.addEventListener("message", async (event) => { 9 | if (event.source !== window || event.data.source !== "AWSDASHCOM_WEB") { 10 | return; 11 | } 12 | 13 | // Forward the message to the background script, including the messageId 14 | const response = await browser.runtime.sendMessage(event.data.payload); 15 | 16 | sendMessageToWeb({ 17 | source: "AWSDASHCOM_EXT", 18 | type: "RESPONSE", 19 | messageId: event.data.messageId, 20 | response, 21 | }); 22 | }); 23 | 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | function sendMessageToWeb(message: any) { 26 | const targetOrigin = window.location.origin; 27 | if (AWSDASHCOM_WEB_URLS.includes(targetOrigin)) { 28 | window.postMessage(message, targetOrigin); 29 | } 30 | } 31 | 32 | async function extensionReadyCheck() { 33 | const [response, manifest] = await Promise.all([ 34 | browser.runtime.sendMessage({ 35 | source: "AWSDASHCOM_EXT", 36 | type: "GET_AWS_PROFILES", 37 | }), 38 | browser.runtime.getManifest(), 39 | ]); 40 | 41 | sendMessageToWeb({ 42 | source: "AWSDASHCOM_EXT", 43 | type: "NOTIFICATION", 44 | payload: { 45 | type: "EXTENSION_READY", 46 | profiles: response, 47 | version: manifest.version, 48 | }, 49 | }); 50 | } 51 | 52 | extensionReadyCheck(); 53 | 54 | // Send ready message again after a short delay, in case the page wasn't ready 55 | setTimeout(() => { 56 | extensionReadyCheck(); 57 | }, 1000); 58 | 59 | const port = browser.runtime.connect({ 60 | name: "awsdashcom-background-to-content", 61 | }); 62 | 63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 64 | port.onMessage.addListener((message: any) => { 65 | if (message.type === "NOTIFICATION") { 66 | console.log("content script receive notification", message.payload); 67 | sendMessageToWeb({ 68 | source: "AWSDASHCOM_EXT", 69 | type: "NOTIFICATION", 70 | payload: message.payload, 71 | }); 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /src/ec2-transforms.ts: -------------------------------------------------------------------------------- 1 | import { Reservation } from "@aws-sdk/client-ec2"; 2 | import { Ec2Instance } from "./types"; 3 | 4 | export function parseReservationResponse( 5 | reservations: Reservation[] | undefined | null, 6 | awsProfile: string 7 | ): Ec2Instance[] { 8 | const instanceList = (reservations || []).reduce((prev, current) => { 9 | return prev.concat( 10 | (current.Instances || []).map((iter) => { 11 | const instance: Ec2Instance = { 12 | id: iter.InstanceId!, 13 | type: iter.InstanceType!, 14 | name: 15 | (iter.Tags || []).find((iter) => iter.Key === "Name")?.Value ?? "", 16 | ipv4: iter.PublicIpAddress!, 17 | ipv4private: iter.PrivateIpAddress!, 18 | vpcId: iter.VpcId!, 19 | launchedAt: iter.LaunchTime!.toISOString(), 20 | keyName: iter.KeyName!, 21 | numCpu: iter.CpuOptions!.CoreCount!, 22 | numThread: iter.CpuOptions!.ThreadsPerCore!, 23 | stateName: iter.State!.Name!, 24 | autoscalingGroup: 25 | (iter.Tags || []).find( 26 | (iter) => iter.Key === "aws:autoscaling:groupName" 27 | )?.Value ?? null, 28 | awsProfile: awsProfile, 29 | }; 30 | return instance; 31 | }) 32 | ); 33 | }, [] as Ec2Instance[]); 34 | 35 | return instanceList; 36 | } 37 | -------------------------------------------------------------------------------- /src/indexdb.ts: -------------------------------------------------------------------------------- 1 | import { openDB, DBSchema, IDBPDatabase, deleteDB } from "idb"; 2 | import { BucketItem } from "./types"; 3 | 4 | export interface AwsDashDB extends DBSchema { 5 | bucket_items: { 6 | key: number; 7 | value: BucketItem; 8 | indexes: { 9 | id: number; 10 | bucket: string; 11 | key: string; 12 | bucketAndKey: [string, string]; 13 | syncTimestamp: number; 14 | }; 15 | }; 16 | } 17 | 18 | const BUCKET_ITEMS = "bucket_items"; 19 | 20 | class DBWrapper { 21 | db: null | IDBPDatabase = null; 22 | DB_NAME = `AwsDashComS3`; 23 | DB_VERSION = 2; 24 | 25 | async connect() { 26 | if (!this.db) { 27 | this.db = await openDB(this.DB_NAME, this.DB_VERSION, { 28 | upgrade(db, oldVersion, newVersion) { 29 | // eslint-disable-next-line no-console 30 | console.log( 31 | `idb open: oldVersion=${oldVersion}, newVersion=${newVersion}`, 32 | ); 33 | 34 | // 35 | // When this code first executes, since the database doesn't yet exist in the browser, 36 | // oldVersion is 0 and the switch statement starts at case 0. 37 | // 38 | // We are deliberately not having a break statement after each case. 39 | // 40 | // This way, if the existing database is a few versions behind (or if it doesn't exist), 41 | // the code continues through the rest of the case blocks until it has executed all the latest changes. 42 | 43 | /* eslint-disable no-fallthrough */ 44 | switch (oldVersion) { 45 | // @ts-expect-error (fallthrough) 46 | case 0: { 47 | const store = db.createObjectStore(BUCKET_ITEMS, { 48 | keyPath: "id", 49 | autoIncrement: true, 50 | }); 51 | store.createIndex("bucket", "bucket"); 52 | store.createIndex("key", "key"); 53 | 54 | store.createIndex("bucketAndKey", ["bucket", "key"], { 55 | unique: true, 56 | }); 57 | store.createIndex("syncTimestamp", "syncTimestamp"); 58 | } 59 | 60 | case 1: { 61 | } 62 | 63 | // Future database migration here 64 | // the last "case" statement should always one version behind the specified DB_VERSION 65 | } 66 | /* eslint-enable no-fallthrough */ 67 | }, 68 | }); 69 | } 70 | 71 | return this.db; 72 | } 73 | 74 | async destroyDatabase() { 75 | await deleteDB(this.DB_NAME); 76 | this.db = null; 77 | } 78 | } 79 | 80 | class BaseStore { 81 | constructor(private dbWrapper: DBWrapper) {} 82 | 83 | get db() { 84 | if (!this.dbWrapper.db) { 85 | throw new Error("db is not initialize!"); 86 | } 87 | return this.dbWrapper.db; 88 | } 89 | } 90 | 91 | class BucketItemsStore extends BaseStore { 92 | async query(bucketName: string) { 93 | const items = (await this.db.getAllFromIndex( 94 | BUCKET_ITEMS, 95 | "bucket", 96 | bucketName, 97 | )) as BucketItem[]; 98 | return items; 99 | } 100 | 101 | async getByBucketAndId(_bucketName: string, id: number) { 102 | return this.db.get(BUCKET_ITEMS, id); 103 | } 104 | 105 | async upsert(item: BucketItem) { 106 | const bucketAndKey = IDBKeyRange.only([item.bucket, item.key]); 107 | 108 | const tx = this.db.transaction(BUCKET_ITEMS, "readwrite"); 109 | const index = tx.store.index("bucketAndKey"); 110 | 111 | const existing = await index.get(bucketAndKey); 112 | 113 | if (existing) { 114 | await tx.store.put({ ...item, id: existing.id }); 115 | } else { 116 | await tx.store.add(item); 117 | } 118 | 119 | await tx.done; 120 | 121 | const doc = await this.db.getFromIndex( 122 | BUCKET_ITEMS, 123 | "bucketAndKey", 124 | bucketAndKey, 125 | ); 126 | return doc!; 127 | } 128 | 129 | async deleteNonCurrentVersion(bucketName: string, syncTimestamp: number) { 130 | const tx = this.db.transaction(BUCKET_ITEMS, "readwrite"); 131 | const index = tx.store.index("bucket"); 132 | 133 | let deletedCount = 0; 134 | 135 | for await (const cursor of index.iterate(bucketName)) { 136 | if ( 137 | cursor.value.bucket === bucketName && 138 | cursor.value.syncTimestamp !== syncTimestamp 139 | ) { 140 | await cursor.delete(); 141 | deletedCount++; 142 | } 143 | } 144 | 145 | await tx.done; 146 | 147 | return { deletedCount }; 148 | } 149 | 150 | async iterate(bucketNames: string[], callback: (item: BucketItem) => void) { 151 | const tx = this.db.transaction(BUCKET_ITEMS); 152 | for await (const cursor of tx.store) { 153 | const doc = cursor.value; 154 | if (bucketNames.includes(doc.bucket)) { 155 | callback(cursor.value); 156 | } 157 | } 158 | } 159 | } 160 | 161 | export const dbWrapper = new DBWrapper(); 162 | 163 | export const bucketItemStore = new BucketItemsStore(dbWrapper); 164 | -------------------------------------------------------------------------------- /src/migrations/1-default-aws-profile.ts: -------------------------------------------------------------------------------- 1 | export async function migrate(browser: typeof import("webextension-polyfill")) { 2 | console.log("Migration: 1-default-aws-profile - start"); 3 | 4 | const res1 = await browser.storage.local.get("awsCredentials"); 5 | const res2 = await browser.storage.local.get("awsProfiles"); 6 | 7 | const legacyCredentials = res1.awsCredentials as 8 | | { 9 | accessKeyId: string; 10 | secretAccessKey: string; 11 | } 12 | | undefined; 13 | 14 | const awsProfiles = res2.awsProfiles as 15 | | { 16 | name: string; 17 | accessKeyId: string; 18 | secretAccessKey: string; 19 | }[] 20 | | undefined; 21 | 22 | console.log("legacyCredentials", legacyCredentials); 23 | console.log("awsProfiles", awsProfiles); 24 | 25 | if (legacyCredentials && (!awsProfiles || awsProfiles.length === 0)) { 26 | console.log( 27 | "Migration: 1-default-aws-profile - found legacy credentials, convert it to default profile" 28 | ); 29 | 30 | const defaultProfile = { 31 | name: "default", 32 | accessKeyId: legacyCredentials.accessKeyId, 33 | secretAccessKey: legacyCredentials.secretAccessKey, 34 | }; 35 | await chrome.storage.local.set({ awsProfiles: [defaultProfile] }); 36 | } else { 37 | console.log("Migration: 1-default-aws-profile - nothing todo"); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/popup.ts: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", function () { 2 | document.getElementById("extension-id")!.textContent = chrome.runtime.id; 3 | 4 | // Add copy functionality 5 | const copyButton = document.getElementById("copy-button"); 6 | const extensionIdElement = document.getElementById("extension-id"); 7 | 8 | if (copyButton && extensionIdElement) { 9 | copyButton.addEventListener("click", () => { 10 | const extensionId = extensionIdElement.textContent; 11 | if (extensionId) { 12 | navigator.clipboard 13 | .writeText(extensionId) 14 | .then(() => { 15 | const originalText = copyButton.textContent; 16 | copyButton.textContent = "Copied!"; 17 | setTimeout(() => { 18 | copyButton.textContent = originalText; 19 | }, 2000); 20 | }) 21 | .catch((err) => { 22 | console.error("Failed to copy text: ", err); 23 | }); 24 | } 25 | }); 26 | } 27 | 28 | // Handle AWS credentials 29 | const awsKeyInput = document.getElementById("aws-key") as HTMLInputElement; 30 | const awsSecretInput = document.getElementById( 31 | "aws-secret" 32 | ) as HTMLInputElement; 33 | const feedbackElement = document.createElement("p"); 34 | feedbackElement.className = "text-green-500 mt-2"; 35 | awsSecretInput.parentNode?.appendChild(feedbackElement); 36 | 37 | let saveTimeout: number | null = null; 38 | 39 | const saveCredentials = () => { 40 | const accessKeyId = awsKeyInput.value.trim(); 41 | const secretAccessKey = awsSecretInput.value.trim(); 42 | 43 | const awsCredentials = { accessKeyId, secretAccessKey }; 44 | chrome.storage.local.set({ awsCredentials }, () => { 45 | feedbackElement.textContent = "Saved!"; 46 | setTimeout(() => { 47 | feedbackElement.textContent = ""; 48 | }, 2000); 49 | }); 50 | }; 51 | 52 | const debounceSave = () => { 53 | if (saveTimeout) { 54 | clearTimeout(saveTimeout); 55 | } 56 | saveTimeout = setTimeout(saveCredentials, 200) as unknown as number; 57 | }; 58 | 59 | if (awsKeyInput && awsSecretInput) { 60 | awsKeyInput.addEventListener("input", debounceSave); 61 | awsSecretInput.addEventListener("input", debounceSave); 62 | 63 | // Load saved credentials 64 | chrome.storage.local.get(["awsCredentials"], (result) => { 65 | if (result.awsCredentials) { 66 | awsKeyInput.value = result.awsCredentials.accessKeyId || ""; 67 | awsSecretInput.value = result.awsCredentials.secretAccessKey || ""; 68 | } 69 | }); 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /src/popup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { PopupApp } from "./components/PopupApp"; 4 | 5 | const rootElement = document.getElementById("root")!; 6 | 7 | ReactDOM.createRoot(rootElement).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | #app { 39 | max-width: 1280px; 40 | margin: 0 auto; 41 | padding: 2rem; 42 | text-align: center; 43 | } 44 | 45 | .logo { 46 | height: 6em; 47 | padding: 1.5em; 48 | will-change: filter; 49 | transition: filter 300ms; 50 | } 51 | .logo:hover { 52 | filter: drop-shadow(0 0 2em #646cffaa); 53 | } 54 | .logo.vanilla:hover { 55 | filter: drop-shadow(0 0 2em #3178c6aa); 56 | } 57 | 58 | .card { 59 | padding: 2em; 60 | } 61 | 62 | .read-the-docs { 63 | color: #888; 64 | } 65 | 66 | button { 67 | border-radius: 8px; 68 | border: 1px solid transparent; 69 | padding: 0.6em 1.2em; 70 | font-size: 1em; 71 | font-weight: 500; 72 | font-family: inherit; 73 | background-color: #1a1a1a; 74 | cursor: pointer; 75 | transition: border-color 0.25s; 76 | } 77 | button:hover { 78 | border-color: #646cff; 79 | } 80 | button:focus, 81 | button:focus-visible { 82 | outline: 4px auto -webkit-focus-ring-color; 83 | } 84 | 85 | @media (prefers-color-scheme: light) { 86 | :root { 87 | color: #213547; 88 | background-color: #ffffff; 89 | } 90 | a:hover { 91 | color: #747bff; 92 | } 93 | button { 94 | background-color: #f9f9f9; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { InstanceStateName } from "@aws-sdk/client-ec2"; 2 | 3 | export interface AWSProfile { 4 | name: string; 5 | accessKeyId: string; 6 | secretAccessKey: string; 7 | } 8 | 9 | export interface Ec2Instance { 10 | id: string; 11 | type: string; 12 | name: string; 13 | ipv4: string; 14 | ipv4private: string; 15 | launchedAt: string; 16 | keyName: string; 17 | numCpu: number; 18 | numThread: number; 19 | vpcId: string; 20 | stateName: InstanceStateName; 21 | autoscalingGroup: string | null; 22 | awsProfile: string; 23 | } 24 | 25 | export interface BucketItem { 26 | id?: string; 27 | awsProfile: string; 28 | bucket: string; 29 | bucketRegion: string; 30 | key: string; 31 | type: "folder" | "file"; 32 | syncTimestamp: number; 33 | uri?: string; 34 | size?: number; 35 | lastModified?: string; 36 | storageClass?: string; 37 | etag?: string; 38 | restoreStatus?: { 39 | isRestoreInProgress?: boolean; 40 | restoreExpiryDate?: string; 41 | }; 42 | } 43 | 44 | export interface SearchResults { 45 | totalCount: number; 46 | results: BucketItem[]; 47 | } 48 | 49 | export interface BucketInfo { 50 | name: string; 51 | location: string; 52 | documentCount?: number; 53 | lastIndexed?: number; 54 | awsProfile: string; 55 | } 56 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "jsx": "react" 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.chrome.content.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | publicDir: false, 5 | build: { 6 | outDir: "dist/chrome/content-scripts", 7 | rollupOptions: { 8 | input: { 9 | "content-script": "src/content-script.ts", 10 | }, 11 | output: { 12 | entryFileNames: "[name].js", 13 | }, 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /vite.config.chrome.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { viteStaticCopy } from "vite-plugin-static-copy"; 3 | import react from "@vitejs/plugin-react"; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | react(), 8 | viteStaticCopy({ 9 | targets: [ 10 | { 11 | src: "./xmanifests/chrome/manifest.json", 12 | dest: "", 13 | }, 14 | ], 15 | }), 16 | ], 17 | build: { 18 | outDir: "dist/chrome", 19 | rollupOptions: { 20 | input: { 21 | background: "src/background.ts", 22 | popup: "popup.html", 23 | // content-scripts are built separately because we don't want rollup to bundle them 24 | // Rollup enforces code-splitting when there are multiple entry-points 25 | // but content-scripts can't use import statements, everything need to be bundled into a single file 26 | // "content-scripts": "src/content-script.ts", 27 | }, 28 | output: { 29 | entryFileNames: "[name].js", 30 | }, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /vite.config.firefox.content.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | publicDir: false, 5 | build: { 6 | outDir: "dist/firefox/content-scripts", 7 | rollupOptions: { 8 | input: { 9 | "content-script": "src/content-script.ts", 10 | }, 11 | output: { 12 | entryFileNames: "[name].js", 13 | }, 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /vite.config.firefox.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { viteStaticCopy } from "vite-plugin-static-copy"; 3 | 4 | export default defineConfig({ 5 | plugins: [ 6 | viteStaticCopy({ 7 | targets: [ 8 | { 9 | src: "./xmanifests/firefox/manifest.json", 10 | dest: "", 11 | }, 12 | ], 13 | }), 14 | ], 15 | build: { 16 | outDir: "dist/firefox", 17 | rollupOptions: { 18 | input: { 19 | background: "src/background.ts", 20 | popup: "popup.html", 21 | // content-scripts are built separately because we don't want rollup to bundle them 22 | // Rollup enforces code-splitting when there are multiple entry-points 23 | // but content-scripts can't use import statements, everything need to be bundled into a single file 24 | // "content-scripts": "src/content-script.ts", 25 | }, 26 | output: { 27 | entryFileNames: "[name].js", 28 | }, 29 | }, 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /xmanifests/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "AwsDash", 4 | "version": "1.0.0-dev", 5 | "description": "Companion extension for AwsDash.com", 6 | "icons": { 7 | "16": "awsdash-logo.png", 8 | "48": "awsdash-logo.png", 9 | "128": "awsdash-logo.png" 10 | }, 11 | "action": { 12 | "default_icon": "awsdash-logo.png", 13 | "default_popup": "popup.html" 14 | }, 15 | "permissions": [ 16 | "storage", 17 | "unlimitedStorage" 18 | ], 19 | "host_permissions": [ 20 | "https://*.amazonaws.com/" 21 | ], 22 | "background": { 23 | "service_worker": "background.js", 24 | "type": "module" 25 | }, 26 | "content_scripts": [ 27 | { 28 | "matches": ["http://localhost:5173/*", "https://awsdash.com/*"], 29 | "js": ["content-scripts/content-script.js"] 30 | } 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /xmanifests/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "AwsDash", 4 | "version": "1.0.0-dev", 5 | "description": "Companion extension for AwsDash.com", 6 | "icons": { 7 | "16": "awsdash-logo.png", 8 | "48": "awsdash-logo.png", 9 | "128": "awsdash-logo.png" 10 | }, 11 | "browser_action": { 12 | "default_icon": "awsdash-logo.png", 13 | "default_popup": "popup.html" 14 | }, 15 | "permissions": [ 16 | "storage", 17 | "unlimitedStorage", 18 | "https://*.amazonaws.com/" 19 | ], 20 | "background": { 21 | "scripts": ["background.js"], 22 | "persistent": false 23 | }, 24 | "content_scripts": [ 25 | { 26 | "matches": ["http://localhost:5173/*", "https://awsdash.com/*"], 27 | "js": ["content-scripts/content-script.js"] 28 | } 29 | ], 30 | "browser_specific_settings": { 31 | "gecko": { 32 | "id": "ptgamr@awsdash.com", 33 | "strict_min_version": "58.0" 34 | } 35 | } 36 | } 37 | --------------------------------------------------------------------------------