feat: add AWS SDK dependencies and database migration for aws_account table
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 14s

This commit is contained in:
vickytechkey 2026-10-23 20:29:22 +05:30
parent 3cae940252
commit 8760d39ff0
14 changed files with 3174 additions and 1 deletions

View file

@ -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");
}
};

422
package-lock.json generated
View file

@ -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",

View file

@ -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",

546
server/aws.js Normal file
View file

@ -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<object>} 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<void>}
*/
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<object[]>}
*/
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<object>}
*/
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<object[]>} 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<Record<string, object>>} 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<object>} 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 };

View file

@ -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<object[]>}
*/
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,
};

View file

@ -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);

View file

@ -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,
});
}
}
});
};

View file

@ -0,0 +1,330 @@
<template>
<form @submit.prevent="submit">
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center">
<font-awesome-icon icon="cloud" class="text-primary me-2" />
{{ id ? $t("Edit AWS Account") : $t("Add AWS Account") }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
<div class="modal-body">
<!-- Account Name -->
<div class="mb-3">
<label for="aws-name" class="form-label">{{ $t("Account Name") }}</label>
<input
id="aws-name"
v-model="account.name"
type="text"
class="form-control"
placeholder="e.g. Production AWS, Analytics Cluster"
required
/>
</div>
<!-- Auth Method -->
<div class="mb-3">
<label class="form-label">{{ $t("Authentication Method") }}</label>
<div class="d-flex gap-3">
<div class="form-check">
<input
id="auth-keys"
v-model="account.authType"
class="form-check-input"
type="radio"
value="keys"
/>
<label class="form-check-label" for="auth-keys">
{{ $t("Access Key & Secret") }}
</label>
</div>
<div class="form-check">
<input
id="auth-iam"
v-model="account.authType"
class="form-check-input"
type="radio"
value="iam_role"
/>
<label class="form-check-label" for="auth-iam">
{{ $t("IAM Role / Environment") }}
</label>
</div>
</div>
<div v-if="account.authType === 'iam_role'" class="form-text mt-1 text-muted">
{{ $t("Uses server's attached IAM Instance Profile, ECS Task Role, or environment variables (AWS_ACCESS_KEY_ID).") }}
</div>
</div>
<!-- Key based inputs -->
<div v-if="account.authType === 'keys'">
<div class="mb-3">
<label for="aws-key-id" class="form-label">{{ $t("AWS Access Key ID") }}</label>
<input
id="aws-key-id"
v-model="account.accessKeyId"
type="text"
class="form-control font-monospace"
placeholder="AKIAIOSFODNN7EXAMPLE"
required
/>
</div>
<div class="mb-3">
<label for="aws-secret" class="form-label">{{ $t("AWS Secret Access Key") }}</label>
<div class="input-group">
<input
id="aws-secret"
v-model="account.secretAccessKey"
:type="showSecret ? 'text' : 'password'"
class="form-control font-monospace"
:placeholder="id ? '•••••••••••••••• (Leave blank to keep unchanged)' : 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'"
:required="!id"
/>
<button
type="button"
class="btn btn-outline-secondary"
@click="showSecret = !showSecret"
>
<font-awesome-icon :icon="showSecret ? 'eye-slash' : 'eye'" />
</button>
</div>
</div>
<div class="mb-3">
<label for="aws-token" class="form-label">
{{ $t("AWS Session Token (Optional)") }}
</label>
<input
id="aws-token"
v-model="account.sessionToken"
type="password"
class="form-control font-monospace"
placeholder="Optional temporary STS session token"
/>
</div>
</div>
<!-- Region -->
<div class="mb-3">
<label for="aws-region" class="form-label">{{ $t("Default Region") }}</label>
<select id="aws-region" v-model="account.region" class="form-select">
<option v-for="r in commonRegions" :key="r.code" :value="r.code">
{{ r.name }} ({{ r.code }})
</option>
</select>
</div>
<!-- Test result banner -->
<div
v-if="testResult"
class="alert mt-3 mb-0"
:class="testResult.ok ? 'alert-success' : 'alert-danger'"
>
<div class="d-flex align-items-center">
<font-awesome-icon
:icon="testResult.ok ? 'check-circle' : 'exclamation-triangle'"
class="me-2"
/>
<div>{{ testResult.message }}</div>
</div>
</div>
</div>
<div class="modal-footer">
<button
v-if="id"
type="button"
class="btn btn-danger me-auto"
:disabled="processing"
@click="deleteConfirm"
>
<font-awesome-icon icon="trash" class="me-1" />
{{ $t("Delete") }}
</button>
<button
type="button"
class="btn btn-outline-secondary"
:disabled="processing || testing"
@click="test"
>
<span v-if="testing" class="spinner-border spinner-border-sm me-1" />
<font-awesome-icon v-else icon="sync-alt" class="me-1" />
{{ $t("Test Connection") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="processing">
<span v-if="processing" class="spinner-border spinner-border-sm me-1" />
{{ $t("Save Account") }}
</button>
</div>
</div>
</div>
</div>
</form>
<Confirm
ref="confirmDelete"
btn-style="btn-danger"
:yes-text="$t('Yes')"
:no-text="$t('No')"
@yes="deleteAccount"
>
{{ $t("Are you sure you want to delete this AWS account configuration?") }}
</Confirm>
</template>
<script>
import { Modal } from "bootstrap";
import Confirm from "./Confirm.vue";
import { useToast } from "vue-toastification";
const toast = useToast();
export default {
components: {
Confirm,
},
emits: ["saved", "deleted"],
data() {
return {
modal: null,
processing: false,
testing: false,
showSecret: false,
id: null,
testResult: null,
account: {
name: "",
authType: "keys",
accessKeyId: "",
secretAccessKey: "",
sessionToken: "",
region: "us-east-1",
},
commonRegions: [
{ code: "us-east-1", name: "US East (N. Virginia)" },
{ code: "us-east-2", name: "US East (Ohio)" },
{ code: "us-west-1", name: "US West (N. California)" },
{ code: "us-west-2", name: "US West (Oregon)" },
{ code: "ap-south-1", name: "Asia Pacific (Mumbai)" },
{ code: "ap-southeast-1", name: "Asia Pacific (Singapore)" },
{ code: "ap-southeast-2", name: "Asia Pacific (Sydney)" },
{ code: "ap-northeast-1", name: "Asia Pacific (Tokyo)" },
{ code: "eu-central-1", name: "Europe (Frankfurt)" },
{ code: "eu-west-1", name: "Europe (Ireland)" },
{ code: "eu-west-2", name: "Europe (London)" },
{ code: "ca-central-1", name: "Canada (Central)" },
{ code: "sa-east-1", name: "South America (São Paulo)" },
],
};
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
methods: {
show(accountID) {
this.testResult = null;
this.showSecret = false;
this.processing = false;
this.testing = false;
if (accountID) {
this.id = accountID;
const found = (this.$root.awsAccountList || []).find((a) => a.id === accountID);
if (found) {
this.account = {
name: found.name || "",
authType: found.auth_type || "keys",
accessKeyId: found.access_key_id || "",
secretAccessKey: "",
sessionToken: "",
region: found.region || "us-east-1",
};
}
} else {
this.id = null;
this.account = {
name: "",
authType: "keys",
accessKeyId: "",
secretAccessKey: "",
sessionToken: "",
region: "us-east-1",
};
}
this.modal.show();
},
deleteConfirm() {
this.modal.hide();
this.$refs.confirmDelete.show();
},
deleteAccount() {
this.processing = true;
this.$root.getSocket().emit("deleteAwsAccount", this.id, (res) => {
this.processing = false;
if (res.ok) {
toast.success(this.$t("AWS account deleted"));
this.$emit("deleted", this.id);
} else {
toast.error(res.msg);
}
});
},
test() {
this.testing = true;
this.testResult = null;
const payload = {
id: this.id,
...this.account,
};
this.$root.getSocket().emit("testAwsAccount", payload, (res) => {
this.testing = false;
if (res.ok) {
this.testResult = {
ok: true,
message: res.message || this.$t("Connection successful!"),
};
} else {
this.testResult = {
ok: false,
message: res.msg || this.$t("Failed to connect to AWS"),
};
}
});
},
submit() {
this.processing = true;
const payload = {
...this.account,
};
this.$root.getSocket().emit("saveAwsAccount", payload, this.id, (res) => {
this.processing = false;
if (res.ok) {
toast.success(this.$t("AWS account saved"));
this.modal.hide();
this.$emit("saved", res.account);
} else {
toast.error(res.msg);
}
});
},
},
};
</script>
<style scoped>
.font-monospace {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
</style>

View file

@ -0,0 +1,699 @@
<template>
<div ref="modal" class="modal fade" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div v-if="instance" class="modal-content">
<div class="modal-header border-bottom">
<div class="d-flex align-items-center gap-3">
<div class="status-circle" :class="stateClass(instance.state)"></div>
<div>
<h5 class="modal-title mb-0 fw-bold">
{{ instance.name }}
<span class="badge bg-secondary ms-2 font-monospace">{{ instance.id }}</span>
</h5>
<small class="text-muted">
{{ instance.type }} &bull; {{ instance.az || region }} &bull;
<span class="text-capitalize">{{ instance.state }}</span>
</small>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<button
type="button"
class="btn btn-sm btn-outline-secondary"
:disabled="loading"
@click="fetchHistory"
>
<font-awesome-icon icon="sync-alt" :spin="loading" class="me-1" />
{{ $t("Refresh") }}
</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" :aria-label="$t('Close')" />
</div>
</div>
<div class="modal-body p-4">
<!-- Nav tabs -->
<ul class="nav nav-pills mb-4">
<li class="nav-item">
<button
class="nav-link"
:class="{ active: currentTab === 'metrics' }"
@click="currentTab = 'metrics'"
>
<font-awesome-icon icon="chart-line" class="me-1" />
{{ $t("Live Resource Metrics") }}
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
:class="{ active: currentTab === 'memory' }"
@click="currentTab = 'memory'"
>
<font-awesome-icon icon="memory" class="me-1" />
{{ $t("Memory Deep-Dive") }}
<span v-if="history && history.hasMemoryAgent" class="badge bg-success ms-1">Active</span>
<span v-else class="badge bg-warning text-dark ms-1">Setup Needed</span>
</button>
</li>
<li class="nav-item">
<button
class="nav-link"
:class="{ active: currentTab === 'details' }"
@click="currentTab = 'details'"
>
<font-awesome-icon icon="info-circle" class="me-1" />
{{ $t("Instance Details & Tags") }}
</button>
</li>
</ul>
<!-- Time period selector for metrics -->
<div v-if="currentTab === 'metrics' || currentTab === 'memory'" class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">
{{ $t("Historical data sampled from AWS CloudWatch") }}
</span>
<div class="btn-group btn-group-sm">
<button
v-for="h in [1, 6, 24]"
:key="h"
type="button"
class="btn"
:class="hours === h ? 'btn-primary' : 'btn-outline-secondary'"
@click="changePeriod(h)"
>
{{ h === 1 ? '1 Hour' : h + ' Hours' }}
</button>
</div>
</div>
<!-- Metrics Tab -->
<div v-show="currentTab === 'metrics'">
<div class="row g-4">
<!-- CPU Chart -->
<div class="col-12 col-lg-6">
<div class="card metric-card shadow-sm h-100">
<div class="card-header bg-transparent d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<font-awesome-icon icon="microchip" class="text-info me-2" />
{{ $t("CPU Utilization") }} (%)
</span>
<span v-if="latestCpu != null" class="badge" :class="cpuBadgeClass(latestCpu)">
Current: {{ latestCpu }}%
</span>
</div>
<div class="card-body">
<div class="chart-container">
<canvas ref="cpuChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Memory Chart -->
<div class="col-12 col-lg-6">
<div class="card metric-card shadow-sm h-100">
<div class="card-header bg-transparent d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<font-awesome-icon icon="memory" class="text-warning me-2" />
{{ $t("Memory Utilization") }} (%)
</span>
<span v-if="history && history.hasMemoryAgent && latestMem != null" class="badge bg-success">
Current: {{ latestMem }}%
</span>
<span v-else class="badge bg-warning text-dark">
CloudWatch Agent Required
</span>
</div>
<div class="card-body">
<div v-if="history && history.hasMemoryAgent" class="chart-container">
<canvas ref="memChart"></canvas>
</div>
<div v-else class="text-center py-4 px-3">
<font-awesome-icon icon="info-circle" class="text-muted fa-2x mb-2" />
<h6 class="fw-bold">{{ $t("Memory metrics are not yet reporting") }}</h6>
<p class="text-muted small mb-3">
{{ $t("AWS EC2 does not report OS memory by default. Install the Amazon CloudWatch Agent to enable live memory monitoring.") }}
</p>
<button class="btn btn-sm btn-outline-primary" @click="currentTab = 'memory'">
{{ $t("View 2-Minute Setup Instructions") }}
</button>
</div>
</div>
</div>
</div>
<!-- Network In/Out Chart -->
<div class="col-12 col-lg-6">
<div class="card metric-card shadow-sm h-100">
<div class="card-header bg-transparent fw-semibold">
<font-awesome-icon icon="network-wired" class="text-primary me-2" />
{{ $t("Network Traffic") }} (KB/s)
</div>
<div class="card-body">
<div class="chart-container">
<canvas ref="netChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Disk Read/Write Chart -->
<div class="col-12 col-lg-6">
<div class="card metric-card shadow-sm h-100">
<div class="card-header bg-transparent fw-semibold">
<font-awesome-icon icon="hdd" class="text-success me-2" />
{{ $t("Disk Activity") }} (KB/s)
</div>
<div class="card-body">
<div class="chart-container">
<canvas ref="diskChart"></canvas>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Memory Deep-Dive Tab -->
<div v-show="currentTab === 'memory'">
<div v-if="history && history.hasMemoryAgent" class="row g-4">
<div class="col-12">
<div class="alert alert-success d-flex align-items-center">
<font-awesome-icon icon="check-circle" class="fa-2x me-3" />
<div>
<h6 class="mb-0 fw-bold">{{ $t("CloudWatch Memory Agent Active") }}</h6>
<small>{{ $t("Receiving live OS memory utilization metrics from CWAgent/mem_used_percent.") }}</small>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-transparent fw-semibold">
{{ $t("Memory Utilization Trend") }}
</div>
<div class="card-body">
<div class="chart-container" style="height: 320px;">
<canvas ref="memTabChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<div v-else class="p-2">
<div class="card border-warning mb-4">
<div class="card-body">
<h5 class="card-title fw-bold text-warning mb-2">
<font-awesome-icon icon="exclamation-triangle" class="me-2" />
{{ $t("How to Enable EC2 Memory Usage Monitoring") }}
</h5>
<p class="text-muted">
AWS CloudWatch only reports hypervisor-level metrics (like CPU and Network) by default. To monitor RAM/Memory usage, AWS provides the free <strong>Amazon CloudWatch Agent</strong>.
</p>
<h6 class="fw-bold mt-4">{{ $t("Step 1: Install CloudWatch Agent (Amazon Linux, Ubuntu, Debian)") }}</h6>
<pre class="bg-dark text-light p-3 rounded font-monospace small"><code># On Amazon Linux / RHEL / CentOS:
sudo yum install -y amazon-cloudwatch-agent
# On Ubuntu / Debian:
wget https://amazoncloudwatch-agent.s3.amazonaws.com/ubuntu/amd64/latest/amazon-cloudwatch-agent.deb
sudo dpkg -i -E ./amazon-cloudwatch-agent.deb</code></pre>
<h6 class="fw-bold mt-4">{{ $t("Step 2: Start Agent with Memory Metrics Configuration") }}</h6>
<pre class="bg-dark text-light p-3 rounded font-monospace small"><code># Save config file /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json:
sudo tee /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json &lt;&lt; 'EOF'
{
"metrics": {
"metrics_collected": {
"mem": {
"measurement": ["mem_used_percent"],
"metrics_collection_interval": 60
}
},
"append_dimensions": {
"InstanceId": "${aws:InstanceId}"
}
}
}
EOF
# Start the agent:
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
-a fetch-config -m ec2 -s -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json</code></pre>
<div class="alert alert-info mt-3 mb-0 small">
<font-awesome-icon icon="info-circle" class="me-1" />
<strong>IAM Permission:</strong> Ensure the EC2 instance has an IAM Role with the <code>CloudWatchAgentServerPolicy</code> policy attached so it can send metrics to CloudWatch.
</div>
</div>
</div>
</div>
</div>
<!-- Details Tab -->
<div v-show="currentTab === 'details'">
<div class="row g-3">
<div class="col-md-6">
<table class="table table-sm table-bordered">
<tbody>
<tr>
<th class="w-40 text-muted">{{ $t("Instance ID") }}</th>
<td class="font-monospace">{{ instance.id }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("State") }}</th>
<td>
<span class="badge" :class="stateBadgeClass(instance.state)">
{{ instance.state }}
</span>
</td>
</tr>
<tr>
<th class="text-muted">{{ $t("Instance Type") }}</th>
<td>{{ instance.type }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("Availability Zone") }}</th>
<td>{{ instance.az || 'N/A' }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("Public IP") }}</th>
<td class="font-monospace">{{ instance.publicIp || 'None' }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("Private IP") }}</th>
<td class="font-monospace">{{ instance.privateIp || 'None' }}</td>
</tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<table class="table table-sm table-bordered">
<tbody>
<tr>
<th class="w-40 text-muted">{{ $t("Architecture") }}</th>
<td>{{ instance.architecture || 'x86_64' }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("Platform") }}</th>
<td>{{ instance.platform || 'Linux/UNIX' }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("VPC ID") }}</th>
<td class="font-monospace">{{ instance.vpcId || 'N/A' }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("Subnet ID") }}</th>
<td class="font-monospace">{{ instance.subnetId || 'N/A' }}</td>
</tr>
<tr>
<th class="text-muted">{{ $t("Launch Time") }}</th>
<td>{{ formatLaunchTime(instance.launchTime) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Tags Table -->
<div class="col-12 mt-3">
<h6 class="fw-bold">{{ $t("Tags") }}</h6>
<div v-if="Object.keys(instance.tags || {}).length > 0" class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $t("Key") }}</th>
<th>{{ $t("Value") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(val, key) in instance.tags" :key="key">
<td class="fw-semibold font-monospace">{{ key }}</td>
<td class="font-monospace">{{ val }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-muted small">
{{ $t("No tags assigned to this instance.") }}
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{{ $t("Close") }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from "bootstrap";
import { Chart } from "chart.js";
import dayjs from "dayjs";
export default {
props: {
accountId: {
type: Number,
default: null,
},
region: {
type: String,
default: "us-east-1",
},
},
data() {
return {
modal: null,
instance: null,
currentTab: "metrics",
hours: 1,
loading: false,
history: null,
charts: {
cpu: null,
mem: null,
memTab: null,
net: null,
disk: null,
},
};
},
computed: {
latestCpu() {
const cpu = this.history?.metrics?.cpu;
if (cpu && cpu.length > 0) {
return cpu[cpu.length - 1].value;
}
return null;
},
latestMem() {
const mem = this.history?.metrics?.mem;
if (mem && mem.length > 0) {
return mem[mem.length - 1].value;
}
return null;
},
},
mounted() {
this.modal = new Modal(this.$refs.modal);
},
beforeUnmount() {
this.destroyAllCharts();
},
methods: {
show(instance) {
this.instance = instance;
this.currentTab = "metrics";
this.hours = 1;
this.modal.show();
this.fetchHistory();
},
changePeriod(h) {
this.hours = h;
this.fetchHistory();
},
fetchHistory() {
if (!this.instance || !this.accountId) return;
this.loading = true;
this.$root.getSocket().emit(
"getEc2InstanceHistory",
this.accountId,
this.region,
this.instance.id,
this.hours,
(res) => {
this.loading = false;
if (res.ok) {
this.history = res.history;
this.$nextTick(() => {
this.renderCharts();
});
}
}
);
},
destroyAllCharts() {
for (const key of Object.keys(this.charts)) {
if (this.charts[key]) {
this.charts[key].destroy();
this.charts[key] = null;
}
}
},
renderCharts() {
if (!this.history || !this.history.metrics) return;
this.destroyAllCharts();
const isDark = this.$root.theme === "dark";
const gridColor = isDark ? "rgba(255, 255, 255, 0.08)" : "rgba(0, 0, 0, 0.06)";
const textColor = isDark ? "#c0c0c0" : "#666666";
const createChartConfig = (labels, datasets, yUnit = "%") => ({
type: "line",
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: datasets.length > 1, labels: { color: textColor } },
},
scales: {
x: {
grid: { color: gridColor },
ticks: { color: textColor, maxTicksLimit: 7 },
},
y: {
grid: { color: gridColor },
ticks: {
color: textColor,
callback: (v) => `${v}${yUnit}`,
},
},
},
},
});
// CPU Chart
if (this.$refs.cpuChart) {
const cpuData = this.history.metrics.cpu || [];
const labels = cpuData.map((d) => dayjs(d.time).format("HH:mm"));
const values = cpuData.map((d) => d.value);
this.charts.cpu = new Chart(
this.$refs.cpuChart,
createChartConfig(
labels,
[
{
label: "CPU %",
data: values,
borderColor: "#0dcaf0",
backgroundColor: "rgba(13, 202, 240, 0.15)",
fill: true,
tension: 0.25,
pointRadius: values.length > 30 ? 0 : 2,
},
],
"%"
)
);
}
// Memory Chart (metrics tab)
if (this.$refs.memChart && this.history.hasMemoryAgent) {
const memData = this.history.metrics.mem || [];
const labels = memData.map((d) => dayjs(d.time).format("HH:mm"));
const values = memData.map((d) => d.value);
this.charts.mem = new Chart(
this.$refs.memChart,
createChartConfig(
labels,
[
{
label: "Memory %",
data: values,
borderColor: "#ffc107",
backgroundColor: "rgba(255, 193, 7, 0.15)",
fill: true,
tension: 0.25,
pointRadius: values.length > 30 ? 0 : 2,
},
],
"%"
)
);
}
// Memory Chart (memory dedicated tab)
if (this.$refs.memTabChart && this.history.hasMemoryAgent) {
const memData = this.history.metrics.mem || [];
const labels = memData.map((d) => dayjs(d.time).format("HH:mm"));
const values = memData.map((d) => d.value);
this.charts.memTab = new Chart(
this.$refs.memTabChart,
createChartConfig(
labels,
[
{
label: "Memory %",
data: values,
borderColor: "#198754",
backgroundColor: "rgba(25, 135, 84, 0.15)",
fill: true,
tension: 0.25,
pointRadius: 2,
},
],
"%"
)
);
}
// Network Chart
if (this.$refs.netChart) {
const netIn = this.history.metrics.netIn || [];
const netOut = this.history.metrics.netOut || [];
const labels = netIn.map((d) => dayjs(d.time).format("HH:mm"));
// Convert bytes to KB/s
const inValues = netIn.map((d) => Math.round((d.value / 1024) * 10) / 10);
const outValues = netOut.map((d) => Math.round((d.value / 1024) * 10) / 10);
this.charts.net = new Chart(
this.$refs.netChart,
createChartConfig(
labels,
[
{
label: "Network In (KB/s)",
data: inValues,
borderColor: "#0d6efd",
backgroundColor: "transparent",
tension: 0.2,
pointRadius: 0,
},
{
label: "Network Out (KB/s)",
data: outValues,
borderColor: "#6f42c1",
backgroundColor: "transparent",
tension: 0.2,
pointRadius: 0,
},
],
" KB/s"
)
);
}
// Disk Chart
if (this.$refs.diskChart) {
const readData = this.history.metrics.diskRead || [];
const writeData = this.history.metrics.diskWrite || [];
const labels = readData.map((d) => dayjs(d.time).format("HH:mm"));
const readValues = readData.map((d) => Math.round((d.value / 1024) * 10) / 10);
const writeValues = writeData.map((d) => Math.round((d.value / 1024) * 10) / 10);
this.charts.disk = new Chart(
this.$refs.diskChart,
createChartConfig(
labels,
[
{
label: "Read (KB/s)",
data: readValues,
borderColor: "#198754",
backgroundColor: "transparent",
tension: 0.2,
pointRadius: 0,
},
{
label: "Write (KB/s)",
data: writeValues,
borderColor: "#dc3545",
backgroundColor: "transparent",
tension: 0.2,
pointRadius: 0,
},
],
" KB/s"
)
);
}
},
formatLaunchTime(t) {
if (!t) return "N/A";
return dayjs(t).format("YYYY-MM-DD HH:mm:ss");
},
stateClass(st) {
if (st === "running") return "bg-success pulse";
if (st === "stopped") return "bg-secondary";
if (st === "pending" || st === "stopping") return "bg-warning";
return "bg-danger";
},
stateBadgeClass(st) {
if (st === "running") return "bg-success";
if (st === "stopped") return "bg-secondary";
if (st === "pending" || st === "stopping") return "bg-warning text-dark";
return "bg-danger";
},
cpuBadgeClass(val) {
if (val >= 80) return "bg-danger";
if (val >= 60) return "bg-warning text-dark";
return "bg-info text-dark";
},
},
};
</script>
<style scoped>
.chart-container {
position: relative;
height: 220px;
width: 100%;
}
.status-circle {
width: 12px;
height: 12px;
border-radius: 50%;
}
.status-circle.pulse {
box-shadow: 0 0 0 rgba(25, 135, 84, 0.4);
animation: pulse-green 2s infinite;
}
@keyframes pulse-green {
0% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.6);
}
70% {
box-shadow: 0 0 0 8px rgba(25, 135, 84, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0);
}
}
.font-monospace {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
</style>

View file

@ -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 };

