172 Commits

Author SHA1 Message Date
headlessdev
51bd61622e v0.0.10 -> main
V0.0.10
2025-04-27 16:03:02 +02:00
headlessdev
127077d929 Added two more icons for server icons 2025-04-27 15:33:34 +02:00
headlessdev
fcb7196d1c Enhance application monitoring by integrating custom uptime check URL support. Updated database queries and application model to include UptimeCheckURL, allowing for more flexible monitoring options. 2025-04-27 15:28:00 +02:00
headlessdev
30aa4bcf57 Add custom uptime check feature to Applications dashboard, allowing users to specify a custom URL for uptime monitoring. Updated state management and UI components to support this functionality. 2025-04-27 15:23:37 +02:00
headlessdev
3dc2c6b204 Add uptimecheckUrl to AddRequest and EditRequest interfaces, updating application creation and editing logic accordingly 2025-04-27 14:58:44 +02:00
headlessdev
65f8e16fbc Notification Name 2025-04-27 14:56:40 +02:00
headlessdev
807d4d6ca9 Add name field to AddRequest interface and update notification creation logic 2025-04-27 14:49:36 +02:00
headlessdev
70e28cb1c7 Add uptimecheckUrl to application model and name to notification model 2025-04-27 14:45:09 +02:00
headlessdev
cba70d5aef Updated Agent Dockerfile 2025-04-26 16:13:04 +02:00
headlessdev
78c844b1fe Split go agent into multiple files for better overview 2025-04-26 16:11:06 +02:00
headlessdev
00a628031e Finish úptime display 2025-04-26 15:38:39 +02:00
headlessdev
a84a985645 Display Uptime in Servers Page 2025-04-26 15:21:20 +02:00
headlessdev
b08d5b0fef Add uptime column to server model 2025-04-26 15:13:00 +02:00
headlessdev
58f70ad1d9 Application Grid View UI Bug Fix 2025-04-26 15:06:46 +02:00
headlessdev
2f61974f6e New view selection menu in servers view 2025-04-26 13:18:26 +02:00
headlessdev
fde7b3b23e New compact view for applications 2025-04-26 13:11:19 +02:00
headlessdev
677d1c5a58 Add custom items per page dropdowns 2025-04-25 23:34:52 +02:00
headlessdev
d81d8c04ad Fixed missing space in docs 2025-04-25 22:57:48 +02:00
headlessdev
b368075b12 Fix ntfy notifications 2025-04-25 22:51:34 +02:00
headlessdev
7ad4390db7 Fix SMTP Notifications 2025-04-25 22:34:20 +02:00
headlessdev
408b80badf Fix allocated server not showing in applications search result 2025-04-25 22:13:24 +02:00
headlessdev
8223cc5822 Memory not displayed fix 2025-04-25 22:11:39 +02:00
headlessdev
c94377c8d5 HOTFIX v2 2025-04-24 17:30:06 +02:00
headlessdev
5592a5fd91 HOTFIX: Uptime Online Status 2025-04-24 15:54:42 +02:00
headlessdev
f3eabb9297 v0.0.9->main
v0.0.9
2025-04-24 14:42:36 +02:00
headlessdev
111864d0b3 Hotifx edit serve rmenu monitoring 2025-04-24 14:38:45 +02:00
headlessdev
343dc15f66 Updated Buymeacoffee links 2025-04-24 14:28:03 +02:00
headlessdev
f297e20ae1 Add Readme.md screenshots directly into the repo 2025-04-24 14:24:38 +02:00
headlessdev
09bdf49d0f Updated Readme 2025-04-24 14:18:42 +02:00
headlessdev
088f46df29 Removed useless notification alert in servers 2025-04-24 14:14:35 +02:00
headlessdev
bd61e665df Docs v0.0.2 2025-04-24 14:13:18 +02:00
headlessdev
c3df67f407 docs add pushover 2025-04-24 14:13:01 +02:00
headlessdev
08cce9fd24 Update Uptime API and Dashboard to support additional timespan options and improve time formatting 2025-04-24 14:06:02 +02:00
headlessdev
4e64701ddf Servers Notifications 2025-04-24 14:02:50 +02:00
headlessdev
512a5aa49e Applications Notifications 2025-04-24 14:01:19 +02:00
headlessdev
861eab8050 Test Notification Processing in agent 2025-04-23 21:34:52 +02:00
headlessdev
b2c47a07a6 Test notification api route 2025-04-23 21:06:17 +02:00
headlessdev
825b25a60b Add test_notification model to Prisma schema 2025-04-23 21:04:43 +02:00
headlessdev
ece4041c74 Fix Timespan dropdown 2025-04-23 20:26:50 +02:00
headlessdev
88c8dbb7f3 Remove Legends 2025-04-23 20:25:45 +02:00
headlessdev
42622f69d2 Last 24 hours chart view 2025-04-23 20:24:24 +02:00
headlessdev
a8f0c7932f VM View Fix 2025-04-23 20:21:33 +02:00
headlessdev
5c2b348c27 Server Link Fix 2025-04-23 20:16:40 +02:00
headlessdev
c8247a8ee0 Fix VM View 2025-04-23 20:14:06 +02:00
headlessdev
6ced93722c Fix Trash Button color 2025-04-23 19:51:42 +02:00
headlessdev
5cfd951f28 Server card full width 2025-04-23 19:51:21 +02:00
headlessdev
6acba7172c Add more spacing to the status indicator 2025-04-23 19:49:51 +02:00
headlessdev
6e7ff512ad Fix edit und delete buttons 2025-04-23 19:49:14 +02:00
headlessdev
9ff39ee241 Fix RAM Usage Get 2025-04-23 19:43:50 +02:00
headlessdev
2b90e9b580 Update ServerDetail component to use full width for usage indicators, enhancing layout consistency for CPU, RAM, and Disk usage displays. 2025-04-23 19:36:17 +02:00
headlessdev
02c5128fae Put Status indicator in the top right corner 2025-04-23 19:35:13 +02:00
headlessdev
c82a527ae4 Add spanGaps option to chart datasets for CPU, RAM, and Disk in ServerDetail component 2025-04-23 19:31:10 +02:00
headlessdev
0c9a7670fc Fix card spacing 2025-04-23 19:25:51 +02:00
headlessdev
a214d41f0c Disable legend view in charts 2025-04-23 19:24:09 +02:00
headlessdev
443581d676 Fix chart y-axis 2025-04-23 19:21:56 +02:00
headlessdev
577a577350 Change N/A to - in Server view 2025-04-23 19:20:30 +02:00
headlessdev
cd8379407a Refactor ServerDetail component to improve chart configuration and cleanup logic. Added common chart options for CPU, RAM, and Disk usage, ensuring consistent y-axis settings. Updated time range selection for better clarity and usability. 2025-04-23 19:16:49 +02:00
headlessdev
4e58dc5a0b Server History Page 2025-04-23 18:55:45 +02:00
headlessdev
4381f20146 Fixed custom server icons not visible in dark mode 2025-04-23 14:15:37 +02:00
headlessdev
b3fe8f83b1 Version Bump 2025-04-23 13:43:07 +02:00
headlessdev
828b1e4fbe Docs Hotfix 2025-04-21 16:06:35 +02:00
headlessdev
686088e08c docs build 2025-04-21 16:02:55 +02:00
headlessdev
f164f9dea2 Update README.md 2025-04-21 15:53:04 +02:00
headlessdev
10acf3c738 v0.0.8->main
V0.0.8
2025-04-21 15:51:17 +02:00
headlessdev
9ee8d28fe8 Update .dockerignore to include 'docs/' directory 2025-04-21 15:43:21 +02:00
headlessdev
c52581e4e3 Update README with new dashboard and applications page images, and mark Simple Server Monitoring History as a task 2025-04-21 15:42:54 +02:00
headlessdev
5b411b9f3d Fix spacing in Pushover add notifcation 2025-04-21 15:39:10 +02:00
headlessdev
1063072757 Pushover Placeholders 2025-04-21 15:35:38 +02:00
headlessdev
93841120aa Add Pushover notification to go 2025-04-21 15:34:56 +02:00
headlessdev
27a63a9025 Add Pushover Configuration to frontend 2025-04-21 15:31:38 +02:00
headlessdev
d51908b48d Add pushover to notification add api 2025-04-21 15:28:36 +02:00
headlessdev
e0c159cb71 pushover settings in notifcations model 2025-04-21 15:27:40 +02:00
headlessdev
ce9bdadb69 Delete server_history of a server on server deletion 2025-04-21 15:20:10 +02:00
headlessdev
082e36a34c Updated Attribution 2025-04-21 15:16:56 +02:00
headlessdev
861ed28e45 Updated Docs 2025-04-21 15:14:40 +02:00
headlessdev
8dade95c75 agent notifications for server offline/online 2025-04-21 13:31:07 +02:00
headlessdev
1ae8b3e324 server_history model 2025-04-21 13:25:55 +02:00
headlessdev
21dd61c597 Update notification text handling in API and dashboard to support separate application and server notification texts. 2025-04-21 13:22:40 +02:00
headlessdev
49eeab4848 Refactor settings model in Prisma schema to rename notification_text field to notification_text_application and add notification_text_server field for improved clarity and organization. 2025-04-21 13:15:30 +02:00
headlessdev
f47e22fe27 Dashboard UI Fix 2025-04-21 13:11:37 +02:00
headlessdev
809bf19eb4 Grid/List View Pagination Fix 2025-04-21 12:56:32 +02:00
headlessdev
a1a5e5e299 Updated Status indicator 2025-04-21 12:40:55 +02:00
headlessdev
df2c788b0b periodic monitoring data updates from the server API. 2025-04-21 12:27:18 +02:00
headlessdev
693368b735 Updated docs 2025-04-21 12:16:53 +02:00
headlessdev
ded9149466 Copy Server Function 2025-04-21 00:13:27 +02:00
headlessdev
f40e588e7d sample Docker Compose configuration for Glances 2025-04-20 23:39:54 +02:00
headlessdev
fdbac4ebff Enhance Servers component by adding a "No host server" option in the server selection dropdown, updating state management to handle monitoring settings based on selected host server, and introducing a new "Hardware Information" section for better organization of server details. 2025-04-20 23:34:57 +02:00
headlessdev
456f40dab2 Updated Status indicator on Server card 2025-04-20 23:25:35 +02:00
headlessdev
ca81165a1c Enhance Servers component with detailed resource usage metrics display, including CPU, RAM, and Disk usage indicators, and update server icon to LucideServer. 2025-04-20 23:19:58 +02:00
headlessdev
f548367e86 Add server monitoring functionality, including server struct, metrics retrieval for CPU, memory, and disk usage, and status updates in the database. 2025-04-20 22:45:54 +02:00
headlessdev
0975bc5c5f Documentation Init 2025-04-20 19:54:58 +02:00
headlessdev
2eb0b4c8a0 Vitepress init 2025-04-20 18:44:27 +02:00
headlessdev
9036110a1c Remove gitbook 2025-04-20 18:37:38 +02:00
headlessdev
7f2ebf6129 Docker Compose depends_on fix 2025-04-20 17:16:10 +02:00
headlessdev
4a647ac19b Add monitoring fields to server add and edit routes, including monitoring status and URL in request handling. 2025-04-20 17:10:26 +02:00
headlessdev
d1549bf096 Add monitoring functionality to Servers component, including state management for monitoring settings and a new monitoring tab in the UI. 2025-04-20 17:09:33 +02:00
headlessdev
a3d1814343 Add monitoring fields and online status to server model in Prisma schema 2025-04-19 21:47:00 +02:00
headlessdev
6c8d4c6ec1 Bump version 2025-04-19 19:44:54 +02:00
headlessdev
63e8744e78 v0.0.7->main
v0.0.7
2025-04-19 16:34:14 +02:00
headlessdev
e39402ff70 small notification text fix 2025-04-19 16:23:53 +02:00
headlessdev
7d49baee6b Update server count query to filter by hostServer set to 0 and null to fix pagination 2025-04-19 16:20:59 +02:00
headlessdev
ceb10a2ffe Update server name display to conditionally include an icon indicator for better visual context. 2025-04-19 16:18:44 +02:00
headlessdev
7986737e0e Update Applications component layout by adjusting width of URL buttons and refining button labels for clarity. 2025-04-19 16:15:56 +02:00
headlessdev
f6debb1629 AAdjusted server name display to include an icon indicator and refined button alignment for management URL access. 2025-04-19 16:12:41 +02:00
headlessdev
8259563c33 Refactor Servers component to improve code readability and structure. Removed unused imports, standardized import statements, and updated state variable declarations for consistency. Enhanced icon selection functionality in the server editing interface. 2025-04-19 15:58:48 +02:00
headlessdev
3580f7f640 Add icon field to AddRequest and EditRequest interfaces, and update Dashboard component to support icon selection and display. Enhanced server creation and editing functionality with icon integration. 2025-04-19 15:46:01 +02:00
headlessdev
b655b7fe2d Add optional icon field to server model in Prisma schema 2025-04-19 15:17:20 +02:00
headlessdev
b42c1a45cc Refactor Servers component by removing unused imports and cleaning up code structure 2025-04-19 15:07:34 +02:00
headlessdev
86d48bc082 VMs are now shown in the search results 2025-04-19 14:39:02 +02:00
headlessdev
44817d6685 Adjust flowchart spacing constants for improved layout and readability 2025-04-19 14:26:42 +02:00
headlessdev
62c27118d6 Add support for Gotify and Ntfy notification types in the agent module
- Introduced new fields for Gotify and Ntfy URLs and tokens in the Notification struct.
- Updated the loadNotifications function to retrieve Gotify and Ntfy data from the database.
- Implemented sendGotify and sendNtfy functions to handle sending notifications via Gotify and Ntfy services.
- Enhanced the sendNotifications function to include logic for sending messages through Gotify and Ntfy.
2025-04-19 13:48:52 +02:00
headlessdev
016c9a2562 Remove color styling from server count display in Dashboard 2025-04-19 13:24:08 +02:00
headlessdev
b835ded157 Add notification type descriptions and icons for Gotify and Ntfy in Settings component 2025-04-19 13:21:39 +02:00
headlessdev
016d52fa1b Remove Bell icon from Add Notification Channel button in Settings component 2025-04-19 13:20:47 +02:00
headlessdev
2b8f7a95d2 Add Gotify and Ntfy configuration fields to Settings component for enhanced notification options 2025-04-19 13:20:04 +02:00
headlessdev
300547e59e Add Gotify and ntfy fields to AddRequest interface and update POST method to handle new notification types 2025-04-19 13:11:18 +02:00
headlessdev
93bffa29cc Add new fields for Gotify and ntfy integration in notification model 2025-04-19 13:07:09 +02:00
headlessdev
2a910c165e Add disabled state and warning message for host server checkbox in Dashboard 2025-04-19 00:29:45 +02:00
headlessdev
6412cbaf1c Filter out the currently edited server from the host server selection in the Dashboard component for improved user experience. 2025-04-19 00:28:22 +02:00
headlessdev
d9304001fe Fix ScrollArea width in Dashboard server card for better layout consistency 2025-04-19 00:20:38 +02:00
headlessdev
7d7897c3f6 Update server card title in Dashboard for clarity 2025-04-19 00:18:02 +02:00
headlessdev
1ae55da3f9 UI improvements server card 2025-04-19 00:17:41 +02:00
headlessdev
113bb3bfb4 Enhance Dashboard UI for server management by updating card layout, improving titles, and adding descriptions for physical and virtual servers. 2025-04-19 00:16:23 +02:00
headlessdev
83ea20545d Update server count handling in Dashboard component to separate physical servers and VMs 2025-04-19 00:13:12 +02:00
headlessdev
965f79f31a Refactor server count retrieval in POST request to differentiate between servers with and without VMs 2025-04-19 00:00:10 +02:00
headlessdev
f1c0cc9deb Network VM spacing fix 2025-04-18 23:57:10 +02:00
headlessdev
f2535cd2b9 Refactor hostServer assignment in PUT request to handle null values correctly 2025-04-18 23:48:29 +02:00
headlessdev
42e584a381 Updated Flowchart 2025-04-18 23:46:20 +02:00
headlessdev
0e1f9edaab improved ui for setting notifications card 2025-04-18 23:03:05 +02:00
headlessdev
c3fe3bc03d Update version to 0.0.7 in package.json 2025-04-18 22:53:02 +02:00
headlessdev
67097725d7 Remove deprecated @next/swc-win32-x64-msvc module from package-lock.json 2025-04-18 22:50:38 +02:00
headlessdev
61468a359d Update README.md 2025-04-18 17:12:49 +02:00
headlessdev
b70d17d844 v0.0.6 notifications->main
v0.0.6
2025-04-18 16:54:16 +02:00
headlessdev
35fae815cf Merge branch 'main' into notifications 2025-04-18 16:54:00 +02:00
headlessdev
8c087792bf DB Healthcheck 2025-04-18 16:52:49 +02:00
headlessdev
8627c54be9 Hotfix 2025-04-18 16:52:14 +02:00
headlessdev
41647a2e6c Updated Application Images 2025-04-18 16:04:41 +02:00
headlessdev
a73a98ddac small ui improvement 2025-04-18 16:01:03 +02:00
headlessdev
3e49932382 Enhance URL validation and error handling in checkAndUpdateStatus function 2025-04-18 15:55:57 +02:00
headlessdev
406091fdcb Basic VM functionality 2025-04-18 14:35:36 +02:00
headlessdev
c266296c4f Add host and hostServer fields to AddRequest and EditRequest interfaces 2025-04-18 12:26:13 +02:00
headlessdev
01470f5ce1 Change hostServer column type from String to Int in server model and migration 2025-04-18 12:11:12 +02:00
headlessdev
82cee64860 Add host and hostServer columns to server model and migration 2025-04-18 11:33:20 +02:00
headlessdev
60dd711856 Fix uptime check crash error 2025-04-18 11:29:14 +02:00
headlessdev
bcbc17d3fe Hotfix 2025-04-18 11:24:42 +02:00
headlessdev
547212cd9e Sidebar Design Update 2025-04-18 00:07:21 +02:00
headlessdev
dec15c0ce0 Mark Notifications as completed in the roadmap 2025-04-17 19:03:05 +02:00
headlessdev
e96ee56aa1 Add dynamic notification message template retrieval 2025-04-17 17:36:58 +02:00
headlessdev
6e1f4eeddd Remove unused Accordion imports from Settings component 2025-04-17 17:27:44 +02:00
headlessdev
70010ce8ee Set Notification Text Functionality 2025-04-17 17:27:01 +02:00
headlessdev
c1b62d8108 Implement endpoint to retrieve notification text from settings 2025-04-17 17:17:14 +02:00
headlessdev
c690a1cb37 Add customizable notification text feature in settings 2025-04-17 17:15:39 +02:00
headlessdev
88d99cee43 Add endpoint for managing notification text in settings 2025-04-17 17:03:06 +02:00
headlessdev
b0c7b813e6 Bump version to 0.0.6 in package.json 2025-04-17 16:51:27 +02:00
headlessdev
88f7f6a9d1 Add notification_text column to settings model in Prisma schema and migration 2025-04-17 16:51:15 +02:00
headlessdev
b9fac8ddb6 Enhance Application struct and update getApplications to include Name; improve HTTP request handling in checkAndUpdateStatus 2025-04-17 16:20:24 +02:00
headlessdev
dacde7153f Update Notifications List after adding Notification 2025-04-17 16:14:52 +02:00
headlessdev
8f647d3489 Implement notification reload mechanism and improve thread safety with mutex 2025-04-17 16:13:29 +02:00
headlessdev
e925f37b19 Notification Agent System 2025-04-17 16:12:10 +02:00
headlessdev
155a0af883 Type Error Fix getNotifications 2025-04-17 15:39:21 +02:00
headlessdev
6fd360b594 Change HTTP request method from HEAD to GET in checkAndUpdateStatus function 2025-04-17 15:34:45 +02:00
headlessdev
e9aba02d5f Add Notification SMTP Layout Fix 2025-04-17 15:29:43 +02:00
headlessdev
4a8759f627 Notifications Display & Delete Notifications 2025-04-17 15:25:43 +02:00
headlessdev
d00ec93133 Fix type annotation for smtpSecure checkbox onChange handler 2025-04-17 15:17:03 +02:00
headlessdev
e7e873c75c Implement endpoint to retrieve notifications 2025-04-17 15:16:38 +02:00
headlessdev
631c5b0c3b Add Notifications System 2025-04-17 15:14:27 +02:00
headlessdev
a51f8c2a3c Add @next/swc-win32-x64-msvc package to package-lock.json 2025-04-17 14:17:51 +02:00
headlessdev
346b79ca22 Implement notification creation and deletion endpoints 2025-04-17 14:13:21 +02:00
headlessdev
2fd8e50f7f Add notification settings with SMTP, Telegram, and Discord options in Settings component 2025-04-17 14:02:54 +02:00
headlessdev
cecc5e0bab Add notification settings with alert dialog in Settings component 2025-04-17 13:17:22 +02:00
headlessdev
2325f9b042 Version to 0.0.5 2025-04-17 13:08:20 +02:00
headlessdev
4b29f7cbed Prisma notification model 2025-04-17 13:07:19 +02:00
211 changed files with 10485 additions and 1165 deletions

View File

@@ -2,4 +2,6 @@ node_modules
npm-debug.log
.env
agent/
.next
.next
docs/
screenshots/

5
.gitignore vendored
View File

@@ -1,5 +1,10 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Vitepress
docs/.vitepress/cache
docs/node_modules/
# dependencies
/node_modules
/.pnp

BIN
ApplicationsFormatted.tsx Normal file

Binary file not shown.

View File

