From 8760d39ff042380e3f3fdd21cd04085b0079d0de Mon Sep 17 00:00:00 2001 From: vickytechkey Date: Fri, 23 Oct 2026 20:29:22 +0530 Subject: [PATCH] feat: add AWS SDK dependencies and database migration for aws_account table --- .../2026-10-23-0000-add-aws-account.js | 23 + package-lock.json | 422 +++++++- package.json | 2 + server/aws.js | 546 +++++++++++ server/client.js | 23 + server/server.js | 4 + server/socket-handlers/aws-socket-handler.js | 169 ++++ src/components/AWSAccountDialog.vue | 330 +++++++ src/components/EC2InstanceDetailModal.vue | 699 ++++++++++++++ src/icon.js | 24 + src/layouts/Layout.vue | 17 + src/mixins/socket.js | 5 + src/pages/EC2Dashboard.vue | 906 ++++++++++++++++++ src/router.js | 5 + 14 files changed, 3174 insertions(+), 1 deletion(-) create mode 100644 db/knex_migrations/2026-10-23-0000-add-aws-account.js create mode 100644 server/aws.js create mode 100644 server/socket-handlers/aws-socket-handler.js create mode 100644 src/components/AWSAccountDialog.vue create mode 100644 src/components/EC2InstanceDetailModal.vue create mode 100644 src/pages/EC2Dashboard.vue diff --git a/db/knex_migrations/2026-10-23-0000-add-aws-account.js b/db/knex_migrations/2026-10-23-0000-add-aws-account.js new file mode 100644 index 0000000..a4d7d0d --- /dev/null +++ b/db/knex_migrations/2026-10-23-0000-add-aws-account.js @@ -0,0 +1,23 @@ +exports.up = async (knex) => { + const hasTable = await knex.schema.hasTable("aws_account"); + if (!hasTable) { + await knex.schema.createTable("aws_account", (table) => { + table.increments("id").primary(); + table.string("name", 255).notNullable(); + table.string("auth_type", 64).defaultTo("keys"); + table.string("access_key_id", 255).nullable(); + table.text("secret_access_key").nullable(); + table.text("session_token").nullable(); + table.string("region", 64).defaultTo("us-east-1"); + table.integer("user_id").unsigned().nullable(); + table.dateTime("created_date").nullable(); + }); + } +}; + +exports.down = async (knex) => { + const hasTable = await knex.schema.hasTable("aws_account"); + if (hasTable) { + await knex.schema.dropTable("aws_account"); + } +}; diff --git a/package-lock.json b/package-lock.json index adf8be3..703d2f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "3.0.0-beta.0", "license": "MIT", "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.1152.0", + "@aws-sdk/client-ec2": "^3.1152.0", "@better-auth/api-key": "1.6.11", "@grpc/grpc-js": "~1.8.22", "@inquirer/core": "~11.2.1", @@ -168,7 +170,7 @@ "whatwg-url": "~12.0.1" }, "engines": { - "node": ">= 26.2.0" + "node": ">= 20.0.0" } }, "node_modules/@actions/github": { @@ -198,6 +200,213 @@ "undici": "^6.23.0" } }, + "node_modules/@aws-sdk/client-cloudwatch": { + "version": "3.1152.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch/-/client-cloudwatch-3.1152.0.tgz", + "integrity": "sha512-ame++euxZ5vKIiF57pXj/SftvrSlhtxKVMPkOUdZAYr6rUEhFHNCYEBTPQpW2260s5G5rEGdppTv3wnHIOQ6mQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/credential-provider-node": "^3.972.86", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/middleware-compression": "^4.6.2", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-ec2": { + "version": "3.1152.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ec2/-/client-ec2-3.1152.0.tgz", + "integrity": "sha512-MYzUWBSeKeMK+7CC9dIOckO26vaQwwfjwY/M0u5f7K+0u5Y1Zq6m4DSXihxCNjDxv5HXPQv4rrvOxqLiid9NIA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/credential-provider-node": "^3.972.86", + "@aws-sdk/middleware-sdk-ec2": "^3.972.62", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.3.tgz", + "integrity": "sha512-nslpzUCTI9b2IQMWSd6mSAXldBexPc8rr0d3dXUutXYFLFaTdsHa2GD7sJdsn3PhyjK0O5K8ypd6zi+RXXPVlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.74.tgz", + "integrity": "sha512-rsRJcW2iFUhCgclyD6OSbMEaPl8Eu0qSeRZb2uuCK1ViZ66hl5wOe/p6Vr6MO3b+ydyKgFeqza2p6tZThvILlw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.76.tgz", + "integrity": "sha512-pnKdh0Br7OJPj+7qKjzgTfDTFW0RhEMKXNDR2jv9CAM4oHuG/4z3hRh8KtutLfpmItuRtDbKBmwWkDqHwwAWXw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.19.tgz", + "integrity": "sha512-kcjvAxqQEV8yuwkX58RelNn3ne65o7JgRWf3Z/gVvbi7Xuw1qSDLJlatrLD76+40cmRxmNkLxBoyp/vAQChQ5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/credential-provider-env": "^3.972.74", + "@aws-sdk/credential-provider-http": "^3.972.76", + "@aws-sdk/credential-provider-login": "^3.972.81", + "@aws-sdk/credential-provider-process": "^3.972.74", + "@aws-sdk/credential-provider-sso": "^3.973.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.80", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.81.tgz", + "integrity": "sha512-CeQHSbE2CnVdaHIJ/NayRaCecIER+Pm9faUPkmRATO54rCoMTEPSbVieGHzEwvm1dZM2HIs6qQZCznhayNTWJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.86", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.86.tgz", + "integrity": "sha512-WNttSyr6e8innPtBSty//CdnqJcgZN6eAbtw3v/CR2IObz6MYZEo8eusdtuCHJ2ujwFqC2bSPHVyhXGHx8HruQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.74", + "@aws-sdk/credential-provider-http": "^3.972.76", + "@aws-sdk/credential-provider-ini": "^3.973.19", + "@aws-sdk/credential-provider-process": "^3.972.74", + "@aws-sdk/credential-provider-sso": "^3.973.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.80", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.74.tgz", + "integrity": "sha512-Yj6kZFJQdbR+PygTkqdVLPHHpdDpOS9GCYVcDCuweCRRig9LF24ELQBMfyUw1vspvrZJUfnOGVyMjhaL094ahw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.18.tgz", + "integrity": "sha512-FnpPhdY4qUGtX/8X6eKxh+uD+9Y19i9FYibgk4SBe0M935s9vocHkHzaxaMNk+YkHmvzgdu8cHA5SCoeU+kkew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/token-providers": "3.1150.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.80.tgz", + "integrity": "sha512-TJF25Ndwy2HxcVj6oxMgp8qVlJAawlExHYBXqhuC6Zt2m+A7WpdkjBD6QglNbiArlteYljZHEY3R7KKmno6ISA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-providers": { "name": "useless-module", "version": "1.0.0", @@ -209,6 +418,109 @@ "node": ">=4.2.4" } }, + "node_modules/@aws-sdk/middleware-sdk-ec2": { + "version": "3.972.62", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-ec2/-/middleware-sdk-ec2-3.972.62.tgz", + "integrity": "sha512-26tKbsnw6+ZxWElcTA7LUtJaT/HqTSjoCerPyi+Qf/3Zfbz4zgYHO0RFtguYZYwdBBxWT5BF0OPnSiEQHPIAbA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.48.tgz", + "integrity": "sha512-B01jdXJtAW/XOiUzGdI2mz67WyEHbGEc9X6Euzw3bjLAa+txTqdZDER4X2FsIEcXzV2ABqudVZ5thali8tCCzQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1150.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1150.0.tgz", + "integrity": "sha512-wDU36ofhabZYu+d2KSriyDbwzxNoaOp+4dG0p3qkabk7HeALR5nIWyQbjoyJZLn3iqWkv/xNdyUfbfevDVDzzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@azure/abort-controller": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", @@ -3401,6 +3713,102 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@smithy/core": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.0.tgz", + "integrity": "sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-compression": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@smithy/middleware-compression/-/middleware-compression-4.6.2.tgz", + "integrity": "sha512-Q9d+luiRjyHT6kCL/9NyGpdZJgodh4vtvfHC6H8SqoVKrV2k9RoyI9/IloVfdCYks3/2DIi7BsYYLcda5KZS0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "fflate": "0.8.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -5856,6 +6264,12 @@ "@popperjs/core": "^2.10.2" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -8633,6 +9047,12 @@ "node": ">=0.4.0" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", diff --git a/package.json b/package.json index 09e9cf7..a4e7ed5 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "audit": "npm audit --omit=dev" }, "dependencies": { + "@aws-sdk/client-cloudwatch": "^3.1152.0", + "@aws-sdk/client-ec2": "^3.1152.0", "@better-auth/api-key": "1.6.11", "@grpc/grpc-js": "~1.8.22", "@inquirer/core": "~11.2.1", diff --git a/server/aws.js b/server/aws.js new file mode 100644 index 0000000..fc090f9 --- /dev/null +++ b/server/aws.js @@ -0,0 +1,546 @@ +const { R } = require("redbean-node"); +const { EC2Client, DescribeInstancesCommand, DescribeRegionsCommand } = require("@aws-sdk/client-ec2"); +const { CloudWatchClient, GetMetricDataCommand } = require("@aws-sdk/client-cloudwatch"); +const { log } = require("../src/util"); + +/** + * AWS Account and EC2 Resource Management Service + */ +class AWSAccount { + /** + * Build AWS Client configuration with credentials and region + * @param {object} account AWS account record or plain object + * @param {string} [overrideRegion] Optional region override + * @returns {object} AWS client options + */ + static getClientConfig(account, overrideRegion) { + const region = overrideRegion || account.region || "us-east-1"; + const config = { region }; + + if (account.auth_type === "keys" && account.access_key_id && account.secret_access_key) { + config.credentials = { + accessKeyId: account.access_key_id.trim(), + secretAccessKey: account.secret_access_key.trim(), + }; + if (account.session_token && account.session_token.trim()) { + config.credentials.sessionToken = account.session_token.trim(); + } + } + // If auth_type === 'iam_role', AWS SDK automatically discovers credentials + // via environment variables, ECS metadata, or EC2 instance profile. + return config; + } + + /** + * Save or update an AWS account + * @param {object} account Account data + * @param {?number} accountID Existing account ID if updating + * @param {number} userID User ID + * @returns {Promise} Saved account + */ + static async save(account, accountID, userID) { + let bean; + + if (accountID) { + bean = await R.findOne("aws_account", " id = ? ", [accountID]); + if (!bean) { + throw new Error("AWS Account not found"); + } + } else { + bean = R.dispense("aws_account"); + bean.created_date = R.isoDateTime(); + } + + bean.name = (account.name || "").trim() || "AWS Account"; + bean.auth_type = account.authType || account.auth_type || "keys"; + bean.region = (account.region || "us-east-1").trim(); + bean.user_id = userID; + + if (bean.auth_type === "keys") { + bean.access_key_id = (account.accessKeyId || account.access_key_id || "").trim(); + // Don't overwrite secret with masked string + const newSecret = account.secretAccessKey || account.secret_access_key; + if (newSecret && !newSecret.startsWith("••••") && !newSecret.startsWith("****")) { + bean.secret_access_key = newSecret.trim(); + } + const newToken = account.sessionToken || account.session_token; + if (newToken !== undefined) { + bean.session_token = newToken ? newToken.trim() : null; + } + } else { + bean.access_key_id = null; + bean.secret_access_key = null; + bean.session_token = null; + } + + await R.store(bean); + return AWSAccount.sanitizeAccount(bean.export()); + } + + /** + * Delete an AWS account + * @param {number} accountID Account ID + * @returns {Promise} + */ + static async delete(accountID) { + const bean = await R.findOne("aws_account", " id = ? ", [accountID]); + if (!bean) { + throw new Error("AWS Account not found"); + } + await R.trash(bean); + } + + /** + * List all AWS accounts (with masked secrets) + * @param {number} [userID] Optional user filter + * @returns {Promise} + */ + static async list(userID) { + let beans; + if (userID) { + beans = await R.find("aws_account", " user_id = ? ORDER BY id ASC ", [userID]); + } else { + beans = await R.find("aws_account", " ORDER BY id ASC "); + } + + return beans.map((bean) => AWSAccount.sanitizeAccount(bean.export())); + } + + /** + * Get account bean including secret for server-side operations + * @param {number} accountID Account ID + * @returns {Promise} + */ + static async getAccountRaw(accountID) { + const bean = await R.findOne("aws_account", " id = ? ", [accountID]); + if (!bean) { + throw new Error(`AWS Account #${accountID} not found`); + } + return bean.export(); + } + + /** + * Sanitize account object for frontend + * @param {object} account Account object + * @returns {object} Sanitized account + */ + static sanitizeAccount(account) { + const sanitized = { ...account }; + if (sanitized.secret_access_key) { + sanitized.hasSecret = true; + sanitized.secret_access_key = "••••••••••••••••"; + } else { + sanitized.hasSecret = false; + } + if (sanitized.session_token) { + sanitized.hasToken = true; + sanitized.session_token = "••••••••"; + } + return sanitized; + } + + /** + * Test connection to AWS + * @param {object} account Account object + * @param {string} [region] Region to test + * @returns {Promise<{ ok: boolean, regionsCount: number, instancesCount: number, message: string }>} + */ + static async testConnection(account, region) { + const testRegion = region || account.region || "us-east-1"; + const config = AWSAccount.getClientConfig(account, testRegion); + const ec2 = new EC2Client(config); + + const startTime = Date.now(); + const describeCmd = new DescribeInstancesCommand({ MaxResults: 10 }); + const res = await ec2.send(describeCmd); + + let instancesCount = 0; + if (res.Reservations) { + for (const r of res.Reservations) { + instancesCount += (r.Instances || []).length; + } + } + + const latency = Date.now() - startTime; + return { + ok: true, + instancesCount, + latencyMs: latency, + message: `Connected successfully (${latency}ms). Found ${instancesCount} instance(s) in sample.`, + }; + } + + /** + * List all AWS EC2 instances for an account and region + * @param {object} account Account object + * @param {string} [region] Region to query + * @returns {Promise} List of instances + */ + static async getEc2Instances(account, region) { + const targetRegion = region || account.region || "us-east-1"; + const config = AWSAccount.getClientConfig(account, targetRegion); + const ec2 = new EC2Client(config); + + const cmd = new DescribeInstancesCommand({}); + const response = await ec2.send(cmd); + + const instances = []; + if (response.Reservations) { + for (const res of response.Reservations) { + if (!res.Instances) continue; + for (const inst of res.Instances) { + let name = inst.InstanceId; + const tags = {}; + if (inst.Tags) { + for (const tag of inst.Tags) { + tags[tag.Key] = tag.Value; + if (tag.Key === "Name" && tag.Value) { + name = tag.Value; + } + } + } + + instances.push({ + id: inst.InstanceId, + name, + state: inst.State?.Name || "unknown", + stateCode: inst.State?.Code, + type: inst.InstanceType, + publicIp: inst.PublicIpAddress || null, + privateIp: inst.PrivateIpAddress || null, + az: inst.Placement?.AvailabilityZone || null, + launchTime: inst.LaunchTime ? new Date(inst.LaunchTime).toISOString() : null, + vpcId: inst.VpcId || null, + subnetId: inst.SubnetId || null, + platform: inst.Platform || "linux", + architecture: inst.Architecture || "x86_64", + tags, + }); + } + } + } + + // Sort: running first, then alphabetically by name + instances.sort((a, b) => { + if (a.state === "running" && b.state !== "running") return -1; + if (a.state !== "running" && b.state === "running") return 1; + return a.name.localeCompare(b.name); + }); + + return instances; + } + + /** + * Fetch CloudWatch metrics for active instances (CPU, Memory via CWAgent, Network, Status) + * @param {object} account Account object + * @param {string} region Region + * @param {string[]} instanceIds List of instance IDs + * @returns {Promise>} Map of instanceId -> metrics + */ + static async getLiveInstanceMetrics(account, region, instanceIds) { + if (!instanceIds || instanceIds.length === 0) { + return {}; + } + + const targetRegion = region || account.region || "us-east-1"; + const config = AWSAccount.getClientConfig(account, targetRegion); + const cw = new CloudWatchClient(config); + + const now = new Date(); + const startTime = new Date(now.getTime() - 20 * 60 * 1000); // last 20 minutes + const queries = []; + + instanceIds.forEach((id, idx) => { + // CPU Utilization (AWS/EC2) + queries.push({ + Id: `cpu_${idx}`, + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + Dimensions: [{ Name: "InstanceId", Value: id }], + }, + Period: 300, + Stat: "Average", + }, + ReturnData: true, + }); + + // Memory Used Percent (CWAgent default) + queries.push({ + Id: `mem_${idx}`, + MetricStat: { + Metric: { + Namespace: "CWAgent", + MetricName: "mem_used_percent", + Dimensions: [{ Name: "InstanceId", Value: id }], + }, + Period: 300, + Stat: "Average", + }, + ReturnData: true, + }); + + // Network In + queries.push({ + Id: `net_in_${idx}`, + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "NetworkIn", + Dimensions: [{ Name: "InstanceId", Value: id }], + }, + Period: 300, + Stat: "Average", + }, + ReturnData: true, + }); + + // Network Out + queries.push({ + Id: `net_out_${idx}`, + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "NetworkOut", + Dimensions: [{ Name: "InstanceId", Value: id }], + }, + Period: 300, + Stat: "Average", + }, + ReturnData: true, + }); + + // Status Check Failed + queries.push({ + Id: `status_${idx}`, + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "StatusCheckFailed", + Dimensions: [{ Name: "InstanceId", Value: id }], + }, + Period: 300, + Stat: "Maximum", + }, + ReturnData: true, + }); + }); + + const metricsByInstance = {}; + instanceIds.forEach((id) => { + metricsByInstance[id] = { + cpuUtilization: null, + memoryUtilization: null, + hasMemoryAgent: false, + networkInBps: null, + networkOutBps: null, + statusCheckFailed: 0, + }; + }); + + // CloudWatch allows up to 500 queries per call. Chunk if needed. + const chunkSize = 100; + for (let i = 0; i < queries.length; i += chunkSize) { + const chunk = queries.slice(i, i + chunkSize); + try { + const cmd = new GetMetricDataCommand({ + StartTime: startTime, + EndTime: now, + MetricDataQueries: chunk, + }); + const response = await cw.send(cmd); + + if (response.MetricDataResults) { + for (const res of response.MetricDataResults) { + const parts = res.Id.split("_"); + const type = parts.slice(0, -1).join("_"); + const idx = parseInt(parts[parts.length - 1], 10); + const instId = instanceIds[idx]; + if (!instId || !metricsByInstance[instId]) continue; + + const val = res.Values && res.Values.length > 0 ? res.Values[0] : null; + + if (type === "cpu") { + metricsByInstance[instId].cpuUtilization = val !== null ? Math.round(val * 100) / 100 : null; + } else if (type === "mem") { + if (val !== null) { + metricsByInstance[instId].memoryUtilization = Math.round(val * 100) / 100; + metricsByInstance[instId].hasMemoryAgent = true; + } + } else if (type === "net_in") { + // Convert 300s average sum to Bytes/sec + metricsByInstance[instId].networkInBps = val !== null ? Math.round(val / 300) : null; + } else if (type === "net_out") { + metricsByInstance[instId].networkOutBps = val !== null ? Math.round(val / 300) : null; + } else if (type === "status") { + metricsByInstance[instId].statusCheckFailed = val !== null ? Math.round(val) : 0; + } + } + } + } catch (err) { + log.warn("aws", `Failed to fetch metric chunk: ${err.message}`); + } + } + + return metricsByInstance; + } + + /** + * Fetch historical time-series metrics for an instance + * @param {object} account Account object + * @param {string} region Region + * @param {string} instanceId Instance ID + * @param {number} [hours=1] Historical period in hours + * @returns {Promise} Time-series data + */ + static async getInstanceHistory(account, region, instanceId, hours = 1) { + const targetRegion = region || account.region || "us-east-1"; + const config = AWSAccount.getClientConfig(account, targetRegion); + const cw = new CloudWatchClient(config); + + const now = new Date(); + const startTime = new Date(now.getTime() - hours * 60 * 60 * 1000); + // Period: 60s for 1h, 300s for >1h + const period = hours <= 2 ? 60 : 300; + + const queries = [ + { + Id: "cpu", + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "CPUUtilization", + Dimensions: [{ Name: "InstanceId", Value: instanceId }], + }, + Period: period, + Stat: "Average", + }, + ReturnData: true, + }, + { + Id: "mem", + MetricStat: { + Metric: { + Namespace: "CWAgent", + MetricName: "mem_used_percent", + Dimensions: [{ Name: "InstanceId", Value: instanceId }], + }, + Period: period, + Stat: "Average", + }, + ReturnData: true, + }, + { + Id: "net_in", + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "NetworkIn", + Dimensions: [{ Name: "InstanceId", Value: instanceId }], + }, + Period: period, + Stat: "Average", + }, + ReturnData: true, + }, + { + Id: "net_out", + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "NetworkOut", + Dimensions: [{ Name: "InstanceId", Value: instanceId }], + }, + Period: period, + Stat: "Average", + }, + ReturnData: true, + }, + { + Id: "disk_read", + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "DiskReadBytes", + Dimensions: [{ Name: "InstanceId", Value: instanceId }], + }, + Period: period, + Stat: "Average", + }, + ReturnData: true, + }, + { + Id: "disk_write", + MetricStat: { + Metric: { + Namespace: "AWS/EC2", + MetricName: "DiskWriteBytes", + Dimensions: [{ Name: "InstanceId", Value: instanceId }], + }, + Period: period, + Stat: "Average", + }, + ReturnData: true, + }, + ]; + + const cmd = new GetMetricDataCommand({ + StartTime: startTime, + EndTime: now, + MetricDataQueries: queries, + }); + + const response = await cw.send(cmd); + const result = { + instanceId, + period, + hasMemoryAgent: false, + metrics: { + cpu: [], + mem: [], + netIn: [], + netOut: [], + diskRead: [], + diskWrite: [], + }, + }; + + if (response.MetricDataResults) { + for (const r of response.MetricDataResults) { + const metricKey = + r.Id === "cpu" ? "cpu" : + r.Id === "mem" ? "mem" : + r.Id === "net_in" ? "netIn" : + r.Id === "net_out" ? "netOut" : + r.Id === "disk_read" ? "diskRead" : + r.Id === "disk_write" ? "diskWrite" : null; + + if (!metricKey) continue; + + const points = []; + const timestamps = r.Timestamps || []; + const values = r.Values || []; + + for (let i = 0; i < timestamps.length; i++) { + points.push({ + time: new Date(timestamps[i]).toISOString(), + value: Math.round(values[i] * 100) / 100, + }); + } + + // Sort chronologically ascending + points.sort((a, b) => new Date(a.time) - new Date(b.time)); + result.metrics[metricKey] = points; + + if (metricKey === "mem" && points.length > 0) { + result.hasMemoryAgent = true; + } + } + } + + return result; + } +} + +module.exports = { AWSAccount }; diff --git a/server/client.js b/server/client.js index bbe9dab..a244236 100644 --- a/server/client.js +++ b/server/client.js @@ -235,6 +235,27 @@ async function sendMonitorTypeList(socket) { io.to(socket.userID).emit("monitorTypeList", Object.fromEntries(result)); } +/** + * Send list of AWS accounts to client + * @param {Socket} socket Socket.io instance + * @returns {Promise} + */ +async function sendAwsAccountList(socket) { + const timeLogger = new TimeLogger(); + let list = []; + try { + const { AWSAccount } = require("./aws"); + list = await AWSAccount.list(socket.userID); + } catch (e) { + // In case table is not yet migrated + list = []; + } + + io.to(socket.userID).emit("awsAccountList", list); + timeLogger.print("Send AWS Account List"); + return list; +} + module.exports = { sendNotificationList, sendImportantHeartbeatList, @@ -245,4 +266,6 @@ module.exports = { sendDockerHostList, sendRemoteBrowserList, sendMonitorTypeList, + sendAwsAccountList, }; + diff --git a/server/server.js b/server/server.js index 53bac07..9e7d95d 100644 --- a/server/server.js +++ b/server/server.js @@ -173,9 +173,11 @@ const { sendAPIKeyList, sendRemoteBrowserList, sendMonitorTypeList, + sendAwsAccountList, } = require("./client"); const { statusPageSocketHandler } = require("./socket-handlers/status-page-socket-handler"); const { databaseSocketHandler } = require("./socket-handlers/database-socket-handler"); +const { awsSocketHandler } = require("./socket-handlers/aws-socket-handler"); const { remoteBrowserSocketHandler } = require("./socket-handlers/remote-browser-socket-handler"); const StatusPage = require("./model/status_page"); const { @@ -1371,6 +1373,7 @@ app.use(function (req, res, next) { remoteBrowserSocketHandler(socket); generalSocketHandler(socket, server); chartSocketHandler(socket); + awsSocketHandler(socket); log.debug("server", "added all socket handlers"); @@ -1452,6 +1455,7 @@ async function afterLogin(socket, user) { sendAPIKeyList(socket), sendRemoteBrowserList(socket), sendMonitorTypeList(socket), + sendAwsAccountList(socket), ]); await StatusPage.sendStatusPageList(io, socket); diff --git a/server/socket-handlers/aws-socket-handler.js b/server/socket-handlers/aws-socket-handler.js new file mode 100644 index 0000000..f6c902b --- /dev/null +++ b/server/socket-handlers/aws-socket-handler.js @@ -0,0 +1,169 @@ +const { checkLogin } = require("../util-server"); +const { AWSAccount } = require("../aws"); +const { sendAwsAccountList } = require("../client"); +const { log } = require("../../src/util"); + +/** + * Socket handlers for AWS EC2 and CloudWatch resource monitoring + * @param {Socket} socket Socket.io instance + * @returns {void} + */ +module.exports.awsSocketHandler = (socket) => { + // Save or update an AWS account + socket.on("saveAwsAccount", async (account, accountID, callback) => { + try { + checkLogin(socket); + + const saved = await AWSAccount.save(account, accountID, socket.userID); + await sendAwsAccountList(socket); + + if (typeof callback === "function") { + callback({ + ok: true, + msg: "Saved.", + msgi18n: true, + account: saved, + }); + } + } catch (e) { + log.error("aws", `Failed to save AWS account: ${e.message}`); + if (typeof callback === "function") { + callback({ + ok: false, + msg: e.message, + }); + } + } + }); + + // Delete an AWS account + socket.on("deleteAwsAccount", async (accountID, callback) => { + try { + checkLogin(socket); + + await AWSAccount.delete(accountID); + await sendAwsAccountList(socket); + + if (typeof callback === "function") { + callback({ + ok: true, + msg: "successDeleted", + msgi18n: true, + }); + } + } catch (e) { + log.error("aws", `Failed to delete AWS account: ${e.message}`); + if (typeof callback === "function") { + callback({ + ok: false, + msg: e.message, + }); + } + } + }); + + // Test AWS connection + socket.on("testAwsAccount", async (account, callback) => { + try { + checkLogin(socket); + + // If account has an ID and masked secret, fill in the actual secret from DB + if (account.id && (!account.secretAccessKey || account.secretAccessKey.startsWith("••••"))) { + const raw = await AWSAccount.getAccountRaw(account.id); + account.secret_access_key = raw.secret_access_key; + account.access_key_id = raw.access_key_id; + account.session_token = raw.session_token; + } + + const result = await AWSAccount.testConnection(account, account.region); + if (typeof callback === "function") { + callback({ + ok: true, + ...result, + }); + } + } catch (e) { + if (typeof callback === "function") { + callback({ + ok: false, + msg: e.message, + }); + } + } + }); + + // Fetch EC2 instances + socket.on("getEc2Instances", async (accountID, region, callback) => { + try { + checkLogin(socket); + + const rawAccount = await AWSAccount.getAccountRaw(accountID); + const instances = await AWSAccount.getEc2Instances(rawAccount, region); + + if (typeof callback === "function") { + callback({ + ok: true, + instances, + }); + } + } catch (e) { + log.error("aws", `Failed to get EC2 instances: ${e.message}`); + if (typeof callback === "function") { + callback({ + ok: false, + msg: e.message, + }); + } + } + }); + + // Fetch live metrics (CPU, Memory, Network, Status) for specified instances + socket.on("getEc2LiveMetrics", async (accountID, region, instanceIds, callback) => { + try { + checkLogin(socket); + + const rawAccount = await AWSAccount.getAccountRaw(accountID); + const metrics = await AWSAccount.getLiveInstanceMetrics(rawAccount, region, instanceIds); + + if (typeof callback === "function") { + callback({ + ok: true, + metrics, + }); + } + } catch (e) { + log.error("aws", `Failed to get live metrics: ${e.message}`); + if (typeof callback === "function") { + callback({ + ok: false, + msg: e.message, + }); + } + } + }); + + // Fetch historical metric series for an instance deep-dive chart + socket.on("getEc2InstanceHistory", async (accountID, region, instanceId, hours, callback) => { + try { + checkLogin(socket); + + const rawAccount = await AWSAccount.getAccountRaw(accountID); + const history = await AWSAccount.getInstanceHistory(rawAccount, region, instanceId, hours); + + if (typeof callback === "function") { + callback({ + ok: true, + history, + }); + } + } catch (e) { + log.error("aws", `Failed to get instance history: ${e.message}`); + if (typeof callback === "function") { + callback({ + ok: false, + msg: e.message, + }); + } + } + }); +}; diff --git a/src/components/AWSAccountDialog.vue b/src/components/AWSAccountDialog.vue new file mode 100644 index 0000000..dec2e49 --- /dev/null +++ b/src/components/AWSAccountDialog.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/src/components/EC2InstanceDetailModal.vue b/src/components/EC2InstanceDetailModal.vue new file mode 100644 index 0000000..991c1aa --- /dev/null +++ b/src/components/EC2InstanceDetailModal.vue @@ -0,0 +1,699 @@ + + + + + diff --git a/src/icon.js b/src/icon.js index eeb953f..9ff28c6 100644 --- a/src/icon.js +++ b/src/icon.js @@ -53,6 +53,18 @@ import { faCertificate, faFolder, faFolderOpen, + faServer, + faCloud, + faMicrochip, + faMemory, + faSyncAlt, + faNetworkWired, + faHdd, + faKey, + faShieldAlt, + faChartLine, + faClock, + faGlobe, } from "@fortawesome/free-solid-svg-icons"; library.add( @@ -103,6 +115,18 @@ library.add( faCertificate, faFolder, faFolderOpen, + faServer, + faCloud, + faMicrochip, + faMemory, + faSyncAlt, + faNetworkWired, + faHdd, + faKey, + faShieldAlt, + faChartLine, + faClock, + faGlobe, ); export { FontAwesomeIcon }; diff --git a/src/layouts/Layout.vue b/src/layouts/Layout.vue index 63fdb1b..0befad0 100644 --- a/src/layouts/Layout.vue +++ b/src/layouts/Layout.vue @@ -39,6 +39,12 @@ {{ $t("Status Pages") }} +
  • +
  • + + + {{ $t("EC2 Resources") }} + +
  • +
  • { + this.awsAccountList = data; + }); + socket.on("remoteBrowserList", (data) => { this.remoteBrowserList = data; }); diff --git a/src/pages/EC2Dashboard.vue b/src/pages/EC2Dashboard.vue new file mode 100644 index 0000000..a99bc05 --- /dev/null +++ b/src/pages/EC2Dashboard.vue @@ -0,0 +1,906 @@ + + + + + diff --git a/src/router.js b/src/router.js index 36d2587..7d05c6c 100644 --- a/src/router.js +++ b/src/router.js @@ -19,6 +19,7 @@ import DockerHosts from "./components/settings/Docker.vue"; import ManageMaintenance from "./pages/ManageMaintenance.vue"; import APIKeys from "./components/settings/APIKeys.vue"; import SetupDatabase from "./pages/SetupDatabase.vue"; +const EC2Dashboard = () => import("./pages/EC2Dashboard.vue"); // Settings - Sub Pages import Appearance from "./components/settings/Appearance.vue"; @@ -161,6 +162,10 @@ const routes = [ path: "/maintenance/clone/:id", component: EditMaintenance, }, + { + path: "/ec2-resources", + component: EC2Dashboard, + }, ], }, ],