commit e69424dab25944a39d1e61be4b48656a98d2766a Author: CN-JS-HuiBai Date: Sat Apr 4 15:13:32 2026 +0800 First Commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..610fc77 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# MySQL Configuration +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your_password +MYSQL_DATABASE=display_wall + +# Server Configuration +PORT=3000 + +# Data refresh interval (ms) +REFRESH_INTERVAL=5000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e8157a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e949518 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# 数据可视化展示大屏 + +多源 Prometheus 服务器监控展示大屏,支持对接多个 Prometheus 实例,实时展示所有服务器的 CPU、内存、磁盘、网络等关键指标。 + +## 功能特性 + +- 🔌 **多数据源管理** - MySQL 存储配置,支持对接多个 Prometheus 实例 +- 📊 **NodeExporter 数据查询** - 自动聚合所有 Prometheus 中的 NodeExporter 数据 +- 🌐 **网络流量统计** - 24 小时网络流量趋势图,总流量统计 +- ⚡ **实时带宽监控** - 所有服务器网络带宽求和,实时显示 +- 💻 **资源使用概览** - CPU、内存、磁盘的总使用率和详细统计 +- 🖥️ **服务器列表** - 所有服务器的详细指标一览表 + +## 快速开始 + +### 1. 环境要求 + +- Node.js >= 16 +- MySQL >= 5.7 + +### 2. 配置 + +复制环境变量文件并修改: + +```bash +cp .env.example .env +``` + +编辑 `.env` 文件,配置 MySQL 连接信息: + +```env +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your_password +MYSQL_DATABASE=display_wall +PORT=3000 +``` + +### 3. 初始化数据库 + +```bash +npm run init-db +``` + +### 4. 安装依赖并启动 + +```bash +npm install +npm run dev +``` + +访问 `http://localhost:3000` 即可看到展示大屏。 + +### 5. 配置 Prometheus 数据源 + +点击右上角的 ⚙️ 按钮,添加你的 Prometheus 地址(如 `http://prometheus.example.com:9090`)。 + +## 技术栈 + +- **后端**: Node.js + Express +- **数据库**: MySQL (mysql2) +- **数据源**: Prometheus HTTP API +- **前端**: 原生 HTML/CSS/JavaScript +- **图表**: 自定义 Canvas 渲染 + +## API 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/sources` | 获取所有数据源 | +| POST | `/api/sources` | 添加数据源 | +| PUT | `/api/sources/:id` | 更新数据源 | +| DELETE | `/api/sources/:id` | 删除数据源 | +| POST | `/api/sources/test` | 测试数据源连接 | +| GET | `/api/metrics/overview` | 获取聚合指标概览 | +| GET | `/api/metrics/network-history` | 获取24h网络流量历史 | +| GET | `/api/metrics/cpu-history` | 获取CPU使用率历史 | diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..874a1d7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1118 @@ +{ + "name": "data-visualization-display-wall", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "data-visualization-display-wall", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.0", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "express": "^4.21.0", + "mysql2": "^3.11.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz", + "integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "peer": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bedab7d --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "data-visualization-display-wall", + "version": "1.0.0", + "description": "Data Visualization Display Wall - Multi-Prometheus Monitoring Dashboard", + "main": "server/index.js", + "scripts": { + "dev": "node server/index.js", + "start": "node server/index.js", + "init-db": "node server/init-db.js" + }, + "dependencies": { + "axios": "^1.7.0", + "cors": "^2.8.5", + "dotenv": "^16.4.0", + "express": "^4.21.0", + "mysql2": "^3.11.0" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..19e32de --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,1050 @@ +/* ============================================================ + DATA VISUALIZATION DISPLAY WALL - MAIN STYLESHEET + Premium dark-theme monitoring dashboard + ============================================================ */ + +/* ---- CSS Variables / Design Tokens ---- */ +:root { + /* Colors */ + --bg-primary: #0a0e1a; + --bg-secondary: #0f1629; + --bg-card: rgba(15, 22, 50, 0.65); + --bg-card-hover: rgba(20, 30, 65, 0.75); + --bg-input: rgba(15, 20, 45, 0.8); + + --border-color: rgba(99, 102, 241, 0.12); + --border-hover: rgba(99, 102, 241, 0.3); + + --text-primary: #e8ecf4; + --text-secondary: #8892b0; + --text-muted: #5a6380; + + /* Accent Colors */ + --accent-indigo: #6366f1; + --accent-cyan: #06b6d4; + --accent-emerald: #10b981; + --accent-amber: #f59e0b; + --accent-rose: #f43f5e; + --accent-purple: #a855f7; + --accent-blue: #3b82f6; + + /* Gradients */ + --gradient-primary: linear-gradient(135deg, #6366f1, #06b6d4); + --gradient-cpu: linear-gradient(135deg, #6366f1, #818cf8); + --gradient-mem: linear-gradient(135deg, #06b6d4, #22d3ee); + --gradient-disk: linear-gradient(135deg, #f59e0b, #fbbf24); + --gradient-bandwidth: linear-gradient(135deg, #10b981, #34d399); + --gradient-servers: linear-gradient(135deg, #a855f7, #c084fc); + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5); + --shadow-glow-indigo: 0 0 30px rgba(99, 102, 241, 0.15); + --shadow-glow-cyan: 0 0 30px rgba(6, 182, 212, 0.15); + + /* Sizing */ + --header-height: 64px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +} + +/* ---- Reset & Base ---- */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + position: relative; +} + +/* ---- Animated Background ---- */ +.bg-grid { + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px); + background-size: 60px 60px; + z-index: 0; + pointer-events: none; +} + +.bg-glow { + position: fixed; + border-radius: 50%; + filter: blur(120px); + opacity: 0.4; + z-index: 0; + pointer-events: none; + animation: glowFloat 20s ease-in-out infinite; +} + +.bg-glow-1 { + width: 600px; + height: 600px; + background: radial-gradient(circle, rgba(99, 102, 241, 0.15), transparent 70%); + top: -200px; + left: -100px; + animation-delay: 0s; +} + +.bg-glow-2 { + width: 500px; + height: 500px; + background: radial-gradient(circle, rgba(6, 182, 212, 0.12), transparent 70%); + bottom: -150px; + right: -100px; + animation-delay: -7s; +} + +.bg-glow-3 { + width: 400px; + height: 400px; + background: radial-gradient(circle, rgba(168, 85, 247, 0.1), transparent 70%); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + animation-delay: -14s; +} + +@keyframes glowFloat { + 0%, 100% { transform: translate(0, 0) scale(1); } + 25% { transform: translate(30px, -30px) scale(1.05); } + 50% { transform: translate(-20px, 20px) scale(0.95); } + 75% { transform: translate(25px, 15px) scale(1.02); } +} + +/* ---- App Container ---- */ +#app { + position: relative; + z-index: 1; + min-height: 100vh; +} + +/* ---- Header ---- */ +.header { + position: sticky; + top: 0; + z-index: 100; + height: var(--header-height); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + background: rgba(10, 14, 26, 0.85); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border-bottom: 1px solid var(--border-color); +} + +.header-left { + display: flex; + align-items: center; + gap: 24px; +} + +.logo { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + width: 32px; + height: 32px; +} + +.logo-text { + font-size: 1.15rem; + font-weight: 700; + background: var(--gradient-primary); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: -0.02em; +} + +.header-meta { + display: flex; + align-items: center; + gap: 16px; + font-size: 0.82rem; + color: var(--text-secondary); +} + +.server-count, .source-count { + display: flex; + align-items: center; + gap: 6px; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-emerald); +} + +.dot-pulse { + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } + 50% { opacity: 0.7; box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.clock { + font-family: var(--font-mono); + font-size: 0.9rem; + font-weight: 500; + color: var(--text-secondary); + letter-spacing: 0.05em; +} + +.btn-settings { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-card); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.25s ease; +} + +.btn-settings svg { + width: 18px; + height: 18px; +} + +.btn-settings:hover { + border-color: var(--border-hover); + color: var(--accent-indigo); + background: rgba(99, 102, 241, 0.08); + transform: rotate(30deg); +} + +/* ---- Dashboard Layout ---- */ +.dashboard { + padding: 24px 28px 40px; + max-width: 1600px; + margin: 0 auto; +} + +/* ---- Stat Cards ---- */ +.stat-cards { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + position: relative; + display: flex; + align-items: center; + gap: 16px; + padding: 20px 22px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + backdrop-filter: blur(12px); + transition: all 0.3s ease; + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + opacity: 0.8; +} + +.stat-card-servers::before { background: var(--gradient-servers); } +.stat-card-cpu::before { background: var(--gradient-cpu); } +.stat-card-mem::before { background: var(--gradient-mem); } +.stat-card-disk::before { background: var(--gradient-disk); } +.stat-card-bandwidth::before { background: var(--gradient-bandwidth); } + +.stat-card:hover { + border-color: var(--border-hover); + background: var(--bg-card-hover); + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-card-icon { + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + flex-shrink: 0; +} + +.stat-card-servers .stat-card-icon { + background: rgba(168, 85, 247, 0.1); + color: var(--accent-purple); +} +.stat-card-cpu .stat-card-icon { + background: rgba(99, 102, 241, 0.1); + color: var(--accent-indigo); +} +.stat-card-mem .stat-card-icon { + background: rgba(6, 182, 212, 0.1); + color: var(--accent-cyan); +} +.stat-card-disk .stat-card-icon { + background: rgba(245, 158, 11, 0.1); + color: var(--accent-amber); +} +.stat-card-bandwidth .stat-card-icon { + background: rgba(16, 185, 129, 0.1); + color: var(--accent-emerald); +} + +.stat-card-icon svg { + width: 24px; + height: 24px; +} + +.stat-card-content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.stat-card-label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat-card-value { + font-size: 1.5rem; + font-weight: 800; + font-family: var(--font-mono); + letter-spacing: -0.02em; + line-height: 1.2; +} + +.stat-card-servers .stat-card-value { color: var(--accent-purple); } +.stat-card-cpu .stat-card-value { color: var(--accent-indigo); } +.stat-card-mem .stat-card-value { color: var(--accent-cyan); } +.stat-card-disk .stat-card-value { color: var(--accent-amber); } +.stat-card-bandwidth .stat-card-value { color: var(--accent-emerald); } + +.stat-card-sub { + font-size: 0.72rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +/* ---- Charts Section ---- */ +.charts-section { + display: grid; + grid-template-columns: 1fr 360px; + gap: 16px; + margin-bottom: 24px; +} + +.chart-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + backdrop-filter: blur(12px); + overflow: hidden; + transition: all 0.3s ease; +} + +.chart-card:hover { + border-color: var(--border-hover); +} + +.chart-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px 0; +} + +.chart-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); +} + +.chart-title-icon { + width: 18px; + height: 18px; + color: var(--accent-indigo); +} + +.chart-legend { + display: flex; + gap: 16px; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.legend-item { + display: flex; + align-items: center; + gap: 6px; +} + +.legend-dot { + width: 10px; + height: 3px; + border-radius: 2px; +} + +.legend-rx { background: var(--accent-cyan); } +.legend-tx { background: var(--accent-indigo); } + +.chart-body { + padding: 12px 22px; + height: 220px; +} + +.chart-body canvas { + width: 100% !important; + height: 100% !important; +} + +.chart-footer { + display: flex; + justify-content: center; + gap: 32px; + padding: 14px 22px; + border-top: 1px solid var(--border-color); + background: rgba(0, 0, 0, 0.15); +} + +.traffic-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.traffic-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.traffic-value { + font-family: var(--font-mono); + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary); +} + +.traffic-stat-total .traffic-value { + color: var(--accent-cyan); +} + +/* ---- Gauges ---- */ +.gauges-container { + display: flex; + justify-content: space-around; + align-items: center; + padding: 24px 16px 32px; +} + +.gauge-wrapper { + display: flex; + flex-direction: column; + align-items: center; +} + +.gauge { + position: relative; + width: 120px; + height: 120px; +} + +.gauge svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.gauge-bg { + fill: none; + stroke: rgba(255, 255, 255, 0.04); + stroke-width: 8; +} + +.gauge-fill { + fill: none; + stroke-width: 8; + stroke-linecap: round; + stroke-dasharray: 326.7; + stroke-dashoffset: 326.7; + transition: stroke-dashoffset 1s cubic-bezier(0.4, 0, 0.2, 1); +} + +.gauge-fill-cpu { stroke: url(#gaugeCpuGrad); } +.gauge-fill-ram { stroke: url(#gaugeRamGrad); } +.gauge-fill-disk { stroke: url(#gaugeDiskGrad); } + +.gauge-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.gauge-value { + font-family: var(--font-mono); + font-size: 1.35rem; + font-weight: 700; + color: var(--text-primary); +} + +.gauge-label { + font-size: 0.7rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 2px; +} + +/* ---- Server Table ---- */ +.server-table-wrap { + overflow-x: auto; + padding: 0 0 8px; +} + +.server-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.server-table thead { + position: sticky; + top: 0; +} + +.server-table th { + padding: 12px 16px; + text-align: left; + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + border-bottom: 1px solid var(--border-color); + background: rgba(0, 0, 0, 0.2); +} + +.server-table td { + padding: 12px 16px; + border-bottom: 1px solid rgba(99, 102, 241, 0.05); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 0.78rem; +} + +.server-table tbody tr { + transition: background 0.2s ease; +} + +.server-table tbody tr:hover { + background: rgba(99, 102, 241, 0.04); +} + +.server-table .empty-row td { + text-align: center; + padding: 40px; + color: var(--text-muted); + font-family: var(--font-sans); + font-size: 0.85rem; +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-dot-online { + background: var(--accent-emerald); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); +} + +.status-dot-offline { + background: var(--accent-rose); + box-shadow: 0 0 8px rgba(244, 63, 94, 0.4); +} + +/* Usage bars in table */ +.usage-bar { + display: flex; + align-items: center; + gap: 8px; +} + +.usage-bar-track { + width: 60px; + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.usage-bar-fill { + height: 100%; + border-radius: 2px; + transition: width 0.6s ease; +} + +.usage-bar-fill-cpu { background: var(--accent-indigo); } +.usage-bar-fill-mem { background: var(--accent-cyan); } +.usage-bar-fill-disk { background: var(--accent-amber); } + +/* ---- Modal ---- */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + width: 90%; + max-width: 720px; + max-height: 80vh; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + overflow: hidden; + transform: translateY(20px) scale(0.97); + transition: transform 0.3s ease; +} + +.modal-overlay.active .modal { + transform: translateY(0) scale(1); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--border-color); +} + +.modal-header h2 { + font-size: 1.05rem; + font-weight: 700; + background: var(--gradient-primary); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--text-muted); + font-size: 1.4rem; + cursor: pointer; + border-radius: var(--radius-sm); + transition: all 0.2s; +} + +.modal-close:hover { + background: rgba(244, 63, 94, 0.1); + color: var(--accent-rose); +} + +.modal-body { + padding: 24px; + overflow-y: auto; + max-height: calc(80vh - 80px); +} + +/* ---- Add Source Form ---- */ +.add-source-form h3, +.source-list h3 { + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 14px; +} + +.form-row { + display: flex; + gap: 12px; + margin-bottom: 12px; +} + +.form-group { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group-wide { + flex: 2; +} + +.form-group label { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); +} + +.form-group input { + padding: 10px 14px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 0.85rem; + outline: none; + transition: all 0.2s; +} + +.form-group input:focus { + border-color: var(--accent-indigo); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.form-group input::placeholder { + color: var(--text-muted); +} + +.form-actions { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.btn { + padding: 10px 18px; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font-sans); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn-test { + background: rgba(6, 182, 212, 0.1); + color: var(--accent-cyan); + border: 1px solid rgba(6, 182, 212, 0.2); +} + +.btn-test:hover { + background: rgba(6, 182, 212, 0.2); +} + +.btn-add { + background: var(--gradient-primary); + color: white; +} + +.btn-add:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.btn-delete { + padding: 6px 12px; + background: rgba(244, 63, 94, 0.1); + color: var(--accent-rose); + border: 1px solid rgba(244, 63, 94, 0.2); +} + +.btn-delete:hover { + background: rgba(244, 63, 94, 0.2); +} + +.form-message { + margin-top: 8px; + padding: 10px 14px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + display: none; +} + +.form-message.success { + display: block; + background: rgba(16, 185, 129, 0.1); + border: 1px solid rgba(16, 185, 129, 0.2); + color: var(--accent-emerald); +} + +.form-message.error { + display: block; + background: rgba(244, 63, 94, 0.1); + border: 1px solid rgba(244, 63, 94, 0.2); + color: var(--accent-rose); +} + +/* ---- Source List ---- */ +.source-list { + margin-top: 24px; +} + +.source-items { + display: flex; + flex-direction: column; + gap: 8px; +} + +.source-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + transition: all 0.2s; +} + +.source-item:hover { + border-color: var(--border-hover); +} + +.source-item-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.source-item-name { + font-weight: 600; + font-size: 0.88rem; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; +} + +.source-item-url { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-muted); +} + +.source-item-desc { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.source-status { + font-size: 0.7rem; + font-weight: 600; + padding: 3px 8px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.source-status-online { + background: rgba(16, 185, 129, 0.1); + color: var(--accent-emerald); +} + +.source-status-offline { + background: rgba(244, 63, 94, 0.1); + color: var(--accent-rose); +} + +.source-empty { + text-align: center; + padding: 32px; + color: var(--text-muted); + font-size: 0.85rem; +} + +.source-item-actions { + display: flex; + gap: 8px; + align-items: center; +} + +/* ---- Animations ---- */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.stat-card { + animation: fadeInUp 0.5s ease forwards; +} + +.stat-card:nth-child(1) { animation-delay: 0.05s; } +.stat-card:nth-child(2) { animation-delay: 0.1s; } +.stat-card:nth-child(3) { animation-delay: 0.15s; } +.stat-card:nth-child(4) { animation-delay: 0.2s; } +.stat-card:nth-child(5) { animation-delay: 0.25s; } + +.chart-card { + animation: fadeInUp 0.5s ease 0.3s forwards; + opacity: 0; +} + +.server-list-section .chart-card { + animation-delay: 0.4s; +} + +/* Value update animation */ +@keyframes valueFlash { + 0% { opacity: 1; } + 50% { opacity: 0.6; } + 100% { opacity: 1; } +} + +.value-update { + animation: valueFlash 0.3s ease; +} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(99, 102, 241, 0.2); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(99, 102, 241, 0.3); +} + +/* ---- Responsive ---- */ +@media (max-width: 1280px) { + .stat-cards { + grid-template-columns: repeat(3, 1fr); + } + .charts-section { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .header { + padding: 0 16px; + } + .header-meta { + display: none; + } + .dashboard { + padding: 16px; + } + .stat-cards { + grid-template-columns: repeat(2, 1fr); + } + .stat-card { + padding: 14px 16px; + } + .stat-card-value { + font-size: 1.2rem; + } + .chart-footer { + flex-wrap: wrap; + gap: 16px; + } + .form-row { + flex-direction: column; + } + .form-actions { + flex-direction: row; + } +} + +@media (max-width: 480px) { + .stat-cards { + grid-template-columns: 1fr; + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..75dbf76 --- /dev/null +++ b/public/index.html @@ -0,0 +1,310 @@ + + + + + + + 数据可视化展示大屏 + + + + + + + +
+
+
+
+ + +
+ + + + +
+ +
+
+
+ + + + + + +
+
+ 服务器总数 + 0 +
+
+
+
+ + + + + + + + +
+
+ CPU 使用率 + 0% + 0 / 0 核心 +
+
+
+
+ + + + +
+
+ 内存使用率 + 0% + 0 / 0 GB +
+
+
+
+ + + + + +
+
+ 磁盘使用率 + 0% + 0 / 0 GB +
+
+
+
+ + + +
+
+ 实时总带宽 + 0 B/s + ↓ 0 ↑ 0 +
+
+
+ + +
+ +
+
+

+ + + + 网络流量趋势 (24h) +

+
+ 接收 (RX) + 发送 (TX) +
+
+
+ +
+ +
+ + +
+
+

+ + + + 资源使用概览 +

+
+
+
+
+ + + + +
+ 0% + CPU +
+
+
+
+
+ + + + +
+ 0% + RAM +
+
+
+
+
+ + + + +
+ 0% + DISK +
+
+
+
+
+
+ + +
+
+
+

+ + + + + + + 服务器详情 +

+
+
+ + + + + + + + + + + + + + + + + + +
状态服务器数据源CPU内存磁盘网络 ↓网络 ↑
暂无数据 - 请先配置 Prometheus 数据源
+
+
+
+
+ + + +
+ + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..70928d4 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,433 @@ +/** + * Main Application - Data Visualization Display Wall + */ +(function () { + 'use strict'; + + // ---- Config ---- + const REFRESH_INTERVAL = 5000; // 5 seconds + const NETWORK_HISTORY_INTERVAL = 60000; // 1 minute + + // ---- DOM Elements ---- + const dom = { + clock: document.getElementById('clock'), + serverCountText: document.getElementById('serverCountText'), + sourceCount: document.getElementById('sourceCount'), + totalServers: document.getElementById('totalServers'), + cpuPercent: document.getElementById('cpuPercent'), + cpuDetail: document.getElementById('cpuDetail'), + memPercent: document.getElementById('memPercent'), + memDetail: document.getElementById('memDetail'), + diskPercent: document.getElementById('diskPercent'), + diskDetail: document.getElementById('diskDetail'), + totalBandwidth: document.getElementById('totalBandwidth'), + bandwidthDetail: document.getElementById('bandwidthDetail'), + traffic24hRx: document.getElementById('traffic24hRx'), + traffic24hTx: document.getElementById('traffic24hTx'), + traffic24hTotal: document.getElementById('traffic24hTotal'), + networkCanvas: document.getElementById('networkCanvas'), + gaugeCpuFill: document.getElementById('gaugeCpuFill'), + gaugeRamFill: document.getElementById('gaugeRamFill'), + gaugeDiskFill: document.getElementById('gaugeDiskFill'), + gaugeCpuValue: document.getElementById('gaugeCpuValue'), + gaugeRamValue: document.getElementById('gaugeRamValue'), + gaugeDiskValue: document.getElementById('gaugeDiskValue'), + serverTableBody: document.getElementById('serverTableBody'), + btnSettings: document.getElementById('btnSettings'), + settingsModal: document.getElementById('settingsModal'), + modalClose: document.getElementById('modalClose'), + sourceName: document.getElementById('sourceName'), + sourceUrl: document.getElementById('sourceUrl'), + sourceDesc: document.getElementById('sourceDesc'), + btnTest: document.getElementById('btnTest'), + btnAdd: document.getElementById('btnAdd'), + formMessage: document.getElementById('formMessage'), + sourceItems: document.getElementById('sourceItems') + }; + + // ---- State ---- + let previousMetrics = null; + let networkChart = null; + + // ---- Initialize ---- + function init() { + // Add SVG gradient definitions for gauges + addGaugeSvgDefs(); + + // Clock + updateClock(); + setInterval(updateClock, 1000); + + // Network chart + networkChart = new AreaChart(dom.networkCanvas); + + // Event listeners + dom.btnSettings.addEventListener('click', openSettings); + dom.modalClose.addEventListener('click', closeSettings); + dom.settingsModal.addEventListener('click', (e) => { + if (e.target === dom.settingsModal) closeSettings(); + }); + dom.btnTest.addEventListener('click', testConnection); + dom.btnAdd.addEventListener('click', addSource); + + // Keyboard shortcut + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeSettings(); + }); + + // Start data fetching + fetchMetrics(); + fetchNetworkHistory(); + setInterval(fetchMetrics, REFRESH_INTERVAL); + setInterval(fetchNetworkHistory, NETWORK_HISTORY_INTERVAL); + } + + // ---- Add SVG Gradient Defs ---- + function addGaugeSvgDefs() { + const svgs = document.querySelectorAll('.gauge svg'); + const gradients = [ + { id: 'gaugeCpuGrad', colors: ['#6366f1', '#818cf8'] }, + { id: 'gaugeRamGrad', colors: ['#06b6d4', '#22d3ee'] }, + { id: 'gaugeDiskGrad', colors: ['#f59e0b', '#fbbf24'] } + ]; + + svgs.forEach((svg, i) => { + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const grad = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); + grad.setAttribute('id', gradients[i].id); + grad.setAttribute('x1', '0%'); + grad.setAttribute('y1', '0%'); + grad.setAttribute('x2', '100%'); + grad.setAttribute('y2', '100%'); + + gradients[i].colors.forEach((color, ci) => { + const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); + stop.setAttribute('offset', ci === 0 ? '0%' : '100%'); + stop.setAttribute('stop-color', color); + grad.appendChild(stop); + }); + + defs.appendChild(grad); + svg.insertBefore(defs, svg.firstChild); + }); + } + + // ---- Clock ---- + function updateClock() { + dom.clock.textContent = formatClock(); + } + + // ---- Fetch Metrics ---- + async function fetchMetrics() { + try { + const response = await fetch('/api/metrics/overview'); + const data = await response.json(); + updateDashboard(data); + } catch (err) { + console.error('Error fetching metrics:', err); + } + } + + // ---- Update Dashboard ---- + function updateDashboard(data) { + // Server count + dom.totalServers.textContent = data.totalServers; + dom.serverCountText.textContent = `${data.totalServers} 台服务器`; + + // CPU + const cpuPct = data.cpu.percent; + dom.cpuPercent.textContent = formatPercent(cpuPct); + dom.cpuDetail.textContent = `${data.cpu.used.toFixed(1)} / ${data.cpu.total.toFixed(0)} 核心`; + + // Memory + const memPct = data.memory.percent; + dom.memPercent.textContent = formatPercent(memPct); + dom.memDetail.textContent = `${formatBytes(data.memory.used)} / ${formatBytes(data.memory.total)}`; + + // Disk + const diskPct = data.disk.percent; + dom.diskPercent.textContent = formatPercent(diskPct); + dom.diskDetail.textContent = `${formatBytes(data.disk.used)} / ${formatBytes(data.disk.total)}`; + + // Bandwidth + dom.totalBandwidth.textContent = formatBandwidth(data.network.totalBandwidth); + dom.bandwidthDetail.textContent = `↓ ${formatBandwidth(data.network.rx)} ↑ ${formatBandwidth(data.network.tx)}`; + + // 24h traffic + dom.traffic24hRx.textContent = formatBytes(data.traffic24h.rx); + dom.traffic24hTx.textContent = formatBytes(data.traffic24h.tx); + dom.traffic24hTotal.textContent = formatBytes(data.traffic24h.total); + + // Update gauges + updateGauge(dom.gaugeCpuFill, dom.gaugeCpuValue, cpuPct); + updateGauge(dom.gaugeRamFill, dom.gaugeRamValue, memPct); + updateGauge(dom.gaugeDiskFill, dom.gaugeDiskValue, diskPct); + + // Update server table + updateServerTable(data.servers); + + // Flash animation + if (previousMetrics) { + [dom.cpuPercent, dom.memPercent, dom.diskPercent, dom.totalBandwidth].forEach(el => { + el.classList.remove('value-update'); + void el.offsetWidth; // Force reflow + el.classList.add('value-update'); + }); + } + + previousMetrics = data; + } + + // ---- Gauge Update ---- + const CIRCUMFERENCE = 2 * Math.PI * 52; // r=52 + + function updateGauge(fillEl, valueEl, percent) { + const clamped = Math.min(100, Math.max(0, percent)); + const offset = CIRCUMFERENCE - (clamped / 100) * CIRCUMFERENCE; + fillEl.style.strokeDashoffset = offset; + valueEl.textContent = formatPercent(clamped); + + // Change color based on usage + const color = getUsageColor(clamped); + // We keep gradient but could override for critical + } + + // ---- Server Table ---- + function updateServerTable(servers) { + if (!servers || servers.length === 0) { + dom.serverTableBody.innerHTML = ` + + 暂无数据 - 请先配置 Prometheus 数据源 + + `; + return; + } + + // Sort servers: online first, then by cpu usage + servers.sort((a, b) => { + if (a.up !== b.up) return b.up ? 1 : -1; + return b.cpuPercent - a.cpuPercent; + }); + + dom.serverTableBody.innerHTML = servers.map(server => { + const memPct = server.memTotal > 0 ? (server.memUsed / server.memTotal * 100) : 0; + const diskPct = server.diskTotal > 0 ? (server.diskUsed / server.diskTotal * 100) : 0; + + return ` + + + + + ${escapeHtml(server.instance)} + ${escapeHtml(server.source)} + +
+
+
+
+ ${formatPercent(server.cpuPercent)} +
+ + +
+
+
+
+ ${formatPercent(memPct)} +
+ + +
+
+
+
+ ${formatPercent(diskPct)} +
+ + ${formatBandwidth(server.netRx)} + ${formatBandwidth(server.netTx)} + + `; + }).join(''); + } + + // ---- Network History ---- + async function fetchNetworkHistory() { + try { + const response = await fetch('/api/metrics/network-history'); + const data = await response.json(); + networkChart.setData(data); + } catch (err) { + console.error('Error fetching network history:', err); + } + } + + // ---- Settings Modal ---- + function openSettings() { + dom.settingsModal.classList.add('active'); + loadSources(); + } + + function closeSettings() { + dom.settingsModal.classList.remove('active'); + hideMessage(); + } + + async function loadSources() { + try { + const response = await fetch('/api/sources'); + const sources = await response.json(); + dom.sourceCount.textContent = `${sources.length} 个数据源`; + renderSources(sources); + } catch (err) { + console.error('Error loading sources:', err); + } + } + + function renderSources(sources) { + if (sources.length === 0) { + dom.sourceItems.innerHTML = '
暂无数据源
'; + return; + } + + dom.sourceItems.innerHTML = sources.map(source => ` +
+
+
+ ${escapeHtml(source.name)} + + ${source.status === 'online' ? '在线' : '离线'} + +
+
${escapeHtml(source.url)}
+ ${source.description ? `
${escapeHtml(source.description)}
` : ''} +
+
+ +
+
+ `).join(''); + } + + // ---- Test Connection ---- + async function testConnection() { + const url = dom.sourceUrl.value.trim(); + if (!url) { + showMessage('请输入 Prometheus URL', 'error'); + return; + } + + dom.btnTest.textContent = '测试中...'; + dom.btnTest.disabled = true; + + try { + const response = await fetch('/api/sources/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + const data = await response.json(); + if (data.status === 'ok') { + showMessage(`连接成功!Prometheus 版本: ${data.version}`, 'success'); + } else { + showMessage(`连接失败: ${data.message}`, 'error'); + } + } catch (err) { + showMessage(`连接失败: ${err.message}`, 'error'); + } finally { + dom.btnTest.textContent = '测试连接'; + dom.btnTest.disabled = false; + } + } + + // ---- Add Source ---- + async function addSource() { + const name = dom.sourceName.value.trim(); + const url = dom.sourceUrl.value.trim(); + const description = dom.sourceDesc.value.trim(); + + if (!name || !url) { + showMessage('请填写名称和URL', 'error'); + return; + } + + dom.btnAdd.textContent = '添加中...'; + dom.btnAdd.disabled = true; + + try { + const response = await fetch('/api/sources', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, url, description }) + }); + + if (response.ok) { + showMessage('数据源添加成功', 'success'); + dom.sourceName.value = ''; + dom.sourceUrl.value = ''; + dom.sourceDesc.value = ''; + loadSources(); + // Refresh metrics immediately + fetchMetrics(); + fetchNetworkHistory(); + } else { + const err = await response.json(); + showMessage(`添加失败: ${err.error}`, 'error'); + } + } catch (err) { + showMessage(`添加失败: ${err.message}`, 'error'); + } finally { + dom.btnAdd.textContent = '添加'; + dom.btnAdd.disabled = false; + } + } + + // ---- Delete Source ---- + window.deleteSource = async function (id) { + if (!confirm('确定要删除这个数据源吗?')) return; + + try { + const response = await fetch(`/api/sources/${id}`, { method: 'DELETE' }); + if (response.ok) { + loadSources(); + fetchMetrics(); + fetchNetworkHistory(); + } + } catch (err) { + console.error('Error deleting source:', err); + } + }; + + // ---- Messages ---- + function showMessage(text, type) { + dom.formMessage.textContent = text; + dom.formMessage.className = `form-message ${type}`; + setTimeout(hideMessage, 5000); + } + + function hideMessage() { + dom.formMessage.className = 'form-message'; + } + + // ---- Escape HTML ---- + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // ---- Load source count on page load ---- + async function loadSourceCount() { + try { + const response = await fetch('/api/sources'); + const sources = await response.json(); + dom.sourceCount.textContent = `${sources.length} 个数据源`; + } catch (err) { + // ignore + } + } + + // ---- Start ---- + loadSourceCount(); + init(); +})(); diff --git a/public/js/chart.js b/public/js/chart.js new file mode 100644 index 0000000..6467e8a --- /dev/null +++ b/public/js/chart.js @@ -0,0 +1,176 @@ +/** + * Custom Canvas Chart Renderer + * Lightweight, no-dependency chart for the network traffic area chart + */ +class AreaChart { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.data = { timestamps: [], rx: [], tx: [] }; + this.animProgress = 0; + this.animFrame = null; + this.dpr = window.devicePixelRatio || 1; + this.padding = { top: 20, right: 16, bottom: 32, left: 56 }; + + this._resize = this.resize.bind(this); + window.addEventListener('resize', this._resize); + this.resize(); + } + + resize() { + const rect = this.canvas.parentElement.getBoundingClientRect(); + this.width = rect.width; + this.height = rect.height; + this.canvas.width = this.width * this.dpr; + this.canvas.height = this.height * this.dpr; + this.canvas.style.width = this.width + 'px'; + this.canvas.style.height = this.height + 'px'; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.draw(); + } + + setData(data) { + this.data = data; + this.animProgress = 0; + this.animate(); + } + + animate() { + if (this.animFrame) cancelAnimationFrame(this.animFrame); + const start = performance.now(); + const duration = 800; + + const step = (now) => { + const elapsed = now - start; + this.animProgress = Math.min(elapsed / duration, 1); + // Ease out cubic + this.animProgress = 1 - Math.pow(1 - this.animProgress, 3); + this.draw(); + if (this.animProgress < 1) { + this.animFrame = requestAnimationFrame(step); + } + }; + + this.animFrame = requestAnimationFrame(step); + } + + draw() { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + const p = this.padding; + const chartW = w - p.left - p.right; + const chartH = h - p.top - p.bottom; + + ctx.clearRect(0, 0, w, h); + + const { timestamps, rx, tx } = this.data; + if (!timestamps || timestamps.length < 2) { + ctx.fillStyle = '#5a6380'; + ctx.font = '13px Inter, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('等待数据...', w / 2, h / 2); + return; + } + + // Find max + let maxVal = 0; + for (let i = 0; i < rx.length; i++) { + maxVal = Math.max(maxVal, rx[i] || 0, tx[i] || 0); + } + maxVal = maxVal * 1.15 || 1; + + const len = timestamps.length; + const xStep = chartW / (len - 1); + + // Helper to get point + const getX = (i) => p.left + i * xStep; + const getY = (val) => p.top + chartH - (val / maxVal) * chartH * this.animProgress; + + // Draw grid lines + ctx.strokeStyle = 'rgba(99, 102, 241, 0.06)'; + ctx.lineWidth = 1; + const gridLines = 4; + for (let i = 0; i <= gridLines; i++) { + const y = p.top + (chartH / gridLines) * i; + ctx.beginPath(); + ctx.moveTo(p.left, y); + ctx.lineTo(p.left + chartW, y); + ctx.stroke(); + + // Y-axis labels + const val = maxVal * (1 - i / gridLines); + ctx.fillStyle = '#5a6380'; + ctx.font = '10px "JetBrains Mono", monospace'; + ctx.textAlign = 'right'; + ctx.fillText(formatBandwidth(val, 1), p.left - 8, y + 3); + } + + // X-axis labels (every ~4 hours) + ctx.fillStyle = '#5a6380'; + ctx.font = '10px "JetBrains Mono", monospace'; + ctx.textAlign = 'center'; + const labelInterval = Math.max(1, Math.floor(len / 6)); + for (let i = 0; i < len; i += labelInterval) { + const x = getX(i); + ctx.fillText(formatTime(timestamps[i]), x, h - 8); + } + // Always show last label + ctx.fillText(formatTime(timestamps[len - 1]), getX(len - 1), h - 8); + + // Draw TX area + this.drawArea(ctx, tx, getX, getY, chartH, p, + 'rgba(99, 102, 241, 0.25)', 'rgba(99, 102, 241, 0.02)', + '#6366f1', len); + + // Draw RX area (on top) + this.drawArea(ctx, rx, getX, getY, chartH, p, + 'rgba(6, 182, 212, 0.25)', 'rgba(6, 182, 212, 0.02)', + '#06b6d4', len); + } + + drawArea(ctx, values, getX, getY, chartH, p, fillColorTop, fillColorBottom, strokeColor, len) { + if (!values || values.length === 0) return; + + // Fill + ctx.beginPath(); + ctx.moveTo(getX(0), getY(values[0] || 0)); + for (let i = 1; i < len; i++) { + const prevX = getX(i - 1); + const currX = getX(i); + const prevY = getY(values[i - 1] || 0); + const currY = getY(values[i] || 0); + const midX = (prevX + currX) / 2; + ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); + } + ctx.lineTo(getX(len - 1), p.top + chartH); + ctx.lineTo(getX(0), p.top + chartH); + ctx.closePath(); + + const gradient = ctx.createLinearGradient(0, p.top, 0, p.top + chartH); + gradient.addColorStop(0, fillColorTop); + gradient.addColorStop(1, fillColorBottom); + ctx.fillStyle = gradient; + ctx.fill(); + + // Stroke + ctx.beginPath(); + ctx.moveTo(getX(0), getY(values[0] || 0)); + for (let i = 1; i < len; i++) { + const prevX = getX(i - 1); + const currX = getX(i); + const prevY = getY(values[i - 1] || 0); + const currY = getY(values[i] || 0); + const midX = (prevX + currX) / 2; + ctx.bezierCurveTo(midX, prevY, midX, currY, currX, currY); + } + ctx.strokeStyle = strokeColor; + ctx.lineWidth = 2; + ctx.stroke(); + } + + destroy() { + window.removeEventListener('resize', this._resize); + if (this.animFrame) cancelAnimationFrame(this.animFrame); + } +} diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100644 index 0000000..e8bf4e9 --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,102 @@ +/** + * Utility Functions + */ + +/** + * Format bytes to human-readable string + */ +function formatBytes(bytes, decimals = 2) { + if (!bytes || bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)); + const value = bytes / Math.pow(k, i); + return value.toFixed(decimals) + ' ' + sizes[i]; +} + +/** + * Format bytes per second to human-readable bandwidth + */ +function formatBandwidth(bytesPerSec, decimals = 2) { + if (!bytesPerSec || bytesPerSec === 0) return '0 B/s'; + const k = 1024; + const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s']; + const i = Math.floor(Math.log(Math.abs(bytesPerSec)) / Math.log(k)); + const value = bytesPerSec / Math.pow(k, i); + return value.toFixed(decimals) + ' ' + sizes[i]; +} + +/** + * Format percentage + */ +function formatPercent(value, decimals = 1) { + if (!value || isNaN(value)) return '0%'; + return value.toFixed(decimals) + '%'; +} + +/** + * Format a timestamp to HH:MM + */ +function formatTime(timestamp) { + const d = new Date(timestamp); + return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); +} + +/** + * Format full clock + */ +function formatClock() { + const now = new Date(); + const date = now.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); + const time = now.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + return `${date} ${time}`; +} + +/** + * Get color based on usage percentage + */ +function getUsageColor(percent) { + if (percent < 50) return '#10b981'; + if (percent < 75) return '#f59e0b'; + return '#f43f5e'; +} + +/** + * Smooth number animation + */ +function animateValue(element, start, end, duration = 600) { + const startTime = performance.now(); + const diff = end - start; + + function update(currentTime) { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + // Ease out cubic + const eased = 1 - Math.pow(1 - progress, 3); + const current = start + diff * eased; + + if (element.dataset.format === 'percent') { + element.textContent = formatPercent(current); + } else if (element.dataset.format === 'bytes') { + element.textContent = formatBytes(current); + } else if (element.dataset.format === 'bandwidth') { + element.textContent = formatBandwidth(current); + } else { + element.textContent = Math.round(current); + } + + if (progress < 1) { + requestAnimationFrame(update); + } + } + + requestAnimationFrame(update); +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..ba71496 --- /dev/null +++ b/server/db.js @@ -0,0 +1,14 @@ +const mysql = require('mysql2/promise'); + +const pool = mysql.createPool({ + host: process.env.MYSQL_HOST || 'localhost', + port: parseInt(process.env.MYSQL_PORT) || 3306, + user: process.env.MYSQL_USER || 'root', + password: process.env.MYSQL_PASSWORD || '', + database: process.env.MYSQL_DATABASE || 'display_wall', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}); + +module.exports = pool; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..6b991bc --- /dev/null +++ b/server/index.js @@ -0,0 +1,246 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const path = require('path'); +const db = require('./db'); +const prometheusService = require('./prometheus-service'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); +app.use(express.static(path.join(__dirname, '..', 'public'))); + +// ==================== Prometheus Source CRUD ==================== + +// Get all Prometheus sources +app.get('/api/sources', async (req, res) => { + try { + const [rows] = await db.query('SELECT * FROM prometheus_sources ORDER BY created_at DESC'); + // Test connectivity for each source + const sourcesWithStatus = await Promise.all(rows.map(async (source) => { + try { + const response = await prometheusService.testConnection(source.url); + return { ...source, status: 'online', version: response }; + } catch (e) { + return { ...source, status: 'offline', version: null }; + } + })); + res.json(sourcesWithStatus); + } catch (err) { + console.error('Error fetching sources:', err); + res.status(500).json({ error: 'Failed to fetch sources' }); + } +}); + +// Add a new Prometheus source +app.post('/api/sources', async (req, res) => { + const { name, url, description } = req.body; + if (!name || !url) { + return res.status(400).json({ error: 'Name and URL are required' }); + } + try { + const [result] = await db.query( + 'INSERT INTO prometheus_sources (name, url, description) VALUES (?, ?, ?)', + [name, url, description || ''] + ); + const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [result.insertId]); + res.status(201).json(rows[0]); + } catch (err) { + console.error('Error adding source:', err); + res.status(500).json({ error: 'Failed to add source' }); + } +}); + +// Update a Prometheus source +app.put('/api/sources/:id', async (req, res) => { + const { name, url, description } = req.body; + try { + await db.query( + 'UPDATE prometheus_sources SET name = ?, url = ?, description = ? WHERE id = ?', + [name, url, description || '', req.params.id] + ); + const [rows] = await db.query('SELECT * FROM prometheus_sources WHERE id = ?', [req.params.id]); + res.json(rows[0]); + } catch (err) { + console.error('Error updating source:', err); + res.status(500).json({ error: 'Failed to update source' }); + } +}); + +// Delete a Prometheus source +app.delete('/api/sources/:id', async (req, res) => { + try { + await db.query('DELETE FROM prometheus_sources WHERE id = ?', [req.params.id]); + res.json({ message: 'Source deleted' }); + } catch (err) { + console.error('Error deleting source:', err); + res.status(500).json({ error: 'Failed to delete source' }); + } +}); + +// Test connection to a Prometheus source +app.post('/api/sources/test', async (req, res) => { + const { url } = req.body; + try { + const version = await prometheusService.testConnection(url); + res.json({ status: 'ok', version }); + } catch (err) { + res.status(400).json({ status: 'error', message: err.message }); + } +}); + +// ==================== Metrics Aggregation ==================== + +// Get all aggregated metrics from all Prometheus sources +app.get('/api/metrics/overview', async (req, res) => { + try { + const [sources] = await db.query('SELECT * FROM prometheus_sources'); + if (sources.length === 0) { + return res.json({ + totalServers: 0, + cpu: { used: 0, total: 0, percent: 0 }, + memory: { used: 0, total: 0, percent: 0 }, + disk: { used: 0, total: 0, percent: 0 }, + network: { totalBandwidth: 0, rx: 0, tx: 0 }, + traffic24h: { rx: 0, tx: 0, total: 0 }, + servers: [] + }); + } + + const allMetrics = await Promise.all(sources.map(source => + prometheusService.getOverviewMetrics(source.url, source.name).catch(err => { + console.error(`Error fetching metrics from ${source.name}:`, err.message); + return null; + }) + )); + + const validMetrics = allMetrics.filter(m => m !== null); + + // Aggregate across all sources + let totalServers = 0; + let cpuUsed = 0, cpuTotal = 0; + let memUsed = 0, memTotal = 0; + let diskUsed = 0, diskTotal = 0; + let netRx = 0, netTx = 0; + let traffic24hRx = 0, traffic24hTx = 0; + let allServers = []; + + for (const m of validMetrics) { + totalServers += m.totalServers; + cpuUsed += m.cpu.used; + cpuTotal += m.cpu.total; + memUsed += m.memory.used; + memTotal += m.memory.total; + diskUsed += m.disk.used; + diskTotal += m.disk.total; + netRx += m.network.rx; + netTx += m.network.tx; + traffic24hRx += m.traffic24h.rx; + traffic24hTx += m.traffic24h.tx; + allServers = allServers.concat(m.servers); + } + + res.json({ + totalServers, + cpu: { + used: cpuUsed, + total: cpuTotal, + percent: cpuTotal > 0 ? (cpuUsed / cpuTotal * 100) : 0 + }, + memory: { + used: memUsed, + total: memTotal, + percent: memTotal > 0 ? (memUsed / memTotal * 100) : 0 + }, + disk: { + used: diskUsed, + total: diskTotal, + percent: diskTotal > 0 ? (diskUsed / diskTotal * 100) : 0 + }, + network: { + totalBandwidth: netRx + netTx, + rx: netRx, + tx: netTx + }, + traffic24h: { + rx: traffic24hRx, + tx: traffic24hTx, + total: traffic24hRx + traffic24hTx + }, + servers: allServers + }); + } catch (err) { + console.error('Error fetching overview metrics:', err); + res.status(500).json({ error: 'Failed to fetch metrics' }); + } +}); + +// Get network traffic history (past 24h in intervals) +app.get('/api/metrics/network-history', async (req, res) => { + try { + const [sources] = await db.query('SELECT * FROM prometheus_sources'); + if (sources.length === 0) { + return res.json({ timestamps: [], rx: [], tx: [] }); + } + + const allHistories = await Promise.all(sources.map(source => + prometheusService.getNetworkHistory(source.url).catch(err => { + console.error(`Error fetching network history from ${source.name}:`, err.message); + return null; + }) + )); + + const validHistories = allHistories.filter(h => h !== null); + if (validHistories.length === 0) { + return res.json({ timestamps: [], rx: [], tx: [] }); + } + + // Merge all histories by timestamp + const merged = prometheusService.mergeNetworkHistories(validHistories); + res.json(merged); + } catch (err) { + console.error('Error fetching network history:', err); + res.status(500).json({ error: 'Failed to fetch network history' }); + } +}); + +// Get CPU usage history for sparklines +app.get('/api/metrics/cpu-history', async (req, res) => { + try { + const [sources] = await db.query('SELECT * FROM prometheus_sources'); + if (sources.length === 0) { + return res.json({ timestamps: [], values: [] }); + } + + const allHistories = await Promise.all(sources.map(source => + prometheusService.getCpuHistory(source.url).catch(err => { + console.error(`Error fetching CPU history from ${source.name}:`, err.message); + return null; + }) + )); + + const validHistories = allHistories.filter(h => h !== null); + if (validHistories.length === 0) { + return res.json({ timestamps: [], values: [] }); + } + + const merged = prometheusService.mergeCpuHistories(validHistories); + res.json(merged); + } catch (err) { + console.error('Error fetching CPU history:', err); + res.status(500).json({ error: 'Failed to fetch CPU history' }); + } +}); + +// SPA fallback +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); +}); + +app.listen(PORT, () => { + console.log(`\n 🚀 Data Visualization Display Wall`); + console.log(` 📊 Server running at http://localhost:${PORT}`); + console.log(` ⚙️ Configure Prometheus sources at http://localhost:${PORT}/settings\n`); +}); diff --git a/server/init-db.js b/server/init-db.js new file mode 100644 index 0000000..ad14ccf --- /dev/null +++ b/server/init-db.js @@ -0,0 +1,47 @@ +/** + * Database Initialization Script + * Run: npm run init-db + * Creates the required MySQL database and tables. + */ +require('dotenv').config(); +const mysql = require('mysql2/promise'); + +async function initDatabase() { + const connection = await mysql.createConnection({ + host: process.env.MYSQL_HOST || 'localhost', + port: parseInt(process.env.MYSQL_PORT) || 3306, + user: process.env.MYSQL_USER || 'root', + password: process.env.MYSQL_PASSWORD || '' + }); + + const dbName = process.env.MYSQL_DATABASE || 'display_wall'; + + console.log('🔧 Initializing database...\n'); + + // Create database + await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`); + console.log(` ✅ Database "${dbName}" ready`); + + await connection.query(`USE \`${dbName}\``); + + // Create prometheus_sources table + await connection.query(` + CREATE TABLE IF NOT EXISTS prometheus_sources ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + url VARCHAR(500) NOT NULL, + description TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + console.log(' ✅ Table "prometheus_sources" ready'); + + console.log('\n🎉 Database initialization complete!\n'); + await connection.end(); +} + +initDatabase().catch(err => { + console.error('❌ Database initialization failed:', err.message); + process.exit(1); +}); diff --git a/server/prometheus-service.js b/server/prometheus-service.js new file mode 100644 index 0000000..6b10280 --- /dev/null +++ b/server/prometheus-service.js @@ -0,0 +1,320 @@ +const axios = require('axios'); + +const QUERY_TIMEOUT = 10000; + +/** + * Create an axios instance for a given Prometheus URL + */ +function createClient(baseUrl) { + return axios.create({ + baseURL: baseUrl.replace(/\/+$/, ''), + timeout: QUERY_TIMEOUT + }); +} + +/** + * Test Prometheus connection + */ +async function testConnection(url) { + const client = createClient(url); + const res = await client.get('/api/v1/status/buildinfo'); + return res.data?.data?.version || 'unknown'; +} + +/** + * Execute a Prometheus instant query + */ +async function query(url, expr) { + const client = createClient(url); + const res = await client.get('/api/v1/query', { params: { query: expr } }); + if (res.data.status !== 'success') { + throw new Error(`Prometheus query failed: ${res.data.error || 'unknown error'}`); + } + return res.data.data.result; +} + +/** + * Execute a Prometheus range query + */ +async function queryRange(url, expr, start, end, step) { + const client = createClient(url); + const res = await client.get('/api/v1/query_range', { + params: { query: expr, start, end, step } + }); + if (res.data.status !== 'success') { + throw new Error(`Prometheus range query failed: ${res.data.error || 'unknown error'}`); + } + return res.data.data.result; +} + +/** + * Get overview metrics from a single Prometheus source + */ +async function getOverviewMetrics(url, sourceName) { + // Run all queries in parallel + const [ + cpuResult, + cpuCountResult, + memTotalResult, + memAvailResult, + diskTotalResult, + diskFreeResult, + netRxResult, + netTxResult, + traffic24hRxResult, + traffic24hTxResult, + upResult + ] = await Promise.all([ + // CPU usage per instance: 1 - avg idle + query(url, '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)').catch(() => []), + // CPU count per instance + query(url, 'count by (instance) (node_cpu_seconds_total{mode="idle"})').catch(() => []), + // Memory total per instance + query(url, 'node_memory_MemTotal_bytes').catch(() => []), + // Memory available per instance + query(url, 'node_memory_MemAvailable_bytes').catch(() => []), + // Disk total per instance (root filesystem) + query(url, 'sum by (instance) (node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"})').catch(() => []), + // Disk free per instance (root filesystem) + query(url, 'sum by (instance) (node_filesystem_free_bytes{mountpoint="/",fstype!="tmpfs"})').catch(() => []), + // Network receive rate (bytes/sec) + query(url, 'sum by (instance) (rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))').catch(() => []), + // Network transmit rate (bytes/sec) + query(url, 'sum by (instance) (rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))').catch(() => []), + // Total traffic received in last 24h + query(url, 'sum by (instance) (increase(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []), + // Total traffic transmitted in last 24h + query(url, 'sum by (instance) (increase(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[24h]))').catch(() => []), + // Up instances + query(url, 'up{job=~".*node.*|.*exporter.*"}').catch(() => []) + ]); + + // Build per-instance data map + const instances = new Map(); + + const getOrCreate = (instance) => { + if (!instances.has(instance)) { + instances.set(instance, { + instance, + source: sourceName, + cpuPercent: 0, + cpuCores: 0, + memTotal: 0, + memUsed: 0, + diskTotal: 0, + diskUsed: 0, + netRx: 0, + netTx: 0, + up: false + }); + } + return instances.get(instance); + }; + + // Parse UP status + for (const r of upResult) { + const inst = getOrCreate(r.metric.instance); + inst.up = parseFloat(r.value[1]) === 1; + } + + // Parse CPU usage + for (const r of cpuResult) { + const inst = getOrCreate(r.metric.instance); + inst.cpuPercent = parseFloat(r.value[1]) || 0; + } + + // Parse CPU count + for (const r of cpuCountResult) { + const inst = getOrCreate(r.metric.instance); + inst.cpuCores = parseFloat(r.value[1]) || 0; + } + + // Parse memory + for (const r of memTotalResult) { + const inst = getOrCreate(r.metric.instance); + inst.memTotal = parseFloat(r.value[1]) || 0; + } + for (const r of memAvailResult) { + const inst = getOrCreate(r.metric.instance); + inst.memUsed = inst.memTotal - (parseFloat(r.value[1]) || 0); + } + + // Parse disk + for (const r of diskTotalResult) { + const inst = getOrCreate(r.metric.instance); + inst.diskTotal = parseFloat(r.value[1]) || 0; + } + for (const r of diskFreeResult) { + const inst = getOrCreate(r.metric.instance); + inst.diskUsed = inst.diskTotal - (parseFloat(r.value[1]) || 0); + } + + // Parse network rates + for (const r of netRxResult) { + const inst = getOrCreate(r.metric.instance); + inst.netRx = parseFloat(r.value[1]) || 0; + } + for (const r of netTxResult) { + const inst = getOrCreate(r.metric.instance); + inst.netTx = parseFloat(r.value[1]) || 0; + } + + // Aggregate + let totalCpuUsed = 0, totalCpuCores = 0; + let totalMemUsed = 0, totalMemTotal = 0; + let totalDiskUsed = 0, totalDiskTotal = 0; + let totalNetRx = 0, totalNetTx = 0; + let totalTraffic24hRx = 0, totalTraffic24hTx = 0; + + for (const inst of instances.values()) { + totalCpuUsed += (inst.cpuPercent / 100) * inst.cpuCores; + totalCpuCores += inst.cpuCores; + totalMemUsed += inst.memUsed; + totalMemTotal += inst.memTotal; + totalDiskUsed += inst.diskUsed; + totalDiskTotal += inst.diskTotal; + totalNetRx += inst.netRx; + totalNetTx += inst.netTx; + } + + // Parse 24h traffic + for (const r of traffic24hRxResult) { + totalTraffic24hRx += parseFloat(r.value[1]) || 0; + } + for (const r of traffic24hTxResult) { + totalTraffic24hTx += parseFloat(r.value[1]) || 0; + } + + return { + totalServers: instances.size, + cpu: { + used: totalCpuUsed, + total: totalCpuCores, + percent: totalCpuCores > 0 ? (totalCpuUsed / totalCpuCores * 100) : 0 + }, + memory: { + used: totalMemUsed, + total: totalMemTotal, + percent: totalMemTotal > 0 ? (totalMemUsed / totalMemTotal * 100) : 0 + }, + disk: { + used: totalDiskUsed, + total: totalDiskTotal, + percent: totalDiskTotal > 0 ? (totalDiskUsed / totalDiskTotal * 100) : 0 + }, + network: { + rx: totalNetRx, + tx: totalNetTx + }, + traffic24h: { + rx: totalTraffic24hRx, + tx: totalTraffic24hTx + }, + servers: Array.from(instances.values()) + }; +} + +/** + * Get network traffic history (past 24h, 15-min intervals) + */ +async function getNetworkHistory(url) { + const now = Math.floor(Date.now() / 1000); + const start = now - 86400; // 24h ago + const step = 900; // 15 minutes + + const [rxResult, txResult] = await Promise.all([ + queryRange(url, + 'sum(rate(node_network_receive_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))', + start, now, step + ).catch(() => []), + queryRange(url, + 'sum(rate(node_network_transmit_bytes_total{device!~"lo|veth.*|docker.*|br-.*"}[5m]))', + start, now, step + ).catch(() => []) + ]); + + // Extract values - each result[0].values = [[timestamp, value], ...] + const rxValues = rxResult.length > 0 ? rxResult[0].values : []; + const txValues = txResult.length > 0 ? txResult[0].values : []; + + return { rxValues, txValues }; +} + +/** + * Merge network histories from multiple sources + */ +function mergeNetworkHistories(histories) { + const timestampMap = new Map(); + + for (const history of histories) { + for (const [ts, val] of history.rxValues) { + const existing = timestampMap.get(ts) || { rx: 0, tx: 0 }; + existing.rx += parseFloat(val) || 0; + timestampMap.set(ts, existing); + } + for (const [ts, val] of history.txValues) { + const existing = timestampMap.get(ts) || { rx: 0, tx: 0 }; + existing.tx += parseFloat(val) || 0; + timestampMap.set(ts, existing); + } + } + + const sorted = [...timestampMap.entries()].sort((a, b) => a[0] - b[0]); + + return { + timestamps: sorted.map(([ts]) => ts * 1000), // ms for JS + rx: sorted.map(([, v]) => v.rx), + tx: sorted.map(([, v]) => v.tx) + }; +} + +/** + * Get CPU usage history (past 1h, 1-min intervals) + */ +async function getCpuHistory(url) { + const now = Math.floor(Date.now() / 1000); + const start = now - 3600; // 1h ago + const step = 60; // 1 minute + + const result = await queryRange(url, + '100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', + start, now, step + ).catch(() => []); + + const values = result.length > 0 ? result[0].values : []; + return values; // [[timestamp, value], ...] +} + +/** + * Merge CPU histories from multiple sources (average) + */ +function mergeCpuHistories(histories) { + const timestampMap = new Map(); + + for (const history of histories) { + for (const [ts, val] of history) { + const existing = timestampMap.get(ts) || { sum: 0, count: 0 }; + existing.sum += parseFloat(val) || 0; + existing.count += 1; + timestampMap.set(ts, existing); + } + } + + const sorted = [...timestampMap.entries()].sort((a, b) => a[0] - b[0]); + + return { + timestamps: sorted.map(([ts]) => ts * 1000), + values: sorted.map(([, v]) => v.sum / v.count) + }; +} + +module.exports = { + testConnection, + query, + queryRange, + getOverviewMetrics, + getNetworkHistory, + mergeNetworkHistories, + getCpuHistory, + mergeCpuHistories +};