feat: add AWS SDK dependencies and database migration for aws_account table
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 14s
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 14s
This commit is contained in:
parent
3cae940252
commit
8760d39ff0
14 changed files with 3174 additions and 1 deletions
23
db/knex_migrations/2026-10-23-0000-add-aws-account.js
Normal file
23
db/knex_migrations/2026-10-23-0000-add-aws-account.js
Normal 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
422
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
546
server/aws.js
Normal 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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
169
server/socket-handlers/aws-socket-handler.js
Normal file
169
server/socket-handlers/aws-socket-handler.js
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
330
src/components/AWSAccountDialog.vue
Normal file
330
src/components/AWSAccountDialog.vue
Normal 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>
|
||||
699
src/components/EC2InstanceDetailModal.vue
Normal file
699
src/components/EC2InstanceDetailModal.vue
Normal 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 }} • {{ instance.az || region }} •
|
||||
<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 << '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>
|
||||
24
src/icon.js
24
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 };
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
906
src/pages/EC2Dashboard.vue
Normal 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> •
|
||||
<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">
|
||||
↓ {{ formatBytesRate(fleetNetIn) }} • ↑ {{ 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 →") }}
|
||||
</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">
|
||||
↓ {{ formatBytesRate(getMetric(inst.id, 'networkInBps')) }} •
|
||||
↑ {{ 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">
|
||||
↓ {{ formatBytesRate(getMetric(inst.id, 'networkInBps')) }} •
|
||||
↑ {{ 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>
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue