mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
feat: add Notification Settings Modal and Stack Alert Sheet components
- Implemented NotificationSettingsModal for configuring notification agents (Discord, Slack, Webhook) and global settings. - Added StackAlertSheet for managing stack-specific alert rules with metrics, thresholds, and cooldowns. - Introduced reusable UI components: DropdownMenu, Popover, Select, Sheet, Slider, and Switch. - Enhanced Tabs component by removing unnecessary client directive.
This commit is contained in:
Generated
+242
@@ -14,6 +14,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"composerize": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
@@ -27,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
@@ -244,6 +246,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -607,6 +619,20 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.6.2",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
|
||||
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -620,6 +646,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -999,6 +1034,30 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
|
||||
@@ -1017,6 +1076,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
@@ -1167,6 +1235,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
@@ -1232,6 +1309,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -1360,6 +1443,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -1488,6 +1577,12 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
@@ -1750,6 +1845,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
@@ -1766,6 +1873,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
@@ -1785,6 +1901,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -1794,6 +1916,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.87.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
|
||||
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
@@ -1943,6 +2077,33 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
||||
@@ -2036,6 +2197,21 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@@ -2258,6 +2434,51 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"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/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"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",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -2338,6 +2559,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -2481,6 +2711,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
@@ -29,6 +30,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"composerize": "^1.7.5",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
|
||||
@@ -15,6 +15,9 @@ import { spawn, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import { HostTerminalService } from './services/HostTerminalService';
|
||||
import { DatabaseService } from './services/DatabaseService';
|
||||
import { NotificationService } from './services/NotificationService';
|
||||
import { MonitorService } from './services/MonitorService';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -658,6 +661,106 @@ app.get('/api/system/stats', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Notification & Alerting Routes ---
|
||||
|
||||
app.get('/api/agents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const agents = DatabaseService.getInstance().getAgents();
|
||||
res.json(agents);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch agents' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/agents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const agent = req.body;
|
||||
DatabaseService.getInstance().upsertAgent(agent);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update agent' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { key, value } = req.body;
|
||||
DatabaseService.getInstance().updateGlobalSetting(key, value);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to update setting' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/alerts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
let stackName = req.query.stackName as string | undefined;
|
||||
if (Array.isArray(stackName)) stackName = stackName[0] as string;
|
||||
|
||||
const alerts = DatabaseService.getInstance().getStackAlerts(stackName);
|
||||
res.json(alerts);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch alerts' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/alerts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const alert = req.body;
|
||||
DatabaseService.getInstance().addStackAlert(alert);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to add alert' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/alerts/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
DatabaseService.getInstance().deleteStackAlert(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to delete alert' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/notifications', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const history = DatabaseService.getInstance().getNotificationHistory();
|
||||
res.json(history);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notifications/read', async (req: Request, res: Response) => {
|
||||
try {
|
||||
DatabaseService.getInstance().markAllNotificationsRead();
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to mark notifications read' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notifications/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, url } = req.body;
|
||||
await NotificationService.getInstance().testDispatch(type, url);
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: 'Test failed', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- System Maintenance Routes (The System Janitor) ---
|
||||
|
||||
app.get('/api/system/orphans', async (req: Request, res: Response) => {
|
||||
@@ -741,6 +844,9 @@ async function startServer() {
|
||||
// Continue starting server even if migration fails
|
||||
}
|
||||
|
||||
// Start Background Watchdog
|
||||
MonitorService.getInstance().start();
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export interface Agent {
|
||||
id?: number;
|
||||
type: 'discord' | 'slack' | 'webhook';
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface StackAlert {
|
||||
id?: number;
|
||||
stack_name: string;
|
||||
metric: string;
|
||||
operator: string;
|
||||
threshold: number;
|
||||
duration_mins: number;
|
||||
cooldown_mins: number;
|
||||
last_fired_at?: number;
|
||||
}
|
||||
|
||||
export interface NotificationHistory {
|
||||
id?: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
timestamp: number;
|
||||
is_read: boolean;
|
||||
}
|
||||
|
||||
export class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private db: Database.Database;
|
||||
|
||||
private constructor() {
|
||||
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbPath = path.join(dataDir, 'sencho.db');
|
||||
this.db = new Database(dbPath);
|
||||
// Use WAL mode for better concurrency performance
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
|
||||
this.initSchema();
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
if (!DatabaseService.instance) {
|
||||
DatabaseService.instance = new DatabaseService();
|
||||
}
|
||||
return DatabaseService.instance;
|
||||
}
|
||||
|
||||
public getDb(): Database.Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private initSchema() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS global_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stack_alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stack_name TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
threshold REAL NOT NULL,
|
||||
duration_mins INTEGER NOT NULL,
|
||||
cooldown_mins INTEGER NOT NULL,
|
||||
last_fired_at INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
is_read INTEGER DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Initialize default global settings if they don't exist
|
||||
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
|
||||
stmt.run('host_cpu_limit', '90');
|
||||
stmt.run('host_ram_limit', '90');
|
||||
stmt.run('host_disk_limit', '90');
|
||||
stmt.run('global_crash', '1');
|
||||
stmt.run('docker_janitor_gb', '5');
|
||||
}
|
||||
|
||||
// --- Agents ---
|
||||
|
||||
public getAgents(): Agent[] {
|
||||
const stmt = this.db.prepare('SELECT * FROM agents');
|
||||
return stmt.all().map((row: any) => ({
|
||||
...row,
|
||||
enabled: row.enabled === 1
|
||||
}));
|
||||
}
|
||||
|
||||
public getEnabledAgents(): Agent[] {
|
||||
const stmt = this.db.prepare('SELECT * FROM agents WHERE enabled = 1');
|
||||
return stmt.all().map((row: any) => ({
|
||||
...row,
|
||||
enabled: row.enabled === 1
|
||||
}));
|
||||
}
|
||||
|
||||
public upsertAgent(agent: Agent): void {
|
||||
const existing = this.db.prepare('SELECT id FROM agents WHERE type = ?').get(agent.type) as any;
|
||||
if (existing) {
|
||||
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE type = ?');
|
||||
stmt.run(agent.url, agent.enabled ? 1 : 0, agent.type);
|
||||
} else {
|
||||
const stmt = this.db.prepare('INSERT INTO agents (type, url, enabled) VALUES (?, ?, ?)');
|
||||
stmt.run(agent.type, agent.url, agent.enabled ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Global Settings ---
|
||||
|
||||
public getGlobalSettings(): Record<string, string> {
|
||||
const stmt = this.db.prepare('SELECT * FROM global_settings');
|
||||
const settings: Record<string, string> = {};
|
||||
stmt.all().forEach((row: any) => {
|
||||
settings[row.key] = row.value;
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
public updateGlobalSetting(key: string, value: string): void {
|
||||
const stmt = this.db.prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)');
|
||||
stmt.run(key, value);
|
||||
}
|
||||
|
||||
// --- Stack Alerts ---
|
||||
|
||||
public getStackAlerts(stackName?: string): StackAlert[] {
|
||||
let stmt;
|
||||
if (stackName) {
|
||||
stmt = this.db.prepare('SELECT * FROM stack_alerts WHERE stack_name = ?');
|
||||
return stmt.all(stackName) as StackAlert[];
|
||||
} else {
|
||||
stmt = this.db.prepare('SELECT * FROM stack_alerts');
|
||||
return stmt.all() as StackAlert[];
|
||||
}
|
||||
}
|
||||
|
||||
public addStackAlert(alert: StackAlert): void {
|
||||
const stmt = this.db.prepare(
|
||||
'INSERT INTO stack_alerts (stack_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
stmt.run(
|
||||
alert.stack_name,
|
||||
alert.metric,
|
||||
alert.operator,
|
||||
alert.threshold,
|
||||
alert.duration_mins,
|
||||
alert.cooldown_mins,
|
||||
alert.last_fired_at || 0
|
||||
);
|
||||
}
|
||||
|
||||
public deleteStackAlert(id: number): void {
|
||||
const stmt = this.db.prepare('DELETE FROM stack_alerts WHERE id = ?');
|
||||
stmt.run(id);
|
||||
}
|
||||
|
||||
public updateStackAlertLastFired(id: number, timestamp: number): void {
|
||||
const stmt = this.db.prepare('UPDATE stack_alerts SET last_fired_at = ? WHERE id = ?');
|
||||
stmt.run(timestamp, id);
|
||||
}
|
||||
|
||||
// --- Notification History ---
|
||||
|
||||
public getNotificationHistory(limit = 50): NotificationHistory[] {
|
||||
const stmt = this.db.prepare('SELECT * FROM notification_history ORDER BY timestamp DESC LIMIT ?');
|
||||
return stmt.all(limit).map((row: any) => ({
|
||||
...row,
|
||||
is_read: row.is_read === 1
|
||||
}));
|
||||
}
|
||||
|
||||
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): void {
|
||||
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
|
||||
stmt.run(notification.level, notification.message, notification.timestamp);
|
||||
|
||||
// Cleanup old notifications (keep last 100)
|
||||
this.db.exec(`
|
||||
DELETE FROM notification_history
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public markAllNotificationsRead(): void {
|
||||
const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1');
|
||||
stmt.run();
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,12 @@ class DockerController {
|
||||
});
|
||||
}
|
||||
|
||||
public async getContainerStatsStream(containerId: string): Promise<string> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const stats = await container.stats({ stream: false });
|
||||
return typeof stats === 'string' ? stats : JSON.stringify(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exec into a container with full session isolation.
|
||||
* All state (exec instance, stream) lives in this closure — no singleton traps.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import si from 'systeminformation';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import DockerController from './DockerController';
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface AlertState {
|
||||
breachStartedAt: number; // timestamp when the rule first breached
|
||||
}
|
||||
|
||||
export class MonitorService {
|
||||
private static instance: MonitorService;
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
private isProcessing = false;
|
||||
|
||||
// Track the duration a specific stack alert rule has been in breach state
|
||||
// key: rule_id, value: AlertState
|
||||
private activeBreaches = new Map<number, AlertState>();
|
||||
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): MonitorService {
|
||||
if (!MonitorService.instance) {
|
||||
MonitorService.instance = new MonitorService();
|
||||
}
|
||||
return MonitorService.instance;
|
||||
}
|
||||
|
||||
public start() {
|
||||
if (this.intervalId) return;
|
||||
|
||||
// Run every 30 seconds
|
||||
this.intervalId = setInterval(() => {
|
||||
this.evaluate();
|
||||
}, 30000);
|
||||
|
||||
// Run an initial evaluation slightly after boot
|
||||
setTimeout(() => this.evaluate(), 5000);
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluate() {
|
||||
if (this.isProcessing) return; // Prevent overlap if slow
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const settings = db.getGlobalSettings();
|
||||
|
||||
await this.evaluateGlobalSettings(settings);
|
||||
await this.evaluateStackAlerts(db);
|
||||
} catch (error) {
|
||||
console.error('MonitorService Evaluation Error:', error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateGlobalSettings(settings: Record<string, string>) {
|
||||
const notifier = NotificationService.getInstance();
|
||||
|
||||
// 1. Host Limits
|
||||
try {
|
||||
const currentLoad = await si.currentLoad();
|
||||
const cpuUsage = currentLoad.currentLoad;
|
||||
const cpuLimit = parseFloat(settings['host_cpu_limit']);
|
||||
if (!isNaN(cpuLimit) && cpuLimit > 0 && cpuUsage > cpuLimit) {
|
||||
await notifier.dispatchAlert('warning', `Host CPU utilization is critically high: ${cpuUsage.toFixed(1)}% (Threshold: ${cpuLimit}%)`);
|
||||
}
|
||||
|
||||
const mem = await si.mem();
|
||||
const ramUsage = (mem.used / mem.total) * 100;
|
||||
const ramLimit = parseFloat(settings['host_ram_limit']);
|
||||
if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) {
|
||||
await notifier.dispatchAlert('warning', `Host Memory utilization is critically high: ${ramUsage.toFixed(1)}% (Threshold: ${ramLimit}%)`);
|
||||
}
|
||||
|
||||
const fsSize = await si.fsSize();
|
||||
const mainDisk = fsSize.find(fs => fs.mount === '/' || fs.mount === 'C:') || fsSize[0];
|
||||
if (mainDisk) {
|
||||
const diskLimit = parseFloat(settings['host_disk_limit']);
|
||||
if (!isNaN(diskLimit) && diskLimit > 0 && mainDisk.use > diskLimit) {
|
||||
await notifier.dispatchAlert('warning', `Host Disk space utilization is critically high: ${mainDisk.use.toFixed(1)}% (Threshold: ${diskLimit}%)`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking host limits in watchdog', e);
|
||||
}
|
||||
|
||||
// 2. Global Crash Detect
|
||||
if (settings['global_crash'] === '1') {
|
||||
try {
|
||||
const docker = DockerController.getInstance();
|
||||
const containers = await docker.getAllContainers();
|
||||
for (const c of containers) {
|
||||
if (c.State === 'exited' || String(c.Status).includes('unhealthy')) {
|
||||
// Basic deduplication could be added, but for now we just dispatch.
|
||||
// Usually, users will restart or remove the container.
|
||||
// To prevent massive spam, we check if it exited in the last 30 secs
|
||||
if (c.State === 'exited') {
|
||||
// Find when it exited, this might need an inspect, but we can do a naive check if it's recent
|
||||
// The status string says something like "Exited (1) 5 minutes ago"
|
||||
if (c.Status.includes('seconds ago')) {
|
||||
await notifier.dispatchAlert('error', `Container Crash Detected: ${c.Names[0]} has exited recently.`);
|
||||
}
|
||||
} else if (String(c.Status).includes('unhealthy')) {
|
||||
await notifier.dispatchAlert('error', `Healthcheck Failed: Container ${c.Names[0]} is unhealthy.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking global crashes', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Docker Janitor Check
|
||||
try {
|
||||
const janitorLimitGb = parseFloat(settings['docker_janitor_gb']);
|
||||
if (!isNaN(janitorLimitGb) && janitorLimitGb > 0) {
|
||||
// Run docker system df to find reclamable space
|
||||
const { stdout } = await execAsync('docker system df --format "{{json .}}"');
|
||||
// Output might be multiple lines of JSON (Images, Containers, Local Volumes, Build Cache)
|
||||
let totalReclaimableBytes = 0;
|
||||
const lines = stdout.split('\n').filter(l => l.trim().length > 0);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// RECLAIMABLE might be something like "1.2GB" or "400MB" Let's parse it manually or just use raw sizes from docker api. Actually docker system df JSON format gives Reclaimable field as string e.g. "1.196GB" (or "0B").
|
||||
let reclaimStr = parsed.Reclaimable;
|
||||
if (reclaimStr) {
|
||||
// Extract the number and the unit. e.g "1.196GB" (92%) -> 1.196
|
||||
const match = reclaimStr.match(/^([0-9.]+)([a-zA-Z]+)/);
|
||||
if (match) {
|
||||
const val = parseFloat(match[1]);
|
||||
const unit = match[2];
|
||||
let bytes = 0;
|
||||
if (unit === 'GB') bytes = val * 1024 * 1024 * 1024;
|
||||
else if (unit === 'MB') bytes = val * 1024 * 1024;
|
||||
else if (unit === 'KB') bytes = val * 1024;
|
||||
else if (unit === 'B') bytes = val;
|
||||
|
||||
totalReclaimableBytes += bytes;
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const reclaimGb = totalReclaimableBytes / (1024 * 1024 * 1024);
|
||||
// Only trigger once every while? To avoid spamming, we just check if it's over limit
|
||||
// Let's ensure we only spam once per limit breach. We can use a local static variable.
|
||||
const LAST_JANITOR_ALERT_KEY = 'last_janitor_alert_timestamp';
|
||||
const lastAlert = parseInt(settings[LAST_JANITOR_ALERT_KEY] || '0', 10);
|
||||
const janitorCooldown = 24 * 60 * 60 * 1000; // 24 hours cooldown for janitor
|
||||
|
||||
if (reclaimGb >= janitorLimitGb) {
|
||||
if (Date.now() - lastAlert > janitorCooldown) {
|
||||
await notifier.dispatchAlert('info', `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`);
|
||||
DatabaseService.getInstance().updateGlobalSetting(LAST_JANITOR_ALERT_KEY, Date.now().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error checking docker janitor limits', e);
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateStackAlerts(db: DatabaseService) {
|
||||
const alerts = db.getStackAlerts();
|
||||
if (alerts.length === 0) return;
|
||||
|
||||
// Group alerts by stack so we only fetch stats for stacks we care about
|
||||
const stacksToMonitor = new Set(alerts.map(a => a.stack_name));
|
||||
|
||||
const docker = DockerController.getInstance();
|
||||
|
||||
for (const stackName of stacksToMonitor) {
|
||||
const stackAlerts = alerts.filter(a => a.stack_name === stackName);
|
||||
if (stackAlerts.length === 0) continue;
|
||||
|
||||
try {
|
||||
const containers = await docker.getContainersByStack(stackName);
|
||||
if (containers.length === 0) continue;
|
||||
|
||||
// Fetch stats for all containers in this stack
|
||||
for (const container of containers) {
|
||||
if (container.State !== 'running') continue;
|
||||
|
||||
try {
|
||||
const rawStats = await docker.getContainerStatsStream(container.Id);
|
||||
const stats = JSON.parse(rawStats);
|
||||
|
||||
const metrics = {
|
||||
cpu_percent: this.calculateCpuPercent(stats),
|
||||
memory_percent: this.calculateMemoryPercent(stats),
|
||||
memory_mb: (stats.memory_stats?.usage || 0) / (1024 * 1024),
|
||||
net_rx: this.calculateNetwork(stats, 'rx'), // KB/s (naive sum for now, proper net_rx requires time delta but we can do total MB for simple alert or just use what fits)
|
||||
net_tx: this.calculateNetwork(stats, 'tx'),
|
||||
restart_count: container.RestartCount || 0
|
||||
};
|
||||
|
||||
for (const rule of stackAlerts) {
|
||||
const ruleId = rule.id!;
|
||||
const currentValue = metrics[rule.metric as keyof typeof metrics];
|
||||
|
||||
if (currentValue === undefined) continue;
|
||||
|
||||
const isBreaching = this.evaluateCondition(currentValue, rule.operator, rule.threshold);
|
||||
|
||||
if (isBreaching) {
|
||||
if (!this.activeBreaches.has(ruleId)) {
|
||||
this.activeBreaches.set(ruleId, { breachStartedAt: Date.now() });
|
||||
}
|
||||
|
||||
const breachState = this.activeBreaches.get(ruleId)!;
|
||||
const durationMs = Date.now() - breachState.breachStartedAt;
|
||||
const requiredDurationMs = rule.duration_mins * 60 * 1000;
|
||||
|
||||
if (durationMs >= requiredDurationMs) {
|
||||
// Duration met! Check cooldown
|
||||
const timeSinceLastFired = Date.now() - (rule.last_fired_at || 0);
|
||||
const requiredCooldownMs = rule.cooldown_mins * 60 * 1000;
|
||||
|
||||
if (timeSinceLastFired >= requiredCooldownMs) {
|
||||
// FIRE THE ALERT!
|
||||
await NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
`Alert for **${rule.stack_name}** (${container.Names[0]}):\nMetric \`${rule.metric}\` is \`${currentValue.toFixed(2)}\`, which is \`${rule.operator}\` the threshold of \`${rule.threshold}\`.`
|
||||
);
|
||||
|
||||
// Update last fired
|
||||
db.updateStackAlertLastFired(ruleId, Date.now());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Rule isn't breaching anymore, reset tracker
|
||||
if (this.activeBreaches.has(ruleId)) {
|
||||
this.activeBreaches.delete(ruleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error parsing stats for container ${container.Id}`, e);
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateCondition(actual: number, operator: string, threshold: number): boolean {
|
||||
switch (operator) {
|
||||
case '>': return actual > threshold;
|
||||
case '<': return actual < threshold;
|
||||
case '>=': return actual >= threshold;
|
||||
case '<=': return actual <= threshold;
|
||||
case '==': return actual === threshold;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private calculateCpuPercent(stats: any): number {
|
||||
let cpuPercent = 0.0;
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const numCpus = stats.cpu_stats.online_cpus || (stats.cpu_stats.cpu_usage.percpu_usage ? stats.cpu_stats.cpu_usage.percpu_usage.length : 1);
|
||||
|
||||
if (systemDelta > 0.0 && cpuDelta > 0.0) {
|
||||
cpuPercent = (cpuDelta / systemDelta) * numCpus * 100.0;
|
||||
}
|
||||
return cpuPercent;
|
||||
}
|
||||
|
||||
private calculateMemoryPercent(stats: any): number {
|
||||
const used_memory = stats.memory_stats.usage - (stats.memory_stats.stats?.cache || 0);
|
||||
const available_memory = stats.memory_stats.limit;
|
||||
if (available_memory > 0) {
|
||||
return (used_memory / available_memory) * 100.0;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
private calculateNetwork(stats: any, direction: 'rx' | 'tx'): number {
|
||||
let bytes = 0;
|
||||
if (stats.networks) {
|
||||
for (const iface in stats.networks) {
|
||||
bytes += stats.networks[iface][`${direction}_bytes`];
|
||||
}
|
||||
}
|
||||
return bytes / (1024 * 1024); // Return in MB
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { DatabaseService } from './DatabaseService';
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
private dbService: DatabaseService;
|
||||
|
||||
private constructor() {
|
||||
this.dbService = DatabaseService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): NotificationService {
|
||||
if (!NotificationService.instance) {
|
||||
NotificationService.instance = new NotificationService();
|
||||
}
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
public async dispatchAlert(level: 'info' | 'warning' | 'error', message: string) {
|
||||
// 1. Log to history
|
||||
this.dbService.addNotificationHistory({
|
||||
level,
|
||||
message,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 2. Fetch enabled agents
|
||||
const agents = this.dbService.getEnabledAgents();
|
||||
if (agents.length === 0) {
|
||||
console.log('No active notification agents found. Skipping external dispatch.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Dispatch to each agent
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
if (agent.type === 'discord') {
|
||||
await this.sendDiscordWebhook(agent.url, level, message);
|
||||
} else if (agent.type === 'slack') {
|
||||
await this.sendSlackWebhook(agent.url, level, message);
|
||||
} else if (agent.type === 'webhook') {
|
||||
await this.sendCustomWebhook(agent.url, level, message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to dispatch notification to ${agent.type}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async testDispatch(type: 'discord' | 'slack' | 'webhook', url: string) {
|
||||
const level = 'info';
|
||||
const message = '🔌 Test Notification from Sencho!';
|
||||
|
||||
if (type === 'discord') {
|
||||
await this.sendDiscordWebhook(url, level, message);
|
||||
} else if (type === 'slack') {
|
||||
await this.sendSlackWebhook(url, level, message);
|
||||
} else if (type === 'webhook') {
|
||||
await this.sendCustomWebhook(url, level, message);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendDiscordWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {
|
||||
const colorMap = {
|
||||
info: 3447003, // Blue
|
||||
warning: 16776960, // Yellow
|
||||
error: 15158332 // Red
|
||||
};
|
||||
|
||||
const payload = {
|
||||
embeds: [{
|
||||
title: `Sencho Alert [${level.toUpperCase()}]`,
|
||||
description: message,
|
||||
color: colorMap[level],
|
||||
timestamp: new Date().toISOString()
|
||||
}]
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord Webhook responded with ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendSlackWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {
|
||||
const emojiMap = {
|
||||
info: 'ℹ️',
|
||||
warning: '⚠️',
|
||||
error: '🚨'
|
||||
};
|
||||
|
||||
const payload = {
|
||||
text: `${emojiMap[level]} *Sencho Alert [${level.toUpperCase()}]*\n${message}`
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Slack Webhook responded with ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendCustomWebhook(url: string, level: 'info' | 'warning' | 'error', message: string) {
|
||||
const payload = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'sencho'
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Custom Webhook responded with ${response.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+285
@@ -11,11 +11,16 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -1348,6 +1353,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dropdown-menu": {
|
||||
"version": "2.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
|
||||
"integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-menu": "2.1.16",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-focus-guards": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
|
||||
@@ -1483,6 +1517,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
|
||||
"integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
"integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
@@ -1666,6 +1813,67 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz",
|
||||
@@ -1712,6 +1920,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
@@ -1730,6 +1971,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||
@@ -1897,6 +2167,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-previous": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
|
||||
@@ -13,11 +13,16 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, Sun, Moon, RotateCw, CloudDownload, Pencil, X, Home, LogOut, Brush, ExternalLink, Bell, Settings, MoreVertical, BellRing } from 'lucide-react';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
@@ -23,7 +23,10 @@ import { ScrollArea } from './ui/scroll-area';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
|
||||
import { NotificationSettingsModal } from './NotificationSettingsModal';
|
||||
import { StackAlertSheet } from './StackAlertSheet';
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
@@ -82,6 +85,17 @@ export default function EditorLayout() {
|
||||
// Maintenance modal state
|
||||
const [maintenanceModalOpen, setMaintenanceModalOpen] = useState(false);
|
||||
|
||||
// Notifications & Settings state
|
||||
const [notifications, setNotifications] = useState<any[]>([]);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
||||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
||||
|
||||
const openAlertSheet = (stackName: string) => {
|
||||
setAlertSheetStack(stackName);
|
||||
setAlertSheetOpen(true);
|
||||
};
|
||||
|
||||
// Theme toggle effect
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
@@ -124,8 +138,28 @@ export default function EditorLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshStacks();
|
||||
fetchNotifications();
|
||||
const notificationInterval = setInterval(fetchNotifications, 30000);
|
||||
return () => clearInterval(notificationInterval);
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/notifications/history');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNotifications(data);
|
||||
}
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await apiFetch('/notifications/read', { method: 'POST' });
|
||||
fetchNotifications();
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const wsMap: Record<string, WebSocket> = {};
|
||||
(containers || []).forEach(container => {
|
||||
@@ -576,7 +610,7 @@ export default function EditorLayout() {
|
||||
key={file}
|
||||
value={file}
|
||||
onSelect={() => loadFile(file)}
|
||||
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
|
||||
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-muted group ${selectedFile === file ? '!bg-accent !text-accent-foreground' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div
|
||||
@@ -584,7 +618,23 @@ export default function EditorLayout() {
|
||||
stackStatuses[file] === 'exited' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
{getDisplayName(file)}
|
||||
<span className="flex-1 truncate">{getDisplayName(file)}</span>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openAlertSheet(file)}>
|
||||
<BellRing className="h-4 w-4 mr-2" />
|
||||
Configure Alerts
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))
|
||||
@@ -640,6 +690,65 @@ export default function EditorLayout() {
|
||||
<Brush className="w-4 h-4 mr-2" />
|
||||
Janitor
|
||||
</Button>
|
||||
|
||||
{/* Settings Modal Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
title="Notification Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
{/* Notifications Popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
|
||||
<Bell className="w-4 h-4" />
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h4 className="font-semibold">Notifications</h4>
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{notifications.map((notif: any) => (
|
||||
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
|
||||
{notif.level}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(notif.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium">{notif.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -924,11 +1033,23 @@ export default function EditorLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Maintenance Modal */}
|
||||
<MaintenanceModal
|
||||
isOpen={maintenanceModalOpen}
|
||||
onClose={() => setMaintenanceModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Notification Settings Modal */}
|
||||
<NotificationSettingsModal
|
||||
isOpen={settingsModalOpen}
|
||||
onClose={() => setSettingsModalOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Stack Alert Sheet */}
|
||||
<StackAlertSheet
|
||||
isOpen={alertSheetOpen}
|
||||
onClose={() => setAlertSheetOpen(false)}
|
||||
stackName={alertSheetStack}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Agent {
|
||||
type: 'discord' | 'slack' | 'webhook';
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface NotificationSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NotificationSettingsModal({ isOpen, onClose }: NotificationSettingsModalProps) {
|
||||
const [agents, setAgents] = useState<Record<string, Agent>>({
|
||||
discord: { type: 'discord', url: '', enabled: false },
|
||||
slack: { type: 'slack', url: '', enabled: false },
|
||||
webhook: { type: 'webhook', url: '', enabled: false },
|
||||
});
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, string>>({
|
||||
host_cpu_limit: '90',
|
||||
host_ram_limit: '90',
|
||||
host_disk_limit: '90',
|
||||
global_crash: '1',
|
||||
docker_janitor_gb: '5'
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchAgents();
|
||||
fetchSettings();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/agents');
|
||||
if (res.ok) {
|
||||
const data: Agent[] = await res.json();
|
||||
const newAgents = { ...agents };
|
||||
data.forEach(a => {
|
||||
newAgents[a.type] = a;
|
||||
});
|
||||
setAgents(newAgents);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch agents', e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings(prev => ({ ...prev, ...data }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settings', e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentChange = (type: string, field: keyof Agent, value: any) => {
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], [field]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSettingChange = (key: string, value: string) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const saveAgent = async (type: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(agents[type])
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success(`${type} settings saved successfully.`);
|
||||
} else {
|
||||
toast.error(`Failed to save ${type} settings.`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testAgent = async (type: string) => {
|
||||
if (!agents[type].url) {
|
||||
toast.error('Please enter a webhook URL first.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/notifications/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type, url: agents[type].url })
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Test notification sent!');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.details || 'Test failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
}
|
||||
toast.success('Global events settings saved.');
|
||||
} catch (e) {
|
||||
toast.error('Failed to save settings.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderAgentTab = (type: 'discord' | 'slack' | 'webhook', title: string) => (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor={`${type}-enabled`} className="font-semibold">Enable {title} Notifications</Label>
|
||||
<Switch
|
||||
id={`${type}-enabled`}
|
||||
checked={agents[type].enabled}
|
||||
onCheckedChange={(c) => handleAgentChange(type, 'enabled', c)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${type}-url`}>Webhook URL</Label>
|
||||
<Input
|
||||
id={`${type}-url`}
|
||||
placeholder="https://..."
|
||||
value={agents[type].url}
|
||||
onChange={(e) => handleAgentChange(type, 'url', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2 justify-end pt-4">
|
||||
<Button variant="outline" onClick={() => testAgent(type)} disabled={isLoading}>Test</Button>
|
||||
<Button onClick={() => saveAgent(type)} disabled={isLoading}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification & Alert Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure where alerts are sent and global system limits.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="global" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="global">Global</TabsTrigger>
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
<TabsTrigger value="slack">Slack</TabsTrigger>
|
||||
<TabsTrigger value="webhook">Webhook</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="global">
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<Label>Host CPU Limit (%)</Label>
|
||||
<span className="text-sm text-muted-foreground">{settings.host_cpu_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_cpu_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_cpu_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<Label>Host RAM Limit (%)</Label>
|
||||
<span className="text-sm text-muted-foreground">{settings.host_ram_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_ram_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_ram_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<Label>Host Disk Limit (%)</Label>
|
||||
<span className="text-sm text-muted-foreground">{settings.host_disk_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_disk_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_disk_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Docker Janitor Alert Threshold (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.docker_janitor_gb}
|
||||
onChange={(e) => handleSettingChange('docker_janitor_gb', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Label htmlFor="global_crash" className="font-semibold">Global Crash Detection (Any Container)</Label>
|
||||
<Switch
|
||||
id="global_crash"
|
||||
checked={settings.global_crash === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('global_crash', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={saveSettings} disabled={isLoading}>Save Global Settings</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
|
||||
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
|
||||
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface StackAlert {
|
||||
id?: number;
|
||||
stack_name: string;
|
||||
metric: string;
|
||||
operator: string;
|
||||
threshold: number;
|
||||
duration_mins: number;
|
||||
cooldown_mins: number;
|
||||
}
|
||||
|
||||
interface StackAlertSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
stackName: string;
|
||||
}
|
||||
|
||||
export function StackAlertSheet({ isOpen, onClose, stackName }: StackAlertSheetProps) {
|
||||
const [alerts, setAlerts] = useState<StackAlert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// New Alert Form State
|
||||
const [metric, setMetric] = useState('cpu_percent');
|
||||
const [operator, setOperator] = useState('>');
|
||||
const [threshold, setThreshold] = useState('');
|
||||
const [duration, setDuration] = useState('5');
|
||||
const [cooldown, setCooldown] = useState('60');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && stackName) {
|
||||
fetchAlerts();
|
||||
}
|
||||
}, [isOpen, stackName]);
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/alerts?stackName=${stackName}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAlerts(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch alerts', e);
|
||||
}
|
||||
};
|
||||
|
||||
const addAlert = async () => {
|
||||
if (!threshold) {
|
||||
toast.error('Please enter a threshold.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const newAlert = {
|
||||
stack_name: stackName,
|
||||
metric,
|
||||
operator,
|
||||
threshold: parseFloat(threshold),
|
||||
duration_mins: parseInt(duration, 10),
|
||||
cooldown_mins: parseInt(cooldown, 10)
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/alerts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newAlert)
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success('Alert rule added.');
|
||||
setThreshold('');
|
||||
fetchAlerts();
|
||||
} else {
|
||||
toast.error('Failed to add alert rule.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAlert = async (id: number) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/alerts/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
toast.success('Alert rule deleted.');
|
||||
fetchAlerts();
|
||||
} else {
|
||||
toast.error('Failed to delete alert rule.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const metricLabels: Record<string, string> = {
|
||||
cpu_percent: 'CPU Usage (%)',
|
||||
memory_percent: 'Memory Usage (%)',
|
||||
memory_mb: 'Memory Usage (MB)',
|
||||
net_rx: 'Network In (MB)',
|
||||
net_tx: 'Network Out (MB)',
|
||||
restart_count: 'Restart Count'
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<SheetContent className="overflow-y-auto sm:max-w-[400px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Stack Alerts: {stackName}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure metric thresholds to trigger notifications for this stack.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* List Existing Alerts */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Existing Rules</h4>
|
||||
{alerts.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground p-4 bg-muted/50 rounded-lg text-center">
|
||||
No active alert rules for this stack.
|
||||
</div>
|
||||
) : (
|
||||
alerts.map(alert => (
|
||||
<div key={alert.id} className="flex flex-col gap-2 p-3 bg-muted/50 rounded-lg border text-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">
|
||||
{metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold}
|
||||
</span>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
Trigger after {alert.duration_mins}m • Cooldown: {alert.cooldown_mins}m
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive shrink-0"
|
||||
onClick={() => alert.id && deleteAlert(alert.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Add New Alert Form */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-semibold">Add New Rule</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Metric</Label>
|
||||
<Select value={metric} onValueChange={setMetric}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(metricLabels).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Operator</Label>
|
||||
<Select value={operator} onValueChange={setOperator}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value=">">Greater than</SelectItem>
|
||||
<SelectItem value=">=">Greater or eq</SelectItem>
|
||||
<SelectItem value="<">Less than</SelectItem>
|
||||
<SelectItem value="<=">Less or eq</SelectItem>
|
||||
<SelectItem value="==">Equals</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Threshold</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={threshold}
|
||||
onChange={e => setThreshold(e.target.value)}
|
||||
placeholder="e.g. 90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Duration (mins)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={e => setDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Cooldown (mins)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={cooldown}
|
||||
onChange={e => setCooldown(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button className="w-full mt-2" onClick={addAlert} disabled={isLoading}>
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user