View file

@ -39,6 +39,12 @@
{{ $t("Status Pages") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item me-2">
<router-link to="/ec2-resources" class="nav-link" :class="{ active: $route.path === '/ec2-resources' }">
<font-awesome-icon icon="cloud" />
{{ $t("EC2 Resources") }}
</router-link>
</li>
<li v-if="$root.loggedIn" class="nav-item me-2">
<router-link to="/dashboard" class="nav-link">
<font-awesome-icon icon="tachometer-alt" />
@ -72,6 +78,17 @@
<li><hr class="dropdown-divider" /></li>
<!-- Functions -->
<li>
<router-link
to="/ec2-resources"
class="dropdown-item"
:class="{ active: $route.path.includes('ec2-resources') }"
>
<font-awesome-icon icon="cloud" />
{{ $t("EC2 Resources") }}
</router-link>
</li>
<li>
<router-link
to="/maintenance"

View file

@ -47,6 +47,7 @@ export default {
domainInfoList: {},
notificationList: [],
dockerHostList: [],
awsAccountList: [],
remoteBrowserList: [],
statusPageListLoaded: false,
statusPageList: [],
@ -184,6 +185,10 @@ export default {
this.dockerHostList = data;
});
socket.on("awsAccountList", (data) => {
this.awsAccountList = data;
});
socket.on("remoteBrowserList", (data) => {
this.remoteBrowserList = data;
});

906
src/pages/EC2Dashboard.vue Normal file
View file

@ -0,0 +1,906 @@
<template>
<div class="container-fluid py-3 px-md-4">
<!-- Top Toolbar -->
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-4">
<div class="d-flex align-items-center gap-3">
<div class="cloud-icon-badge shadow-sm">
<font-awesome-icon icon="cloud" class="text-white fa-lg" />
</div>
<div>
<h3 class="mb-0 fw-bold d-flex align-items-center gap-2">
{{ $t("EC2 Resource Explorer") }}
<span v-if="selectedAccount" class="badge bg-primary-subtle text-primary border fs-6">
{{ selectedAccount.name }}
</span>
</h3>
<small class="text-muted">
{{ $t("Live performance metrics, memory viewer & fleet health monitoring") }}
</small>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<!-- Auto-refresh interval dropdown -->
<div class="dropdown">
<button
class="btn btn-outline-secondary btn-sm dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
>
<font-awesome-icon icon="clock" class="me-1" />
{{ autoRefreshSec ? `${autoRefreshSec}s` : $t('Auto-Refresh: Off') }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item" :class="{ active: autoRefreshSec === 15 }" @click="setAutoRefresh(15)">15s</button></li>
<li><button class="dropdown-item" :class="{ active: autoRefreshSec === 30 }" @click="setAutoRefresh(30)">30s</button></li>
<li><button class="dropdown-item" :class="{ active: autoRefreshSec === 60 }" @click="setAutoRefresh(60)">60s</button></li>
<li><hr class="dropdown-divider" /></li>
<li><button class="dropdown-item" :class="{ active: autoRefreshSec === 0 }" @click="setAutoRefresh(0)">Off</button></li>
</ul>
</div>
<!-- Manual refresh -->
<button
type="button"
class="btn btn-outline-primary btn-sm"
:disabled="loading || !selectedAccountId"
@click="refreshAll"
>
<font-awesome-icon icon="sync-alt" :spin="loading" class="me-1" />
{{ $t("Refresh") }}
</button>
<!-- Manage AWS Accounts -->
<button
type="button"
class="btn btn-primary btn-sm shadow-sm"
@click="openAccountDialog(null)"
>
<font-awesome-icon icon="plus" class="me-1" />
{{ $t("AWS Accounts") }}
</button>
</div>
</div>
<!-- Filter & Selector Bar -->
<div class="card shadow-sm border-0 mb-4 filter-bar">
<div class="card-body p-3">
<div class="row g-3 align-items-center">
<!-- Account Selector -->
<div class="col-12 col-md-3">
<label class="form-label small text-muted mb-1">{{ $t("AWS Account") }}</label>
<select
v-model="selectedAccountId"
class="form-select form-select-sm"
:disabled="accounts.length === 0"
@change="onAccountChange"
>
<option v-if="accounts.length === 0" :value="null">
{{ $t("No AWS Accounts Configured") }}
</option>
<option v-for="acc in accounts" :key="acc.id" :value="acc.id">
{{ acc.name }} ({{ acc.region }})
</option>
</select>
</div>
<!-- Region Selector -->
<div class="col-12 col-md-3">
<label class="form-label small text-muted mb-1">{{ $t("Region") }}</label>
<select
v-model="currentRegion"
class="form-select form-select-sm"
:disabled="!selectedAccountId || loading"
@change="fetchInstances"
>
<option v-for="r in regions" :key="r.code" :value="r.code">
{{ r.name }} ({{ r.code }})
</option>
</select>
</div>
<!-- Search Filter -->
<div class="col-12 col-md-3">
<label class="form-label small text-muted mb-1">{{ $t("Search Instances") }}</label>
<div class="input-group input-group-sm">
<span class="input-group-text bg-transparent border-end-0">
<font-awesome-icon icon="search" class="text-muted" />
</span>
<input
v-model="searchQuery"
type="text"
class="form-control border-start-0"
placeholder="Name, ID, or IP..."
/>
</div>
</div>
<!-- State Filter & View Mode -->
<div class="col-12 col-md-3 d-flex align-items-end justify-content-between gap-2">
<div>
<label class="form-label small text-muted mb-1">{{ $t("State") }}</label>
<div class="btn-group btn-group-sm d-flex">
<button
type="button"
class="btn"
:class="stateFilter === 'all' ? 'btn-primary' : 'btn-outline-secondary'"
@click="stateFilter = 'all'"
>
All ({{ instances.length }})
</button>
<button
type="button"
class="btn"
:class="stateFilter === 'running' ? 'btn-success' : 'btn-outline-secondary'"
@click="stateFilter = 'running'"
>
Running ({{ runningCount }})
</button>
<button
type="button"
class="btn"
:class="stateFilter === 'stopped' ? 'btn-secondary' : 'btn-outline-secondary'"
@click="stateFilter = 'stopped'"
>
Stopped ({{ stoppedCount }})
</button>
</div>
</div>
<!-- View mode toggle -->
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn"
:class="viewMode === 'grid' ? 'btn-dark' : 'btn-outline-secondary'"
@click="viewMode = 'grid'"
>
<font-awesome-icon icon="images" />
</button>
<button
type="button"
class="btn"
:class="viewMode === 'table' ? 'btn-dark' : 'btn-outline-secondary'"
@click="viewMode = 'table'"
>
<font-awesome-icon icon="list" />
</button>
</div>
</div>
</div>
</div>
</div>
<!-- No Accounts Configured State -->
<div v-if="accounts.length === 0" class="text-center py-5 my-4">
<div class="card empty-card mx-auto shadow-sm p-4 p-md-5" style="max-width: 550px;">
<div class="empty-icon-circle mx-auto mb-3">
<font-awesome-icon icon="cloud" class="fa-2x text-primary" />
</div>
<h4 class="fw-bold mb-2">{{ $t("Connect Your AWS Account") }}</h4>
<p class="text-muted mb-4">
{{ $t("Connect via AWS IAM Credentials or server IAM Role to inspect active EC2 instances, monitor CPU/Memory utilization, and stream live CloudWatch performance metrics.") }}
</p>
<div>
<button class="btn btn-primary px-4 py-2 shadow-sm" @click="openAccountDialog(null)">
<font-awesome-icon icon="plus" class="me-1" />
{{ $t("Add AWS Account") }}
</button>
</div>
</div>
</div>
<!-- Active View When Accounts Configured -->
<div v-else>
<!-- KPI Summary Cards -->
<div class="row g-3 mb-4">
<!-- Total / Running Instances -->
<div class="col-6 col-lg-3">
<div class="card kpi-card shadow-sm border-0 h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small fw-semibold text-uppercase">{{ $t("Fleet Instances") }}</div>
<div class="fs-3 fw-bold mt-1">
{{ runningCount }} <span class="fs-6 text-muted fw-normal">/ {{ instances.length }}</span>
</div>
</div>
<div class="kpi-icon-badge bg-primary-subtle text-primary">
<font-awesome-icon icon="server" />
</div>
</div>
<div class="mt-2 small text-muted">
<span class="text-success fw-semibold">{{ runningCount }} running</span> &bull;
<span class="text-secondary">{{ stoppedCount }} stopped</span>
</div>
</div>
</div>
</div>
<!-- Fleet Average CPU -->
<div class="col-6 col-lg-3">
<div class="card kpi-card shadow-sm border-0 h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small fw-semibold text-uppercase">{{ $t("Fleet Avg CPU") }}</div>
<div class="fs-3 fw-bold mt-1" :class="metricColorClass(fleetAvgCpu)">
{{ fleetAvgCpu !== null ? fleetAvgCpu + '%' : 'N/A' }}
</div>
</div>
<div class="kpi-icon-badge bg-info-subtle text-info">
<font-awesome-icon icon="microchip" />
</div>
</div>
<div class="mt-2 small text-muted">
Across {{ activeMetricInstancesCount }} active instances
</div>
</div>
</div>
</div>
<!-- Fleet Average Memory (CWAgent) -->
<div class="col-6 col-lg-3">
<div class="card kpi-card shadow-sm border-0 h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small fw-semibold text-uppercase">{{ $t("Fleet Avg Memory") }}</div>
<div class="fs-3 fw-bold mt-1" :class="metricColorClass(fleetAvgMem)">
{{ fleetAvgMem !== null ? fleetAvgMem + '%' : 'N/A' }}
</div>
</div>
<div class="kpi-icon-badge bg-warning-subtle text-warning">
<font-awesome-icon icon="memory" />
</div>
</div>
<div class="mt-2 small text-muted">
<span v-if="memoryReportingCount > 0" class="badge bg-success-subtle text-success">
{{ memoryReportingCount }} reporting
</span>
<span v-else class="text-muted">
CloudWatch Agent needed
</span>
</div>
</div>
</div>
</div>
<!-- Fleet Total Network Throughput -->
<div class="col-6 col-lg-3">
<div class="card kpi-card shadow-sm border-0 h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small fw-semibold text-uppercase">{{ $t("Total Network I/O") }}</div>
<div class="fs-4 fw-bold mt-1">
{{ formatBytesRate(fleetTotalNet) }}
</div>
</div>
<div class="kpi-icon-badge bg-success-subtle text-success">
<font-awesome-icon icon="network-wired" />
</div>
</div>
<div class="mt-2 small text-muted">
&darr; {{ formatBytesRate(fleetNetIn) }} &bull; &uarr; {{ formatBytesRate(fleetNetOut) }}
</div>
</div>
</div>
</div>
</div>
<!-- Loading overlay or spinner -->
<div v-if="loading && filteredInstances.length === 0" class="text-center py-5">
<div class="spinner-border text-primary mb-2" role="status"></div>
<div class="text-muted">{{ $t("Querying AWS EC2 & CloudWatch...") }}</div>
</div>
<!-- Empty Instances in Region -->
<div v-else-if="filteredInstances.length === 0" class="card shadow-sm border-0 text-center py-5">
<div class="card-body">
<font-awesome-icon icon="server" class="fa-3x text-muted mb-3" />
<h5 class="fw-bold">{{ $t("No EC2 instances found") }}</h5>
<p class="text-muted small mb-0">
{{ $t("No instances match your current region and filters.") }}
</p>
</div>
</div>
<!-- Grid View -->
<div v-else-if="viewMode === 'grid'" class="row g-3">
<div v-for="inst in filteredInstances" :key="inst.id" class="col-12 col-md-6 col-xl-4">
<div class="card instance-card shadow-sm border-0 h-100">
<div class="card-body p-3">
<!-- Instance Top Bar -->
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="d-flex align-items-center gap-2">
<div class="status-indicator" :class="stateClass(inst.state)"></div>
<div>
<h6 class="fw-bold mb-0 text-truncate" style="max-width: 220px;" :title="inst.name">
{{ inst.name }}
</h6>
<div class="d-flex align-items-center gap-1">
<span class="text-muted font-monospace small">{{ inst.id }}</span>
<button
class="btn btn-link btn-xs p-0 text-muted"
:title="$t('Copy ID')"
@click="copyText(inst.id)"
>
<font-awesome-icon icon="copy" />
</button>
</div>
</div>
</div>
<span class="badge" :class="stateBadgeClass(inst.state)">
{{ inst.state }}
</span>
</div>
<!-- Meta Details Pills -->
<div class="d-flex flex-wrap gap-1 mb-3">
<span class="badge bg-light text-dark border">
{{ inst.type }}
</span>
<span v-if="inst.az" class="badge bg-light text-muted border">
{{ inst.az }}
</span>
<span v-if="inst.publicIp" class="badge bg-light text-muted border font-monospace">
{{ inst.publicIp }}
</span>
<span v-else-if="inst.privateIp" class="badge bg-light text-muted border font-monospace">
{{ inst.privateIp }}
</span>
</div>
<!-- Live Resource Metrics -->
<div class="resource-meters bg-body-tertiary p-2 rounded mb-3">
<!-- CPU Utilization -->
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center small mb-1">
<span class="text-muted fw-semibold">
<font-awesome-icon icon="microchip" class="me-1 text-info" />
{{ $t("CPU Usage") }}
</span>
<span class="fw-bold" :class="metricColorClass(getMetric(inst.id, 'cpuUtilization'))">
{{ formatPercent(getMetric(inst.id, 'cpuUtilization')) }}
</span>
</div>
<div class="progress progress-thin">
<div
class="progress-bar"
:class="progressBarClass(getMetric(inst.id, 'cpuUtilization'))"
role="progressbar"
:style="{ width: `${Math.min(getMetric(inst.id, 'cpuUtilization') || 0, 100)}%` }"
></div>
</div>
</div>
<!-- Memory Utilization (CWAgent) -->
<div class="mb-2">
<div class="d-flex justify-content-between align-items-center small mb-1">
<span class="text-muted fw-semibold">
<font-awesome-icon icon="memory" class="me-1 text-warning" />
{{ $t("Memory Usage") }}
</span>
<span v-if="hasMemAgent(inst.id)" class="fw-bold text-success">
{{ formatPercent(getMetric(inst.id, 'memoryUtilization')) }}
</span>
<span v-else class="badge bg-secondary-subtle text-secondary small">
{{ $t("Agent Not Detected") }}
</span>
</div>
<div v-if="hasMemAgent(inst.id)" class="progress progress-thin">
<div
class="progress-bar bg-warning"
role="progressbar"
:style="{ width: `${Math.min(getMetric(inst.id, 'memoryUtilization') || 0, 100)}%` }"
></div>
</div>
<div v-else class="small text-muted fst-italic">
<a href="#" class="text-decoration-none small text-primary" @click.prevent="openDetailModal(inst)">
{{ $t("Enable Memory Metrics &rarr;") }}
</a>
</div>
</div>
<!-- Network I/O Rate -->
<div class="d-flex justify-content-between align-items-center small pt-1 border-top">
<span class="text-muted">
<font-awesome-icon icon="network-wired" class="me-1 text-primary" />
{{ $t("Network Rate") }}
</span>
<span class="font-monospace small">
&darr; {{ formatBytesRate(getMetric(inst.id, 'networkInBps')) }} &bull;
&uarr; {{ formatBytesRate(getMetric(inst.id, 'networkOutBps')) }}
</span>
</div>
</div>
<!-- Action Footer -->
<div class="d-flex justify-content-between align-items-center">
<span class="small" :class="getMetric(inst.id, 'statusCheckFailed') === 0 ? 'text-success' : 'text-danger'">
<font-awesome-icon
:icon="getMetric(inst.id, 'statusCheckFailed') === 0 ? 'check-circle' : 'exclamation-triangle'"
class="me-1"
/>
{{ getMetric(inst.id, 'statusCheckFailed') === 0 ? 'Checks Passed' : 'Check Failed' }}
</span>
<button class="btn btn-outline-primary btn-sm" @click="openDetailModal(inst)">
<font-awesome-icon icon="chart-line" class="me-1" />
{{ $t("Deep-Dive Metrics") }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Table View -->
<div v-else class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{{ $t("Instance") }}</th>
<th>{{ $t("State") }}</th>
<th>{{ $t("Type") }}</th>
<th>{{ $t("IP Address") }}</th>
<th>{{ $t("CPU Usage") }}</th>
<th>{{ $t("Memory Usage") }}</th>
<th>{{ $t("Network I/O") }}</th>
<th>{{ $t("Status") }}</th>
<th class="text-end">{{ $t("Actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="inst in filteredInstances" :key="inst.id">
<td>
<div class="fw-bold">{{ inst.name }}</div>
<small class="text-muted font-monospace">{{ inst.id }}</small>
</td>
<td>
<span class="badge" :class="stateBadgeClass(inst.state)">
{{ inst.state }}
</span>
</td>
<td>{{ inst.type }}</td>
<td class="font-monospace small">
{{ inst.publicIp || inst.privateIp || 'N/A' }}
</td>
<td style="width: 140px;">
<div class="d-flex align-items-center gap-2">
<div class="progress progress-thin flex-grow-1">
<div
class="progress-bar"
:class="progressBarClass(getMetric(inst.id, 'cpuUtilization'))"
:style="{ width: `${Math.min(getMetric(inst.id, 'cpuUtilization') || 0, 100)}%` }"
></div>
</div>
<span class="small fw-semibold">
{{ formatPercent(getMetric(inst.id, 'cpuUtilization')) }}
</span>
</div>
</td>
<td style="width: 140px;">
<div v-if="hasMemAgent(inst.id)" class="d-flex align-items-center gap-2">
<div class="progress progress-thin flex-grow-1">
<div
class="progress-bar bg-warning"
:style="{ width: `${Math.min(getMetric(inst.id, 'memoryUtilization') || 0, 100)}%` }"
></div>
</div>
<span class="small fw-semibold text-success">
{{ formatPercent(getMetric(inst.id, 'memoryUtilization')) }}
</span>
</div>
<span v-else class="badge bg-secondary-subtle text-secondary small">
{{ $t("Agent Needed") }}
</span>
</td>
<td class="font-monospace small">
&darr; {{ formatBytesRate(getMetric(inst.id, 'networkInBps')) }} &bull;
&uarr; {{ formatBytesRate(getMetric(inst.id, 'networkOutBps')) }}
</td>
<td>
<span :class="getMetric(inst.id, 'statusCheckFailed') === 0 ? 'text-success' : 'text-danger'">
<font-awesome-icon
:icon="getMetric(inst.id, 'statusCheckFailed') === 0 ? 'check-circle' : 'exclamation-triangle'"
/>
</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" @click="openDetailModal(inst)">
<font-awesome-icon icon="chart-line" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Modals -->
<AWSAccountDialog ref="accountDialog" @saved="onAccountSaved" @deleted="onAccountDeleted" />
<EC2InstanceDetailModal
ref="detailModal"
:account-id="selectedAccountId"
:region="currentRegion"
/>
</div>
</template>
<script>
import AWSAccountDialog from "../components/AWSAccountDialog.vue";
import EC2InstanceDetailModal from "../components/EC2InstanceDetailModal.vue";
import { useToast } from "vue-toastification";
const toast = useToast();
export default {
components: {
AWSAccountDialog,
EC2InstanceDetailModal,
},
data() {
return {
selectedAccountId: null,
currentRegion: "us-east-1",
searchQuery: "",
stateFilter: "all",
viewMode: "grid",
loading: false,
instances: [],
liveMetrics: {},
autoRefreshSec: 30,
refreshTimer: null,
regions: [
{ code: "us-east-1", name: "US East (N. Virginia)" },
{ code: "us-east-2", name: "US East (Ohio)" },
{ code: "us-west-1", name: "US West (N. California)" },
{ code: "us-west-2", name: "US West (Oregon)" },
{ code: "ap-south-1", name: "Asia Pacific (Mumbai)" },
{ code: "ap-southeast-1", name: "Asia Pacific (Singapore)" },
{ code: "ap-southeast-2", name: "Asia Pacific (Sydney)" },
{ code: "ap-northeast-1", name: "Asia Pacific (Tokyo)" },
{ code: "eu-central-1", name: "Europe (Frankfurt)" },
{ code: "eu-west-1", name: "Europe (Ireland)" },
{ code: "eu-west-2", name: "Europe (London)" },
{ code: "ca-central-1", name: "Canada (Central)" },
{ code: "sa-east-1", name: "South America (São Paulo)" },
],
};
},
computed: {
accounts() {
return this.$root.awsAccountList || [];
},
selectedAccount() {
return this.accounts.find((a) => a.id === this.selectedAccountId) || null;
},
runningCount() {
return this.instances.filter((i) => i.state === "running").length;
},
stoppedCount() {
return this.instances.filter((i) => i.state === "stopped").length;
},
filteredInstances() {
return this.instances.filter((inst) => {
if (this.stateFilter !== "all" && inst.state !== this.stateFilter) {
return false;
}
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
const name = (inst.name || "").toLowerCase();
const id = (inst.id || "").toLowerCase();
const pub = (inst.publicIp || "").toLowerCase();
const priv = (inst.privateIp || "").toLowerCase();
return name.includes(q) || id.includes(q) || pub.includes(q) || priv.includes(q);
}
return true;
});
},
activeMetricInstancesCount() {
return Object.values(this.liveMetrics).filter((m) => m && m.cpuUtilization !== null).length;
},
fleetAvgCpu() {
const vals = Object.values(this.liveMetrics)
.map((m) => m?.cpuUtilization)
.filter((v) => v !== null && v !== undefined);
if (vals.length === 0) return null;
const sum = vals.reduce((a, b) => a + b, 0);
return Math.round((sum / vals.length) * 10) / 10;
},
fleetAvgMem() {
const vals = Object.values(this.liveMetrics)
.map((m) => m?.memoryUtilization)
.filter((v) => v !== null && v !== undefined);
if (vals.length === 0) return null;
const sum = vals.reduce((a, b) => a + b, 0);
return Math.round((sum / vals.length) * 10) / 10;
},
memoryReportingCount() {
return Object.values(this.liveMetrics).filter((m) => m && m.hasMemoryAgent).length;
},
fleetNetIn() {
return Object.values(this.liveMetrics)
.map((m) => m?.networkInBps || 0)
.reduce((a, b) => a + b, 0);
},
fleetNetOut() {
return Object.values(this.liveMetrics)
.map((m) => m?.networkOutBps || 0)
.reduce((a, b) => a + b, 0);
},
fleetTotalNet() {
return this.fleetNetIn + this.fleetNetOut;
},
},
watch: {
accounts: {
immediate: true,
handler(newAccounts) {
if (newAccounts && newAccounts.length > 0 && !this.selectedAccountId) {
this.selectedAccountId = newAccounts[0].id;
this.currentRegion = newAccounts[0].region || "us-east-1";
this.fetchInstances();
}
},
},
},
mounted() {
this.startAutoRefresh();
},
beforeUnmount() {
this.stopAutoRefresh();
},
methods: {
onAccountChange() {
if (this.selectedAccount) {
this.currentRegion = this.selectedAccount.region || "us-east-1";
}
this.fetchInstances();
},
fetchInstances() {
if (!this.selectedAccountId) return;
this.loading = true;
this.$root.getSocket().emit(
"getEc2Instances",
this.selectedAccountId,
this.currentRegion,
(res) => {
this.loading = false;
if (res.ok) {
this.instances = res.instances || [];
this.fetchLiveMetrics();
} else {
toast.error(res.msg || this.$t("Failed to fetch EC2 instances"));
}
}
);
},
fetchLiveMetrics() {
if (!this.selectedAccountId || this.instances.length === 0) return;
const runningIds = this.instances
.filter((i) => i.state === "running")
.map((i) => i.id);
if (runningIds.length === 0) return;
this.$root.getSocket().emit(
"getEc2LiveMetrics",
this.selectedAccountId,
this.currentRegion,
runningIds,
(res) => {
if (res.ok) {
this.liveMetrics = { ...this.liveMetrics, ...(res.metrics || {}) };
}
}
);
},
refreshAll() {
this.fetchInstances();
},
setAutoRefresh(sec) {
this.autoRefreshSec = sec;
this.startAutoRefresh();
},
startAutoRefresh() {
this.stopAutoRefresh();
if (this.autoRefreshSec > 0) {
this.refreshTimer = setInterval(() => {
if (this.selectedAccountId && !this.loading) {
this.fetchLiveMetrics();
}
}, this.autoRefreshSec * 1000);
}
},
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
},
openAccountDialog(accountID) {
this.$refs.accountDialog.show(accountID);
},
onAccountSaved(account) {
this.selectedAccountId = account.id;
this.currentRegion = account.region || "us-east-1";
this.fetchInstances();
},
onAccountDeleted(accountID) {
if (this.selectedAccountId === accountID) {
this.selectedAccountId = this.accounts.length > 0 ? this.accounts[0].id : null;
this.instances = [];
this.liveMetrics = {};
if (this.selectedAccountId) {
this.fetchInstances();
}
}
},
openDetailModal(inst) {
this.$refs.detailModal.show(inst);
},
copyText(txt) {
navigator.clipboard.writeText(txt);
toast.info(this.$t("Copied to clipboard"));
},
getMetric(instId, key) {
return this.liveMetrics[instId]?.[key] ?? null;
},
hasMemAgent(instId) {
return Boolean(this.liveMetrics[instId]?.hasMemoryAgent);
},
formatPercent(val) {
if (val === null || val === undefined) return "N/A";
return `${val}%`;
},
formatBytesRate(bps) {
if (bps === null || bps === undefined || isNaN(bps)) return "0 B/s";
if (bps >= 1024 * 1024) {
return `${(bps / (1024 * 1024)).toFixed(1)} MB/s`;
}
if (bps >= 1024) {
return `${(bps / 1024).toFixed(1)} KB/s`;
}
return `${Math.round(bps)} B/s`;
},
stateClass(st) {
if (st === "running") return "bg-success pulse";
if (st === "stopped") return "bg-secondary";
if (st === "pending" || st === "stopping") return "bg-warning";
return "bg-danger";
},
stateBadgeClass(st) {
if (st === "running") return "bg-success";
if (st === "stopped") return "bg-secondary";
if (st === "pending" || st === "stopping") return "bg-warning text-dark";
return "bg-danger";
},
metricColorClass(val) {
if (val === null || val === undefined) return "text-muted";
if (val >= 80) return "text-danger";
if (val >= 60) return "text-warning";
return "text-success";
},
progressBarClass(val) {
if (val >= 80) return "bg-danger";
if (val >= 60) return "bg-warning";
return "bg-info";
},
},
};
</script>
<style scoped>
.cloud-icon-badge {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, #ff9900 0%, #ff5500 100%);
display: flex;
align-items: center;
justify-content: center;
}
.kpi-card {
border-radius: 14px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.kpi-card:hover {
transform: translateY(-2px);
}
.kpi-icon-badge {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.instance-card {
border-radius: 14px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.instance-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-indicator.pulse {
animation: status-pulse 2s infinite;
}
@keyframes status-pulse {
0% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.7);
}
70% {
box-shadow: 0 0 0 6px rgba(25, 135, 84, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0);
}
}
.progress-thin {
height: 6px;
border-radius: 4px;
}
.empty-card {
border-radius: 20px;
}
.empty-icon-circle {
width: 72px;
height: 72px;
border-radius: 50%;
background-color: rgba(13, 110, 253, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
.btn-xs {
font-size: 0.75rem;
}
.font-monospace {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
</style>

View file

@ -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,
},
],
},
],