@@ -6,7 +6,7 @@
The only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance.
<a href="https://buymeacoffee.com/corecontrol" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>
[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/corecontrol)
## Features
@@ -17,31 +17,34 @@ The only dashboard you'll ever need to manage your entire server infrastructure.
## Screenshots
Login Page:
![Login Page](https://i.ibb.co/DfS7BJdX/image.png)
![Login Page](/screenshots/login.png)
Dashboard Page:
![Dashboard Page](https://i.ibb.co/m5xMXz73/image.png)
![Dashboard Page](/screenshots/dashboard.png)
Servers Page:
![Servers Page](https://i.ibb.co/QFrFRp1B/image.png)
![Servers Page](/screenshots/servers.png)
Server Detail Page
![Server Detail Page](/screenshots/server.png)
Applications Page:
![Applications Page](https://i.ibb.co/1JK3pFYG/image.png)
![Applications Page](/screenshots/applications.png)
Uptime Page:
![Uptime Page](https://i.ibb.co/99LTnZ14/image.png)
![Uptime Page](/screenshots/uptime.png)
Network Page:
![Network Page](https://i.ibb.co/1Y6ypKHk/image.png)
![Network Page](/screenshots/network.png)
Settings Page:
![Settings Page](https://i.ibb.co/mrdjqy7f/image.png)
![Settings Page](/screenshots/settings.png)
## Roadmap
- [X] Edit Applications, Applications searchbar
- [X] Uptime History
- [ ] Notifications
- [ ] Simple Server Monitoring
- [X] Notifications
- [X] Simple Server Monitoring
- [ ] Improved Network Flowchart with custom elements (like Network switches)
- [ ] Advanced Settings (Disable Uptime Tracking & more)
@@ -57,14 +60,14 @@ services:
environment:
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
- agent
agent:
image: haedlessdev/corecontrol-agent:latest
environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
@@ -75,6 +78,11 @@ services:
POSTGRES_DB: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 2s
retries: 10
volumes:
postgres_data:
@@ -94,6 +102,7 @@ The application is build with:
- Icons by [Lucide](https://lucide.dev/)
- Flowcharts by [React Flow](https://reactflow.dev/)
- Application icons by [selfh.st/icons](selfh.st/icons)
- Monitoring Tool by [Glances](https://github.com/nicolargo/glances)
- and a lot of love ❤️
## Star History

View File

@@ -1,25 +1,25 @@
# --- Build Stage ---
FROM golang:1.24-alpine AS builder
FROM golang:1.19-alpine AS builder
WORKDIR /app
ENV GO111MODULE=on
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app .
# --- Run Stage ---
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/app .
CMD ["./app"]
WORKDIR /app
ENV GO111MODULE=on
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app ./cmd/agent
# --- Run Stage ---
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/app .
CMD ["./app"]

111
agent/cmd/agent/main.go Normal file
View File

@@ -0,0 +1,111 @@
package main
import (
"fmt"
"net/http"
"time"
"github.com/corecontrol/agent/internal/app"
"github.com/corecontrol/agent/internal/database"
"github.com/corecontrol/agent/internal/notifications"
"github.com/corecontrol/agent/internal/server"
)
func main() {
// Initialize database
db, err := database.InitDB()
if err != nil {
panic(fmt.Sprintf("Database initialization failed: %v\n", err))
}
defer db.Close()
// Initialize notification sender
notifSender := notifications.NewNotificationSender()
// Initial load of notifications
notifs, err := database.LoadNotifications(db)
if err != nil {
panic(fmt.Sprintf("Failed to load notifications: %v", err))
}
notifSender.UpdateNotifications(notifs)
// Reload notification configs every minute
go func() {
reloadTicker := time.NewTicker(time.Minute)
defer reloadTicker.Stop()
for range reloadTicker.C {
newNotifs, err := database.LoadNotifications(db)
if err != nil {
fmt.Printf("Failed to reload notifications: %v\n", err)
continue
}
notifSender.UpdateNotifications(newNotifs)
fmt.Println("Reloaded notification configurations")
}
}()
// Clean up old entries hourly
go func() {
deletionTicker := time.NewTicker(time.Hour)
defer deletionTicker.Stop()
for range deletionTicker.C {
if err := database.DeleteOldEntries(db); err != nil {
fmt.Printf("Error deleting old entries: %v\n", err)
}
}
}()
// Check for test notifications every 10 seconds
go func() {
testNotifTicker := time.NewTicker(10 * time.Second)
defer testNotifTicker.Stop()
for range testNotifTicker.C {
notifs := notifSender.GetNotifications()
database.CheckAndSendTestNotifications(db, notifs, notifSender.SendSpecificNotification)
}
}()
// HTTP clients
appClient := &http.Client{
Timeout: 4 * time.Second,
}
serverClient := &http.Client{
Timeout: 5 * time.Second,
}
// Server monitoring every 5 seconds
go func() {
serverTicker := time.NewTicker(5 * time.Second)
defer serverTicker.Stop()
for range serverTicker.C {
servers, err := database.GetServers(db)
if err != nil {
fmt.Printf("Error getting servers: %v\n", err)
continue
}
server.MonitorServers(db, serverClient, servers, notifSender)
}
}()
// Application monitoring every 10 seconds
appTicker := time.NewTicker(time.Second)
defer appTicker.Stop()
for now := range appTicker.C {
if now.Second()%10 != 0 {
continue
}
apps, err := database.GetApplications(db)
if err != nil {
fmt.Printf("Error getting applications: %v\n", err)
continue
}
app.MonitorApplications(db, appClient, apps, notifSender)
}
}

View File

@@ -1,20 +1,22 @@
module agent
module github.com/corecontrol/agent
go 1.24.1
go 1.19
require (
github.com/jackc/pgx/v4 v4.18.3
github.com/jackc/pgx/v4 v4.18.1
github.com/joho/godotenv v1.5.1
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
)
require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgconn v1.14.3 // indirect
github.com/jackc/pgconn v1.14.0 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.3 // indirect
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgtype v1.14.0 // indirect
golang.org/x/crypto v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/text v0.7.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
)

View File

@@ -25,8 +25,8 @@ github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsU
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w=
github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM=
github.com/jackc/pgconn v1.14.0 h1:vrbA9Ud87g6JdFWkHTJXppVce58qPIdP7N8y0Ml/A7Q=
github.com/jackc/pgconn v1.14.0/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
@@ -42,8 +42,8 @@ github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvW
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag=
github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.2 h1:7eY55bdBeCz1F2fTzSz69QC+pG46jYq9/jtSPiJ5nn0=
github.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
@@ -57,11 +57,12 @@ github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.18.3 h1:dE2/TrEsGX3RBprb3qryqSV9Y60iZN1C6i8IrmW9/BA=
github.com/jackc/pgx/v4 v4.18.3/go.mod h1:Ey4Oru5tH5sB6tV7hDmfWFahwF15Eb7DNXlRKx2CkVw=
github.com/jackc/pgx/v4 v4.18.1 h1:YP7G1KABtKpB5IHrO9vYwSrCOhs7p3uqhvhhQBptya0=
github.com/jackc/pgx/v4 v4.18.1/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzkb+qQE=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -98,13 +99,18 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@@ -126,17 +132,22 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg=
golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -149,15 +160,21 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@@ -165,15 +182,21 @@ golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,130 @@
package app
import (
"context"
"crypto/x509"
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/corecontrol/agent/internal/models"
"github.com/corecontrol/agent/internal/notifications"
)
// MonitorApplications checks and updates the status of all applications
func MonitorApplications(db *sql.DB, client *http.Client, apps []models.Application, notifSender *notifications.NotificationSender) {
var notificationTemplate string
err := db.QueryRow("SELECT notification_text_application FROM settings LIMIT 1").Scan(&notificationTemplate)
if err != nil || notificationTemplate == "" {
notificationTemplate = "The application !name (!url) went !status!"
}
for _, app := range apps {
logPrefix := fmt.Sprintf("[App %s (%s)]", app.Name, app.PublicURL)
fmt.Printf("%s Checking...\n", logPrefix)
// Determine which URL to use for monitoring
checkURL := app.PublicURL
if app.UptimeCheckURL != "" {
checkURL = app.UptimeCheckURL
fmt.Printf("%s Using custom uptime check URL: %s\n", logPrefix, checkURL)
}
parsedURL, parseErr := url.Parse(checkURL)
if parseErr != nil {
fmt.Printf("%s Invalid URL: %v\n", logPrefix, parseErr)
continue
}
hostIsIP := isIPAddress(parsedURL.Hostname())
var isOnline bool
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", checkURL, nil)
if err != nil {
fmt.Printf("%s Request creation failed: %v\n", logPrefix, err)
continue
}
resp, err := client.Do(req)
if err == nil {
defer resp.Body.Close()
isOnline = resp.StatusCode >= 200 && resp.StatusCode < 400
fmt.Printf("%s Response status: %d\n", logPrefix, resp.StatusCode)
} else {
fmt.Printf("%s Connection error: %v\n", logPrefix, err)
if hostIsIP {
var urlErr *url.Error
if errors.As(err, &urlErr) {
var certErr x509.HostnameError
var unknownAuthErr x509.UnknownAuthorityError
if errors.As(urlErr.Err, &certErr) || errors.As(urlErr.Err, &unknownAuthErr) {
fmt.Printf("%s Ignoring TLS error for IP, marking as online\n", logPrefix)
isOnline = true
}
}
}
}
if isOnline != app.Online {
status := "offline"
if isOnline {
status = "online"
}
message := strings.ReplaceAll(notificationTemplate, "!name", app.Name)
message = strings.ReplaceAll(message, "!url", app.PublicURL)
message = strings.ReplaceAll(message, "!status", status)
notifSender.SendNotifications(message)
}
// Update application status in database
updateApplicationStatus(db, app.ID, isOnline)
// Add entry to uptime history
addUptimeHistoryEntry(db, app.ID, isOnline)
}
}
// Helper function to update application status
func updateApplicationStatus(db *sql.DB, appID int, online bool) {
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer dbCancel()
_, err := db.ExecContext(dbCtx,
`UPDATE application SET online = $1 WHERE id = $2`,
online, appID,
)
if err != nil {
fmt.Printf("DB update failed for app ID %d: %v\n", appID, err)
}
}
// Helper function to add uptime history entry
func addUptimeHistoryEntry(db *sql.DB, appID int, online bool) {
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer dbCancel()
_, err := db.ExecContext(dbCtx,
`INSERT INTO uptime_history("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
appID, online,
)
if err != nil {
fmt.Printf("History insert failed for app ID %d: %v\n", appID, err)
}
}
// Helper function to check if a host is an IP address
func isIPAddress(host string) bool {
ip := net.ParseIP(host)
return ip != nil
}

View File

@@ -0,0 +1,198 @@
package database
import (
"context"
"database/sql"
"fmt"
"os"
"time"
"github.com/corecontrol/agent/internal/models"
_ "github.com/jackc/pgx/v4/stdlib"
"github.com/joho/godotenv"
)
// InitDB initializes the database connection
func InitDB() (*sql.DB, error) {
// Load environment variables
if err := godotenv.Load(); err != nil {
fmt.Println("No env vars found")
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL not set")
}
db, err := sql.Open("pgx", dbURL)
if err != nil {
return nil, fmt.Errorf("database connection failed: %v", err)
}
return db, nil
}
// GetApplications fetches all applications with public URLs
func GetApplications(db *sql.DB) ([]models.Application, error) {
rows, err := db.Query(
`SELECT id, name, "publicURL", online, "uptimeCheckURL" FROM application WHERE "publicURL" IS NOT NULL`,
)
if err != nil {
return nil, fmt.Errorf("error fetching applications: %v", err)
}
defer rows.Close()
var apps []models.Application
for rows.Next() {
var app models.Application
if err := rows.Scan(&app.ID, &app.Name, &app.PublicURL, &app.Online, &app.UptimeCheckURL); err != nil {
fmt.Printf("Error scanning row: %v\n", err)
continue
}
apps = append(apps, app)
}
return apps, nil
}
// GetServers fetches all servers with monitoring enabled
func GetServers(db *sql.DB) ([]models.Server, error) {
rows, err := db.Query(
`SELECT id, name, monitoring, "monitoringURL", online, "cpuUsage", "ramUsage", "diskUsage"
FROM server WHERE monitoring = true`,
)
if err != nil {
return nil, fmt.Errorf("error fetching servers: %v", err)
}
defer rows.Close()
var servers []models.Server
for rows.Next() {
var server models.Server
if err := rows.Scan(
&server.ID, &server.Name, &server.Monitoring, &server.MonitoringURL,
&server.Online, &server.CpuUsage, &server.RamUsage, &server.DiskUsage,
); err != nil {
fmt.Printf("Error scanning server row: %v\n", err)
continue
}
servers = append(servers, server)
}
return servers, nil
}
// LoadNotifications loads all enabled notifications
func LoadNotifications(db *sql.DB) ([]models.Notification, error) {
rows, err := db.Query(
`SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo",
"telegramChatId", "telegramToken", "discordWebhook", "gotifyUrl", "gotifyToken", "ntfyUrl", "ntfyToken",
"pushoverUrl", "pushoverToken", "pushoverUser"
FROM notification
WHERE enabled = true`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []models.Notification
for rows.Next() {
var n models.Notification
if err := rows.Scan(
&n.ID, &n.Enabled, &n.Type,
&n.SMTPHost, &n.SMTPPort, &n.SMTPFrom, &n.SMTPUser, &n.SMTPPass, &n.SMTPSecure, &n.SMTPTo,
&n.TelegramChatID, &n.TelegramToken, &n.DiscordWebhook,
&n.GotifyUrl, &n.GotifyToken, &n.NtfyUrl, &n.NtfyToken,
&n.PushoverUrl, &n.PushoverToken, &n.PushoverUser,
); err != nil {
fmt.Printf("Error scanning notification: %v\n", err)
continue
}
configs = append(configs, n)
}
return configs, nil
}
// DeleteOldEntries removes entries older than 30 days
func DeleteOldEntries(db *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Delete old uptime history entries
res, err := db.ExecContext(ctx,
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`,
)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
fmt.Printf("Deleted %d old entries from uptime_history\n", affected)
// Delete old server history entries
res, err = db.ExecContext(ctx,
`DELETE FROM server_history WHERE "createdAt" < now() - interval '30 days'`,
)
if err != nil {
return err
}
affected, _ = res.RowsAffected()
fmt.Printf("Deleted %d old entries from server_history\n", affected)
return nil
}
// UpdateServerStatus updates a server's status and metrics
func UpdateServerStatus(db *sql.DB, serverID int, online bool, cpuUsage, ramUsage, diskUsage float64, uptime string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.ExecContext(ctx,
`UPDATE server SET online = $1, "cpuUsage" = $2::float8, "ramUsage" = $3::float8, "diskUsage" = $4::float8, "uptime" = $5
WHERE id = $6`,
online, cpuUsage, ramUsage, diskUsage, uptime, serverID,
)
return err
}
// CheckAndSendTestNotifications checks for and processes test notifications
func CheckAndSendTestNotifications(db *sql.DB, notifications []models.Notification, sendFunc func(models.Notification, string)) {
// Query for test notifications
rows, err := db.Query(`SELECT tn.id, tn."notificationId" FROM test_notification tn`)
if err != nil {
fmt.Printf("Error fetching test notifications: %v\n", err)
return
}
defer rows.Close()
// Process each test notification
var testIds []int
for rows.Next() {
var id, notificationId int
if err := rows.Scan(&id, &notificationId); err != nil {
fmt.Printf("Error scanning test notification: %v\n", err)
continue
}
// Add to list of IDs to delete
testIds = append(testIds, id)
// Find the notification configuration
for _, n := range notifications {
if n.ID == notificationId {
// Send test notification
fmt.Printf("Sending test notification to notification ID %d\n", notificationId)
sendFunc(n, "Test notification from CoreControl")
}
}
}
// Delete processed test notifications
if len(testIds) > 0 {
for _, id := range testIds {
_, err := db.Exec(`DELETE FROM test_notification WHERE id = $1`, id)
if err != nil {
fmt.Printf("Error deleting test notification (ID: %d): %v\n", id, err)
}
}
}
}

View File

@@ -0,0 +1,75 @@
package models
import (
"database/sql"
)
type Application struct {
ID int
Name string
PublicURL string
Online bool
UptimeCheckURL string
}
type Server struct {
ID int
Name string
Monitoring bool
MonitoringURL sql.NullString
Online bool
CpuUsage sql.NullFloat64
RamUsage sql.NullFloat64
DiskUsage sql.NullFloat64
Uptime sql.NullString
}
type CPUResponse struct {
Total float64 `json:"total"`
}
type MemoryResponse struct {
Active int64 `json:"active"`
Available int64 `json:"available"`
Buffers int64 `json:"buffers"`
Cached int64 `json:"cached"`
Free int64 `json:"free"`
Inactive int64 `json:"inactive"`
Percent float64 `json:"percent"`
Shared int64 `json:"shared"`
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type FSResponse []struct {
DeviceName string `json:"device_name"`
MntPoint string `json:"mnt_point"`
Percent float64 `json:"percent"`
}
type UptimeResponse struct {
Value string `json:"value"`
}
type Notification struct {
ID int
Enabled bool
Type string
SMTPHost sql.NullString
SMTPPort sql.NullInt64
SMTPFrom sql.NullString
SMTPUser sql.NullString
SMTPPass sql.NullString
SMTPSecure sql.NullBool
SMTPTo sql.NullString
TelegramChatID sql.NullString
TelegramToken sql.NullString
DiscordWebhook sql.NullString
GotifyUrl sql.NullString
GotifyToken sql.NullString
NtfyUrl sql.NullString
NtfyToken sql.NullString
PushoverUrl sql.NullString
PushoverToken sql.NullString
PushoverUser sql.NullString
}

View File

@@ -0,0 +1,239 @@
package notifications
import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/corecontrol/agent/internal/models"
"gopkg.in/gomail.v2"
)
type NotificationSender struct {
notifications []models.Notification
notifMutex sync.RWMutex
}
// NewNotificationSender creates a new notification sender
func NewNotificationSender() *NotificationSender {
return &NotificationSender{
notifications: []models.Notification{},
notifMutex: sync.RWMutex{},
}
}
// UpdateNotifications updates the stored notifications
func (ns *NotificationSender) UpdateNotifications(notifs []models.Notification) {
ns.notifMutex.Lock()
defer ns.notifMutex.Unlock()
copyDst := make([]models.Notification, len(notifs))
copy(copyDst, notifs)
ns.notifications = copyDst
}
// GetNotifications returns a safe copy of current notifications
func (ns *NotificationSender) GetNotifications() []models.Notification {
ns.notifMutex.RLock()
defer ns.notifMutex.RUnlock()
copyDst := make([]models.Notification, len(ns.notifications))
copy(copyDst, ns.notifications)
return copyDst
}
// SendNotifications sends a message to all configured notifications
func (ns *NotificationSender) SendNotifications(message string) {
notifs := ns.GetNotifications()
for _, n := range notifs {
ns.SendSpecificNotification(n, message)
}
}
// SendSpecificNotification sends a message to a specific notification
func (ns *NotificationSender) SendSpecificNotification(n models.Notification, message string) {
fmt.Println("Sending specific notification..." + n.Type)
switch n.Type {
case "smtp":
if n.SMTPHost.Valid && n.SMTPTo.Valid {
ns.sendEmail(n, message)
}
case "telegram":
if n.TelegramToken.Valid && n.TelegramChatID.Valid {
ns.sendTelegram(n, message)
}
case "discord":
if n.DiscordWebhook.Valid {
ns.sendDiscord(n, message)
}
case "gotify":
if n.GotifyUrl.Valid && n.GotifyToken.Valid {
ns.sendGotify(n, message)
}
case "ntfy":
if n.NtfyUrl.Valid && n.NtfyToken.Valid {
ns.sendNtfy(n, message)
}
case "pushover":
if n.PushoverUrl.Valid && n.PushoverToken.Valid && n.PushoverUser.Valid {
ns.sendPushover(n, message)
}
}
}
// Helper function to check if a host is an IP address
func (ns *NotificationSender) isIPAddress(host string) bool {
ip := net.ParseIP(host)
return ip != nil
}
// Individual notification methods
func (ns *NotificationSender) sendEmail(n models.Notification, body string) {
// Initialize SMTP dialer with host, port, user, pass
d := gomail.NewDialer(
n.SMTPHost.String,
int(n.SMTPPort.Int64),
n.SMTPUser.String,
n.SMTPPass.String,
)
if n.SMTPSecure.Valid && n.SMTPSecure.Bool {
d.SSL = true
}
m := gomail.NewMessage()
m.SetHeader("From", n.SMTPFrom.String)
m.SetHeader("To", n.SMTPTo.String)
m.SetHeader("Subject", "Uptime Notification")
m.SetBody("text/plain", body)
if err := d.DialAndSend(m); err != nil {
fmt.Printf("Email send failed: %v\n", err)
}
}
func (ns *NotificationSender) sendTelegram(n models.Notification, message string) {
apiUrl := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&text=%s",
n.TelegramToken.String,
n.TelegramChatID.String,
message,
)
resp, err := http.Get(apiUrl)
if err != nil {
fmt.Printf("Telegram send failed: %v\n", err)
return
}
resp.Body.Close()
}
func (ns *NotificationSender) sendDiscord(n models.Notification, message string) {
payload := fmt.Sprintf(`{"content": "%s"}`, message)
req, err := http.NewRequest("POST", n.DiscordWebhook.String, strings.NewReader(payload))
if err != nil {
fmt.Printf("Discord request creation failed: %v\n", err)
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Discord send failed: %v\n", err)
return
}
resp.Body.Close()
}
func (ns *NotificationSender) sendGotify(n models.Notification, message string) {
baseURL := strings.TrimSuffix(n.GotifyUrl.String, "/")
targetURL := fmt.Sprintf("%s/message", baseURL)
form := url.Values{}
form.Add("message", message)
form.Add("priority", "5")
req, err := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
if err != nil {
fmt.Printf("Gotify: ERROR creating request: %v\n", err)
return
}
req.Header.Set("X-Gotify-Key", n.GotifyToken.String)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Gotify: ERROR sending request: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Gotify: ERROR status code: %d\n", resp.StatusCode)
}
}
func (ns *NotificationSender) sendNtfy(n models.Notification, message string) {
fmt.Println("Sending Ntfy notification...")
baseURL := strings.TrimSuffix(n.NtfyUrl.String, "/")
// Don't append a topic to the URL - the URL itself should have the correct endpoint
requestURL := baseURL
// Send message directly as request body instead of JSON
req, err := http.NewRequest("POST", requestURL, strings.NewReader(message))
if err != nil {
fmt.Printf("Ntfy: ERROR creating request: %v\n", err)
return
}
if n.NtfyToken.Valid {
req.Header.Set("Authorization", "Bearer "+n.NtfyToken.String)
}
// Use text/plain instead of application/json
req.Header.Set("Content-Type", "text/plain")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Ntfy: ERROR sending request: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Ntfy: ERROR status code: %d\n", resp.StatusCode)
}
}
func (ns *NotificationSender) sendPushover(n models.Notification, message string) {
form := url.Values{}
form.Add("token", n.PushoverToken.String)
form.Add("user", n.PushoverUser.String)
form.Add("message", message)
req, err := http.NewRequest("POST", n.PushoverUrl.String, strings.NewReader(form.Encode()))
if err != nil {
fmt.Printf("Pushover: ERROR creating request: %v\n", err)
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Pushover: ERROR sending request: %v\n", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf("Pushover: ERROR status code: %d\n", resp.StatusCode)
}
}

View File

@@ -0,0 +1,291 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/corecontrol/agent/internal/models"
"github.com/corecontrol/agent/internal/notifications"
)
// MonitorServers checks and updates the status of all servers
func MonitorServers(db *sql.DB, client *http.Client, servers []models.Server, notifSender *notifications.NotificationSender) {
var notificationTemplate string
err := db.QueryRow("SELECT notification_text_server FROM settings LIMIT 1").Scan(&notificationTemplate)
if err != nil || notificationTemplate == "" {
notificationTemplate = "The server !name is now !status!"
}
for _, server := range servers {
if !server.Monitoring || !server.MonitoringURL.Valid {
continue
}
logPrefix := fmt.Sprintf("[Server %s]", server.Name)
fmt.Printf("%s Checking...\n", logPrefix)
baseURL := strings.TrimSuffix(server.MonitoringURL.String, "/")
var cpuUsage, ramUsage, diskUsage float64
var online = true
var uptimeStr string
// Get CPU usage
online, cpuUsage = fetchCPUUsage(client, baseURL, logPrefix)
if !online {
updateServerStatus(db, server.ID, false, 0, 0, 0, "")
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
addServerHistoryEntry(db, server.ID, false, 0, 0, 0)
continue
}
// Get uptime if server is online
uptimeStr = fetchUptime(client, baseURL, logPrefix)
// Get Memory usage
memOnline, memUsage := fetchMemoryUsage(client, baseURL, logPrefix)
if !memOnline {
online = false
updateServerStatus(db, server.ID, false, 0, 0, 0, "")
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
addServerHistoryEntry(db, server.ID, false, 0, 0, 0)
continue
}
ramUsage = memUsage
// Get Disk usage
diskOnline, diskUsageVal := fetchDiskUsage(client, baseURL, logPrefix)
if !diskOnline {
online = false
updateServerStatus(db, server.ID, false, 0, 0, 0, "")
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
addServerHistoryEntry(db, server.ID, false, 0, 0, 0)
continue
}
diskUsage = diskUsageVal
// Check if status changed and send notification if needed
if online != server.Online {
sendStatusChangeNotification(server, online, notificationTemplate, notifSender)
}
// Update server status with metrics
updateServerStatus(db, server.ID, online, cpuUsage, ramUsage, diskUsage, uptimeStr)
// Add entry to server history
addServerHistoryEntry(db, server.ID, online, cpuUsage, ramUsage, diskUsage)
fmt.Printf("%s Updated - CPU: %.2f%%, RAM: %.2f%%, Disk: %.2f%%, Uptime: %s\n",
logPrefix, cpuUsage, ramUsage, diskUsage, uptimeStr)
}
}
// Helper function to fetch CPU usage
func fetchCPUUsage(client *http.Client, baseURL, logPrefix string) (bool, float64) {
cpuResp, err := client.Get(fmt.Sprintf("%s/api/4/cpu", baseURL))
if err != nil {
fmt.Printf("%s CPU request failed: %v\n", logPrefix, err)
return false, 0
}
defer cpuResp.Body.Close()
if cpuResp.StatusCode != http.StatusOK {
fmt.Printf("%s Bad CPU status code: %d\n", logPrefix, cpuResp.StatusCode)
return false, 0
}
var cpuData models.CPUResponse
if err := json.NewDecoder(cpuResp.Body).Decode(&cpuData); err != nil {
fmt.Printf("%s Failed to parse CPU JSON: %v\n", logPrefix, err)
return false, 0
}
return true, cpuData.Total
}
// Helper function to fetch memory usage
func fetchMemoryUsage(client *http.Client, baseURL, logPrefix string) (bool, float64) {
memResp, err := client.Get(fmt.Sprintf("%s/api/4/mem", baseURL))
if err != nil {
fmt.Printf("%s Memory request failed: %v\n", logPrefix, err)
return false, 0
}
defer memResp.Body.Close()
if memResp.StatusCode != http.StatusOK {
fmt.Printf("%s Bad memory status code: %d\n", logPrefix, memResp.StatusCode)
return false, 0
}
var memData models.MemoryResponse
if err := json.NewDecoder(memResp.Body).Decode(&memData); err != nil {
fmt.Printf("%s Failed to parse memory JSON: %v\n", logPrefix, err)
return false, 0
}
return true, memData.Percent
}
// Helper function to fetch disk usage
func fetchDiskUsage(client *http.Client, baseURL, logPrefix string) (bool, float64) {
fsResp, err := client.Get(fmt.Sprintf("%s/api/4/fs", baseURL))
if err != nil {
fmt.Printf("%s Filesystem request failed: %v\n", logPrefix, err)
return false, 0
}
defer fsResp.Body.Close()
if fsResp.StatusCode != http.StatusOK {
fmt.Printf("%s Bad filesystem status code: %d\n", logPrefix, fsResp.StatusCode)
return false, 0
}
var fsData models.FSResponse
if err := json.NewDecoder(fsResp.Body).Decode(&fsData); err != nil {
fmt.Printf("%s Failed to parse filesystem JSON: %v\n", logPrefix, err)
return false, 0
}
if len(fsData) > 0 {
return true, fsData[0].Percent
}
return true, 0
}
// Helper function to fetch uptime
func fetchUptime(client *http.Client, baseURL, logPrefix string) string {
uptimeResp, err := client.Get(fmt.Sprintf("%s/api/4/uptime", baseURL))
if err != nil || uptimeResp.StatusCode != http.StatusOK {
if err != nil {
fmt.Printf("%s Uptime request failed: %v\n", logPrefix, err)
} else {
fmt.Printf("%s Bad uptime status code: %d\n", logPrefix, uptimeResp.StatusCode)
uptimeResp.Body.Close()
}
return ""
}
defer uptimeResp.Body.Close()
// Read the response body as a string first
uptimeBytes, err := io.ReadAll(uptimeResp.Body)
if err != nil {
fmt.Printf("%s Failed to read uptime response: %v\n", logPrefix, err)
return ""
}
uptimeStr := strings.Trim(string(uptimeBytes), "\"")
// Try to parse as JSON object first, then fallback to direct string if that fails
var uptimeData models.UptimeResponse
if jsonErr := json.Unmarshal(uptimeBytes, &uptimeData); jsonErr == nil && uptimeData.Value != "" {
uptimeStr = formatUptime(uptimeData.Value)
} else {
// Use the string directly
uptimeStr = formatUptime(uptimeStr)
}
fmt.Printf("%s Uptime: %s (formatted: %s)\n", logPrefix, string(uptimeBytes), uptimeStr)
return uptimeStr
}
// Helper function to send notification about status change
func sendStatusChangeNotification(server models.Server, online bool, template string, notifSender *notifications.NotificationSender) {
status := "offline"
if online {
status = "online"
}
message := strings.ReplaceAll(template, "!name", server.Name)
message = strings.ReplaceAll(message, "!status", status)
notifSender.SendNotifications(message)
}
// Helper function to update server status
func updateServerStatus(db *sql.DB, serverID int, online bool, cpuUsage, ramUsage, diskUsage float64, uptime string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.ExecContext(ctx,
`UPDATE server SET online = $1, "cpuUsage" = $2::float8, "ramUsage" = $3::float8, "diskUsage" = $4::float8, "uptime" = $5
WHERE id = $6`,
online, cpuUsage, ramUsage, diskUsage, uptime, serverID,
)
if err != nil {
fmt.Printf("Failed to update server status (ID: %d): %v\n", serverID, err)
}
}
// Helper function to add server history entry
func addServerHistoryEntry(db *sql.DB, serverID int, online bool, cpuUsage, ramUsage, diskUsage float64) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.ExecContext(ctx,
`INSERT INTO server_history(
"serverId", online, "cpuUsage", "ramUsage", "diskUsage", "createdAt"
) VALUES ($1, $2, $3, $4, $5, now())`,
serverID, online, fmt.Sprintf("%.2f", cpuUsage), fmt.Sprintf("%.2f", ramUsage), fmt.Sprintf("%.2f", diskUsage),
)
if err != nil {
fmt.Printf("Failed to insert server history (ID: %d): %v\n", serverID, err)
}
}
// FormatUptime formats the uptime string to a standard format
func formatUptime(uptimeStr string) string {
// Example input: "3 days, 3:52:36"
// Target output: "28.6 13:52"
now := time.Now()
// Parse the uptime components
parts := strings.Split(uptimeStr, ", ")
var days int
var timeStr string
if len(parts) == 2 {
// Has days part and time part
_, err := fmt.Sscanf(parts[0], "%d days", &days)
if err != nil {
// Try singular "day"
_, err = fmt.Sscanf(parts[0], "%d day", &days)
if err != nil {
return uptimeStr // Return original if parsing fails
}
}
timeStr = parts[1]
} else if len(parts) == 1 {
// Only has time part (less than a day)
days = 0
timeStr = parts[0]
} else {
return uptimeStr // Return original if format is unexpected
}
// Parse the time component (hours:minutes:seconds)
var hours, minutes, seconds int
_, err := fmt.Sscanf(timeStr, "%d:%d:%d", &hours, &minutes, &seconds)
if err != nil {
return uptimeStr // Return original if parsing fails
}
// Calculate the total duration
duration := time.Duration(days)*24*time.Hour +
time.Duration(hours)*time.Hour +
time.Duration(minutes)*time.Minute +
time.Duration(seconds)*time.Second
// Calculate the start time by subtracting the duration from now
startTime := now.Add(-duration)
// Format the result in the required format (day.month hour:minute)
return startTime.Format("2.1 15:04")
}

View File

@@ -1,171 +0,0 @@
package main
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"time"
_ "github.com/jackc/pgx/v4/stdlib"
"github.com/joho/godotenv"
)
type Application struct {
ID int
PublicURL string
Online bool
}
func main() {
if err := godotenv.Load(); err != nil {
fmt.Println("No env vars found")
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
panic("DATABASE_URL not set")
}
db, err := sql.Open("pgx", dbURL)
if err != nil {
panic(fmt.Sprintf("Database connection failed: %v\n", err))
}
defer db.Close()
go func() {
deletionTicker := time.NewTicker(1 * time.Hour)
defer deletionTicker.Stop()
for range deletionTicker.C {
if err := deleteOldEntries(db); err != nil {
fmt.Printf("Error deleting old entries: %v\n", err)
}
}
}()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
client := &http.Client{
Timeout: 4 * time.Second,
}
for now := range ticker.C {
if now.Second()%10 != 0 {
continue
}
apps := getApplications(db)
checkAndUpdateStatus(db, client, apps)
}
}
func deleteOldEntries(db *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := db.ExecContext(ctx,
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
fmt.Printf("Deleted %d old entries from uptime_history\n", affected)
return nil
}
func getApplications(db *sql.DB) []Application {
rows, err := db.Query(`
SELECT id, "publicURL", online
FROM application
WHERE "publicURL" IS NOT NULL
`)
if err != nil {
fmt.Printf("Error fetching applications: %v\n", err)
return nil
}
defer rows.Close()
var apps []Application
for rows.Next() {
var app Application
err := rows.Scan(&app.ID, &app.PublicURL, &app.Online)
if err != nil {
fmt.Printf("Error scanning row: %v\n", err)
continue
}
apps = append(apps, app)
}
return apps
}
func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
fmt.Printf("Start checking %d applications at %v\n", len(apps), time.Now())
for i, app := range apps {
logPrefix := fmt.Sprintf("[App %d/%d URL: %s]", i+1, len(apps), app.PublicURL)
fmt.Printf("%s Starting check\n", logPrefix)
// HTTP Check
startHTTP := time.Now()
httpCtx, httpCancel := context.WithTimeout(context.Background(), 4*time.Second)
defer httpCancel()
req, err := http.NewRequestWithContext(httpCtx, "HEAD", app.PublicURL, nil)
if err != nil {
fmt.Printf("%s Request creation failed: %v\n", logPrefix, err)
continue
}
resp, err := client.Do(req)
httpDuration := time.Since(startHTTP)
// Log HTTP details
if err != nil {
fmt.Printf("%s HTTP error after %v: %v\n", logPrefix, httpDuration, err)
} else {
fmt.Printf("%s HTTP %d after %v (ContentLength: %d)\n",
logPrefix, resp.StatusCode, httpDuration, resp.ContentLength)
resp.Body.Close() // Important to prevent leaks
}
isOnline := err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 || resp.StatusCode == 405
// Database Update
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer dbCancel()
startUpdate := time.Now()
updateRes, err := db.ExecContext(dbCtx,
`UPDATE application SET online = $1 WHERE id = $2`,
isOnline,
app.ID,
)
updateDuration := time.Since(startUpdate)
if err != nil {
fmt.Printf("%s UPDATE failed after %v: %v\n", logPrefix, updateDuration, err)
} else {
affected, _ := updateRes.RowsAffected()
fmt.Printf("%s UPDATE OK (%d rows) after %v\n", logPrefix, affected, updateDuration)
}
// History Insert
startInsert := time.Now()
insertRes, err := db.ExecContext(dbCtx,
`INSERT INTO uptime_history ("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
app.ID,
isOnline,
)
insertDuration := time.Since(startInsert)
if err != nil {
fmt.Printf("%s INSERT failed after %v: %v\n", logPrefix, insertDuration, err)
} else {
inserted, _ := insertRes.RowsAffected()
fmt.Printf("%s INSERT OK (%d rows) after %v\n", logPrefix, inserted, insertDuration)
}
}
}

View File

@@ -8,12 +8,13 @@ interface AddRequest {
icon: string;
publicURL: string;
localURL: string;
uptimecheckUrl: string;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { serverId, name, description, icon, publicURL, localURL } = body;
const { serverId, name, description, icon, publicURL, localURL, uptimecheckUrl } = body;
const application = await prisma.application.create({
data: {
@@ -22,7 +23,8 @@ export async function POST(request: NextRequest) {
description,
icon,
publicURL,
localURL
localURL,
uptimecheckUrl
}
});

View File

@@ -9,12 +9,13 @@ interface EditRequest {
icon: string;
publicURL: string;
localURL: string;
uptimecheckUrl: string;
}
export async function PUT(request: NextRequest) {
try {
const body: EditRequest = await request.json();
const { id, name, description, serverId, icon, publicURL, localURL } = body;
const { id, name, description, serverId, icon, publicURL, localURL, uptimecheckUrl } = body;
const existingApp = await prisma.application.findUnique({ where: { id } });
if (!existingApp) {
@@ -29,7 +30,8 @@ export async function PUT(request: NextRequest) {
description,
icon,
publicURL,
localURL
localURL,
uptimecheckUrl
}
});

View File

@@ -40,7 +40,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
applications: applicationsWithServers,
servers: servers_all,
maxPage
maxPage,
totalItems: totalCount
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";

View File

@@ -23,7 +23,23 @@ export async function POST(request: NextRequest) {
const searchResults = fuse.search(searchterm);
const results = searchResults.map(({ item }) => item);
const searchedApps = searchResults.map(({ item }) => item);
// Get server IDs from the search results
const serverIds = searchedApps
.map(app => app.serverId)
.filter((id): id is number => id !== null);
// Fetch server data for these applications
const servers = await prisma.server.findMany({
where: { id: { in: serverIds } }
});
// Add server name to each application
const results = searchedApps.map(app => ({
...app,
server: servers.find(s => s.id === app.serverId)?.name || "No server"
}));
return NextResponse.json({ results });
} catch (error: any) {

View File

@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
interface RequestBody {
timespan?: number;
page?: number;
itemsPerPage?: number;
}
@@ -12,22 +13,27 @@ const getTimeRange = (timespan: number) => {
switch (timespan) {
case 1:
return {
start: new Date(now.getTime() - 30 * 60 * 1000),
start: new Date(now.getTime() - 60 * 60 * 1000),
interval: 'minute'
};
case 2:
return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
interval: '3hour'
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
interval: 'hour'
};
case 3:
return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
interval: 'day'
};
case 4:
return {
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
interval: 'day'
};
default:
return {
start: new Date(now.getTime() - 30 * 60 * 1000),
start: new Date(now.getTime() - 60 * 60 * 1000),
interval: 'minute'
};
}
@@ -38,23 +44,31 @@ const generateIntervals = (timespan: number) => {
now.setSeconds(0, 0);
switch (timespan) {
case 1:
return Array.from({ length: 30 }, (_, i) => {
case 1: // 1 hour - 60 one-minute intervals
return Array.from({ length: 60 }, (_, i) => {
const d = new Date(now);
d.setMinutes(d.getMinutes() - i);
d.setSeconds(0, 0);
return d;
});
case 2:
return Array.from({ length: 56 }, (_, i) => {
case 2: // 1 day - 24 one-hour intervals
return Array.from({ length: 24 }, (_, i) => {
const d = new Date(now);
d.setHours(d.getHours() - (i * 3));
d.setHours(d.getHours() - i);
d.setMinutes(0, 0, 0);
return d;
});
case 3:
case 3: // 7 days
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(now);
d.setDate(d.getDate() - i);
d.setHours(0, 0, 0, 0);
return d;
});
case 4: // 30 days
return Array.from({ length: 30 }, (_, i) => {
const d = new Date(now);
d.setDate(d.getDate() - i);
@@ -70,14 +84,14 @@ const generateIntervals = (timespan: number) => {
const getIntervalKey = (date: Date, timespan: number) => {
const d = new Date(date);
switch (timespan) {
case 1:
case 1: // 1 hour - minute intervals
d.setSeconds(0, 0);
return d.toISOString();
case 2:
d.setHours(Math.floor(d.getHours() / 3) * 3);
case 2: // 1 day - hour intervals
d.setMinutes(0, 0, 0);
return d.toISOString();
case 3:
case 3: // 7 days - day intervals
case 4: // 30 days - day intervals
d.setHours(0, 0, 0, 0);
return d.toISOString();
default:
@@ -87,8 +101,7 @@ const getIntervalKey = (date: Date, timespan: number) => {
export async function POST(request: NextRequest) {
try {
const { timespan = 1, page = 1 }: RequestBody = await request.json();
const itemsPerPage = 5;
const { timespan = 1, page = 1, itemsPerPage = 5 }: RequestBody = await request.json();
const skip = (page - 1) * itemsPerPage;
// Get paginated and sorted applications

View File

@@ -3,7 +3,19 @@ import { prisma } from "@/lib/prisma";
export async function POST(request: NextRequest) {
try {
const serverCount = await prisma.server.count();
const serverCountNoVMs = await prisma.server.count({
where: {
hostServer: 0
}
});
const serverCountOnlyVMs = await prisma.server.count({
where: {
hostServer: {
not: 0
}
}
});
const applicationCount = await prisma.application.count();
@@ -12,7 +24,8 @@ export async function POST(request: NextRequest) {
});
return NextResponse.json({
serverCount,
serverCountNoVMs,
serverCountOnlyVMs,
applicationCount,
onlineApplicationsCount
});

View File

@@ -30,6 +30,8 @@ interface Server {
id: number;
name: string;
ip: string;
host: boolean;
hostServer: number | null;
}
interface Application {
@@ -43,11 +45,15 @@ const NODE_WIDTH = 220;
const NODE_HEIGHT = 60;
const APP_NODE_WIDTH = 160;
const APP_NODE_HEIGHT = 40;
const HORIZONTAL_SPACING = 280;
const VERTICAL_SPACING = 60;
const HORIZONTAL_SPACING = 700;
const VERTICAL_SPACING = 80;
const START_Y = 120;
const ROOT_NODE_WIDTH = 300;
const CONTAINER_PADDING = 40;
const COLUMN_SPACING = 220;
const VM_APP_SPACING = 220;
const MIN_VM_SPACING = 10;
const APP_ROW_SPACING = 15;
export async function GET() {
try {
@@ -60,74 +66,138 @@ export async function GET() {
}) as Promise<Application[]>,
]);
// Root Node
const rootNode: Node = {
id: "root",
type: "infrastructure",
data: { label: "My Infrastructure" },
position: { x: 0, y: 0 },
style: {
background: "#ffffff",
color: "#0f0f0f",
border: "2px solid #e6e4e1",
borderRadius: "8px",
padding: "16px",
width: ROOT_NODE_WIDTH,
height: NODE_HEIGHT,
fontSize: "1.2rem",
fontWeight: "bold",
},
};
// Level 2: Physical Servers
const serverNodes: Node[] = servers
.filter(server => !server.hostServer)
.map((server, index, filteredServers) => {
const xPos =
index * HORIZONTAL_SPACING -
((filteredServers.length - 1) * HORIZONTAL_SPACING) / 2;
// Server Nodes
const serverNodes: Node[] = servers.map((server, index) => {
const xPos =
index * HORIZONTAL_SPACING -
((servers.length - 1) * HORIZONTAL_SPACING) / 2;
return {
id: `server-${server.id}`,
type: "server",
data: {
label: `${server.name}\n${server.ip}`,
...server,
},
position: { x: xPos, y: START_Y },
style: {
background: "#ffffff",
color: "#0f0f0f",
border: "2px solid #e6e4e1",
borderRadius: "4px",
padding: "8px",
width: NODE_WIDTH,
height: NODE_HEIGHT,
fontSize: "0.9rem",
lineHeight: "1.2",
whiteSpace: "pre-wrap",
},
};
});
return {
id: `server-${server.id}`,
type: "server",
data: {
label: `${server.name}\n${server.ip}`,
...server,
},
position: { x: xPos, y: START_Y },
style: {
background: "#ffffff",
color: "#0f0f0f",
border: "2px solid #e6e4e1",
borderRadius: "4px",
padding: "8px",
width: NODE_WIDTH,
height: NODE_HEIGHT,
fontSize: "0.9rem",
lineHeight: "1.2",
whiteSpace: "pre-wrap",
},
};
});
// Application Nodes
const appNodes: Node[] = [];
// Level 3: Services and VMs
const serviceNodes: Node[] = [];
const vmNodes: Node[] = [];
servers.forEach((server) => {
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
const serverX = serverNode?.position.x || 0;
const xOffset = (NODE_WIDTH - APP_NODE_WIDTH) / 2;
if (serverNode) {
const serverX = serverNode.position.x;
// Services (left column)
applications
.filter(app => app.serverId === server.id)
.forEach((app, appIndex) => {
serviceNodes.push({
id: `service-${app.id}`,
type: "service",
data: {
label: `${app.name}\n${app.localURL}`,
...app,
},
position: {
x: serverX - COLUMN_SPACING,
y: START_Y + NODE_HEIGHT + VERTICAL_SPACING + appIndex * (APP_NODE_HEIGHT + 20),
},
style: {
background: "#f0f9ff",
color: "#0f0f0f",
border: "2px solid #60a5fa",
borderRadius: "4px",
padding: "6px",
width: APP_NODE_WIDTH,
height: APP_NODE_HEIGHT,
fontSize: "0.8rem",
lineHeight: "1.1",
whiteSpace: "pre-wrap",
},
});
});
// VMs (middle column) mit dynamischem Abstand
const hostVMs = servers.filter(vm => vm.hostServer === server.id);
let currentY = START_Y + NODE_HEIGHT + VERTICAL_SPACING;
hostVMs.forEach(vm => {
const appCount = applications.filter(app => app.serverId === vm.id).length;
vmNodes.push({
id: `vm-${vm.id}`,
type: "vm",
data: {
label: `${vm.name}\n${vm.ip}`,
...vm,
},
position: {
x: serverX,
y: currentY,
},
style: {
background: "#fef2f2",
color: "#0f0f0f",
border: "2px solid #fecaca",
borderRadius: "4px",
padding: "6px",
width: APP_NODE_WIDTH,
height: APP_NODE_HEIGHT,
fontSize: "0.8rem",
lineHeight: "1.1",
whiteSpace: "pre-wrap",
},
});
// Dynamischer Abstand basierend auf Anzahl Apps
const requiredSpace = appCount > 0
? (appCount * (APP_NODE_HEIGHT + APP_ROW_SPACING))
: 0;
currentY += Math.max(
requiredSpace + MIN_VM_SPACING,
MIN_VM_SPACING + APP_NODE_HEIGHT
);
});
}
});
// Level 4: VM Applications (right column)
const vmAppNodes: Node[] = [];
vmNodes.forEach((vm) => {
const vmX = vm.position.x;
applications
.filter((app) => app.serverId === server.id)
.filter(app => app.serverId === vm.data.id)
.forEach((app, appIndex) => {
appNodes.push({
id: `app-${app.id}`,
vmAppNodes.push({
id: `vm-app-${app.id}`,
type: "application",
data: {
label: `${app.name}\n${app.localURL}`,
...app,
},
position: {
x: serverX + xOffset,
y: START_Y + NODE_HEIGHT + 30 + appIndex * VERTICAL_SPACING,
x: vmX + VM_APP_SPACING,
y: vm.position.y + appIndex * (APP_NODE_HEIGHT + 20),
},
style: {
background: "#f5f5f5",
@@ -145,38 +215,14 @@ export async function GET() {
});
});
// Connections
const connections: Edge[] = [
...servers.map((server) => ({
id: `conn-root-${server.id}`,
source: "root",
target: `server-${server.id}`,
type: "straight",
style: {
stroke: "#94a3b8",
strokeWidth: 2,
},
})),
...applications.map((app) => ({
id: `conn-${app.serverId}-${app.id}`,
source: `server-${app.serverId}`,
target: `app-${app.id}`,
type: "straight",
style: {
stroke: "#60a5fa",
strokeWidth: 2,
},
})),
];
// Container Box
const allNodes = [rootNode, ...serverNodes, ...appNodes];
// Calculate dimensions for root node positioning
const tempNodes = [...serverNodes, ...serviceNodes, ...vmNodes, ...vmAppNodes];
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
allNodes.forEach((node) => {
tempNodes.forEach((node) => {
const width = parseInt(node.style.width?.toString() || "0", 10);
const height = parseInt(node.style.height?.toString() || "0", 10);
@@ -186,17 +232,47 @@ export async function GET() {
maxY = Math.max(maxY, node.position.y + height);
});
const centerX = (minX + maxX) / 2;
const rootX = centerX - ROOT_NODE_WIDTH / 2;
// Level 1: Root Node (centered at top)
const rootNode: Node = {
id: "root",
type: "infrastructure",
data: { label: "My Infrastructure" },
position: { x: rootX, y: 0 },
style: {
background: "#ffffff",
color: "#0f0f0f",
border: "2px solid #e6e4e1",
borderRadius: "8px",
padding: "16px",
width: ROOT_NODE_WIDTH,
height: NODE_HEIGHT,
fontSize: "1.2rem",
fontWeight: "bold",
},
};
// Update dimensions with root node
const allNodes = [rootNode, ...tempNodes];
let newMinX = Math.min(minX, rootNode.position.x);
let newMaxX = Math.max(maxX, rootNode.position.x + ROOT_NODE_WIDTH);
let newMinY = Math.min(minY, rootNode.position.y);
let newMaxY = Math.max(maxY, rootNode.position.y + NODE_HEIGHT);
// Container Node
const containerNode: Node = {
id: 'container',
type: 'container',
data: { label: '' },
position: {
x: minX - CONTAINER_PADDING,
y: minY - CONTAINER_PADDING
x: newMinX - CONTAINER_PADDING,
y: newMinY - CONTAINER_PADDING
},
style: {
width: maxX - minX + 2 * CONTAINER_PADDING,
height: maxY - minY + 2 * CONTAINER_PADDING,
width: newMaxX - newMinX + 2 * CONTAINER_PADDING,
height: newMaxY - newMinY + 2 * CONTAINER_PADDING,
background: 'transparent',
border: '2px dashed #e2e8f0',
borderRadius: '8px',
@@ -207,6 +283,116 @@ export async function GET() {
zIndex: -1,
};
// Connections with hierarchical chaining
const connections: Edge[] = [];
// Root to Servers
serverNodes.forEach((server) => {
connections.push({
id: `conn-root-${server.id}`,
source: "root",
target: server.id,
type: "straight",
style: {
stroke: "#94a3b8",
strokeWidth: 2,
},
});
});
// Services chaining
const servicesByServer = new Map<number, Node[]>();
serviceNodes.forEach(service => {
const serverId = service.data.serverId;
if (!servicesByServer.has(serverId)) servicesByServer.set(serverId, []);
servicesByServer.get(serverId)!.push(service);
});
servicesByServer.forEach((services, serverId) => {
services.sort((a, b) => a.position.y - b.position.y);
services.forEach((service, index) => {
if (index === 0) {
connections.push({
id: `conn-service-${service.id}`,
source: `server-${serverId}`,
target: service.id,
type: "straight",
style: { stroke: "#60a5fa", strokeWidth: 2 },
});
} else {
const prevService = services[index - 1];
connections.push({
id: `conn-service-${service.id}-${prevService.id}`,
source: prevService.id,
target: service.id,
type: "straight",
style: { stroke: "#60a5fa", strokeWidth: 2 },
});
}
});
});
// VMs chaining
const vmsByHost = new Map<number, Node[]>();
vmNodes.forEach(vm => {
const hostId = vm.data.hostServer;
if (!vmsByHost.has(hostId)) vmsByHost.set(hostId, []);
vmsByHost.get(hostId)!.push(vm);
});
vmsByHost.forEach((vms, hostId) => {
vms.sort((a, b) => a.position.y - b.position.y);
vms.forEach((vm, index) => {
if (index === 0) {
connections.push({
id: `conn-vm-${vm.id}`,
source: `server-${hostId}`,
target: vm.id,
type: "straight",
style: { stroke: "#f87171", strokeWidth: 2 },
});
} else {
const prevVm = vms[index - 1];
connections.push({
id: `conn-vm-${vm.id}-${prevVm.id}`,
source: prevVm.id,
target: vm.id,
type: "straight",
style: { stroke: "#f87171", strokeWidth: 2 },
});
}
});
});
// VM Applications chaining
const appsByVM = new Map<number, Node[]>();
vmAppNodes.forEach(app => {
const vmId = app.data.serverId;
if (!appsByVM.has(vmId)) appsByVM.set(vmId, []);
appsByVM.get(vmId)!.push(app);
});
appsByVM.forEach((apps, vmId) => {
apps.sort((a, b) => a.position.y - b.position.y);
apps.forEach((app, index) => {
if (index === 0) {
connections.push({
id: `conn-vm-app-${app.id}`,
source: `vm-${vmId}`,
target: app.id,
type: "straight",
style: { stroke: "#f87171", strokeWidth: 2 },
});
} else {
const prevApp = apps[index - 1];
connections.push({
id: `conn-vm-app-${app.id}-${prevApp.id}`,
source: prevApp.id,
target: app.id,
type: "straight",
style: { stroke: "#f87171", strokeWidth: 2 },
});
}
});
});
return NextResponse.json({
nodes: [containerNode, ...allNodes],
edges: connections,

View File

@@ -0,0 +1,59 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface AddRequest {
type: string;
name: string;
smtpHost?: string;
smtpPort?: number;
smtpSecure?: boolean;
smtpUsername?: string;
smtpPassword?: string;
smtpFrom?: string;
smtpTo?: string;
telegramToken?: string;
telegramChatId?: string;
discordWebhook?: string;
gotifyUrl?: string;
gotifyToken?: string;
ntfyUrl?: string;
ntfyToken?: string;
pushoverUrl?: string;
pushoverToken?: string;
pushoverUser?: string;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { type, name, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken, pushoverUrl, pushoverToken, pushoverUser } = body;
const notification = await prisma.notification.create({
data: {
type: type,
name: name,
smtpHost: smtpHost,
smtpPort: smtpPort,
smtpFrom: smtpFrom,
smtpUser: smtpUsername,
smtpPass: smtpPassword,
smtpSecure: smtpSecure,
smtpTo: smtpTo,
telegramChatId: telegramChatId,
telegramToken: telegramToken,
discordWebhook: discordWebhook,
gotifyUrl: gotifyUrl,
gotifyToken: gotifyToken,
ntfyUrl: ntfyUrl,
ntfyToken: ntfyToken,
pushoverUrl: pushoverUrl,
pushoverToken: pushoverToken,
pushoverUser: pushoverUser,
}
});
return NextResponse.json({ message: "Success", notification });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const id = Number(body.id);
if (!id) {
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
}
await prisma.notification.delete({
where: { id: id }
});
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(request: NextRequest) {
try {
const notifications = await prisma.notification.findMany();
return NextResponse.json({
notifications
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,23 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface AddRequest {
notificationId: number;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { notificationId } = body;
const notification = await prisma.test_notification.create({
data: {
notificationId: notificationId,
}
});
return NextResponse.json({ message: "Success", notification });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -2,7 +2,10 @@ import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface AddRequest {
host: boolean;
hostServer: number;
name: string;
icon: string;
os: string;
ip: string;
url: string;
@@ -10,24 +13,31 @@ interface AddRequest {
gpu: string;
ram: string;
disk: string;
monitoring: boolean;
monitoringURL: string;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { name, os, ip, url, cpu, gpu, ram, disk } = body;
const { host, hostServer, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body;
const server = await prisma.server.create({
data: {
host,
hostServer,
name,
icon,
os,
ip,
url,
cpu,
gpu,
ram,
disk
disk,
monitoring,
monitoringURL
}
});

View File

@@ -18,6 +18,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Cannot delete server with associated applications" }, { status: 400 });
}
// Delete all server history records for this server
await prisma.server_history.deleteMany({
where: { serverId: id }
});
// Delete the server
await prisma.server.delete({
where: { id: id }
});

View File

@@ -2,8 +2,11 @@ import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface EditRequest {
host: boolean;
hostServer: number;
id: number;
name: string;
icon: string;
os: string;
ip: string;
url: string;
@@ -11,29 +14,43 @@ interface EditRequest {
gpu: string;
ram: string;
disk: string;
monitoring: boolean;
monitoringURL: string;
}
export async function PUT(request: NextRequest) {
try {
const body: EditRequest = await request.json();
const { id, name, os, ip, url, cpu, gpu, ram, disk } = body;
const { host, hostServer, id, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body;
const existingServer = await prisma.server.findUnique({ where: { id } });
if (!existingServer) {
return NextResponse.json({ error: "Server not found" }, { status: 404 });
}
let newHostServer = hostServer;
if (hostServer === null) {
newHostServer = 0;
} else {
newHostServer = hostServer;
}
const updatedServer = await prisma.server.update({
where: { id },
data: {
host,
hostServer: newHostServer,
name,
icon,
os,
ip,
url,
cpu,
gpu,
ram,
disk
disk,
monitoring,
monitoringURL
}
});

View File

@@ -1,30 +1,250 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
interface GetRequest {
page?: number;
ITEMS_PER_PAGE?: number;
timeRange?: '1h' | '1d' | '7d' | '30d';
serverId?: number;
}
const getTimeRange = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
const now = new Date();
switch (timeRange) {
case '1d':
return {
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
end: now,
intervalMinutes: 15 // 15 minute intervals
};
case '7d':
return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
end: now,
intervalMinutes: 60 // 1 hour intervals
};
case '30d':
return {
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
end: now,
intervalMinutes: 240 // 4 hour intervals
};
case '1h':
default:
return {
start: new Date(now.getTime() - 60 * 60 * 1000),
end: now,
intervalMinutes: 1 // 1 minute intervals
};
}
};
const getIntervals = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
const { start, end, intervalMinutes } = getTimeRange(timeRange);
let intervalCount: number;
switch (timeRange) {
case '1d':
intervalCount = 96; // 24 hours * 4 (15-minute intervals)
break;
case '7d':
intervalCount = 168; // 7 days * 24 hours
break;
case '30d':
intervalCount = 180; // 30 days * 6 (4-hour intervals)
break;
case '1h':
default:
intervalCount = 60;
break;
}
// Calculate the total time span in minutes
const totalMinutes = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
// Create equally spaced intervals
return Array.from({ length: intervalCount }, (_, i) => {
const minutesFromEnd = Math.floor(i * (totalMinutes / (intervalCount - 1)));
const d = new Date(end.getTime() - minutesFromEnd * 60 * 1000);
return d;
}).reverse(); // Return in chronological order
};
const parseUsageValue = (value: string | null): number => {
if (!value) return 0;
return Math.round(parseFloat(value.replace('%', '')) * 100) / 100;
};
export async function POST(request: NextRequest) {
try {
const body: GetRequest = await request.json();
const page = Math.max(1, body.page || 1);
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
const servers = await prisma.server.findMany({
skip: (page - 1) * ITEMS_PER_PAGE,
take: ITEMS_PER_PAGE,
orderBy: { name: 'asc' }
});
const timeRange = body.timeRange || '1h';
const serverId = body.serverId;
const totalCount = await prisma.server.count();
const maxPage = Math.ceil(totalCount / ITEMS_PER_PAGE);
// If serverId is provided, only fetch that specific server
const hostsQuery = serverId
? { id: serverId }
: { hostServer: 0 };
let hosts;
if (!serverId) {
hosts = await prisma.server.findMany({
where: hostsQuery,
orderBy: { name: 'asc' as Prisma.SortOrder },
skip: (page - 1) * ITEMS_PER_PAGE,
take: ITEMS_PER_PAGE,
});
} else {
hosts = await prisma.server.findMany({
where: hostsQuery,
orderBy: { name: 'asc' as Prisma.SortOrder },
});
}
const { start } = getTimeRange(timeRange);
const intervals = getIntervals(timeRange);
const hostsWithVms = await Promise.all(
hosts.map(async (host) => {
const vms = await prisma.server.findMany({
where: { hostServer: host.id },
orderBy: { name: 'asc' }
});
// Get server history for the host
const serverHistory = await prisma.server_history.findMany({
where: {
serverId: host.id,
createdAt: {
gte: start
}
},
orderBy: {
createdAt: 'asc'
}
});
// Process history data into intervals
const historyMap = new Map<string, {
cpu: number[],
ram: number[],
disk: number[],
online: boolean[]
}>();
// Initialize intervals
intervals.forEach(date => {
const key = date.toISOString();
historyMap.set(key, {
cpu: [],
ram: [],
disk: [],
online: []
});
});
// Group data by interval
serverHistory.forEach(record => {
const recordDate = new Date(record.createdAt);
let nearestInterval: Date = intervals[0];
let minDiff = Infinity;
// Find the nearest interval for this record
intervals.forEach(intervalDate => {
const diff = Math.abs(recordDate.getTime() - intervalDate.getTime());
if (diff < minDiff) {
minDiff = diff;
nearestInterval = intervalDate;
}
});
const key = nearestInterval.toISOString();
const interval = historyMap.get(key);
if (interval) {
interval.cpu.push(parseUsageValue(record.cpuUsage));
interval.ram.push(parseUsageValue(record.ramUsage));
interval.disk.push(parseUsageValue(record.diskUsage));
interval.online.push(record.online);
}
});
// Calculate averages for each interval
const historyData = intervals.map(date => {
const key = date.toISOString();
const data = historyMap.get(key) || {
cpu: [],
ram: [],
disk: [],
online: []
};
const average = (arr: number[]) =>
arr.length ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : null;
return {
timestamp: key,
cpu: average(data.cpu),
ram: average(data.ram),
disk: average(data.disk),
online: data.online.length ?
data.online.filter(Boolean).length / data.online.length >= 0.5
: null
};
});
// Add isVM flag to VMs
const vmsWithFlag = vms.map(vm => ({
...vm,
isVM: true,
hostedVMs: [] // Initialize empty hostedVMs array for VMs
}));
return {
...host,
isVM: false,
hostedVMs: vmsWithFlag,
history: {
labels: intervals.map(d => d.toISOString()),
datasets: {
cpu: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.cpu || [];
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}),
ram: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.ram || [];
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}),
disk: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.disk || [];
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}),
online: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.online || [];
return data.length ? data.filter(Boolean).length / data.length >= 0.5 : null;
})
}
}
};
})
);
// Only calculate maxPage when not requesting a specific server
let maxPage = 1;
let totalHosts = 0;
if (!serverId) {
totalHosts = await prisma.server.count({
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
});
maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
}
return NextResponse.json({
servers,
maxPage
servers: hostsWithVms,
maxPage,
totalItems: totalHosts
});
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });

View File

@@ -0,0 +1,21 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(request: NextRequest) {
try {
const servers = await prisma.server.findMany({
where: { host: true },
});
// Add required properties to ensure consistency
const serversWithProps = servers.map(server => ({
...server,
isVM: false,
hostedVMs: [] // Initialize empty hostedVMs array
}));
return NextResponse.json({ servers: serversWithProps });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const servers = await prisma.server.findMany({
select: {
id: true,
online: true,
cpuUsage: true,
ramUsage: true,
diskUsage: true,
uptime: true
}
});
const monitoringData = servers.map((server: {
id: number;
online: boolean;
cpuUsage: string | null;
ramUsage: string | null;
diskUsage: string | null;
uptime: string | null;
}) => ({
id: server.id,
online: server.online,
cpuUsage: server.cpuUsage ? parseInt(server.cpuUsage) : 0,
ramUsage: server.ramUsage ? parseInt(server.ramUsage) : 0,
diskUsage: server.diskUsage ? parseInt(server.diskUsage) : 0,
uptime: server.uptime || ""
}));
return NextResponse.json(monitoringData)
} catch (error) {
return new NextResponse("Internal Error", { status: 500 })
}
}

View File

@@ -11,15 +11,51 @@ export async function POST(request: NextRequest) {
const body: SearchRequest = await request.json();
const { searchterm } = body;
// Fetch all servers
const servers = await prisma.server.findMany({});
// Create a map of host servers with their hosted VMs
const serverMap = new Map();
servers.forEach(server => {
if (server.host) {
serverMap.set(server.id, {
...server,
isVM: false,
hostedVMs: []
});
}
});
// Add VMs to their host servers and mark them as VMs
const serversWithType = servers.map(server => {
// If not a host and has a hostServer, it's a VM
if (!server.host && server.hostServer) {
const hostServer = serverMap.get(server.hostServer);
if (hostServer) {
hostServer.hostedVMs.push({
...server,
isVM: true
});
}
return {
...server,
isVM: true
};
}
return {
...server,
isVM: false,
hostedVMs: serverMap.get(server.id)?.hostedVMs || []
};
});
const fuseOptions = {
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk'],
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk', 'os'],
threshold: 0.3,
includeScore: true,
};
const fuse = new Fuse(servers, fuseOptions);
const fuse = new Fuse(serversWithType, fuseOptions);
const searchResults = fuse.search(searchterm);

View File

@@ -0,0 +1,25 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(request: NextRequest) {
try {
// Check if there are any settings entries
const existingSettings = await prisma.settings.findFirst();
if (!existingSettings) {
return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" });
}
// If settings entry exists, fetch it
const settings = await prisma.settings.findFirst({
where: { id: existingSettings.id },
});
if (!settings) {
return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" });
}
// Return the settings entry
return NextResponse.json({ "notification_text_application": settings.notification_text_application, "notification_text_server": settings.notification_text_server });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface AddRequest {
text_application: string;
text_server: string;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { text_application, text_server } = body;
// Check if there is already a settings entry
const existingSettings = await prisma.settings.findFirst();
if (existingSettings) {
// Update the existing settings entry
const updatedSettings = await prisma.settings.update({
where: { id: existingSettings.id },
data: { notification_text_application: text_application, notification_text_server: text_server },
});
return NextResponse.json({ message: "Success", updatedSettings });
}
// If no settings entry exists, create a new one
const settings = await prisma.settings.create({
data: {
notification_text_application: text_application,
notification_text_server: text_server,
}
});
return NextResponse.json({ message: "Success", settings });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -19,20 +19,23 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Button } from "@/components/ui/button"
interface StatsResponse {
serverCount: number
serverCountNoVMs: number
serverCountOnlyVMs: number
applicationCount: number
onlineApplicationsCount: number
}
export default function Dashboard() {
const [serverCount, setServerCount] = useState<number>(0)
const [serverCountNoVMs, setServerCountNoVMs] = useState<number>(0)
const [serverCountOnlyVMs, setServerCountOnlyVMs] = useState<number>(0)
const [applicationCount, setApplicationCount] = useState<number>(0)
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
const getStats = async () => {
try {
const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
setServerCount(response.data.serverCount)
setServerCountNoVMs(response.data.serverCountNoVMs)
setServerCountOnlyVMs(response.data.serverCountOnlyVMs)
setApplicationCount(response.data.applicationCount)
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
} catch (error: any) {
@@ -69,53 +72,94 @@ export default function Dashboard() {
<h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1>
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-lg transition-all hover:shadow-xl hover:border-t-rose-600">
<CardHeader className="py-3 pb-1">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Servers</CardTitle>
<Server className="h-6 w-6 text-rose-500" />
<div>
<CardTitle className="text-2xl font-semibold">Servers</CardTitle>
<CardDescription className="mt-1">Physical and virtual servers overview</CardDescription>
</div>
<Server className="h-8 w-8 text-rose-500 p-1.5 rounded-lg" />
</div>
<CardDescription>Manage your server infrastructure</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active servers</p>
<CardContent className="pt-1 pb-2 min-h-[120px]">
<div className="grid grid-cols-2 gap-4">
{/* Physical Servers */}
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
<div className="bg-rose-100 p-2 rounded-full">
<Server className="h-6 w-6 text-rose-600" />
</div>
<div>
<div className="text-3xl font-bold">{serverCountNoVMs}</div>
<p className="text-sm text-muted-foreground">Physical Servers</p>
</div>
</div>
{/* Virtual Machines */}
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
<div className="bg-violet-100 p-2 rounded-full">
<Network className="h-6 w-6 text-violet-600" />
</div>
<div>
<div className="text-3xl font-bold">{serverCountOnlyVMs}</div>
<p className="text-sm text-muted-foreground">Virtual Servers</p>
</div>
</div>
</div>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/servers">View all servers</Link>
<CardFooter className="border-t bg-muted/10 py-2 px-4">
<Button
variant="outline"
size="default"
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
asChild
>
<Link href="/dashboard/servers" className="flex items-center justify-between">
<span>Manage Servers</span>
</Link>
</Button>
</CardFooter>
</Card>
<Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-lg transition-all hover:shadow-xl hover:border-t-amber-600">
<CardHeader className="py-3 pb-1">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Applications</CardTitle>
<Layers className="h-6 w-6 text-amber-500" />
<div>
<CardTitle className="text-2xl font-semibold">Applications</CardTitle>
<CardDescription className="mt-1">Manage your deployed applications</CardDescription>
</div>
<Layers className="h-8 w-8 text-amber-500 p-1.5 rounded-lg" />
</div>
<CardDescription>Manage your deployed applications</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<CardContent className="pt-1 pb-2 min-h-[120px]">
<div className="text-4xl font-bold">{applicationCount}</div>
<p className="text-sm text-muted-foreground mt-2">Running applications</p>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/applications">View all applications</Link>
<CardFooter className="border-t bg-muted/10 py-2 px-4">
<Button
variant="outline"
size="default"
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
asChild
>
<Link href="/dashboard/applications" className="flex items-center justify-between">
<span>View all applications</span>
</Link>
</Button>
</CardFooter>
</Card>
<Card className="overflow-hidden border-t-4 border-t-emerald-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<Card className="overflow-hidden border-t-4 border-t-emerald-500 shadow-lg transition-all hover:shadow-xl hover:border-t-emerald-600">
<CardHeader className="py-3 pb-1">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Uptime</CardTitle>
<Activity className="h-6 w-6 text-emerald-500" />
<div>
<CardTitle className="text-2xl font-semibold">Uptime</CardTitle>
<CardDescription className="mt-1">Monitor your service availability</CardDescription>
</div>
<Activity className="h-8 w-8 text-emerald-500 p-1.5 rounded-lg" />
</div>
<CardDescription>Monitor your service availability</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<CardContent className="pt-1 pb-2 min-h-[120px]">
<div className="flex flex-col">
<div className="text-4xl font-bold flex items-center justify-between">
<span>
@@ -136,28 +180,44 @@ export default function Dashboard() {
<p className="text-sm text-muted-foreground mt-2">Online applications</p>
</div>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/uptime">View uptime metrics</Link>
<CardFooter className="border-t bg-muted/10 py-2 px-4">
<Button
variant="outline"
size="default"
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
asChild
>
<Link href="/dashboard/uptime" className="flex items-center justify-between">
<span>View uptime metrics</span>
</Link>
</Button>
</CardFooter>
</Card>
<Card className="overflow-hidden border-t-4 border-t-sky-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<Card className="overflow-hidden border-t-4 border-t-sky-500 shadow-lg transition-all hover:shadow-xl hover:border-t-sky-600">
<CardHeader className="py-3 pb-1">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Network</CardTitle>
<Network className="h-6 w-6 text-sky-500" />
<div>
<CardTitle className="text-2xl font-semibold">Network</CardTitle>
<CardDescription className="mt-1">Manage network configuration</CardDescription>
</div>
<Network className="h-8 w-8 text-sky-500 p-1.5 rounded-lg" />
</div>
<CardDescription>Manage network configuration</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount + applicationCount}</div>
<CardContent className="pt-1 pb-2 min-h-[120px]">
<div className="text-4xl font-bold">{serverCountNoVMs + serverCountOnlyVMs + applicationCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active connections</p>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/network">View network details</Link>
<CardFooter className="border-t bg-muted/10 py-2 px-4">
<Button
variant="outline"
size="default"
className="w-full font-semibold transition-colors border border-muted-foreground/20 hover:bg-primary hover:text-primary-foreground"
asChild
>
<Link href="/dashboard/network" className="flex items-center justify-between">
<span>View network details</span>
</Link>
</Button>
</CardFooter>
</Card>

View File

@@ -25,6 +25,9 @@ import {
List,
Pencil,
Zap,
ViewIcon,
Grid3X3,
HelpCircle,
} from "lucide-react";
import {
Card,
@@ -65,7 +68,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import axios from "axios";
import {
Tooltip,
@@ -73,6 +76,15 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { StatusIndicator } from "@/components/status-indicator";
import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface Application {
id: number;
@@ -84,6 +96,7 @@ interface Application {
server?: string;
online: boolean;
serverId: number;
uptimecheckUrl?: string;
}
interface Server {
@@ -95,6 +108,7 @@ interface ApplicationsResponse {
applications: Application[];
servers: Server[];
maxPage: number;
totalItems?: number;
}
export default function Dashboard() {
@@ -104,6 +118,8 @@ export default function Dashboard() {
const [publicURL, setPublicURL] = useState<string>("");
const [localURL, setLocalURL] = useState<string>("");
const [serverId, setServerId] = useState<number | null>(null);
const [customUptimeCheck, setCustomUptimeCheck] = useState<boolean>(false);
const [uptimecheckUrl, setUptimecheckUrl] = useState<string>("");
const [editName, setEditName] = useState<string>("");
const [editDescription, setEditDescription] = useState<string>("");
@@ -112,34 +128,85 @@ export default function Dashboard() {
const [editLocalURL, setEditLocalURL] = useState<string>("");
const [editId, setEditId] = useState<number | null>(null);
const [editServerId, setEditServerId] = useState<number | null>(null);
const [editCustomUptimeCheck, setEditCustomUptimeCheck] = useState<boolean>(false);
const [editUptimecheckUrl, setEditUptimecheckUrl] = useState<string>("");
const [currentPage, setCurrentPage] = useState<number>(1);
const [maxPage, setMaxPage] = useState<number>(1);
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
const [applications, setApplications] = useState<Application[]>([]);
const [servers, setServers] = useState<Server[]>([]);
const [isGridLayout, setIsGridLayout] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [searchTerm, setSearchTerm] = useState<string>("");
const [isSearching, setIsSearching] = useState<boolean>(false);
useEffect(() => {
const savedLayout = Cookies.get("layoutPreference-app");
const layout_bool = savedLayout === "grid";
setIsGridLayout(layout_bool);
setItemsPerPage(layout_bool ? 15 : 5);
}, []);
const savedLayout = Cookies.get("layoutPreference-app");
const savedItemsPerPage = Cookies.get("itemsPerPage-app");
const initialIsGridLayout = savedLayout === "grid";
const initialIsCompactLayout = savedLayout === "compact";
const defaultItemsPerPage = initialIsGridLayout ? 15 : (initialIsCompactLayout ? 30 : 5);
const initialItemsPerPage = savedItemsPerPage ? parseInt(savedItemsPerPage) : defaultItemsPerPage;
const toggleLayout = () => {
const newLayout = !isGridLayout;
setIsGridLayout(newLayout);
Cookies.set("layoutPreference-app", newLayout ? "grid" : "standard", {
expires: 365,
path: "/",
sameSite: "strict",
});
setItemsPerPage(newLayout ? 15 : 5);
const [isGridLayout, setIsGridLayout] = useState<boolean>(initialIsGridLayout);
const [isCompactLayout, setIsCompactLayout] = useState<boolean>(initialIsCompactLayout);
const [itemsPerPage, setItemsPerPage] = useState<number>(initialItemsPerPage);
const customInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const toggleLayout = (layout: string) => {
if (layout === "standard") {
setIsGridLayout(false);
setIsCompactLayout(false);
Cookies.set("layoutPreference-app", "standard", {
expires: 365,
path: "/",
sameSite: "strict",
});
} else if (layout === "grid") {
setIsGridLayout(true);
setIsCompactLayout(false);
Cookies.set("layoutPreference-app", "grid", {
expires: 365,
path: "/",
sameSite: "strict",
});
} else if (layout === "compact") {
setIsGridLayout(false);
setIsCompactLayout(true);
Cookies.set("layoutPreference-app", "compact", {
expires: 365,
path: "/",
sameSite: "strict",
});
}
};
const handleItemsPerPageChange = (value: string) => {
// Clear any existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set a new timer
debounceTimerRef.current = setTimeout(() => {
const newItemsPerPage = parseInt(value);
// Ensure the value is within the valid range
if (isNaN(newItemsPerPage) || newItemsPerPage < 1) {
toast.error("Please enter a number between 1 and 100");
return;
}
const validatedValue = Math.min(Math.max(newItemsPerPage, 1), 100);
setItemsPerPage(validatedValue);
setCurrentPage(1); // Reset to first page when changing items per page
Cookies.set("itemsPerPage-app", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
}, 300); // 300ms delay
};
const add = async () => {
@@ -151,10 +218,13 @@ export default function Dashboard() {
publicURL,
localURL,
serverId,
uptimecheckUrl: customUptimeCheck ? uptimecheckUrl : "",
});
getApplications();
toast.success("Application added successfully");
} catch (error: any) {
console.log(error.response?.data);
toast.error("Failed to add application");
}
};
@@ -168,12 +238,21 @@ export default function Dashboard() {
setApplications(response.data.applications);
setServers(response.data.servers);
setMaxPage(response.data.maxPage);
if (response.data.totalItems !== undefined) {
setTotalItems(response.data.totalItems);
}
setLoading(false);
} catch (error: any) {
console.log(error.response?.data);
toast.error("Failed to get applications");
}
};
// Calculate current range of items being displayed
const [totalItems, setTotalItems] = useState<number>(0);
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
useEffect(() => {
getApplications();
}, [currentPage, itemsPerPage]);
@@ -186,8 +265,10 @@ export default function Dashboard() {
try {
await axios.post("/api/applications/delete", { id });
getApplications();
toast.success("Application deleted successfully");
} catch (error: any) {
console.log(error.response?.data);
toast.error("Failed to delete application");
}
};
@@ -199,6 +280,14 @@ export default function Dashboard() {
setEditIcon(app.icon || "");
setEditLocalURL(app.localURL || "");
setEditPublicURL(app.publicURL || "");
if (app.uptimecheckUrl) {
setEditCustomUptimeCheck(true);
setEditUptimecheckUrl(app.uptimecheckUrl);
} else {
setEditCustomUptimeCheck(false);
setEditUptimecheckUrl("");
}
};
const edit = async () => {
@@ -213,11 +302,14 @@ export default function Dashboard() {
icon: editIcon,
publicURL: editPublicURL,
localURL: editLocalURL,
uptimecheckUrl: editCustomUptimeCheck ? editUptimecheckUrl : "",
});
getApplications();
setEditId(null);
toast.success("Application edited successfully");
} catch (error: any) {
console.log(error.response.data);
toast.error("Failed to edit application");
}
};
@@ -281,24 +373,120 @@ export default function Dashboard() {
</Breadcrumb>
</div>
</header>
<Toaster />
<div className="p-6">
<div className="flex justify-between items-center">
<span className="text-3xl font-bold">Your Applications</span>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={toggleLayout}
title={
isGridLayout ? "Switch to list view" : "Switch to grid view"
}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" title="Change view">
{isCompactLayout ? (
<Grid3X3 className="h-4 w-4" />
) : isGridLayout ? (
<LayoutGrid className="h-4 w-4" />
) : (
<List className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => toggleLayout("standard")}>
<List className="h-4 w-4 mr-2" /> List View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleLayout("grid")}>
<LayoutGrid className="h-4 w-4 mr-2" /> Grid View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleLayout("compact")}>
<Grid3X3 className="h-4 w-4 mr-2" /> Compact View
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={String(itemsPerPage)}
onValueChange={handleItemsPerPageChange}
onOpenChange={(open) => {
if (open && customInputRef.current) {
customInputRef.current.value = String(itemsPerPage);
}
}}
>
{isGridLayout ? (
<List className="h-4 w-4" />
) : (
<LayoutGrid className="h-4 w-4" />
)}
</Button>
<SelectTrigger className="w-[140px]">
<SelectValue>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{![5, 10, 15, 20, 25].includes(itemsPerPage) ? (
<SelectItem value={String(itemsPerPage)}>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'} (custom)
</SelectItem>
) : null}
<SelectItem value="5">5 items</SelectItem>
<SelectItem value="10">10 items</SelectItem>
<SelectItem value="15">15 items</SelectItem>
<SelectItem value="20">20 items</SelectItem>
<SelectItem value="25">25 items</SelectItem>
<div className="p-2 border-t mt-1">
<Label htmlFor="custom-items" className="text-xs font-medium">Custom (1-100)</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id="custom-items"
ref={customInputRef}
type="number"
min="1"
max="100"
className="h-8"
defaultValue={itemsPerPage}
onChange={(e) => {
// Don't immediately apply the change while typing
// Just validate the input for visual feedback
const value = parseInt(e.target.value);
if (isNaN(value) || value < 1 || value > 100) {
e.target.classList.add("border-red-500");
} else {
e.target.classList.remove("border-red-500");
}
}}
onBlur={(e) => {
// Apply the change when the input loses focus
const value = parseInt(e.target.value);
if (value >= 1 && value <= 100) {
handleItemsPerPageChange(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
// Clear any existing debounce timer to apply immediately
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const value = parseInt((e.target as HTMLInputElement).value);
if (value >= 1 && value <= 100) {
// Apply change immediately on Enter
const validatedValue = Math.min(Math.max(value, 1), 100);
setItemsPerPage(validatedValue);
setCurrentPage(1);
Cookies.set("itemsPerPage-app", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
// Close the dropdown
document.body.click();
}
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">items</span>
</div>
</div>
</SelectContent>
</Select>
{servers.length === 0 ? (
<p className="text-muted-foreground">
You must first add a server.
@@ -395,6 +583,36 @@ export default function Dashboard() {
onChange={(e) => setLocalURL(e.target.value)}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="custom-uptime-check"
checked={customUptimeCheck}
onChange={(e) => setCustomUptimeCheck(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Label htmlFor="custom-uptime-check">Custom Uptime Check URL</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
When enabled, this URL replaces the Public URL for uptime monitoring checks
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{customUptimeCheck && (
<div className="grid w-full items-center gap-1.5">
<Label>Uptime Check URL</Label>
<Input
placeholder="https://example.com/status"
value={uptimecheckUrl}
onChange={(e) => setUptimecheckUrl(e.target.value)}
/>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -424,88 +642,307 @@ export default function Dashboard() {
{!loading ? (
<div
className={
isGridLayout
isCompactLayout
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-2"
: isGridLayout
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
: "space-y-4"
}
>
{applications.map((app) => (
<Card
key={app.id}
className={
isGridLayout
? "h-full flex flex-col justify-between relative"
: "w-full mb-4 relative"
}
>
<CardHeader>
<div className="absolute top-2 right-2">
<div
className={`w-4 h-4 rounded-full flex items-center justify-center ${
app.online ? "bg-green-700" : "bg-red-700"
}`}
title={app.online ? "Online" : "Offline"}
>
<div
className={`w-2 h-2 rounded-full ${
app.online ? "bg-green-500" : "bg-red-500"
}`}
/>
</div>
isCompactLayout ? (
<div
key={app.id}
className="bg-card rounded-md border p-3 flex flex-col items-center justify-between h-[120px] w-full cursor-pointer hover:shadow-md transition-shadow relative"
onClick={() => window.open(app.publicURL, "_blank")}
title={app.name}
>
<div className="absolute top-1 right-1">
<StatusIndicator isOnline={app.online} showLabel={false} />
</div>
<div className="flex items-center justify-between w-full">
<div className="flex items-center">
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md">
{app.icon ? (
<img
src={app.icon}
alt={app.name}
className="w-full h-full object-contain rounded-md"
/>
) : (
<span className="text-gray-500 text-xs">Image</span>
)}
</div>
<div className="ml-4">
<CardTitle className="text-2xl font-bold">
{app.name}
</CardTitle>
<CardDescription className="text-md">
{app.description}
{app.description && (
<br className="hidden md:block" />
)}
Server: {app.server || "No server"}
</CardDescription>
</div>
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center">
{app.icon ? (
<img
src={app.icon}
alt={app.name}
className="w-full h-full object-contain rounded-md"
/>
) : (
<span className="text-gray-500 text-xs">Icon</span>
)}
</div>
<div className="text-center mt-2">
<h3 className="text-sm font-medium truncate w-full max-w-[110px]">{app.name}</h3>
</div>
</div>
) : (
<Card
key={app.id}
className={
isGridLayout
? "h-full flex flex-col justify-between relative"
: "w-full mb-4 relative"
}
>
<CardHeader>
<div className="absolute top-2 right-2">
<StatusIndicator isOnline={app.online} />
</div>
<div className="flex flex-col items-end justify-start space-y-2 w-[270px]">
<div className={`flex ${isGridLayout ? 'flex-col' : 'items-center justify-between'} w-full mt-4 mb-4`}>
<div className="flex items-center">
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center rounded-md">
{app.icon ? (
<img
src={app.icon}
alt={app.name}
className="w-full h-full object-contain rounded-md"
/>
) : (
<span className="text-gray-500 text-xs">Image</span>
)}
</div>
<div className="ml-4">
<CardTitle className="text-2xl font-bold">
{app.name}
</CardTitle>
<CardDescription className="text-md">
{app.description}
{app.description && (
<br className="hidden md:block" />
)}
Server: {app.server || "No server"}
</CardDescription>
</div>
</div>
{!isGridLayout && (
<div className="flex flex-col items-end justify-start space-y-2 w-[190px]">
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col space-y-2 flex-grow">
<Button
variant="outline"
className="gap-2 w-full"
onClick={() =>
window.open(app.publicURL, "_blank")
}
>
<Link className="h-4 w-4" />
Public URL
</Button>
{app.localURL && (
<Button
variant="outline"
className="gap-2 w-full"
onClick={() =>
window.open(app.localURL, "_blank")
}
>
<Home className="h-4 w-4" />
Local URL
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<Button
variant="destructive"
size="icon"
className="h-9 w-9"
onClick={() => deleteApplication(app.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
className="h-9 w-9"
onClick={() => openEditDialog(app)}
>
<Pencil className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Edit Application
</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label>Name</Label>
<Input
placeholder="e.g. Portainer"
value={editName}
onChange={(e) =>
setEditName(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>Server</Label>
<Select
value={
editServerId !== null
? String(editServerId)
: undefined
}
onValueChange={(v) =>
setEditServerId(Number(v))
}
required
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select server" />
</SelectTrigger>
<SelectContent>
{servers.map((server) => (
<SelectItem
key={server.id}
value={String(server.id)}
>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Description{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Textarea
placeholder="Application description"
value={editDescription}
onChange={(e) =>
setEditDescription(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Icon URL{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<div className="flex gap-2">
<Input
placeholder="https://example.com/icon.png"
value={editIcon}
onChange={(e) =>
setEditIcon(e.target.value)
}
/>
<Button variant="outline" size="icon" onClick={generateEditIconURL}>
<Zap />
</Button>
</div>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>Public URL</Label>
<Input
placeholder="https://example.com"
value={editPublicURL}
onChange={(e) =>
setEditPublicURL(e.target.value)
}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label>
Local URL{" "}
<span className="text-stone-600">
(optional)
</span>
</Label>
<Input
placeholder="http://localhost:3000"
value={editLocalURL}
onChange={(e) =>
setEditLocalURL(e.target.value)
}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="edit-custom-uptime-check"
checked={editCustomUptimeCheck}
onChange={(e) => setEditCustomUptimeCheck(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Label htmlFor="edit-custom-uptime-check">Custom Uptime Check URL</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
When enabled, this URL replaces the Public URL for uptime monitoring checks
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{editCustomUptimeCheck && (
<div className="grid w-full items-center gap-1.5">
<Label>Uptime Check URL</Label>
<Input
placeholder="https://example.com/status"
value={editUptimecheckUrl}
onChange={(e) => setEditUptimecheckUrl(e.target.value)}
/>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={edit}
disabled={
!editName || !editPublicURL || !editServerId
}
>
Save Changes
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
)}
</div>
</CardHeader>
{isGridLayout && (
<CardFooter className="mt-auto">
<div className="flex items-center gap-2 w-full">
<div className="flex flex-col space-y-2 flex-grow">
<div className={`grid ${app.localURL ? 'grid-cols-2' : 'grid-cols-1'} gap-2 flex-grow`}>
<Button
variant="outline"
className="gap-2 w-full"
onClick={() =>
window.open(app.publicURL, "_blank")
}
onClick={() => window.open(app.publicURL, "_blank")}
>
<Link className="h-4 w-4" />
Open Public URL
Public URL
</Button>
{app.localURL && (
<Button
variant="outline"
className="gap-2 w-full"
onClick={() =>
window.open(app.localURL, "_blank")
}
onClick={() => window.open(app.localURL, "_blank")}
>
<Home className="h-4 w-4" />
Open Local URL
Local URL
</Button>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
variant="destructive"
size="icon"
@@ -629,6 +1066,36 @@ export default function Dashboard() {
}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="edit-custom-uptime-check"
checked={editCustomUptimeCheck}
onChange={(e) => setEditCustomUptimeCheck(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Label htmlFor="edit-custom-uptime-check">Custom Uptime Check URL</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
When enabled, this URL replaces the Public URL for uptime monitoring checks
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{editCustomUptimeCheck && (
<div className="grid w-full items-center gap-1.5">
<Label>Uptime Check URL</Label>
<Input
placeholder="https://example.com/status"
value={editUptimecheckUrl}
onChange={(e) => setEditUptimecheckUrl(e.target.value)}
/>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -647,10 +1114,10 @@ export default function Dashboard() {
</AlertDialog>
</div>
</div>
</div>
</div>
</CardHeader>
</Card>
</CardFooter>
)}
</Card>
)
))}
</div>
) : (
@@ -682,6 +1149,11 @@ export default function Dashboard() {
</div>
)}
<div className="pt-4 pb-4">
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-muted-foreground">
{totalItems > 0 ? `Showing ${startItem}-${endItem} of ${totalItems} applications` : "No applications found"}
</div>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,748 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import axios from "axios"
import Chart from 'chart.js/auto'
import { AppSidebar } from "@/components/app-sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { Separator } from "@/components/ui/separator"
import { Link, Cpu, MicroscopeIcon as Microchip, MemoryStick, HardDrive, MonitorIcon as MonitorCog, FileDigit, History } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusIndicator } from "@/components/status-indicator"
import { DynamicIcon } from "lucide-react/dynamic"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import NextLink from "next/link"
interface ServerHistory {
labels: string[];
datasets: {
cpu: (number | null)[];
ram: (number | null)[];
disk: (number | null)[];
online: (boolean | null)[];
}
}
interface Server {
id: number;
name: string;
icon: string;
host: boolean;
hostServer: number | null;
os?: string;
ip?: string;
url?: string;
cpu?: string;
gpu?: string;
ram?: string;
disk?: string;
hostedVMs?: Server[];
isVM?: boolean;
monitoring?: boolean;
monitoringURL?: string;
online?: boolean;
cpuUsage: number;
ramUsage: number;
diskUsage: number;
history?: ServerHistory;
port: number;
uptime?: string;
}
interface GetServersResponse {
servers: Server[];
maxPage: number;
}
export default function ServerDetail() {
const params = useParams()
const serverId = params.server_id as string
const [server, setServer] = useState<Server | null>(null)
const [timeRange, setTimeRange] = useState<'1h' | '1d' | '7d' | '30d'>('1h')
const [loading, setLoading] = useState(true)
// Chart references
const cpuChartRef = { current: null as Chart | null }
const ramChartRef = { current: null as Chart | null }
const diskChartRef = { current: null as Chart | null }
const fetchServerDetails = async () => {
try {
setLoading(true)
const response = await axios.post<GetServersResponse>("/api/servers/get", {
serverId: parseInt(serverId),
timeRange: timeRange
})
if (response.data.servers && response.data.servers.length > 0) {
setServer(response.data.servers[0])
}
setLoading(false)
} catch (error) {
console.error("Failed to fetch server details:", error)
setLoading(false)
}
}
useEffect(() => {
fetchServerDetails()
}, [serverId, timeRange])
useEffect(() => {
if (!server || !server.history) return;
// Clean up existing charts
if (cpuChartRef.current) cpuChartRef.current.destroy();
if (ramChartRef.current) ramChartRef.current.destroy();
if (diskChartRef.current) diskChartRef.current.destroy();
// Wait for DOM to be ready
const initTimer = setTimeout(() => {
const history = server.history as ServerHistory;
// Format time labels based on the selected time range
const timeLabels = history.labels.map((date: string) => {
const d = new Date(date)
if (timeRange === '1h') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} else if (timeRange === '1d') {
// For 1 day, show hours and minutes
return d.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
} else if (timeRange === '7d') {
// For 7 days, show day and time
return d.toLocaleDateString([], {
weekday: 'short',
month: 'numeric',
day: 'numeric'
}) + ' ' + d.toLocaleTimeString([], {
hour: '2-digit'
})
} else {
// For 30 days
return d.toLocaleDateString([], {
month: 'numeric',
day: 'numeric'
})
}
})
// Create a time range title for the chart
const getRangeTitle = () => {
const now = new Date()
const startDate = new Date(history.labels[0])
if (timeRange === '1h') {
return `Last Hour (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
} else if (timeRange === '1d') {
return `Last 24 Hours (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
} else if (timeRange === '7d') {
return `Last 7 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
} else {
return `Last 30 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
}
}
// Directly hardcode the y-axis maximum in each chart option
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'nearest' as const,
axis: 'x' as const,
intersect: false
},
scales: {
y: {
min: 0,
max: 100,
beginAtZero: true,
ticks: {
stepSize: 25,
autoSkip: false,
callback: function(value: any) {
return value + '%';
}
},
title: {
display: true,
text: 'Usage %'
}
},
x: {
grid: {
display: false
}
}
},
elements: {
point: {
radius: 0
},
line: {
tension: 0.4,
spanGaps: true
}
}
};
// Create charts with very explicit y-axis max values
const cpuCanvas = document.getElementById(`cpu-chart`) as HTMLCanvasElement
if (cpuCanvas) {
cpuChartRef.current = new Chart(cpuCanvas, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: 'CPU Usage',
data: history.datasets.cpu,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
fill: true,
spanGaps: false
}]
},
options: {
...commonOptions,
plugins: {
title: {
display: true,
text: 'CPU Usage History',
font: {
size: 14
}
},
tooltip: {
callbacks: {
title: function(tooltipItems: any) {
return timeLabels[tooltipItems[0].dataIndex];
}
}
},
legend: {
display: false
}
},
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
max: 100 // Force this to ensure it's applied
}
}
}
})
}
const ramCanvas = document.getElementById(`ram-chart`) as HTMLCanvasElement
if (ramCanvas) {
ramChartRef.current = new Chart(ramCanvas, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: 'RAM Usage',
data: history.datasets.ram,
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.1)',
fill: true,
spanGaps: false
}]
},
options: {
...commonOptions,
plugins: {
title: {
display: true,
text: 'RAM Usage History',
font: {
size: 14
}
},
tooltip: {
callbacks: {
title: function(tooltipItems: any) {
return timeLabels[tooltipItems[0].dataIndex];
}
}
},
legend: {
display: false
}
},
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
max: 100 // Force this to ensure it's applied
}
}
}
})
}
const diskCanvas = document.getElementById(`disk-chart`) as HTMLCanvasElement
if (diskCanvas) {
diskChartRef.current = new Chart(diskCanvas, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: 'Disk Usage',
data: history.datasets.disk,
borderColor: 'rgb(255, 159, 64)',
backgroundColor: 'rgba(255, 159, 64, 0.1)',
fill: true,
spanGaps: false
}]
},
options: {
...commonOptions,
plugins: {
title: {
display: true,
text: 'Disk Usage History',
font: {
size: 14
}
},
tooltip: {
callbacks: {
title: function(tooltipItems: any) {
return timeLabels[tooltipItems[0].dataIndex];
}
}
},
legend: {
display: false
}
},
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
max: 100 // Force this to ensure it's applied
}
}
}
})
}
}, 100);
return () => {
clearTimeout(initTimer);
if (cpuChartRef.current) cpuChartRef.current.destroy();
if (ramChartRef.current) ramChartRef.current.destroy();
if (diskChartRef.current) diskChartRef.current.destroy();
};
}, [server, timeRange]);
// Function to refresh data
const refreshData = () => {
fetchServerDetails()
}
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbPage>/</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>My Infrastructure</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<NextLink href="/dashboard/servers" className="hover:underline">
<BreadcrumbPage>Servers</BreadcrumbPage>
</NextLink>
</BreadcrumbItem>
{server && (
<>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>{server.name}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="inline-block" role="status" aria-label="loading">
<svg
className="w-6 h-6 stroke-white animate-spin "
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_9023_61563)">
<path
d="M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444"
stroke="stroke-current"
strokeWidth="1.4"
strokeLinecap="round"
className="my-path"
></path>
</g>
<defs>
<clipPath id="clip0_9023_61563">
<rect width="24" height="24" fill="white"></rect>
</clipPath>
</defs>
</svg>
<span className="sr-only">Loading...</span>
</div>
</div>
) : server ? (
<div className="space-y-6">
{/* Server header card */}
<Card>
<CardHeader className="relative">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{server.icon && <DynamicIcon name={server.icon as any} size={32} />}
<div>
<CardTitle className="text-2xl flex items-center gap-2">
{server.name}
</CardTitle>
<CardDescription>
{server.os || "No OS specified"} {server.isVM ? "Virtual Machine" : "Physical Server"}
{server.isVM && server.hostServer && (
<> Hosted on {server.hostedVMs?.[0]?.name}</>
)}
</CardDescription>
</div>
</div>
</div>
{server.monitoring && (
<div className="absolute top-0 right-4 flex flex-col items-end">
<StatusIndicator isOnline={server.online} />
{server.online && server.uptime && (
<span className="text-xs text-muted-foreground mt-1 w-max text-right whitespace-nowrap">
since {server.uptime}
</span>
)}
</div>
)}
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Hardware</h3>
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
<div className="text-muted-foreground">CPU:</div>
<div>{server.cpu || "-"}</div>
<div className="text-muted-foreground">GPU:</div>
<div>{server.gpu || "-"}</div>
<div className="text-muted-foreground">RAM:</div>
<div>{server.ram || "-"}</div>
<div className="text-muted-foreground">Disk:</div>
<div>{server.disk || "-"}</div>
</div>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Network</h3>
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
<div className="text-muted-foreground">IP Address:</div>
<div>{server.ip || "-"}</div>
<div className="text-muted-foreground">Management URL:</div>
<div>
{server.url ? (
<a href={server.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-500 hover:underline">
{server.url} <Link className="h-3 w-3" />
</a>
) : (
"-"
)}
</div>
</div>
</div>
{server.monitoring && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Current Usage</h3>
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
<div className="text-muted-foreground">CPU Usage:</div>
<div className="flex items-center gap-2">
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full ${server.cpuUsage > 80 ? "bg-destructive" : server.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${server.cpuUsage}%` }}
/>
</div>
<span>{server.cpuUsage}%</span>
</div>
<div className="text-muted-foreground">RAM Usage:</div>
<div className="flex items-center gap-2">
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full ${server.ramUsage > 80 ? "bg-destructive" : server.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${server.ramUsage}%` }}
/>
</div>
<span>{server.ramUsage}%</span>
</div>
<div className="text-muted-foreground">Disk Usage:</div>
<div className="flex items-center gap-2">
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full ${server.diskUsage > 80 ? "bg-destructive" : server.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${server.diskUsage}%` }}
/>
</div>
<span>{server.diskUsage}%</span>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Charts */}
{server.monitoring && server.history && (
<div className="grid grid-cols-1 gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Resource Usage History</CardTitle>
<CardDescription>
{timeRange === '1h'
? 'Last hour, per minute'
: timeRange === '1d'
? 'Last 24 hours, 15-minute intervals'
: timeRange === '7d'
? 'Last 7 days, hourly intervals'
: 'Last 30 days, 4-hour intervals'}
</CardDescription>
</div>
<div className="flex gap-2">
<Select value={timeRange} onValueChange={(value: '1h' | '1d' | '7d' | '30d') => setTimeRange(value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last Hour</SelectItem>
<SelectItem value="1d">Last 24 Hours</SelectItem>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={refreshData}>Refresh</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-8">
<div className="h-[200px] relative bg-background">
<canvas id="cpu-chart" />
</div>
<div className="h-[200px] relative bg-background">
<canvas id="ram-chart" />
</div>
<div className="h-[200px] relative bg-background">
<canvas id="disk-chart" />
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Virtual Machines */}
{server.hostedVMs && server.hostedVMs.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Virtual Machines</CardTitle>
<CardDescription>Virtual machines hosted on this server</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{server.hostedVMs.map((hostedVM) => (
<div
key={hostedVM.id}
className="flex flex-col gap-2 border border-muted py-2 px-4 rounded-md"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{hostedVM.icon && (
<DynamicIcon
name={hostedVM.icon as any}
size={24}
/>
)}
<NextLink href={`/dashboard/servers/${hostedVM.id}`} className="hover:underline">
<div className="text-base font-extrabold">
{hostedVM.icon && "・ "}
{hostedVM.name}
</div>
</NextLink>
</div>
{hostedVM.monitoring && (
<div className="flex flex-col items-end">
<StatusIndicator isOnline={hostedVM.online} />
{hostedVM.online && hostedVM.uptime && (
<span className="text-xs text-muted-foreground mt-1 w-max text-right whitespace-nowrap">
since {hostedVM.uptime}
</span>
)}
</div>
)}
</div>
<div className="col-span-full pb-2">
<Separator />
</div>
<div className="flex gap-5 pb-2">
<div className="flex items-center gap-2 text-foreground/80">
<MonitorCog className="h-4 w-4 text-muted-foreground" />
<span>
<b>OS:</b> {hostedVM.os || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<FileDigit className="h-4 w-4 text-muted-foreground" />
<span>
<b>IP:</b> {hostedVM.ip || "Not set"}
</span>
</div>
</div>
<div className="col-span-full mb-2">
<h4 className="text-sm font-semibold">Hardware Information</h4>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span>
<b>CPU:</b> {hostedVM.cpu || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<Microchip className="h-4 w-4 text-muted-foreground" />
<span>
<b>GPU:</b> {hostedVM.gpu || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span>
<b>RAM:</b> {hostedVM.ram || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span>
<b>Disk:</b> {hostedVM.disk || "-"}
</span>
</div>
{hostedVM.monitoring && (
<>
<div className="col-span-full pt-2 pb-2">
<Separator />
</div>
<div className="col-span-full grid grid-cols-3 gap-4">
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">CPU</span>
</div>
<span className="text-xs font-medium">
{hostedVM.cpuUsage || 0}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
<div
className={`h-full ${hostedVM.cpuUsage && hostedVM.cpuUsage > 80 ? "bg-destructive" : hostedVM.cpuUsage && hostedVM.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${hostedVM.cpuUsage || 0}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">RAM</span>
</div>
<span className="text-xs font-medium">
{hostedVM.ramUsage || 0}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
<div
className={`h-full ${hostedVM.ramUsage && hostedVM.ramUsage > 80 ? "bg-destructive" : hostedVM.ramUsage && hostedVM.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${hostedVM.ramUsage || 0}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Disk</span>
</div>
<span className="text-xs font-medium">
{hostedVM.diskUsage || 0}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
<div
className={`h-full ${hostedVM.diskUsage && hostedVM.diskUsage > 80 ? "bg-destructive" : hostedVM.diskUsage && hostedVM.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${hostedVM.diskUsage || 0}%` }}
/>
</div>
</div>
</div>
</>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
) : (
<div className="text-center p-12">
<h2 className="text-2xl font-bold">Server not found</h2>
<p className="text-muted-foreground mt-2">The requested server could not be found or you don't have permission to view it.</p>
</div>
)}
</div>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";
import ServerDetail from "./Server"
import axios from "axios";
export default function DashboardPage() {
const router = useRouter();
const [isAuthChecked, setIsAuthChecked] = useState(false);
const [isValid, setIsValid] = useState(false);
useEffect(() => {
const token = Cookies.get("token");
if (!token) {
router.push("/");
} else {
const checkToken = async () => {
try {
const response = await axios.post("/api/auth/validate", {
token: token,
});
if (response.status === 200) {
setIsValid(true);
}
} catch (error: any) {
Cookies.remove("token");
router.push("/");
}
}
checkToken();
}
setIsAuthChecked(true);
}, [router]);
if (!isAuthChecked) {
return (
<div className="flex items-center justify-center h-screen">
<div className='inline-block' role='status' aria-label='loading'>
<svg className='w-6 h-6 stroke-white animate-spin ' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'>
<g clipPath='url(#clip0_9023_61563)'>
<path d='M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444' stroke='stroke-current' strokeWidth='1.4' strokeLinecap='round' className='my-path'></path>
</g>
<defs>
<clipPath id='clip0_9023_61563'>
<rect width='24' height='24' fill='white'></rect>
</clipPath>
</defs>
</svg>
<span className='sr-only'>Loading...</span>
</div>
</div>
)
}
return isValid ? <ServerDetail /> : null;
}

View File

@@ -1,42 +1,52 @@
import { AppSidebar } from "@/components/app-sidebar";
"use client"
import { AppSidebar } from "@/components/app-sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useTheme } from "next-themes";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { useTheme } from "next-themes"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { useState } from "react";
import axios from "axios";
import Cookies from "js-cookie";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react"
import axios from "axios"
import Cookies from "js-cookie"
import { Button } from "@/components/ui/button"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertCircle, Check, Palette, User } from "lucide-react";
import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2, Play } from "lucide-react"
import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { Textarea } from "@/components/ui/textarea"
interface NotificationsResponse {
notifications: any[]
}
interface NotificationResponse {
notification_text_application?: string
notification_text_server?: string
}
export default function Settings() {
const { theme, setTheme } = useTheme();
const { theme, setTheme } = useTheme()
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
@@ -51,86 +61,214 @@ export default function Settings() {
const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false)
const [emailSuccess, setEmailSuccess] = useState<boolean>(false)
const [notificationType, setNotificationType] = useState<string>("")
const [notificationName, setNotificationName] = useState<string>("")
const [smtpHost, setSmtpHost] = useState<string>("")
const [smtpPort, setSmtpPort] = useState<number>(0)
const [smtpSecure, setSmtpSecure] = useState<boolean>(false)
const [smtpUsername, setSmtpUsername] = useState<string>("")
const [smtpPassword, setSmtpPassword] = useState<string>("")
const [smtpFrom, setSmtpFrom] = useState<string>("")
const [smtpTo, setSmtpTo] = useState<string>("")
const [telegramToken, setTelegramToken] = useState<string>("")
const [telegramChatId, setTelegramChatId] = useState<string>("")
const [discordWebhook, setDiscordWebhook] = useState<string>("")
const [gotifyUrl, setGotifyUrl] = useState<string>("")
const [gotifyToken, setGotifyToken] = useState<string>("")
const [ntfyUrl, setNtfyUrl] = useState<string>("")
const [ntfyToken, setNtfyToken] = useState<string>("")
const [pushoverUrl, setPushoverUrl] = useState<string>("")
const [pushoverToken, setPushoverToken] = useState<string>("")
const [pushoverUser, setPushoverUser] = useState<string>("")
const [notifications, setNotifications] = useState<any[]>([])
const [notificationTextApplication, setNotificationTextApplication] = useState<string>("")
const [notificationTextServer, setNotificationTextServer] = useState<string>("")
const changeEmail = async () => {
setEmailErrorVisible(false);
setEmailSuccess(false);
setEmailError("");
setEmailErrorVisible(false)
setEmailSuccess(false)
setEmailError("")
if (!email) {
setEmailError("Email is required");
setEmailErrorVisible(true);
setEmailError("Email is required")
setEmailErrorVisible(true)
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}
, 3000);
return;
setEmailErrorVisible(false)
setEmailError("")
}, 3000)
return
}
try {
await axios.post('/api/auth/edit_email', {
await axios.post("/api/auth/edit_email", {
newEmail: email,
jwtToken: Cookies.get('token')
});
setEmailSuccess(true);
setEmail("");
jwtToken: Cookies.get("token"),
})
setEmailSuccess(true)
setEmail("")
setTimeout(() => {
setEmailSuccess(false);
}, 3000);
setEmailSuccess(false)
}, 3000)
} catch (error: any) {
setEmailError(error.response.data.error);
setEmailErrorVisible(true);
setEmailError(error.response.data.error)
setEmailErrorVisible(true)
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}, 3000);
setEmailErrorVisible(false)
setEmailError("")
}, 3000)
}
}
const changePassword = async () => {
try {
if (password !== confirmPassword) {
setPasswordError("Passwords do not match");
setPasswordErrorVisible(true);
setPasswordError("Passwords do not match")
setPasswordErrorVisible(true)
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
setPasswordErrorVisible(false)
setPasswordError("")
}, 3000)
return
}
if (!oldPassword || !password || !confirmPassword) {
setPasswordError("All fields are required");
setPasswordErrorVisible(true);
setPasswordError("All fields are required")
setPasswordErrorVisible(true)
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
setPasswordErrorVisible(false)
setPasswordError("")
}, 3000)
return
}
const response = await axios.post('/api/auth/edit_password', {
const response = await axios.post("/api/auth/edit_password", {
oldPassword: oldPassword,
newPassword: password,
jwtToken: Cookies.get('token')
});
jwtToken: Cookies.get("token"),
})
if (response.status === 200) {
setPasswordSuccess(true);
setPassword("");
setOldPassword("");
setConfirmPassword("");
setPasswordSuccess(true)
setPassword("")
setOldPassword("")
setConfirmPassword("")
setTimeout(() => {
setPasswordSuccess(false);
}, 3000);
setPasswordSuccess(false)
}, 3000)
}
} catch (error: any) {
setPasswordErrorVisible(true);
setPasswordError(error.response.data.error);
setPasswordErrorVisible(true)
setPasswordError(error.response.data.error)
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
setPasswordErrorVisible(false)
setPasswordError("")
}, 3000)
}
}
const addNotification = async () => {
try {
const response = await axios.post("/api/notifications/add", {
name: notificationName,
type: notificationType,
smtpHost: smtpHost,
smtpPort: smtpPort,
smtpSecure: smtpSecure,
smtpUsername: smtpUsername,
smtpPassword: smtpPassword,
smtpFrom: smtpFrom,
smtpTo: smtpTo,
telegramToken: telegramToken,
telegramChatId: telegramChatId,
discordWebhook: discordWebhook,
gotifyUrl: gotifyUrl,
gotifyToken: gotifyToken,
ntfyUrl: ntfyUrl,
ntfyToken: ntfyToken,
pushoverUrl: pushoverUrl,
pushoverToken: pushoverToken,
pushoverUser: pushoverUser,
})
getNotifications()
} catch (error: any) {
alert(error.response.data.error)
}
}
const deleteNotification = async (id: number) => {
try {
const response = await axios.post("/api/notifications/delete", {
id: id,
})
if (response.status === 200) {
getNotifications()
}
} catch (error: any) {
alert(error.response.data.error)
}
}
const getNotifications = async () => {
try {
const response = await axios.post<NotificationsResponse>("/api/notifications/get", {})
if (response.status === 200 && response.data) {
setNotifications(response.data.notifications)
}
} catch (error: any) {
alert(error.response.data.error)
}
}
useEffect(() => {
getNotifications()
}, [])
const getNotificationText = async () => {
try {
const response = await axios.post<NotificationResponse>("/api/settings/get_notification_text", {})
if (response.status === 200) {
if (response.data.notification_text_application) {
setNotificationTextApplication(response.data.notification_text_application)
} else {
setNotificationTextApplication("The application !name (!url) is now !status.")
}
if (response.data.notification_text_server) {
setNotificationTextServer(response.data.notification_text_server)
} else {
setNotificationTextServer("The server !name is now !status.")
}
}
} catch (error: any) {
alert(error.response.data.error)
}
}
const editNotificationText = async () => {
try {
const response = await axios.post("/api/settings/notification_text", {
text_application: notificationTextApplication,
text_server: notificationTextServer,
})
} catch (error: any) {
alert(error.response.data.error)
}
}
useEffect(() => {
getNotificationText()
}, [])
const testNotification = async (id: number) => {
try {
const response = await axios.post("/api/notifications/test", {
notificationId: id,
})
toast.success("Notification will be sent in a few seconds.")
} catch (error: any) {
toast.error(error.response.data.error)
}
}
return (
<SidebarProvider>
<AppSidebar />
@@ -289,6 +427,404 @@ export default function Settings() {
</div>
</CardContent>
</Card>
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
<div className="flex items-center gap-3">
<div className="bg-muted/20 p-2 rounded-full">
<Bell className="h-5 w-5 text-primary" />
</div>
<h2 className="text-xl font-semibold">Notifications</h2>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="text-sm text-muted-foreground mb-6">
Set up notifications to get instantly alerted when an application changes status.
</div>
<div className="grid gap-4 md:grid-cols-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full h-11 flex items-center gap-2">
Add Notification Channel
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Add Notification</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-4">
<Input
type="text"
id="notificationName"
placeholder="Notification Name (optional)"
onChange={(e) => setNotificationName(e.target.value)}
/>
<Select value={notificationType} onValueChange={(value: string) => setNotificationType(value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Notification Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="smtp">SMTP</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="discord">Discord</SelectItem>
<SelectItem value="gotify">Gotify</SelectItem>
<SelectItem value="ntfy">Ntfy</SelectItem>
<SelectItem value="pushover">Pushover</SelectItem>
</SelectContent>
{notificationType === "smtp" && (
<div className="mt-4 space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpHost">SMTP Host</Label>
<Input
type="text"
id="smtpHost"
placeholder="smtp.example.com"
onChange={(e) => setSmtpHost(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpPort">SMTP Port</Label>
<Input
type="number"
id="smtpPort"
placeholder="587"
onChange={(e) => setSmtpPort(Number(e.target.value))}
/>
</div>
</div>
<div className="flex items-center space-x-2 pt-2 pb-4">
<Checkbox id="smtpSecure" onCheckedChange={(checked: any) => setSmtpSecure(checked)} />
<Label htmlFor="smtpSecure" className="text-sm font-medium leading-none">
Secure Connection (TLS/SSL)
</Label>
</div>
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpUser">SMTP Username</Label>
<Input
type="text"
id="smtpUser"
placeholder="user@example.com"
onChange={(e) => setSmtpUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpPass">SMTP Password</Label>
<Input
type="password"
id="smtpPass"
placeholder="••••••••"
onChange={(e) => setSmtpPassword(e.target.value)}
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpFrom">From Address</Label>
<Input
type="email"
id="smtpFrom"
placeholder="noreply@example.com"
onChange={(e) => setSmtpFrom(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpTo">To Address</Label>
<Input
type="email"
id="smtpTo"
placeholder="admin@example.com"
onChange={(e) => setSmtpTo(e.target.value)}
/>
</div>
</div>
</div>
</div>
)}
{notificationType === "telegram" && (
<div className="mt-4 space-y-2">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="telegramToken">Bot Token</Label>
<Input
type="text"
id="telegramToken"
placeholder=""
onChange={(e) => setTelegramToken(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="telegramChatId">Chat ID</Label>
<Input
type="text"
id="telegramChatId"
placeholder=""
onChange={(e) => setTelegramChatId(e.target.value)}
/>
</div>
</div>
)}
{notificationType === "discord" && (
<div className="mt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="discordWebhook">Webhook URL</Label>
<Input
type="text"
id="discordWebhook"
placeholder=""
onChange={(e) => setDiscordWebhook(e.target.value)}
/>
</div>
</div>
)}
{notificationType === "gotify" && (
<div className="mt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="gotifyUrl">Gotify URL</Label>
<Input
type="text"
id="gotifyUrl"
placeholder=""
onChange={(e) => setGotifyUrl(e.target.value)}
/>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="gotifyToken">Gotify Token</Label>
<Input
type="text"
id="gotifyToken"
placeholder=""
onChange={(e) => setGotifyToken(e.target.value)}
/>
</div>
</div>
</div>
)}
{notificationType === "ntfy" && (
<div className="mt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="ntfyUrl">Ntfy URL</Label>
<Input
type="text"
id="ntfyUrl"
placeholder=""
onChange={(e) => setNtfyUrl(e.target.value)}
/>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="ntfyToken">Ntfy Token</Label>
<Input
type="text"
id="ntfyToken"
placeholder=""
onChange={(e) => setNtfyToken(e.target.value)}
/>
</div>
</div>
</div>
)}
{notificationType === "pushover" && (
<div className="mt-4 flex flex-col gap-2">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="pushoverUrl">Pushover URL</Label>
<Input
type="text"
id="pushoverUrl"
placeholder="e.g. https://api.pushover.net/1/messages.json"
onChange={(e) => setPushoverUrl(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="pushoverToken">Pushover Token</Label>
<Input
type="text"
id="pushoverToken"
placeholder="e.g. 1234567890"
onChange={(e) => setPushoverToken(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="pushoverUser">Pushover User</Label>
<Input
type="text"
id="pushoverUser"
placeholder="e.g. 1234567890"
onChange={(e) => setPushoverUser(e.target.value)}
/>
</div>
</div>
)}
</Select>
</div>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={addNotification}>Add</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full h-11" variant="outline">
Customize Notification Text
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Customize Notification Text</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="text_application">Notification Text for Applications</Label>
<Textarea
id="text_application"
placeholder="Type here..."
value={notificationTextApplication}
onChange={(e) => setNotificationTextApplication(e.target.value)}
rows={4}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="text_server">Notification Text for Servers</Label>
<Textarea
id="text_server"
placeholder="Type here..."
value={notificationTextServer}
onChange={(e) => setNotificationTextServer(e.target.value)}
rows={4}
/>
</div>
</div>
<div className="pt-4 text-sm text-muted-foreground">
You can use the following placeholders in the text:
<ul className="list-disc list-inside space-y-1 pt-2">
<li>
<b>Server related:</b>
<ul className="list-disc list-inside ml-4 space-y-1 pt-1 text-muted-foreground">
<li>!name - The name of the server</li>
<li>!status - The current status of the server (online/offline)</li>
</ul>
</li>
<li>
<b>Application related:</b>
<ul className="list-disc list-inside ml-4 space-y-1 pt-1 text-muted-foreground">
<li>!name - The name of the application</li>
<li>!url - The URL where the application is hosted</li>
<li>!status - The current status of the application (online/offline)</li>
</ul>
</li>
</ul>
</div>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={editNotificationText}>Save</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="mt-8">
<h3 className="text-lg font-medium mb-4">Active Notification Channels</h3>
<div className="space-y-3">
{notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
className="flex items-center justify-between p-4 rounded-lg border bg-card transition-all hover:shadow-sm"
>
<div className="flex items-center gap-3">
{notification.type === "smtp" && (
<div className="bg-muted/20 p-2 rounded-full">
<AtSign className="h-5 w-5 text-primary" />
</div>
)}
{notification.type === "telegram" && (
<div className="bg-muted/20 p-2 rounded-full">
<Send className="h-5 w-5 text-primary" />
</div>
)}
{notification.type === "discord" && (
<div className="bg-muted/20 p-2 rounded-full">
<MessageSquare className="h-5 w-5 text-primary" />
</div>
)}
{notification.type === "gotify" && (
<div className="bg-muted/20 p-2 rounded-full">
<Bell className="h-5 w-5 text-primary" />
</div>
)}
{notification.type === "ntfy" && (
<div className="bg-muted/20 p-2 rounded-full">
<Bell className="h-5 w-5 text-primary" />
</div>
)}
{notification.type === "pushover" && (
<div className="bg-muted/20 p-2 rounded-full">
<Bell className="h-5 w-5 text-primary" />
</div>
)}
<div className="space-y-1">
<h3 className="font-medium capitalize">{notification.name && notification.name !== "" ? notification.name : notification.type}</h3>
<p className="text-xs text-muted-foreground">
{notification.type === "smtp" && "Email notifications"}
{notification.type === "telegram" && "Telegram bot alerts"}
{notification.type === "discord" && "Discord webhook alerts"}
{notification.type === "gotify" && "Gotify notifications"}
{notification.type === "ntfy" && "Ntfy notifications"}
{notification.type === "pushover" && "Pushover notifications"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="hover:bg-muted/20"
onClick={() => testNotification(notification.id)}
>
<Play className="h-4 w-4 mr-1" />
Test
</Button>
<Button
variant="ghost"
size="sm"
className="hover:bg-muted/20"
onClick={() => deleteNotification(notification.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
))
) : (
<div className="text-center py-12 border rounded-lg bg-muted/5">
<div className="flex justify-center mb-3">
<div className="bg-muted/20 p-3 rounded-full">
<Bell className="h-6 w-6 text-muted-foreground" />
</div>
</div>
<h3 className="text-lg font-medium mb-1">No notifications configured</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Add a notification channel to get alerted when your applications change status.
</p>
</div>
)}
</div>
</div>
<Toaster />
</CardContent>
</Card>
</div>
</div>
</SidebarInset>

View File

@@ -13,7 +13,6 @@ import {
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useEffect, useState } from "react";
import axios from "axios";
import { Card, CardHeader } from "@/components/ui/card";
import * as Tooltip from "@radix-ui/react-tooltip";
@@ -26,6 +25,12 @@ import {
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import { useState, useEffect, useRef } from "react";
import Cookies from "js-cookie";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
const timeFormats = {
1: (timestamp: string) =>
@@ -34,14 +39,18 @@ const timeFormats = {
minute: '2-digit',
hour12: false
}),
2: (timestamp: string) => {
const start = new Date(timestamp);
const end = new Date(start.getTime() + 3 * 60 * 60 * 1000);
return `${start.toLocaleDateString([], { day: '2-digit', month: 'short' })}
${start.getHours().toString().padStart(2, '0')}:00 -
${end.getHours().toString().padStart(2, '0')}:00`;
},
2: (timestamp: string) =>
new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
3: (timestamp: string) =>
new Date(timestamp).toLocaleDateString([], {
day: '2-digit',
month: 'short'
}),
4: (timestamp: string) =>
new Date(timestamp).toLocaleDateString([], {
day: '2-digit',
month: 'short'
@@ -49,9 +58,10 @@ const timeFormats = {
};
const minBoxWidths = {
1: 24,
2: 24,
3: 24
1: 20,
2: 20,
3: 24,
4: 24
};
interface UptimeData {
@@ -72,15 +82,23 @@ interface PaginationData {
export default function Uptime() {
const [data, setData] = useState<UptimeData[]>([]);
const [timespan, setTimespan] = useState<1 | 2 | 3>(1);
const [timespan, setTimespan] = useState<1 | 2 | 3 | 4>(1);
const [pagination, setPagination] = useState<PaginationData>({
currentPage: 1,
totalPages: 1,
totalItems: 0
});
const [isLoading, setIsLoading] = useState(false);
const savedItemsPerPage = Cookies.get("itemsPerPage-uptime");
const defaultItemsPerPage = 5;
const initialItemsPerPage = savedItemsPerPage ? parseInt(savedItemsPerPage) : defaultItemsPerPage;
const [itemsPerPage, setItemsPerPage] = useState<number>(initialItemsPerPage);
const customInputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const getData = async (selectedTimespan: number, page: number) => {
const getData = async (selectedTimespan: number, page: number, itemsPerPage: number) => {
setIsLoading(true);
try {
const response = await axios.post<{
@@ -88,7 +106,8 @@ export default function Uptime() {
pagination: PaginationData;
}>("/api/applications/uptime", {
timespan: selectedTimespan,
page
page,
itemsPerPage
});
setData(response.data.data);
@@ -109,17 +128,48 @@ export default function Uptime() {
const handlePrevious = () => {
const newPage = Math.max(1, pagination.currentPage - 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
getData(timespan, newPage, itemsPerPage);
};
const handleNext = () => {
const newPage = Math.min(pagination.totalPages, pagination.currentPage + 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
getData(timespan, newPage, itemsPerPage);
};
const handleItemsPerPageChange = (value: string) => {
// Clear any existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set a new timer
debounceTimerRef.current = setTimeout(() => {
const newItemsPerPage = parseInt(value);
// Ensure the value is within the valid range
if (isNaN(newItemsPerPage) || newItemsPerPage < 1) {
toast.error("Please enter a number between 1 and 100");
return;
}
const validatedValue = Math.min(Math.max(newItemsPerPage, 1), 100);
setItemsPerPage(validatedValue);
setPagination(prev => ({...prev, currentPage: 1})); // Reset to first page
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
// Fetch data with new pagination
getData(timespan, 1, validatedValue);
}, 300); // 300ms delay
};
useEffect(() => {
getData(timespan, 1);
getData(timespan, 1, itemsPerPage);
}, [timespan]);
return (
@@ -147,26 +197,116 @@ export default function Uptime() {
</Breadcrumb>
</div>
</header>
<Toaster />
<div className="p-6">
<div className="flex justify-between items-center">
<span className="text-3xl font-bold">Uptime</span>
<Select
value={String(timespan)}
onValueChange={(v) => {
setTimespan(Number(v) as 1 | 2 | 3);
setPagination(prev => ({...prev, currentPage: 1}));
}}
disabled={isLoading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 30 minutes</SelectItem>
<SelectItem value="2">Last 7 days</SelectItem>
<SelectItem value="3">Last 30 days</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select
value={String(itemsPerPage)}
onValueChange={handleItemsPerPageChange}
onOpenChange={(open) => {
if (open && customInputRef.current) {
customInputRef.current.value = String(itemsPerPage);
}
}}
>
<SelectTrigger className="w-[140px]">
<SelectValue>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'}
</SelectValue>
</SelectTrigger>
<SelectContent>
{![5, 10, 15, 20, 25].includes(itemsPerPage) ? (
<SelectItem value={String(itemsPerPage)}>
{itemsPerPage} {itemsPerPage === 1 ? 'item' : 'items'} (custom)
</SelectItem>
) : null}
<SelectItem value="5">5 items</SelectItem>
<SelectItem value="10">10 items</SelectItem>
<SelectItem value="15">15 items</SelectItem>
<SelectItem value="20">20 items</SelectItem>
<SelectItem value="25">25 items</SelectItem>
<div className="p-2 border-t mt-1">
<Label htmlFor="custom-items" className="text-xs font-medium">Custom (1-100)</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id="custom-items"
ref={customInputRef}
type="number"
min="1"
max="100"
className="h-8"
defaultValue={itemsPerPage}
onChange={(e) => {
// Don't immediately apply the change while typing
// Just validate the input for visual feedback
const value = parseInt(e.target.value);
if (isNaN(value) || value < 1 || value > 100) {
e.target.classList.add("border-red-500");
} else {
e.target.classList.remove("border-red-500");
}
}}
onBlur={(e) => {
// Apply the change when the input loses focus
const value = parseInt(e.target.value);
if (value >= 1 && value <= 100) {
handleItemsPerPageChange(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
// Clear any existing debounce timer to apply immediately
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
const value = parseInt((e.target as HTMLInputElement).value);
if (value >= 1 && value <= 100) {
// Apply change immediately on Enter
const validatedValue = Math.min(Math.max(value, 1), 100);
setItemsPerPage(validatedValue);
setPagination(prev => ({...prev, currentPage: 1}));
Cookies.set("itemsPerPage-uptime", String(validatedValue), {
expires: 365,
path: "/",
sameSite: "strict",
});
getData(timespan, 1, validatedValue);
// Close the dropdown
document.body.click();
}
}
}}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-muted-foreground whitespace-nowrap">items</span>
</div>
</div>
</SelectContent>
</Select>
<Select
value={String(timespan)}
onValueChange={(v) => {
setTimespan(Number(v) as 1 | 2 | 3 | 4);
setPagination(prev => ({...prev, currentPage: 1}));
}}
disabled={isLoading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 1 hour</SelectItem>
<SelectItem value="2">Last 1 day</SelectItem>
<SelectItem value="3">Last 7 days</SelectItem>
<SelectItem value="4">Last 30 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="pt-4 space-y-4">
@@ -219,18 +359,14 @@ export default function Uptime() {
>
<div className="flex flex-col gap-1">
<p className="font-medium">
{timespan === 2 ? (
timeFormats[2](entry.timestamp)
) : (
new Date(entry.timestamp).toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: timespan === 3 ? undefined : '2-digit',
hour12: false
})
)}
{new Date(entry.timestamp).toLocaleString([], {
year: 'numeric',
month: 'short',
day: timespan > 2 ? 'numeric' : undefined,
hour: '2-digit',
minute: timespan === 1 ? '2-digit' : undefined,
hour12: false
})}
</p>
<p>
{entry.missing
@@ -258,6 +394,13 @@ export default function Uptime() {
{pagination.totalItems > 0 && !isLoading && (
<div className="pt-4 pb-4">
<div className="flex justify-between items-center mb-2">
<div className="text-sm text-muted-foreground">
{pagination.totalItems > 0
? `Showing ${((pagination.currentPage - 1) * itemsPerPage) + 1}-${Math.min(pagination.currentPage * itemsPerPage, pagination.totalItems)} of ${pagination.totalItems} items`
: "No items found"}
</div>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>

View File

@@ -1,10 +1,25 @@
import * as React from "react"
"use client"
import type * as React from "react"
import Image from "next/image"
import { AppWindow, Settings, LayoutDashboardIcon, Briefcase, Server, Network, Activity } from "lucide-react"
import { usePathname } from "next/navigation"
import {
AppWindow,
Settings,
LayoutDashboardIcon,
Briefcase,
Server,
Network,
Activity,
LogOut,
ChevronDown,
} from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
@@ -13,12 +28,15 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
SidebarFooter,
} from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import Cookies from "js-cookie"
import { useRouter } from "next/navigation"
import packageJson from "@/package.json"
import { cn } from "@/lib/utils"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
interface NavItem {
title: string
@@ -33,7 +51,7 @@ const data: { navMain: NavItem[] } = {
{
title: "Dashboard",
icon: LayoutDashboardIcon,
url: "/dashboard"
url: "/dashboard",
},
{
title: "My Infrastructure",
@@ -59,7 +77,7 @@ const data: { navMain: NavItem[] } = {
title: "Network",
icon: Network,
url: "/dashboard/network",
},
},
],
},
{
@@ -72,71 +90,119 @@ const data: { navMain: NavItem[] } = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const router = useRouter()
const pathname = usePathname()
const logout = async () => {
Cookies.remove('token')
Cookies.remove("token")
router.push("/")
}
// Check if a path is active (exact match or starts with path for parent items)
const isActive = (path: string) => {
if (path === "#") return false
return pathname === path || (path !== "/dashboard" && pathname?.startsWith(path))
}
// Check if any child item is active
const hasActiveChild = (items?: NavItem[]) => {
if (!items) return false
return items.some((item) => isActive(item.url))
}
return (
<Sidebar {...props}>
<SidebarHeader>
<SidebarHeader className="border-b border-sidebar-border/30 pb-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="https://github.com/crocofied/corecontrol">
<Image src="/logo.png" width={48} height={48} alt="Logo"/>
<SidebarMenuButton size="lg" asChild className="gap-3">
<a href="https://github.com/crocofied/corecontrol" className="transition-all hover:opacity-80">
<div className="flex items-center justify-center rounded-lg overflow-hidden bg-gradient-to-br from-teal-500 to-emerald-600 shadow-sm">
<Image src="/logo.png" width={48} height={48} alt="CoreControl Logo" className="object-cover" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">CoreControl</span>
<span className="">v{packageJson.version}</span>
<span className="font-semibold text-base">CoreControl</span>
<span className="text-xs text-sidebar-foreground/70">v{packageJson.version}</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="flex flex-col h-full">
<SidebarContent className="flex flex-col h-full py-4">
<SidebarGroup className="flex-grow">
<SidebarMenu>
{data.navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link href={item.url} className="font-medium">
{item.icon && <item.icon className="mr-2" />}
{item.title}
</Link>
</SidebarMenuButton>
{item.items?.length && (
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={subItem.isActive ?? false}
<SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/60 uppercase tracking-wider px-4 mb-2">
Main Navigation
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{data.navMain.map((item) =>
item.items?.length ? (
<Collapsible key={item.title} defaultOpen={hasActiveChild(item.items)} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
className={cn(
"font-medium transition-all",
(hasActiveChild(item.items) || isActive(item.url)) &&
"text-sidebar-accent-foreground bg-sidebar-accent/50",
)}
>
<Link href={subItem.url}>
{subItem.icon && <subItem.icon className="mr-2" />}
{subItem.title}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
))}
</SidebarMenu>
{item.icon && <item.icon className="h-4 w-4" />}
<span>{item.title}</span>
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild isActive={isActive(subItem.url)} className="transition-all">
<Link href={subItem.url} className="flex items-center">
{subItem.icon && <subItem.icon className="h-3.5 w-3.5 mr-2" />}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
) : (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
className={cn(
"font-medium transition-all",
isActive(item.url) && "text-sidebar-accent-foreground bg-sidebar-accent/50",
)}
>
<Link href={item.url}>
{item.icon && <item.icon className="h-4 w-4" />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
),
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<div className="p-4">
<Button variant="destructive" className="w-full" onClick={logout}>
<SidebarFooter className="border-t border-sidebar-border/30 pt-4 mt-auto">
<Button
variant="outline"
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10 border-none shadow-none"
onClick={logout}
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</Button>
</div>
</SidebarFooter>
</SidebarContent>
<SidebarRail />
</Sidebar>
)
}
}

View File

@@ -0,0 +1,38 @@
"use client"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
interface StatusIndicatorProps {
isOnline?: boolean
className?: string
showLabel?: boolean
pulseAnimation?: boolean
}
export function StatusIndicator({
isOnline = false,
className,
showLabel = true,
pulseAnimation = true,
}: StatusIndicatorProps) {
return (
<Badge
variant="outline"
className={cn(
"flex items-center gap-2 px-2 py-1 border-transparent transition-colors duration-300",
isOnline
? "bg-green-100 dark:bg-green-950/30 text-green-800 dark:text-green-300"
: "bg-red-100 dark:bg-red-950/30 text-red-800 dark:text-red-300",
className,
)}
>
<span className={cn("relative flex h-2.5 w-2.5 rounded-full", isOnline ? "bg-green-500" : "bg-red-500")}>
{isOnline && pulseAnimation && (
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
)}
</span>
{showLabel && <span className="text-xs font-medium">{isOnline ? "Online" : "Offline"}</span>}
</Badge>
)
}

46
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

25
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -6,14 +6,14 @@ services:
environment:
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
- agent
agent:
image: haedlessdev/corecontrol-agent:latest
environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
@@ -24,6 +24,11 @@ services:
POSTGRES_DB: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 2s
retries: 10
volumes:
postgres_data:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -0,0 +1,66 @@
import { defineConfig } from 'vitepress'
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "CoreControl",
description: "Dashboard to manage your entire server infrastructure",
lastUpdated: true,
cleanUrls: true,
metaChunk: true,
head: [
['link', { rel: 'icon', type: 'image/png', href: '/logo.png' }],
],
themeConfig: {
logo: '/logo.png',
nav: [
{ text: 'Home', link: '/' },
{ text: 'Installation', link: '/installation' }
],
footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2025-present CoreControl',
},
search: {
provider: 'local',
},
sidebar: [
{
text: 'Deploy',
items: [
{ text: 'Installation', link: '/installation' },
]
},
{
text: 'General',
items: [
{ text: 'Dashboard', link: '/general/Dashboard' },
{ text: 'Servers', link: '/general/Servers' },
{ text: 'Applications', link: '/general/Applications' },
{ text: 'Uptime', link: '/general/Uptime' },
{ text: 'Network', link: '/general/Network' },
{ text: 'Settings', link: '/general/Settings' },
]
},
{
text: 'Notifications',
items: [
{ text: 'General', link: '/notifications/General' },
{ text: 'Email', link: '/notifications/Email' },
{ text: 'Telegram', link: '/notifications/Telegram' },
{ text: 'Discord', link: '/notifications/Discord' },
{ text: 'Gotify', link: '/notifications/Gotify' },
{ text: 'Ntfy', link: '/notifications/Ntfy' },
{ text: 'Pushover', link: '/notifications/Pushover' },
]
}
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/crocofied/corecontrol' },
{ icon: 'buymeacoffee', link: 'https://www.buymeacoffee.com/corecontrol' }
]
}
})

23
docs/.vitepress/dist/404.html vendored Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en-US" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>404 | CoreControl</title>
<meta name="description" content="Not Found">
<meta name="generator" content="VitePress v1.6.3">
<link rel="preload stylesheet" href="/assets/style.DEOyzpKL.css" as="style">
<link rel="preload stylesheet" href="/vp-icons.css" as="style">
<script type="module" src="/assets/chunks/metadata.d21683cf.js"></script>
<script type="module" src="/assets/app.DiWcjlN4.js"></script>
<link rel="preload" href="/assets/inter-roman-latin.Di8DUHzh.woff2" as="font" type="font/woff2" crossorigin="">
<link rel="icon" type="image/png" href="/logo.png">
<script id="check-dark-mode">(()=>{const e=localStorage.getItem("vitepress-theme-appearance")||"auto",a=window.matchMedia("(prefers-color-scheme: dark)").matches;(!e||e==="auto"?a:e==="dark")&&document.documentElement.classList.add("dark")})();</script>
<script id="check-mac-os">document.documentElement.classList.toggle("mac",/Mac|iPhone|iPod|iPad/i.test(navigator.platform));</script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1 @@
import{t as p}from"./chunks/theme.9-rJywIy.js";import{R as s,a2 as i,a3 as u,a4 as c,a5 as l,a6 as f,a7 as d,a8 as m,a9 as h,aa as g,ab as A,d as v,u as y,v as C,s as P,ac as b,ad as w,ae as R,af as E}from"./chunks/framework.DPDPlp3K.js";function r(e){if(e.extends){const a=r(e.extends);return{...a,...e,async enhanceApp(t){a.enhanceApp&&await a.enhanceApp(t),e.enhanceApp&&await e.enhanceApp(t)}}}return e}const n=r(p),S=v({name:"VitePressApp",setup(){const{site:e,lang:a,dir:t}=y();return C(()=>{P(()=>{document.documentElement.lang=a.value,document.documentElement.dir=t.value})}),e.value.router.prefetchLinks&&b(),w(),R(),n.setup&&n.setup(),()=>E(n.Layout)}});async function T(){globalThis.__VITEPRESS__=!0;const e=_(),a=D();a.provide(u,e);const t=c(e.route);return a.provide(l,t),a.component("Content",f),a.component("ClientOnly",d),Object.defineProperties(a.config.globalProperties,{$frontmatter:{get(){return t.frontmatter.value}},$params:{get(){return t.page.value.params}}}),n.enhanceApp&&await n.enhanceApp({app:a,router:e,siteData:m}),{app:a,router:e,data:t}}function D(){return A(S)}function _(){let e=s;return h(a=>{let t=g(a),o=null;return t&&(e&&(t=t.replace(/\.js$/,".lean.js")),o=import(t)),s&&(e=!1),o},n.NotFound)}s&&T().then(({app:e,router:a,data:t})=>{a.go().then(()=>{i(a.route,t.site),e.mount("#app")})});export{T as createApp};

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
window.__VP_HASH_MAP__=JSON.parse("{\"general_applications.md\":\"DFVqSlCw\",\"general_dashboard.md\":\"DW5yESFW\",\"general_network.md\":\"tbP8aEzX\",\"general_servers.md\":\"BaASA60T\",\"general_settings.md\":\"DrC2XV32\",\"general_uptime.md\":\"CKBdQg4u\",\"index.md\":\"BeIP42w_\",\"installation.md\":\"Cz1eOHOr\",\"notifications_discord.md\":\"C0x5CxmR\",\"notifications_email.md\":\"Cugw2BRs\",\"notifications_general.md\":\"D7AVsSjD\",\"notifications_gotify.md\":\"vFHjr6ko\",\"notifications_ntfy.md\":\"CPMnGQVP\",\"notifications_pushover.md\":\"lZwGAQ0A\",\"notifications_telegram.md\":\"B6_EzaEX\"}");window.__VP_SITE_DATA__=JSON.parse("{\"lang\":\"en-US\",\"dir\":\"ltr\",\"title\":\"CoreControl\",\"description\":\"Dashboard to manage your entire server infrastructure\",\"base\":\"/\",\"head\":[],\"router\":{\"prefetchLinks\":true},\"appearance\":true,\"themeConfig\":{\"logo\":\"/logo.png\",\"nav\":[{\"text\":\"Home\",\"link\":\"/\"},{\"text\":\"Installation\",\"link\":\"/installation\"}],\"footer\":{\"message\":\"Released under the MIT License.\",\"copyright\":\"Copyright © 2025-present CoreControl\"},\"search\":{\"provider\":\"local\"},\"sidebar\":[{\"text\":\"Deploy\",\"items\":[{\"text\":\"Installation\",\"link\":\"/installation\"}]},{\"text\":\"General\",\"items\":[{\"text\":\"Dashboard\",\"link\":\"/general/Dashboard\"},{\"text\":\"Servers\",\"link\":\"/general/Servers\"},{\"text\":\"Applications\",\"link\":\"/general/Applications\"},{\"text\":\"Uptime\",\"link\":\"/general/Uptime\"},{\"text\":\"Network\",\"link\":\"/general/Network\"},{\"text\":\"Settings\",\"link\":\"/general/Settings\"}]},{\"text\":\"Notifications\",\"items\":[{\"text\":\"General\",\"link\":\"/notifications/General\"},{\"text\":\"Email\",\"link\":\"/notifications/Email\"},{\"text\":\"Telegram\",\"link\":\"/notifications/Telegram\"},{\"text\":\"Discord\",\"link\":\"/notifications/Discord\"},{\"text\":\"Gotify\",\"link\":\"/notifications/Gotify\"},{\"text\":\"Ntfy\",\"link\":\"/notifications/Ntfy\"},{\"text\":\"Pushover\",\"link\":\"/notifications/Pushover\"}]}],\"socialLinks\":[{\"icon\":\"github\",\"link\":\"https://github.com/crocofied/corecontrol\"},{\"icon\":\"buymeacoffee\",\"link\":\"https://www.buymeacoffee.com/corecontrol\"}]},\"locales\":{},\"scrollOffset\":134,\"cleanUrls\":true}");

View File

@@ -0,0 +1 @@
const s="/assets/settings_notifications.DwqFpmxq.png";export{s as _};

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1 @@
import{_ as i,c as a,o,ag as e}from"./chunks/framework.DPDPlp3K.js";const l="/assets/applications_add_button.CTBM75AA.png",n="/assets/applications_display.D1ZCmp63.png",_=JSON.parse('{"title":"Applications","description":"","frontmatter":{},"headers":[],"relativePath":"general/Applications.md","filePath":"general/Applications.md","lastUpdated":1745241280000}'),p={name:"general/Applications.md"};function r(s,t,c,d,h,u){return o(),a("div",null,t[0]||(t[0]=[e('<h1 id="applications" tabindex="-1">Applications <a class="header-anchor" href="#applications" aria-label="Permalink to &quot;Applications&quot;"></a></h1><p>All your self-hosted applications are displayed here.</p><h2 id="add-an-application" tabindex="-1">Add an application <a class="header-anchor" href="#add-an-application" aria-label="Permalink to &quot;Add an application&quot;"></a></h2><p>To add a new application to CoreControl, follow these steps:</p><ol><li><p>Click the &quot;Add Application&quot; button in the top right corner of the server menu: <img src="'+l+'" alt="Application Add Button"></p></li><li><p>Fill out the server details across the following information:</p></li></ol><ul><li><strong>Name</strong>: Enter the name of the application</li><li><strong>Server</strong>: Select the server on which the application is running</li><li><strong>Description</strong>: Enter a short (or long) description of the server</li><li><strong>Icon URL</strong>: Add the url pointing to the logo of the application. With the flash button the logo will be automatically selected.</li><li><strong>Public URL</strong>: Enter the public URL of your application. This will be used to track the uptime.</li><li><strong>Local URL</strong>: Enter the local URL of your application, i.e. the URL via which the application is only accessible in the local network</li></ul><p>After filling out the required information, click &quot;Add&quot; to add the application to CoreControl.</p><h2 id="application-display" tabindex="-1">Application Display <a class="header-anchor" href="#application-display" aria-label="Permalink to &quot;Application Display&quot;"></a></h2><p>Your applications are displayed in a list or grid (depending on the display settings) - each application in its own card <img src="'+n+'" alt="Application card"></p>',9)]))}const f=i(p,[["render",r]]);export{_ as __pageData,f as default};

View File

@@ -0,0 +1 @@
import{_ as i,c as a,o,ag as e}from"./chunks/framework.DPDPlp3K.js";const l="/assets/applications_add_button.CTBM75AA.png",n="/assets/applications_display.D1ZCmp63.png",_=JSON.parse('{"title":"Applications","description":"","frontmatter":{},"headers":[],"relativePath":"general/Applications.md","filePath":"general/Applications.md","lastUpdated":1745241280000}'),p={name:"general/Applications.md"};function r(s,t,c,d,h,u){return o(),a("div",null,t[0]||(t[0]=[e("",9)]))}const f=i(p,[["render",r]]);export{_ as __pageData,f as default};

View File

@@ -0,0 +1 @@
import{_ as r,c as e,o as s,ag as t}from"./chunks/framework.DPDPlp3K.js";const o="/assets/dashboard_card_servers.DNNhRbkY.png",i="/assets/dashboard_card_applications.DErIEBeJ.png",d="/assets/dashboard_card_uptime.B6cdZ6Ju.png",l="/assets/dashboard_card_network.C2YTU2ur.png",f=JSON.parse('{"title":"Dashboard","description":"","frontmatter":{},"headers":[],"relativePath":"general/Dashboard.md","filePath":"general/Dashboard.md","lastUpdated":1745241280000}'),n={name:"general/Dashboard.md"};function c(p,a,h,u,m,_){return s(),e("div",null,a[0]||(a[0]=[t('<h1 id="dashboard" tabindex="-1">Dashboard <a class="header-anchor" href="#dashboard" aria-label="Permalink to &quot;Dashboard&quot;"></a></h1><p>The dashboard is the most important place to get a quick overview of your infrastructure.</p><h2 id="cards-overview" tabindex="-1">Cards Overview <a class="header-anchor" href="#cards-overview" aria-label="Permalink to &quot;Cards Overview&quot;"></a></h2><p>The dashboard is divided into 4 cards that provide different aspects of your infrastructure monitoring:</p><h3 id="servers-card" tabindex="-1">Servers Card <a class="header-anchor" href="#servers-card" aria-label="Permalink to &quot;Servers Card&quot;"></a></h3><p><img src="'+o+'" alt="Servers Card"></p><p>The Servers card displays information about all your connected servers, including:</p><ul><li>Number of Physical Servers</li><li>Number of Virtual Servers</li></ul><h3 id="applications-card" tabindex="-1">Applications Card <a class="header-anchor" href="#applications-card" aria-label="Permalink to &quot;Applications Card&quot;"></a></h3><p><img src="'+i+'" alt="Applications Card"></p><p>The Applications card shows you:</p><ul><li>Number of running applications across your infrastructure</li></ul><h3 id="uptime-card" tabindex="-1">Uptime Card <a class="header-anchor" href="#uptime-card" aria-label="Permalink to &quot;Uptime Card&quot;"></a></h3><p><img src="'+d+'" alt="Uptime Card"></p><p>The Uptime card provides:</p><ul><li>Number of online applications</li></ul><h3 id="network-card" tabindex="-1">Network Card <a class="header-anchor" href="#network-card" aria-label="Permalink to &quot;Network Card&quot;"></a></h3><p><img src="'+l+'" alt="Network Card"></p><p>The Network card displays:</p><ul><li>Sum of servers and applications</li></ul>',20)]))}const v=r(n,[["render",c]]);export{f as __pageData,v as default};

View File

@@ -0,0 +1 @@
import{_ as r,c as e,o as s,ag as t}from"./chunks/framework.DPDPlp3K.js";const o="/assets/dashboard_card_servers.DNNhRbkY.png",i="/assets/dashboard_card_applications.DErIEBeJ.png",d="/assets/dashboard_card_uptime.B6cdZ6Ju.png",l="/assets/dashboard_card_network.C2YTU2ur.png",f=JSON.parse('{"title":"Dashboard","description":"","frontmatter":{},"headers":[],"relativePath":"general/Dashboard.md","filePath":"general/Dashboard.md","lastUpdated":1745241280000}'),n={name:"general/Dashboard.md"};function c(p,a,h,u,m,_){return s(),e("div",null,a[0]||(a[0]=[t("",20)]))}const v=r(n,[["render",c]]);export{f as __pageData,v as default};

View File

@@ -0,0 +1 @@
import{_ as r,c as a,o as n,j as e,a as o}from"./chunks/framework.DPDPlp3K.js";const u=JSON.parse('{"title":"Network","description":"","frontmatter":{},"headers":[],"relativePath":"general/Network.md","filePath":"general/Network.md","lastUpdated":1745241280000}'),s={name:"general/Network.md"};function i(l,t,c,d,h,p){return n(),a("div",null,t[0]||(t[0]=[e("h1",{id:"network",tabindex:"-1"},[o("Network "),e("a",{class:"header-anchor",href:"#network","aria-label":'Permalink to "Network"'},"")],-1),e("p",null,"A network flowchart is automatically generated on this page, which shows the connections of your infrastructure. The main servers are displayed based on the main node “My Infrastrucutre”. Below this are the applications running directly on this server and next to it the VMs running on the server, if it is a host server. To the right of the VMs, all applications running on the respective VM are listed.",-1)]))}const w=r(s,[["render",i]]);export{u as __pageData,w as default};

View File

@@ -0,0 +1 @@
import{_ as r,c as a,o as n,j as e,a as o}from"./chunks/framework.DPDPlp3K.js";const u=JSON.parse('{"title":"Network","description":"","frontmatter":{},"headers":[],"relativePath":"general/Network.md","filePath":"general/Network.md","lastUpdated":1745241280000}'),s={name:"general/Network.md"};function i(l,t,c,d,h,p){return n(),a("div",null,t[0]||(t[0]=[e("h1",{id:"network",tabindex:"-1"},[o("Network "),e("a",{class:"header-anchor",href:"#network","aria-label":'Permalink to "Network"'},"")],-1),e("p",null,"A network flowchart is automatically generated on this page, which shows the connections of your infrastructure. The main servers are displayed based on the main node “My Infrastrucutre”. Below this are the applications running directly on this server and next to it the VMs running on the server, if it is a host server. To the right of the VMs, all applications running on the respective VM are listed.",-1)]))}const w=r(s,[["render",i]]);export{u as __pageData,w as default};

View File

@@ -0,0 +1,12 @@
import{_ as s,c as a,o as i,ag as t}from"./chunks/framework.DPDPlp3K.js";const r="/assets/servers_add_button.DqYHhWPq.png",n="/assets/servers_display.f8nBpTOs.png",l="/assets/servers_vms_button.CXHECWPE.png",o="/assets/servers_vms_list.C3B4ERR1.png",v=JSON.parse('{"title":"Servers","description":"","frontmatter":{},"headers":[],"relativePath":"general/Servers.md","filePath":"general/Servers.md","lastUpdated":1745241280000}'),h={name:"general/Servers.md"};function p(d,e,c,g,k,u){return i(),a("div",null,e[0]||(e[0]=[t('<h1 id="servers" tabindex="-1">Servers <a class="header-anchor" href="#servers" aria-label="Permalink to &quot;Servers&quot;"></a></h1><p>In the server menu you can see all your servers and add more if required</p><h2 id="add-a-server" tabindex="-1">Add a Server <a class="header-anchor" href="#add-a-server" aria-label="Permalink to &quot;Add a Server&quot;"></a></h2><p>To add a new server to CoreControl, follow these steps:</p><ol><li><p>Click the &quot;Add Server&quot; button in the top right corner of the server menu: <img src="'+r+`" alt="Servers Add Button"></p></li><li><p>Fill out the server details across the following tabs:</p></li></ol><h3 id="general-tab" tabindex="-1">General Tab <a class="header-anchor" href="#general-tab" aria-label="Permalink to &quot;General Tab&quot;"></a></h3><p>Configure the basic server information:</p><ul><li><strong>Icon</strong>: Choose a custom icon for your server</li><li><strong>Name</strong>: Enter a descriptive name for the server</li><li><strong>Operating System</strong>: Select the server&#39;s operating system</li><li><strong>IP Address</strong>: Enter the server&#39;s IP address</li><li><strong>Management URL</strong>: Add the URL used to manage the server (optional)</li></ul><h3 id="hardware-tab" tabindex="-1">Hardware Tab <a class="header-anchor" href="#hardware-tab" aria-label="Permalink to &quot;Hardware Tab&quot;"></a></h3><p>Specify the server&#39;s hardware specifications:</p><ul><li><strong>CPU</strong>: Enter CPU model and specifications</li><li><strong>GPU</strong>: Add graphics card details if applicable</li><li><strong>RAM</strong>: Specify the amount of RAM</li><li><strong>Disk</strong>: Enter storage capacity and configuration</li></ul><h3 id="virtualization-tab" tabindex="-1">Virtualization Tab <a class="header-anchor" href="#virtualization-tab" aria-label="Permalink to &quot;Virtualization Tab&quot;"></a></h3><p>Configure virtualization settings:</p><ul><li><strong>Host Server Settings</strong>: <ul><li>Enable &quot;Host Server&quot; if this server will host virtual machines</li><li>Perfect for hypervisors like Proxmox, VMware, or similar</li></ul></li><li><strong>VM Settings</strong>: <ul><li>Select a host server if this server is a virtual machine</li><li>This creates a logical connection between the VM and its host</li></ul></li></ul><h3 id="monitoring-tab" tabindex="-1">Monitoring Tab <a class="header-anchor" href="#monitoring-tab" aria-label="Permalink to &quot;Monitoring Tab&quot;"></a></h3><p>Set up server monitoring options (see &quot;Monitoring&quot; section for detailed information)</p><p>After filling out the required information, click &quot;Add&quot; to add the server to CoreControl.</p><h2 id="monitoring" tabindex="-1">Monitoring <a class="header-anchor" href="#monitoring" aria-label="Permalink to &quot;Monitoring&quot;"></a></h2><p>If you want to monitor the hardware usage and status of your servers, you will have to enable monitoring in the monitoring tab.</p><p>After you have done this you need to install <a href="https://github.com/nicolargo/glances" target="_blank" rel="noreferrer">Glances</a> on the server. To help you with this, we have created a sample compose that you can simply copy. For detailed customizations, please refer to the <a href="https://glances.readthedocs.io/en/latest/" target="_blank" rel="noreferrer">Glances docs</a>.</p><div class="language-yaml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">services</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> glances</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> image</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">nicolargo/glances:latest</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> container_name</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">glances</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> restart</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">unless-stopped</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> ports</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> - </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">&quot;61208:61208&quot;</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> pid</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">&quot;host&quot;</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> volumes</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> - </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">/var/run/docker.sock:/var/run/docker.sock:ro</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> environment</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> - </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">GLANCES_OPT=-w --disable-webui</span></span></code></pre></div><div class="warning custom-block"><p class="custom-block-title">WARNING</p><p>Please also make sure that CoreControl can reach the specified API URL of Glances. In addition, the Glances API URL should be specified in the format <code>http://&lt;IP_OF_SERVER&gt;:61208</code>.</p></div><h2 id="server-display" tabindex="-1">Server Display <a class="header-anchor" href="#server-display" aria-label="Permalink to &quot;Server Display&quot;"></a></h2><p>Your servers are displayed in a list or grid (depending on the display settings) - each server in its own card <img src="`+n+'" alt="Server Card"></p><p>There are also three action buttons at the end of each card.</p><ul><li>Link Button - With this you can open the specified management URL of the server with one click</li><li>Delete Button - Direct deletion of the server</li><li>Edit Button - Customize the server with the same menu as when creating the server</li></ul><h2 id="vms" tabindex="-1">VMs <a class="header-anchor" href="#vms" aria-label="Permalink to &quot;VMs&quot;"></a></h2><p>If a host server contains VMs, you can display them using the “VMs” button <img src="'+l+'" alt="VMs Button"></p><p>The associated VMs are then displayed in a clearly arranged list. <img src="'+o+'" alt="VM List"></p>',29)]))}const E=s(h,[["render",p]]);export{v as __pageData,E as default};

View File

@@ -0,0 +1 @@
import{_ as s,c as a,o as i,ag as t}from"./chunks/framework.DPDPlp3K.js";const r="/assets/servers_add_button.DqYHhWPq.png",n="/assets/servers_display.f8nBpTOs.png",l="/assets/servers_vms_button.CXHECWPE.png",o="/assets/servers_vms_list.C3B4ERR1.png",v=JSON.parse('{"title":"Servers","description":"","frontmatter":{},"headers":[],"relativePath":"general/Servers.md","filePath":"general/Servers.md","lastUpdated":1745241280000}'),h={name:"general/Servers.md"};function p(d,e,c,g,k,u){return i(),a("div",null,e[0]||(e[0]=[t("",29)]))}const E=s(h,[["render",p]]);export{v as __pageData,E as default};

View File

@@ -0,0 +1 @@
import{_ as e}from"./chunks/settings_notifications.DL7eQG4d.js";import{_ as s,c as a,o as i,ag as n}from"./chunks/framework.DPDPlp3K.js";const r="/assets/settings_user.eib6RZVK.png",o="/assets/settings_theme.AZP0Uw0g.png",f=JSON.parse('{"title":"Settings","description":"","frontmatter":{},"headers":[],"relativePath":"general/Settings.md","filePath":"general/Settings.md","lastUpdated":1745241280000}'),g={name:"general/Settings.md"};function l(c,t,_,h,m,p){return i(),a("div",null,t[0]||(t[0]=[n('<h1 id="settings" tabindex="-1">Settings <a class="header-anchor" href="#settings" aria-label="Permalink to &quot;Settings&quot;"></a></h1><p>Here you can manage the complete settings of CoreControl.</p><h2 id="user-settings" tabindex="-1">User Settings <a class="header-anchor" href="#user-settings" aria-label="Permalink to &quot;User Settings&quot;"></a></h2><p><img src="'+r+'" alt="User Settings"></p><h2 id="theme-settings" tabindex="-1">Theme Settings <a class="header-anchor" href="#theme-settings" aria-label="Permalink to &quot;Theme Settings&quot;"></a></h2><p><img src="'+o+'" alt="Theme Settings"></p><h2 id="notification-settings" tabindex="-1">Notification Settings <a class="header-anchor" href="#notification-settings" aria-label="Permalink to &quot;Notification Settings&quot;"></a></h2><p><img src="'+e+'" alt="Notification Settings"></p>',8)]))}const u=s(g,[["render",l]]);export{f as __pageData,u as default};

View File

@@ -0,0 +1 @@
import{_ as e}from"./chunks/settings_notifications.DL7eQG4d.js";import{_ as s,c as a,o as i,ag as n}from"./chunks/framework.DPDPlp3K.js";const r="/assets/settings_user.eib6RZVK.png",o="/assets/settings_theme.AZP0Uw0g.png",f=JSON.parse('{"title":"Settings","description":"","frontmatter":{},"headers":[],"relativePath":"general/Settings.md","filePath":"general/Settings.md","lastUpdated":1745241280000}'),g={name:"general/Settings.md"};function l(c,t,_,h,m,p){return i(),a("div",null,t[0]||(t[0]=[n("",8)]))}const u=s(g,[["render",l]]);export{f as __pageData,u as default};

View File

@@ -0,0 +1 @@
import{_ as a,c as i,o as s,j as e,a as n}from"./chunks/framework.DPDPlp3K.js";const r="/assets/uptime.Dt6hpqNV.png",f=JSON.parse('{"title":"Uptime","description":"","frontmatter":{},"headers":[],"relativePath":"general/Uptime.md","filePath":"general/Uptime.md","lastUpdated":1745241280000}'),p={name:"general/Uptime.md"};function l(o,t,m,c,d,u){return s(),i("div",null,t[0]||(t[0]=[e("h1",{id:"uptime",tabindex:"-1"},[n("Uptime "),e("a",{class:"header-anchor",href:"#uptime","aria-label":'Permalink to "Uptime"'},"")],-1),e("p",null,"The uptime of all your Applications is shown here in a clear list.",-1),e("p",null,[e("img",{src:r,alt:"Uptime"})],-1),e("p",null,"With the Select menu you can also filter the time span (30min, 7 days and 30 days)",-1)]))}const _=a(p,[["render",l]]);export{f as __pageData,_ as default};

View File

@@ -0,0 +1 @@
import{_ as a,c as i,o as s,j as e,a as n}from"./chunks/framework.DPDPlp3K.js";const r="/assets/uptime.Dt6hpqNV.png",f=JSON.parse('{"title":"Uptime","description":"","frontmatter":{},"headers":[],"relativePath":"general/Uptime.md","filePath":"general/Uptime.md","lastUpdated":1745241280000}'),p={name:"general/Uptime.md"};function l(o,t,m,c,d,u){return s(),i("div",null,t[0]||(t[0]=[e("h1",{id:"uptime",tabindex:"-1"},[n("Uptime "),e("a",{class:"header-anchor",href:"#uptime","aria-label":'Permalink to "Uptime"'},"")],-1),e("p",null,"The uptime of all your Applications is shown here in a clear list.",-1),e("p",null,[e("img",{src:r,alt:"Uptime"})],-1),e("p",null,"With the Select menu you can also filter the time span (30min, 7 days and 30 days)",-1)]))}const _=a(p,[["render",l]]);export{f as __pageData,_ as default};

View File

@@ -0,0 +1 @@
import{_ as e,c as t,o as a}from"./chunks/framework.DPDPlp3K.js";const u=JSON.parse('{"title":"","description":"","frontmatter":{"layout":"home","hero":{"name":"CoreControl","text":"Manage your server infrastructure","actions":[{"theme":"brand","text":"Install","link":"/installation"},{"theme":"alt","text":"GitHub","link":"https://github.com/crocofied/corecontrol"}],"image":{"src":"/logo.png","alt":"Logo"}},"features":[{"icon":"🚀","title":"Easy Deployment","details":"Deploy and manage your servers with just a few clicks - thanks to docker"},{"icon":"🔒","title":"Secure Management","details":"Secure connections with the panel and a more secure authentication system"},{"icon":"📊","title":"Real-time Monitoring","details":"Monitor server performance, resource usage and uptime in real-time"},{"icon":"🎮","title":"Easy to Manage","details":"Simple and intuitive management interface for all your needs"},{"icon":"🔔","title":"Notifications","details":"Stay informed with alerts and notifications about your servers & applications status"},{"icon":"✨","title":"Clean UI","details":"Modern and user-friendly interface designed for the best user experience"}]},"headers":[],"relativePath":"index.md","filePath":"index.md","lastUpdated":1745241416000}'),n={name:"index.md"};function i(o,r,s,c,l,d){return a(),t("div")}const p=e(n,[["render",i]]);export{u as __pageData,p as default};

View File

@@ -0,0 +1 @@
import{_ as e,c as t,o as a}from"./chunks/framework.DPDPlp3K.js";const u=JSON.parse('{"title":"","description":"","frontmatter":{"layout":"home","hero":{"name":"CoreControl","text":"Manage your server infrastructure","actions":[{"theme":"brand","text":"Install","link":"/installation"},{"theme":"alt","text":"GitHub","link":"https://github.com/crocofied/corecontrol"}],"image":{"src":"/logo.png","alt":"Logo"}},"features":[{"icon":"🚀","title":"Easy Deployment","details":"Deploy and manage your servers with just a few clicks - thanks to docker"},{"icon":"🔒","title":"Secure Management","details":"Secure connections with the panel and a more secure authentication system"},{"icon":"📊","title":"Real-time Monitoring","details":"Monitor server performance, resource usage and uptime in real-time"},{"icon":"🎮","title":"Easy to Manage","details":"Simple and intuitive management interface for all your needs"},{"icon":"🔔","title":"Notifications","details":"Stay informed with alerts and notifications about your servers & applications status"},{"icon":"✨","title":"Clean UI","details":"Modern and user-friendly interface designed for the best user experience"}]},"headers":[],"relativePath":"index.md","filePath":"index.md","lastUpdated":1745241416000}'),n={name:"index.md"};function i(o,r,s,c,l,d){return a(),t("div")}const p=e(n,[["render",i]]);export{u as __pageData,p as default};

View File

@@ -0,0 +1,36 @@
import{_ as i,c as a,o as n,ag as t}from"./chunks/framework.DPDPlp3K.js";const d=JSON.parse('{"title":"Installation","description":"","frontmatter":{},"headers":[],"relativePath":"installation.md","filePath":"installation.md","lastUpdated":1745171698000}'),l={name:"installation.md"};function e(p,s,h,k,r,o){return n(),a("div",null,s[0]||(s[0]=[t(`<h1 id="installation" tabindex="-1">Installation <a class="header-anchor" href="#installation" aria-label="Permalink to &quot;Installation&quot;"></a></h1><p>The easiest way to install CoreControl is using Docker Compose. Follow these steps:</p><h2 id="docker-compose-installation" tabindex="-1">Docker Compose Installation <a class="header-anchor" href="#docker-compose-installation" aria-label="Permalink to &quot;Docker Compose Installation&quot;"></a></h2><div class="danger custom-block"><p class="custom-block-title">DANGER</p><p>CoreControl is at an early stage of development and is subject to change. It is not recommended for use in a production environment at this time.</p></div><ol><li><p>Make sure <a href="https://docs.docker.com/get-docker/" target="_blank" rel="noreferrer">Docker</a> and <a href="https://docs.docker.com/compose/install/" target="_blank" rel="noreferrer">Docker Compose</a> are installed on your system.</p></li><li><p>Create a file named <code>docker-compose.yml</code> with the following content:</p></li></ol><div class="language-yaml vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">yaml</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">services</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> web</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> image</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">haedlessdev/corecontrol:latest</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> ports</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> - </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">&quot;3000:3000&quot;</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> environment</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> JWT_SECRET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">RANDOM_SECRET</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"> # Replace with a secure random string</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> DATABASE_URL</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">&quot;postgresql://postgres:postgres@db:5432/postgres&quot;</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> agent</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> image</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">haedlessdev/corecontrol-agent:latest</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> environment</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> DATABASE_URL</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">&quot;postgresql://postgres:postgres@db:5432/postgres&quot;</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> depends_on</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> db</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> condition</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">service_healthy</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> db</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> image</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">postgres:17</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> restart</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">always</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> environment</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> POSTGRES_USER</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">postgres</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> POSTGRES_PASSWORD</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">postgres</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> POSTGRES_DB</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">postgres</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> volumes</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;"> - </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">postgres_data:/var/lib/postgresql/data</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> healthcheck</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> test</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: [</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">&quot;CMD-SHELL&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">&quot;pg_isready -U postgres&quot;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">]</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> interval</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">2s</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> timeout</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;">2s</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> retries</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;">10</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;">volumes</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span>
<span class="line"><span style="--shiki-light:#22863A;--shiki-dark:#85E89D;"> postgres_data</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8;">:</span></span></code></pre></div><ol start="3"><li>Generate a custom JWT_SECRET with e.g. <a href="https://jwtsecret.com/generate" target="_blank" rel="noreferrer">jwtsecret.com/generate</a></li><li>Start CoreControl with the following command:</li></ol><div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0"><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">docker-compose</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> up</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> -d</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D;"># OR</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0;">docker</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> compose</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF;"> up</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF;"> -d</span></span></code></pre></div><ol start="5"><li>The application is now available at <code>http://localhost:3000</code>.</li></ol><h2 id="authentication" tabindex="-1">Authentication <a class="header-anchor" href="#authentication" aria-label="Permalink to &quot;Authentication&quot;"></a></h2><p>CoreControl comes with a default administrator account:</p><ul><li><strong>Email</strong>: <a href="mailto:admin@example.com" target="_blank" rel="noreferrer">admin@example.com</a></li><li><strong>Password</strong>: admin</li></ul><div class="warning custom-block"><p class="custom-block-title">WARNING</p><p>For security reasons, it is strongly recommended to change the default credentials immediately after your first login.</p></div><p>You can change the administrator password in the settings after logging in.</p>`,14)]))}const g=i(l,[["render",e]]);export{d as __pageData,g as default};

View File

@@ -0,0 +1 @@
import{_ as i,c as a,o as n,ag as t}from"./chunks/framework.DPDPlp3K.js";const d=JSON.parse('{"title":"Installation","description":"","frontmatter":{},"headers":[],"relativePath":"installation.md","filePath":"installation.md","lastUpdated":1745171698000}'),l={name:"installation.md"};function e(p,s,h,k,r,o){return n(),a("div",null,s[0]||(s[0]=[t("",14)]))}const g=i(l,[["render",e]]);export{d as __pageData,g as default};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1 @@
import{_ as o,c as a,o as e,j as t,a as i}from"./chunks/framework.DPDPlp3K.js";const r="/assets/notifications_discord.BzLLVI_K.png",D=JSON.parse('{"title":"Discord","description":"","frontmatter":{},"headers":[],"relativePath":"notifications/Discord.md","filePath":"notifications/Discord.md","lastUpdated":1745241280000}'),c={name:"notifications/Discord.md"};function d(n,s,l,p,f,_){return e(),a("div",null,s[0]||(s[0]=[t("h1",{id:"discord",tabindex:"-1"},[i("Discord "),t("a",{class:"header-anchor",href:"#discord","aria-label":'Permalink to "Discord"'},"")],-1),t("p",null,[t("img",{src:r,alt:"Discord"})],-1)]))}const h=o(c,[["render",d]]);export{D as __pageData,h as default};

View File

@@ -0,0 +1 @@
import{_ as o,c as a,o as e,j as t,a as i}from"./chunks/framework.DPDPlp3K.js";const r="/assets/notifications_discord.BzLLVI_K.png",D=JSON.parse('{"title":"Discord","description":"","frontmatter":{},"headers":[],"relativePath":"notifications/Discord.md","filePath":"notifications/Discord.md","lastUpdated":1745241280000}'),c={name:"notifications/Discord.md"};function d(n,s,l,p,f,_){return e(),a("div",null,s[0]||(s[0]=[t("h1",{id:"discord",tabindex:"-1"},[i("Discord "),t("a",{class:"header-anchor",href:"#discord","aria-label":'Permalink to "Discord"'},"")],-1),t("p",null,[t("img",{src:r,alt:"Discord"})],-1)]))}const h=o(c,[["render",d]]);export{D as __pageData,h as default};

View File

@@ -0,0 +1 @@
import{_ as e,c as i,o as s,j as a,a as o}from"./chunks/framework.DPDPlp3K.js";const n="/assets/notifications_smtp.C9OYC6IZ.png",E=JSON.parse('{"title":"Email","description":"","frontmatter":{},"headers":[],"relativePath":"notifications/Email.md","filePath":"notifications/Email.md","lastUpdated":1745241280000}'),r={name:"notifications/Email.md"};function l(m,t,c,d,p,f){return s(),i("div",null,t[0]||(t[0]=[a("h1",{id:"email",tabindex:"-1"},[o("Email "),a("a",{class:"header-anchor",href:"#email","aria-label":'Permalink to "Email"'},"")],-1),a("p",null,[a("img",{src:n,alt:"Set up"})],-1)]))}const u=e(r,[["render",l]]);export{E as __pageData,u as default};

View File

@@ -0,0 +1 @@
import{_ as e,c as i,o as s,j as a,a as o}from"./chunks/framework.DPDPlp3K.js";const n="/assets/notifications_smtp.C9OYC6IZ.png",E=JSON.parse('{"title":"Email","description":"","frontmatter":{},"headers":[],"relativePath":"notifications/Email.md","filePath":"notifications/Email.md","lastUpdated":1745241280000}'),r={name:"notifications/Email.md"};function l(m,t,c,d,p,f){return s(),i("div",null,t[0]||(t[0]=[a("h1",{id:"email",tabindex:"-1"},[o("Email "),a("a",{class:"header-anchor",href:"#email","aria-label":'Permalink to "Email"'},"")],-1),a("p",null,[a("img",{src:n,alt:"Set up"})],-1)]))}const u=e(r,[["render",l]]);export{E as __pageData,u as default};

Some files were not shown because too many files have changed in this diff Show More