74 Commits

Author SHA1 Message Date
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
36 changed files with 3035 additions and 750 deletions

View File

@@ -20,27 +20,30 @@ Login Page:
![Login Page](https://i.ibb.co/DfS7BJdX/image.png)
Dashboard Page:
![Dashboard Page](https://i.ibb.co/m5xMXz73/image.png)
![Dashboard Page](https://i.ibb.co/wFMb7StT/image.png)
Servers Page:
![Servers Page](https://i.ibb.co/QFrFRp1B/image.png)
![Servers Page](https://i.ibb.co/HLMD9HPZ/image.png)
VM Display:
![VM Display](https://i.ibb.co/My45mv8k/image.png)
Applications Page:
![Applications Page](https://i.ibb.co/1JK3pFYG/image.png)
![Applications Page](https://i.ibb.co/qMwrKwn3/image.png)
Uptime Page:
![Uptime Page](https://i.ibb.co/99LTnZ14/image.png)
![Uptime Page](https://i.ibb.co/jvGcL9Y6/image.png)
Network Page:
![Network Page](https://i.ibb.co/1Y6ypKHk/image.png)
![Network Page](https://i.ibb.co/qYcL2Fws/image.png)
Settings Page:
![Settings Page](https://i.ibb.co/mrdjqy7f/image.png)
![Settings Page](https://i.ibb.co/rRQB9Hcz/image.png)
## Roadmap
- [X] Edit Applications, Applications searchbar
- [X] Uptime History
- [ ] Notifications
- [X] Notifications
- [ ] Simple Server Monitoring
- [ ] Improved Network Flowchart with custom elements (like Network switches)
- [ ] Advanced Settings (Disable Uptime Tracking & more)
@@ -65,6 +68,9 @@ services:
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 +81,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:

View File

@@ -17,4 +17,6 @@ require (
github.com/jackc/pgtype v1.14.0 // indirect
golang.org/x/crypto v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect
)

View File

@@ -171,9 +171,13 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T
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

@@ -1,23 +1,58 @@
package main
import (
"bytes"
"context"
"crypto/x509"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
_ "github.com/jackc/pgx/v4/stdlib"
"github.com/joho/godotenv"
"gopkg.in/gomail.v2"
)
type Application struct {
ID int
Name string
PublicURL string
Online bool
}
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
}
var (
notifications []Notification
notifMutex sync.RWMutex
)
func main() {
if err := godotenv.Load(); err != nil {
fmt.Println("No env vars found")
@@ -34,8 +69,36 @@ func main() {
}
defer db.Close()
// initial load
notifs, err := loadNotifications(db)
if err != nil {
panic(fmt.Sprintf("Failed to load notifications: %v", err))
}
notifMutex.Lock()
notifications = notifMutexCopy(notifs)
notifMutex.Unlock()
// reload notification configs every minute
go func() {
deletionTicker := time.NewTicker(1 * time.Hour)
reloadTicker := time.NewTicker(time.Minute)
defer reloadTicker.Stop()
for range reloadTicker.C {
newNotifs, err := loadNotifications(db)
if err != nil {
fmt.Printf("Failed to reload notifications: %v\n", err)
continue
}
notifMutex.Lock()
notifications = notifMutexCopy(newNotifs)
notifMutex.Unlock()
fmt.Println("Reloaded notification configurations")
}
}()
// clean up old entries hourly
go func() {
deletionTicker := time.NewTicker(time.Hour)
defer deletionTicker.Stop()
for range deletionTicker.C {
@@ -45,7 +108,7 @@ func main() {
}
}()
ticker := time.NewTicker(1 * time.Second)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
client := &http.Client{
@@ -62,12 +125,53 @@ func main() {
}
}
// helper to safely copy slice
func notifMutexCopy(src []Notification) []Notification {
copyDst := make([]Notification, len(src))
copy(copyDst, src)
return copyDst
}
func isIPAddress(host string) bool {
ip := net.ParseIP(host)
return ip != nil
}
func loadNotifications(db *sql.DB) ([]Notification, error) {
rows, err := db.Query(
`SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo",
"telegramChatId", "telegramToken", "discordWebhook", "gotifyUrl", "gotifyToken", "ntfyUrl", "ntfyToken"
FROM notification
WHERE enabled = true`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []Notification
for rows.Next() {
var n 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,
); err != nil {
fmt.Printf("Error scanning notification: %v\n", err)
continue
}
configs = append(configs, n)
}
return configs, nil
}
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'`)
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`,
)
if err != nil {
return err
}
@@ -77,11 +181,9 @@ func deleteOldEntries(db *sql.DB) error {
}
func getApplications(db *sql.DB) []Application {
rows, err := db.Query(`
SELECT id, "publicURL", online
FROM application
WHERE "publicURL" IS NOT NULL
`)
rows, err := db.Query(
`SELECT id, name, "publicURL", online FROM application WHERE "publicURL" IS NOT NULL`,
)
if err != nil {
fmt.Printf("Error fetching applications: %v\n", err)
return nil
@@ -91,8 +193,7 @@ func getApplications(db *sql.DB) []Application {
var apps []Application
for rows.Next() {
var app Application
err := rows.Scan(&app.ID, &app.PublicURL, &app.Online)
if err != nil {
if err := rows.Scan(&app.ID, &app.Name, &app.PublicURL, &app.Online); err != nil {
fmt.Printf("Error scanning row: %v\n", err)
continue
}
@@ -102,70 +203,251 @@ func getApplications(db *sql.DB) []Application {
}
func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
fmt.Printf("Start checking %d applications at %v\n", len(apps), time.Now())
var notificationTemplate string
err := db.QueryRow("SELECT notification_text FROM settings LIMIT 1").Scan(&notificationTemplate)
if err != nil || notificationTemplate == "" {
notificationTemplate = "The application !name (!url) went !status!"
}
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)
for _, app := range apps {
logPrefix := fmt.Sprintf("[App %s (%s)]", app.Name, app.PublicURL)
fmt.Printf("%s Checking...\n", logPrefix)
parsedURL, parseErr := url.Parse(app.PublicURL)
if parseErr != nil {
fmt.Printf("%s Invalid URL: %v\n", logPrefix, parseErr)
continue
}
hostIsIP := isIPAddress(parsedURL.Hostname())
// 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)
httpCancel()
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
if err != nil || (resp != nil && (resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented)) {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
fmt.Printf("%s HEAD failed, trying GET...\n", logPrefix)
req.Method = "GET"
resp, err = client.Do(req)
}
isOnline := err == nil && resp != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 || resp.StatusCode == 405
var isOnline bool
if err == nil && resp != nil {
isOnline = (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == 405
resp.Body.Close()
} else {
if err != nil {
fmt.Printf("%s HTTP error: %v\n", logPrefix, err)
// Sonderbehandlung für IP-Adressen + TLS-Zertifikatfehler
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
}
}
}
}
}
httpCancel()
if isOnline != app.Online {
status := "offline"
if isOnline {
status = "online"
}
message := notificationTemplate
message = strings.ReplaceAll(message, "!name", app.Name)
message = strings.ReplaceAll(message, "!url", app.PublicURL)
message = strings.ReplaceAll(message, "!status", status)
sendNotifications(message)
}
// Database Update
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer dbCancel()
startUpdate := time.Now()
updateRes, err := db.ExecContext(dbCtx,
_, err = db.ExecContext(dbCtx,
`UPDATE application SET online = $1 WHERE id = $2`,
isOnline,
app.ID,
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)
fmt.Printf("%s DB update failed: %v\n", logPrefix, err)
}
dbCancel()
// History Insert
startInsert := time.Now()
insertRes, err := db.ExecContext(dbCtx,
dbCtx2, dbCancel2 := context.WithTimeout(context.Background(), 5*time.Second)
_, err = db.ExecContext(dbCtx2,
`INSERT INTO uptime_history("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
app.ID,
isOnline,
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)
fmt.Printf("%s Insert into history failed: %v\n", logPrefix, err)
}
dbCancel2()
}
}
func sendNotifications(message string) {
notifMutex.RLock()
notifs := notifMutexCopy(notifications)
notifMutex.RUnlock()
for _, n := range notifs {
switch n.Type {
case "email":
if n.SMTPHost.Valid && n.SMTPTo.Valid {
sendEmail(n, message)
}
case "telegram":
if n.TelegramToken.Valid && n.TelegramChatID.Valid {
sendTelegram(n, message)
}
case "discord":
if n.DiscordWebhook.Valid {
sendDiscord(n, message)
}
case "gotify":
if n.GotifyUrl.Valid && n.GotifyToken.Valid {
sendGotify(n, message)
}
case "ntfy":
if n.NtfyUrl.Valid && n.NtfyToken.Valid {
sendNtfy(n, message)
}
}
}
}
func sendEmail(n 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 sendTelegram(n Notification, message string) {
url := 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(url)
if err != nil {
fmt.Printf("Telegram send failed: %v\n", err)
return
}
resp.Body.Close()
}
func sendDiscord(n 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 sendGotify(n 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 sendNtfy(n Notification, message string) {
baseURL := strings.TrimSuffix(n.NtfyUrl.String, "/")
topic := "corecontrol"
requestURL := fmt.Sprintf("%s/%s", baseURL, topic)
payload := map[string]string{"message": message}
jsonData, err := json.Marshal(payload)
if err != nil {
fmt.Printf("Ntfy: ERROR marshaling JSON: %v\n", err)
return
}
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
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)
}
req.Header.Set("Content-Type", "application/json")
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)
}
}

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,30 +66,13 @@ 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",
},
};
// Server Nodes
const serverNodes: Node[] = servers.map((server, index) => {
// Level 2: Physical Servers
const serverNodes: Node[] = servers
.filter(server => !server.hostServer)
.map((server, index, filteredServers) => {
const xPos =
index * HORIZONTAL_SPACING -
((servers.length - 1) * HORIZONTAL_SPACING) / 2;
((filteredServers.length - 1) * HORIZONTAL_SPACING) / 2;
return {
id: `server-${server.id}`,
@@ -108,26 +97,107 @@ export async function GET() {
};
});
// 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)
.filter(app => app.serverId === server.id)
.forEach((app, appIndex) => {
appNodes.push({
id: `app-${app.id}`,
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 === vm.data.id)
.forEach((app, appIndex) => {
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,51 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface AddRequest {
type: 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;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { type, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken } = body;
const notification = await prisma.notification.create({
data: {
type: type,
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,
}
});
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

@@ -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;
@@ -16,11 +19,14 @@ interface AddRequest {
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 } = body;
const server = await prisma.server.create({
data: {
host,
hostServer,
name,
icon,
os,
ip,
url,

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;
@@ -16,17 +19,27 @@ interface EditRequest {
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 } = 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,

View File

@@ -6,24 +6,49 @@ interface GetRequest {
ITEMS_PER_PAGE?: number;
}
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({
const hosts = await prisma.server.findMany({
where: { hostServer: 0 },
skip: (page - 1) * ITEMS_PER_PAGE,
take: ITEMS_PER_PAGE,
orderBy: { name: 'asc' }
});
const totalCount = await prisma.server.count();
const maxPage = Math.ceil(totalCount / ITEMS_PER_PAGE);
const hostsWithVms = await Promise.all(
hosts.map(async (host) => {
const vms = await prisma.server.findMany({
where: { hostServer: host.id },
orderBy: { name: 'asc' }
});
// Add isVM flag to VMs
const vmsWithFlag = vms.map(vm => ({
...vm,
isVM: true,
hostedVMs: [] // Initialize empty hostedVMs array for VMs
}));
return {
...host,
isVM: false, // Mark as physical server/not a VM
hostedVMs: vmsWithFlag
};
})
);
const totalHosts = await prisma.server.count({
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
});
const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
return NextResponse.json({
servers,
servers: hostsWithVms,
maxPage
});
} catch (error: any) {

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

@@ -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": "" });
}
// If settings entry exists, fetch it
const settings = await prisma.settings.findFirst({
where: { id: existingSettings.id },
});
if (!settings) {
return NextResponse.json({ "notification_text": "" });
}
// Return the settings entry
return NextResponse.json({ "notification_text": settings.notification_text });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,34 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface AddRequest {
text: string;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { text } = 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: text },
});
return NextResponse.json({ message: "Success", updatedSettings });
}
// If no settings entry exists, create a new one
const settings = await prisma.settings.create({
data: {
notification_text: text,
}
});
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,21 +72,51 @@ 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">
<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="pb-2">
<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>
<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 p-4">
<Button
variant="ghost"
size="lg"
className="w-full hover:bg-rose-50font-semibold transition-colors"
asChild
>
<Link href="/dashboard/servers" className="flex items-center justify-between">
<span>Manage Servers</span>
</Link>
</Button>
</CardFooter>
</Card>
@@ -152,7 +185,7 @@ export default function Dashboard() {
<CardDescription>Manage network configuration</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount + applicationCount}</div>
<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">

View File

@@ -479,7 +479,7 @@ export default function Dashboard() {
</CardDescription>
</div>
</div>
<div className="flex flex-col items-end justify-start space-y-2 w-[270px]">
<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
@@ -490,7 +490,7 @@ export default function Dashboard() {
}
>
<Link className="h-4 w-4" />
Open Public URL
Public URL
</Button>
{app.localURL && (
<Button
@@ -501,7 +501,7 @@ export default function Dashboard() {
}
>
<Home className="h-4 w-4" />
Open Local URL
Local URL
</Button>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,49 @@
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 } from "lucide-react"
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?: 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 +58,188 @@ export default function Settings() {
const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false)
const [emailSuccess, setEmailSuccess] = useState<boolean>(false)
const [notificationType, setNotificationType] = 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 [notifications, setNotifications] = useState<any[]>([])
const [notificationText, setNotificationText] = 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", {
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,
})
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) {
setNotificationText(response.data.notification_text)
} else {
setNotificationText("The application !name (!url) 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: notificationText,
})
} catch (error: any) {
alert(error.response.data.error)
}
}
useEffect(() => {
getNotificationText()
}, [])
return (
<SidebarProvider>
<AppSidebar />
@@ -289,6 +398,328 @@ 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>
<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>
</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>
)}
</Select>
</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">Notification Text</Label>
<Textarea
id="text"
placeholder="Type here..."
value={notificationText}
onChange={(e) => setNotificationText(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>
<strong>!name</strong> - Application name
</li>
<li>
<strong>!url</strong> - Application URL
</li>
<li>
<strong>!status</strong> - Application status (online/offline)
</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>
)}
<div className="space-y-1">
<h3 className="font-medium capitalize">{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"}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="hover:bg-muted/20"
onClick={() => deleteNotification(notification.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
Remove
</Button>
</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>
</CardContent>
</Card>
</div>
</div>
</SidebarInset>

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",
@@ -72,23 +90,38 @@ 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>
@@ -96,44 +129,77 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarMenu>
</SidebarHeader>
<SidebarContent className="flex flex-col h-full">
<SidebarContent className="flex flex-col h-full py-4">
<SidebarGroup className="flex-grow">
<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) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link href={item.url} className="font-medium">
{item.icon && <item.icon className="mr-2" />}
{item.title}
</Link>
{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",
)}
>
{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>
{item.items?.length && (
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={subItem.isActive ?? false}
>
<Link href={subItem.url}>
{subItem.icon && <subItem.icon className="mr-2" />}
{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 />

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 }

View File

@@ -14,6 +14,9 @@ services:
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 +27,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:

131
package-lock.json generated
View File

@@ -1,20 +1,24 @@
{
"name": "corecontrol",
"version": "0.0.4",
"version": "0.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "corecontrol",
"version": "0.0.4",
"version": "0.0.7",
"dependencies": {
"@prisma/client": "^6.6.0",
"@prisma/extension-accelerate": "^1.3.0",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.0",
@@ -1240,6 +1244,36 @@
}
}
},
"node_modules/@radix-ui/react-checkbox": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.5.tgz",
"integrity": "sha512-B0gYIVxl77KYDR25AY9EGe/G//ef85RVBIxQvK+m5pxAC7XihAc/8leMHhDvjvhDu02SBSb6BuytlWr/G7F3+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.3",
"@radix-ui/react-primitive": "2.0.3",
"@radix-ui/react-use-controllable-state": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.4.tgz",
@@ -1657,6 +1691,53 @@
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz",
"integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz",
@@ -1688,6 +1769,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.4.tgz",
"integrity": "sha512-G9rdWTQjOR4sk76HwSdROhPU0jZWpfozn9skU1v4N0/g9k7TmswrJn8W8WMU+aYktnLLpk5LX6fofj2bGe5NFQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.3",
"@radix-ui/react-primitive": "2.0.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.7.tgz",
@@ -4545,6 +4657,21 @@
"optional": true
}
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.0.tgz",
"integrity": "sha512-vHUQS4YVGJPmpjn7r5lEZuMhK5UQBNBRSB+iGDvJjaNk649pTIcRluDWNb9siunyLLiu/LDPHfvxBtNamyuLTw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "corecontrol",
"version": "0.0.4",
"version": "0.0.7",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -13,9 +13,13 @@
"@prisma/extension-accelerate": "^1.3.0",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.0",

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "notification" (
"id" SERIAL NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"type" TEXT NOT NULL,
"smtpHost" TEXT,
"smtpPort" INTEGER,
"smtpFrom" TEXT,
"smtpUser" TEXT,
"smtpPass" TEXT,
"smtpSecure" BOOLEAN,
"smtpTo" TEXT,
"telegramChatId" TEXT,
"telegramToken" TEXT,
"discordWebhook" TEXT,
CONSTRAINT "notification_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "notification_text" TEXT;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "server" ADD COLUMN "host" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "hostServer" TEXT;

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- The `hostServer` column on the `server` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "server" DROP COLUMN "hostServer",
ADD COLUMN "hostServer" INTEGER;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "notification" ADD COLUMN "gotifyToken" TEXT,
ADD COLUMN "gotifyUrl" TEXT,
ADD COLUMN "ntfyToken" TEXT,
ADD COLUMN "ntfyUrl" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "server" ADD COLUMN "icon" TEXT;

View File

@@ -34,7 +34,10 @@ model uptime_history {
model server {
id Int @id @default(autoincrement())
host Boolean @default(false)
hostServer Int?
name String
icon String?
os String?
ip String?
url String?
@@ -47,6 +50,7 @@ model server {
model settings {
id Int @id @default(autoincrement())
uptime_checks Boolean @default(true)
notification_text String?
}
model user {
@@ -54,3 +58,23 @@ model user {
email String @unique
password String
}
model notification {
id Int @id @default(autoincrement())
enabled Boolean @default(true)
type String
smtpHost String?
smtpPort Int?
smtpFrom String?
smtpUser String?
smtpPass String?
smtpSecure Boolean?
smtpTo String?
telegramChatId String?
telegramToken String?
discordWebhook String?
gotifyUrl String?
gotifyToken String?
ntfyUrl String?
ntfyToken String?
}