From c6eb9d76e7f63a76eec3f0d1f1a6c28d3ee78813 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Thu, 26 Feb 2026 23:09:47 -0500 Subject: [PATCH] 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. --- backend/package-lock.json | 242 ++++++++++++++ backend/package.json | 2 + backend/src/index.ts | 106 +++++++ backend/src/services/DatabaseService.ts | 220 +++++++++++++ backend/src/services/DockerController.ts | 6 + backend/src/services/MonitorService.ts | 299 ++++++++++++++++++ backend/src/services/NotificationService.ts | 129 ++++++++ frontend/package-lock.json | 285 +++++++++++++++++ frontend/package.json | 5 + frontend/src/components/EditorLayout.tsx | 131 +++++++- .../components/NotificationSettingsModal.tsx | 267 ++++++++++++++++ frontend/src/components/StackAlertSheet.tsx | 248 +++++++++++++++ frontend/src/components/ui/dropdown-menu.tsx | 199 ++++++++++++ frontend/src/components/ui/popover.tsx | 31 ++ frontend/src/components/ui/select.tsx | 157 +++++++++ frontend/src/components/ui/sheet.tsx | 140 ++++++++ frontend/src/components/ui/slider.tsx | 26 ++ frontend/src/components/ui/switch.tsx | 29 ++ frontend/src/components/ui/tabs.tsx | 2 - 19 files changed, 2517 insertions(+), 7 deletions(-) create mode 100644 backend/src/services/DatabaseService.ts create mode 100644 backend/src/services/MonitorService.ts create mode 100644 backend/src/services/NotificationService.ts create mode 100644 frontend/src/components/NotificationSettingsModal.tsx create mode 100644 frontend/src/components/StackAlertSheet.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/slider.tsx create mode 100644 frontend/src/components/ui/switch.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index e8d0b29e..1e2dee12 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 494b22bd..2001fa52 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/index.ts b/backend/src/index.ts index 26c02a68..27e67a87 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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}`); }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts new file mode 100644 index 00000000..e6f6fc99 --- /dev/null +++ b/backend/src/services/DatabaseService.ts @@ -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 { + const stmt = this.db.prepare('SELECT * FROM global_settings'); + const settings: Record = {}; + 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): 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(); + } +} diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 74efb711..d64e1f5a 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -288,6 +288,12 @@ class DockerController { }); } + public async getContainerStatsStream(containerId: string): Promise { + 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. diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts new file mode 100644 index 00000000..b377a6b4 --- /dev/null +++ b/backend/src/services/MonitorService.ts @@ -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(); + + 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) { + 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 + } +} diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts new file mode 100644 index 00000000..ebcf0033 --- /dev/null +++ b/backend/src/services/NotificationService.ts @@ -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}`); + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 423e742e..9c0a0717 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 98729df2..7ec4f1ab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 40b7fc7b..c513a759 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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([]); + 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 = {}; (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' : ''}`} >
- {getDisplayName(file)} + {getDisplayName(file)} + +
e.stopPropagation()}> + + + + + + openAlertSheet(file)}> + + Configure Alerts + + + +
)) @@ -640,6 +690,65 @@ export default function EditorLayout() { Janitor + + {/* Settings Modal Toggle */} + + + {/* Notifications Popover */} + + + + + +
+

Notifications

+ {notifications.filter(n => !n.is_read).length > 0 && ( + + )} +
+ + {notifications.length === 0 ? ( +
No notifications
+ ) : ( +
+ {notifications.map((notif: any) => ( +
+
+ + {notif.level} + + + {new Date(notif.timestamp).toLocaleString()} + +
+

{notif.message}

+
+ ))} +
+ )} +
+
+
+ {/* Theme Toggle */}
); } diff --git a/frontend/src/components/NotificationSettingsModal.tsx b/frontend/src/components/NotificationSettingsModal.tsx new file mode 100644 index 00000000..7a096cfd --- /dev/null +++ b/frontend/src/components/NotificationSettingsModal.tsx @@ -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>({ + discord: { type: 'discord', url: '', enabled: false }, + slack: { type: 'slack', url: '', enabled: false }, + webhook: { type: 'webhook', url: '', enabled: false }, + }); + + const [settings, setSettings] = useState>({ + 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) => ( +
+
+ + handleAgentChange(type, 'enabled', c)} + /> +
+
+ + handleAgentChange(type, 'url', e.target.value)} + /> +
+
+ + +
+
+ ); + + return ( + !open && onClose()}> + + + Notification & Alert Settings + + Configure where alerts are sent and global system limits. + + + + + + Global + Discord + Slack + Webhook + + + +
+
+
+ + {settings.host_cpu_limit}% +
+ handleSettingChange('host_cpu_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_ram_limit}% +
+ handleSettingChange('host_ram_limit', v[0].toString())} + /> +
+ +
+
+ + {settings.host_disk_limit}% +
+ handleSettingChange('host_disk_limit', v[0].toString())} + /> +
+ +
+ + handleSettingChange('docker_janitor_gb', e.target.value)} + /> +
+ +
+ + handleSettingChange('global_crash', c ? '1' : '0')} + /> +
+ +
+ +
+
+
+ + {renderAgentTab('discord', 'Discord')} + {renderAgentTab('slack', 'Slack')} + {renderAgentTab('webhook', 'Custom Webhook')} +
+
+
+ ); +} diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx new file mode 100644 index 00000000..92787303 --- /dev/null +++ b/frontend/src/components/StackAlertSheet.tsx @@ -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([]); + 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 = { + 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 ( + !open && onClose()}> + + + Stack Alerts: {stackName} + + Configure metric thresholds to trigger notifications for this stack. + + + +
+ {/* List Existing Alerts */} +
+

Existing Rules

+ {alerts.length === 0 ? ( +
+ No active alert rules for this stack. +
+ ) : ( + alerts.map(alert => ( +
+
+
+ + {metricLabels[alert.metric] || alert.metric} {alert.operator} {alert.threshold} + +
+ Trigger after {alert.duration_mins}m â€ĸ Cooldown: {alert.cooldown_mins}m +
+
+ +
+
+ )) + )} +
+ +
+ + {/* Add New Alert Form */} +
+

Add New Rule

+ +
+ + +
+ +
+
+ + +
+
+ + setThreshold(e.target.value)} + placeholder="e.g. 90" + /> +
+
+ +
+
+ + setDuration(e.target.value)} + /> +
+
+ + setCooldown(e.target.value)} + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..e804bca1 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} + /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 00000000..fdcb511d --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 00000000..2ca1df8d --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx new file mode 100644 index 00000000..272cb721 --- /dev/null +++ b/frontend/src/components/ui/sheet.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, 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, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx new file mode 100644 index 00000000..9398b331 --- /dev/null +++ b/frontend/src/components/ui/slider.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx new file mode 100644 index 00000000..5f4117f0 --- /dev/null +++ b/frontend/src/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 0f4caebb..85d83bea 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import * as TabsPrimitive from "@radix-ui/react-tabs"