diff --git a/README.md b/README.md index add9013..eee0dc4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/agent/go.mod b/agent/go.mod index 0693b03..551dcaa 100644 --- a/agent/go.mod +++ b/agent/go.mod @@ -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 ) diff --git a/agent/go.sum b/agent/go.sum index e7903f5..b2a3b40 100644 --- a/agent/go.sum +++ b/agent/go.sum @@ -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= diff --git a/agent/main.go b/agent/main.go index b25deee..5290bec 100644 --- a/agent/main.go +++ b/agent/main.go @@ -2,22 +2,51 @@ package main import ( "context" + "crypto/x509" "database/sql" + "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 +} + +var ( + notifications []Notification + notifMutex sync.RWMutex +) + func main() { if err := godotenv.Load(); err != nil { fmt.Println("No env vars found") @@ -34,8 +63,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 +102,7 @@ func main() { } }() - ticker := time.NewTicker(1 * time.Second) + ticker := time.NewTicker(time.Second) defer ticker.Stop() client := &http.Client{ @@ -62,12 +119,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" + 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, + ); 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 +175,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 +187,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,15 +197,25 @@ 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(¬ificationTemplate) + 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()) - startHTTP := time.Now() httpCtx, httpCancel := context.WithTimeout(context.Background(), 4*time.Second) - req, err := http.NewRequestWithContext(httpCtx, "HEAD", app.PublicURL, nil) if err != nil { fmt.Printf("%s Request creation failed: %v\n", logPrefix, err) @@ -119,64 +224,150 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) { } resp, err := client.Do(req) - httpDuration := time.Since(startHTTP) - if err != nil || resp == nil || (resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented) { + 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 fallback...\n", logPrefix) + fmt.Printf("%s HEAD failed, trying GET...\n", logPrefix) req.Method = "GET" resp, err = client.Do(req) - httpDuration = time.Since(startHTTP) } - isOnline := false + var isOnline bool if err == nil && resp != nil { isOnline = (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == 405 - } - - 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() + } 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) + } + dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second) - - 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) } - - 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) - } - dbCancel() + + 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, + ) + if err != nil { + 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) + } + } + } +} + +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() +} diff --git a/app/api/notifications/add/route.ts b/app/api/notifications/add/route.ts new file mode 100644 index 0000000..721bf5a --- /dev/null +++ b/app/api/notifications/add/route.ts @@ -0,0 +1,43 @@ +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; +} + +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 } = 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, + } + }); + + return NextResponse.json({ message: "Success", notification }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/app/api/notifications/delete/route.ts b/app/api/notifications/delete/route.ts new file mode 100644 index 0000000..67966a6 --- /dev/null +++ b/app/api/notifications/delete/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/notifications/get/route.ts b/app/api/notifications/get/route.ts new file mode 100644 index 0000000..dff6e7f --- /dev/null +++ b/app/api/notifications/get/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/servers/add/route.ts b/app/api/servers/add/route.ts index d0234d8..34634ce 100644 --- a/app/api/servers/add/route.ts +++ b/app/api/servers/add/route.ts @@ -2,6 +2,8 @@ import { NextResponse, NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; interface AddRequest { + host: boolean; + hostServer: number; name: string; os: string; ip: string; @@ -16,10 +18,12 @@ 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, os, ip, url, cpu, gpu, ram, disk } = body; const server = await prisma.server.create({ data: { + host, + hostServer, name, os, ip, diff --git a/app/api/servers/edit/route.ts b/app/api/servers/edit/route.ts index 0690369..6a96261 100644 --- a/app/api/servers/edit/route.ts +++ b/app/api/servers/edit/route.ts @@ -2,6 +2,8 @@ import { NextResponse, NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; interface EditRequest { + host: boolean; + hostServer: number; id: number; name: string; os: string; @@ -16,7 +18,7 @@ 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, os, ip, url, cpu, gpu, ram, disk } = body; const existingServer = await prisma.server.findUnique({ where: { id } }); if (!existingServer) { @@ -26,6 +28,8 @@ export async function PUT(request: NextRequest) { const updatedServer = await prisma.server.update({ where: { id }, data: { + host, + hostServer, name, os, ip, diff --git a/app/api/servers/get/route.ts b/app/api/servers/get/route.ts index bbeaf22..a14ef80 100644 --- a/app/api/servers/get/route.ts +++ b/app/api/servers/get/route.ts @@ -6,24 +6,37 @@ 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) => ({ + ...host, + hostedVMs: await prisma.server.findMany({ + where: { hostServer: host.id }, + orderBy: { name: 'asc' } + }) + })) + ); + + const totalHosts = await prisma.server.count({ + where: { hostServer: null } + }); + + const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE); return NextResponse.json({ - servers, + servers: hostsWithVms, maxPage }); } catch (error: any) { diff --git a/app/api/servers/hosts/route.ts b/app/api/servers/hosts/route.ts new file mode 100644 index 0000000..de4f716 --- /dev/null +++ b/app/api/servers/hosts/route.ts @@ -0,0 +1,13 @@ +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 }, + }); + return NextResponse.json({ servers }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/settings/get_notification_text/route.ts b/app/api/settings/get_notification_text/route.ts new file mode 100644 index 0000000..65f3a1d --- /dev/null +++ b/app/api/settings/get_notification_text/route.ts @@ -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 }); + } +} diff --git a/app/api/settings/notification_text/route.ts b/app/api/settings/notification_text/route.ts new file mode 100644 index 0000000..0a37722 --- /dev/null +++ b/app/api/settings/notification_text/route.ts @@ -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 }); + } +} diff --git a/app/dashboard/servers/Servers.tsx b/app/dashboard/servers/Servers.tsx index a75da3e..137db15 100644 --- a/app/dashboard/servers/Servers.tsx +++ b/app/dashboard/servers/Servers.tsx @@ -29,6 +29,7 @@ import { Microchip, MemoryStick, HardDrive, + Server, } from "lucide-react"; import { Card, @@ -77,10 +78,15 @@ import Cookies from "js-cookie"; import { useState, useEffect } from "react"; import axios from "axios"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Alert } from "@/components/ui/alert"; +import { ScrollArea } from "@/components/ui/scroll-area"; interface Server { id: number; name: string; + host: boolean; + hostServer: number | null; os?: string; ip?: string; url?: string; @@ -88,6 +94,7 @@ interface Server { gpu?: string; ram?: string; disk?: string; + hostedVMs: Server[]; } interface GetServersResponse { @@ -96,6 +103,8 @@ interface GetServersResponse { } export default function Dashboard() { + const [host, setHost] = useState(false); + const [hostServer, setHostServer] = useState(0); const [name, setName] = useState(""); const [os, setOs] = useState(""); const [ip, setIp] = useState(""); @@ -113,6 +122,8 @@ export default function Dashboard() { const [loading, setLoading] = useState(true); const [editId, setEditId] = useState(null); + const [editHost, setEditHost] = useState(false); + const [editHostServer, setEditHostServer] = useState(0); const [editName, setEditName] = useState(""); const [editOs, setEditOs] = useState(""); const [editIp, setEditIp] = useState(""); @@ -125,6 +136,9 @@ export default function Dashboard() { const [searchTerm, setSearchTerm] = useState(""); const [isSearching, setIsSearching] = useState(false); + const [hostServers, setHostServers] = useState([]); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + useEffect(() => { const savedLayout = Cookies.get("layoutPreference-servers"); const layout_bool = savedLayout === "grid"; @@ -146,6 +160,8 @@ export default function Dashboard() { const add = async () => { try { await axios.post("/api/servers/add", { + host, + hostServer, name, os, ip, @@ -155,6 +171,18 @@ export default function Dashboard() { ram, disk, }); + setIsAddDialogOpen(false); + setHost(false); + setHostServer(0); + + setName(""); + setOs(""); + setIp(""); + setUrl(""); + setCpu(""); + setGpu(""); + setRam(""); + setDisk(""); getServers(); } catch (error: any) { console.log(error.response.data); @@ -171,6 +199,10 @@ export default function Dashboard() { ITEMS_PER_PAGE: itemsPerPage, } ); + for (const server of response.data.servers) { + console.log("Host Server:" + server.hostServer); + console.log("ID:" + server.id); + } setServers(response.data.servers); setMaxPage(response.data.maxPage); setLoading(false); @@ -202,6 +234,8 @@ export default function Dashboard() { const openEditDialog = (server: Server) => { setEditId(server.id); + setEditHost(server.host); + setEditHostServer(server.hostServer || null); setEditName(server.name); setEditOs(server.os || ""); setEditIp(server.ip || ""); @@ -218,6 +252,8 @@ export default function Dashboard() { try { await axios.put("/api/servers/edit", { id: editId, + host: editHost, + hostServer: editHostServer, name: editName, os: editOs, ip: editIp, @@ -261,12 +297,29 @@ export default function Dashboard() { return () => clearTimeout(delayDebounce); }, [searchTerm]); + useEffect(() => { + const fetchHostServers = async () => { + try { + const response = await axios.get<{ servers: Server[] }>( + "/api/servers/hosts" + ); + setHostServers(response.data.servers); + } catch (error) { + console.error("Error fetching host servers:", error); + } + }; + + if (isAddDialogOpen || editId !== null) { + fetchHostServers(); + } + }, [isAddDialogOpen, editId]); + return ( -
-
+
+
@@ -312,7 +365,7 @@ export default function Dashboard() { - +
+ +
+
+ + setHost(checked === true) + } + /> + +
+ {!host && ( +
+ + +
+ )} +
+
@@ -487,245 +584,750 @@ export default function Dashboard() { : "space-y-4" } > - {servers.map((server) => ( - - -
-
-
- - {server.name} - - -
- - - OS: {server.os || "-"} - -
-
- - - IP: {server.ip || "Not set"} - -
- -
- -
- -
- - - CPU: {server.cpu || "-"} - -
-
- - - GPU: {server.gpu || "-"} - -
-
- - - RAM: {server.ram || "-"} - -
-
- - - Disk: {server.disk || "-"} - -
-
-
-
-
-
-
- {server.url && ( - - )} -
-
- - - - - - - - - Edit Server - - - - - - General - - - Hardware - - - -
-
- - -
-
- - - setEditIp(e.target.value) - } - /> -
-
- - - setEditUrl(e.target.value) - } - /> -
-
-
+
+ + + OS: {server.os || "-"} + +
+
+ + + IP: {server.ip || "Not set"} + +
- -
-
- - - setEditCpu(e.target.value) - } - /> +
+ +
+ +
+ + + CPU: {server.cpu || "-"} + +
+
+ + + GPU: {server.gpu || "-"} + +
+
+ + + RAM: {server.ram || "-"} + +
+
+ + + Disk: {server.disk || "-"} + +
+ +
+
+
+
+
+ {server.url && ( + + )} +
+
+ + + + + + + + + Edit Server + + + + + + General + + + Hardware + + + Virtualization + + + +
+
+ + + setEditName(e.target.value) + } + /> +
+
+ + +
+
+ + + setEditIp(e.target.value) + } + /> +
+
+ + + setEditUrl(e.target.value) + } + /> +
-
- - - setEditGpu(e.target.value) - } - /> + + + +
+
+ + + setEditCpu(e.target.value) + } + /> +
+
+ + + setEditGpu(e.target.value) + } + /> +
+
+ + + setEditRam(e.target.value) + } + /> +
+
+ + + setEditDisk(e.target.value) + } + /> +
-
- - - setEditRam(e.target.value) - } - /> + + +
+
+ + setEditHost(checked === true) + } + /> + +
+ {!editHost && ( +
+ + +
+ )}
-
- - - setEditDisk(e.target.value) - } - /> + + + + + + + Cancel + + + + + + + {server.hostedVMs.length > 0 && ( + + + + + + + + Hosted VMs + + + {server.host && ( +
+ +
+ {server.hostedVMs?.map( + (hostedVM) => ( +
+
+
+ {hostedVM.name} +
+
+ + + + + + + + + + + Edit VM + + + + + + General + + + Hardware + + + Virtualization + + + +
+
+ + + setEditName( + e + .target + .value + ) + } + /> +
+
+ + +
+
+ + + setEditIp( + e + .target + .value + ) + } + /> +
+
+ + + setEditUrl( + e + .target + .value + ) + } + /> +
+
+
+ + +
+
+ + + setEditCpu( + e + .target + .value + ) + } + /> +
+
+ + + setEditGpu( + e + .target + .value + ) + } + /> +
+
+ + + setEditRam( + e + .target + .value + ) + } + /> +
+
+ + + setEditDisk( + e + .target + .value + ) + } + /> +
+
+
+ +
+
+ + setEditHost( + checked === + true + ) + } + /> + +
+ {!editHost && ( +
+ + +
+ )} +
+
+
+
+
+ + + Cancel + + + +
+
+
+
+ +
+ +
+ +
+
+ + + OS:{" "} + {hostedVM.os || "-"} + +
+
+ + + IP:{" "} + {hostedVM.ip || + "Not set"} + +
+
+ +
+ + + CPU:{" "} + {hostedVM.cpu || "-"} + +
+
+ + + GPU:{" "} + {hostedVM.gpu || "-"} + +
+
+ + + RAM:{" "} + {hostedVM.ram || "-"} + +
+
+ + + Disk:{" "} + {hostedVM.disk || "-"} + +
+
+ ) + )} +
+
-
-
- - - - - Cancel - - - - + )} + + + + + Close + + + + + )} +
-
- - - ))} + + + ))}
) : (
@@ -762,7 +1364,9 @@ export default function Dashboard() { 1} - style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }} + style={{ + cursor: currentPage === 1 ? "not-allowed" : "pointer", + }} /> @@ -774,7 +1378,10 @@ export default function Dashboard() { diff --git a/app/dashboard/settings/Settings.tsx b/app/dashboard/settings/Settings.tsx index 171935f..931e2fd 100644 --- a/app/dashboard/settings/Settings.tsx +++ b/app/dashboard/settings/Settings.tsx @@ -21,19 +21,34 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion" import { Input } from "@/components/ui/input" -import { useState } from "react"; +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 } 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(); @@ -51,6 +66,22 @@ export default function Settings() { const [passwordSuccess, setPasswordSuccess] = useState(false) const [emailSuccess, setEmailSuccess] = useState(false) + const [notificationType, setNotificationType] = useState("") + const [smtpHost, setSmtpHost] = useState("") + const [smtpPort, setSmtpPort] = useState(0) + const [smtpSecure, setSmtpSecure] = useState(false) + const [smtpUsername, setSmtpUsername] = useState("") + const [smtpPassword, setSmtpPassword] = useState("") + const [smtpFrom, setSmtpFrom] = useState("") + const [smtpTo, setSmtpTo] = useState("") + const [telegramToken, setTelegramToken] = useState("") + const [telegramChatId, setTelegramChatId] = useState("") + const [discordWebhook, setDiscordWebhook] = useState("") + + const [notifications, setNotifications] = useState([]) + + const [notificationText, setNotificationText] = useState("") + const changeEmail = async () => { setEmailErrorVisible(false); setEmailSuccess(false); @@ -131,6 +162,88 @@ export default function Settings() { }, 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 + }); + 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('/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('/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 ( @@ -289,6 +402,213 @@ export default function Settings() {
+ + + + +
+ +

Notifications

+
+
+ +
+ Set up Notifications to get notified when an application goes offline or online. +
+ + + + + + + Add Notification + + setSmtpHost(e.target.value)} + /> +
+
+ + setSmtpPort(Number(e.target.value))} + /> +
+
+ +
+ setSmtpSecure(checked)} + /> + +
+ +
+
+ + setSmtpUsername(e.target.value)} + /> +
+ +
+ + setSmtpPassword(e.target.value)} + /> +
+ +
+
+ + setSmtpFrom(e.target.value)} + /> +
+ +
+ + setSmtpTo(e.target.value)} + /> +
+
+
+
+ )} + + {notificationType === "telegram" && ( +
+
+ + setTelegramToken(e.target.value)} /> +
+
+ + setTelegramChatId(e.target.value)} /> +
+
+ )} + + {notificationType === "discord" && ( +
+
+ + setDiscordWebhook(e.target.value)} /> +
+
+ )} + + + + + Cancel + + Add + + + + + + + +
+ +
+
+ + Customize Notification Text + +
+
+ +