45 Commits

Author SHA1 Message Date
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
f024b0166f v0.0.5 uptime_fix->main
v0.0.5
2025-04-17 14:53:40 +02:00
headlessdev
d6889a27b5 Fix: Update online status check to account for HTTP 405 response 2025-04-17 14:41:50 +02:00
headlessdev
edbc72a7c9 Enhance application status check logging and error handling 2025-04-17 14:18:50 +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
headlessdev
7e82b42b29 HOTFIX: Agent context deadline fix 2025-04-17 11:36:35 +02:00
headlessdev
f5c835b5d9 Hotfix Docker file 'bcrypt' error 2025-04-16 14:55:09 +02:00
29 changed files with 2052 additions and 341 deletions

View File

@@ -6,9 +6,13 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
COPY ./prisma ./prisma COPY ./prisma ./prisma
# Install all dependencies (including devDependencies)
RUN npm install RUN npm install
# Generate Prisma client
RUN npx prisma generate RUN npx prisma generate
# Build the application
COPY . . COPY . .
RUN npm run build RUN npm run build
@@ -19,13 +23,16 @@ WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
# Install production dependencies INCLUDING prisma # Copy package files
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --production --ignore-scripts
# Copy needed Prisma files # Copy node_modules from builder
COPY --from=builder /app/node_modules/.prisma /app/node_modules/.prisma COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/node_modules/@prisma /app/node_modules/@prisma
# Remove dev dependencies
RUN npm prune --production
# Copy Prisma files
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
# Copy built application # Copy built application
@@ -36,5 +43,5 @@ COPY --from=builder /app/next.config.js* ./
EXPOSE 3000 EXPOSE 3000
# Run migrations first, then start app # Run migrations and start
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"] CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]

View File

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

View File

@@ -17,4 +17,6 @@ require (
github.com/jackc/pgtype v1.14.0 // indirect github.com/jackc/pgtype v1.14.0 // indirect
golang.org/x/crypto v0.20.0 // indirect golang.org/x/crypto v0.20.0 // indirect
golang.org/x/text v0.14.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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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 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/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/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/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.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= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,22 +2,51 @@ package main
import ( import (
"context" "context"
"crypto/x509"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"strings"
"sync"
"time" "time"
_ "github.com/jackc/pgx/v4/stdlib" _ "github.com/jackc/pgx/v4/stdlib"
"github.com/joho/godotenv" "github.com/joho/godotenv"
"gopkg.in/gomail.v2"
) )
type Application struct { type Application struct {
ID int ID int
Name string
PublicURL string PublicURL string
Online bool 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() { func main() {
if err := godotenv.Load(); err != nil { if err := godotenv.Load(); err != nil {
fmt.Println("No env vars found") fmt.Println("No env vars found")
@@ -34,8 +63,36 @@ func main() {
} }
defer db.Close() 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() { 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() defer deletionTicker.Stop()
for range deletionTicker.C { for range deletionTicker.C {
@@ -45,7 +102,7 @@ func main() {
} }
}() }()
ticker := time.NewTicker(1 * time.Second) ticker := time.NewTicker(time.Second)
defer ticker.Stop() defer ticker.Stop()
client := &http.Client{ 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 { func deleteOldEntries(db *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
res, err := db.ExecContext(ctx, 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 { if err != nil {
return err return err
} }
@@ -77,11 +175,9 @@ func deleteOldEntries(db *sql.DB) error {
} }
func getApplications(db *sql.DB) []Application { func getApplications(db *sql.DB) []Application {
rows, err := db.Query(` rows, err := db.Query(
SELECT id, "publicURL", online `SELECT id, name, "publicURL", online FROM application WHERE "publicURL" IS NOT NULL`,
FROM application )
WHERE "publicURL" IS NOT NULL
`)
if err != nil { if err != nil {
fmt.Printf("Error fetching applications: %v\n", err) fmt.Printf("Error fetching applications: %v\n", err)
return nil return nil
@@ -91,8 +187,7 @@ func getApplications(db *sql.DB) []Application {
var apps []Application var apps []Application
for rows.Next() { for rows.Next() {
var app Application var app Application
err := rows.Scan(&app.ID, &app.PublicURL, &app.Online) if err := rows.Scan(&app.ID, &app.Name, &app.PublicURL, &app.Online); err != nil {
if err != nil {
fmt.Printf("Error scanning row: %v\n", err) fmt.Printf("Error scanning row: %v\n", err)
continue continue
} }
@@ -102,38 +197,177 @@ func getApplications(db *sql.DB) []Application {
} }
func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) { func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
for _, app := range apps { var notificationTemplate string
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) err := db.QueryRow("SELECT notification_text FROM settings LIMIT 1").Scan(&notificationTemplate)
defer cancel() if err != nil || notificationTemplate == "" {
notificationTemplate = "The application '!name' (!url) went !status!"
}
req, err := http.NewRequestWithContext(ctx, "HEAD", app.PublicURL, nil) 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())
httpCtx, httpCancel := context.WithTimeout(context.Background(), 4*time.Second)
req, err := http.NewRequestWithContext(httpCtx, "HEAD", app.PublicURL, nil)
if err != nil { if err != nil {
fmt.Printf("Error creating request: %v\n", err) fmt.Printf("%s Request creation failed: %v\n", logPrefix, err)
httpCancel()
continue continue
} }
resp, err := client.Do(req) resp, err := client.Do(req)
isOnline := false
if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 300 { if err != nil || (resp != nil && (resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented)) {
isOnline = true 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)
} }
_, err = db.ExecContext(ctx, 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)
}
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
_, err = db.ExecContext(dbCtx,
`UPDATE application SET online = $1 WHERE id = $2`, `UPDATE application SET online = $1 WHERE id = $2`,
isOnline, isOnline, app.ID,
app.ID,
) )
if err != nil { if err != nil {
fmt.Printf("Update failed for app %d: %v\n", app.ID, err) fmt.Printf("%s DB update failed: %v\n", logPrefix, err)
} }
dbCancel()
_, err = db.ExecContext(ctx, dbCtx2, dbCancel2 := context.WithTimeout(context.Background(), 5*time.Second)
`INSERT INTO uptime_history ("applicationId", online, "createdAt") VALUES ($1, $2, now())`, _, err = db.ExecContext(dbCtx2,
app.ID, `INSERT INTO uptime_history("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
isOnline, app.ID, isOnline,
) )
if err != nil { if err != nil {
fmt.Printf("Insert into uptime_history failed for app %d: %v\n", app.ID, err) 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()
}

View File

@@ -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 });
}
}

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,6 +2,8 @@ import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
interface AddRequest { interface AddRequest {
host: boolean;
hostServer: number;
name: string; name: string;
os: string; os: string;
ip: string; ip: string;
@@ -16,10 +18,12 @@ interface AddRequest {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body: AddRequest = await request.json(); 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({ const server = await prisma.server.create({
data: { data: {
host,
hostServer,
name, name,
os, os,
ip, ip,

View File

@@ -2,6 +2,8 @@ import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
interface EditRequest { interface EditRequest {
host: boolean;
hostServer: number;
id: number; id: number;
name: string; name: string;
os: string; os: string;
@@ -16,7 +18,7 @@ interface EditRequest {
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
const body: EditRequest = await request.json(); 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 } }); const existingServer = await prisma.server.findUnique({ where: { id } });
if (!existingServer) { if (!existingServer) {
@@ -26,6 +28,8 @@ export async function PUT(request: NextRequest) {
const updatedServer = await prisma.server.update({ const updatedServer = await prisma.server.update({
where: { id }, where: { id },
data: { data: {
host,
hostServer,
name, name,
os, os,
ip, ip,

View File

@@ -6,24 +6,37 @@ interface GetRequest {
ITEMS_PER_PAGE?: number; ITEMS_PER_PAGE?: number;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body: GetRequest = await request.json(); const body: GetRequest = await request.json();
const page = Math.max(1, body.page || 1); const page = Math.max(1, body.page || 1);
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4; 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, skip: (page - 1) * ITEMS_PER_PAGE,
take: ITEMS_PER_PAGE, take: ITEMS_PER_PAGE,
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}); });
const totalCount = await prisma.server.count(); const hostsWithVms = await Promise.all(
const maxPage = Math.ceil(totalCount / ITEMS_PER_PAGE); 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({ return NextResponse.json({
servers, servers: hostsWithVms,
maxPage maxPage
}); });
} catch (error: any) { } catch (error: any) {

View File

@@ -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 });
}
}

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 });
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,19 +21,34 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { useState } from "react"; import { useEffect, useState } from "react";
import axios from "axios"; import axios from "axios";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 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() { export default function Settings() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -51,6 +66,22 @@ export default function Settings() {
const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false) const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false)
const [emailSuccess, setEmailSuccess] = 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 [notifications, setNotifications] = useState<any[]>([])
const [notificationText, setNotificationText] = useState<string>("")
const changeEmail = async () => { const changeEmail = async () => {
setEmailErrorVisible(false); setEmailErrorVisible(false);
setEmailSuccess(false); setEmailSuccess(false);
@@ -131,6 +162,88 @@ export default function Settings() {
}, 3000); }, 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<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 ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
@@ -289,6 +402,213 @@ export default function Settings() {
</div> </div>
</CardContent> </CardContent>
</Card> </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-2">
<Bell className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold">Notifications</h2>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="text-sm text-muted-foreground mb-6">
Set up Notifications to get notified when an application goes offline or online.
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full">
Add Notification
</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>
</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>
)}
</Select>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={addNotification}>
Add
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<div className="pt-4 pb-2">
<Button className="w-full" variant="secondary">
Customize Notification Text
</Button>
</div>
</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 className="mt-6 space-y-4">
{notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
className="flex items-center justify-between p-4 bg-muted/10 rounded-lg border"
>
<div className="space-y-1">
<h3 className="font-medium capitalize">{notification.type}</h3>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteNotification(notification.id)}
>
Delete
</Button>
</div>
))
) : (
<div className="text-center text-muted-foreground py-6">
No notifications configured
</div>
)}
</div>
</CardContent>
</Card>
</div> </div>
</div> </div>
</SidebarInset> </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 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 { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarGroup, SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
@@ -13,12 +28,15 @@ import {
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
SidebarRail, SidebarRail,
SidebarFooter,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import Link from "next/link" import Link from "next/link"
import Cookies from "js-cookie" import Cookies from "js-cookie"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import packageJson from "@/package.json" import packageJson from "@/package.json"
import { cn } from "@/lib/utils"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
interface NavItem { interface NavItem {
title: string title: string
@@ -33,7 +51,7 @@ const data: { navMain: NavItem[] } = {
{ {
title: "Dashboard", title: "Dashboard",
icon: LayoutDashboardIcon, icon: LayoutDashboardIcon,
url: "/dashboard" url: "/dashboard",
}, },
{ {
title: "My Infrastructure", title: "My Infrastructure",
@@ -59,7 +77,7 @@ const data: { navMain: NavItem[] } = {
title: "Network", title: "Network",
icon: Network, icon: Network,
url: "/dashboard/network", url: "/dashboard/network",
}, },
], ],
}, },
{ {
@@ -72,71 +90,119 @@ const data: { navMain: NavItem[] } = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const router = useRouter() const router = useRouter()
const pathname = usePathname()
const logout = async () => { const logout = async () => {
Cookies.remove('token') Cookies.remove("token")
router.push("/") 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 ( return (
<Sidebar {...props}> <Sidebar {...props}>
<SidebarHeader> <SidebarHeader className="border-b border-sidebar-border/30 pb-2">
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild className="gap-3">
<a href="https://github.com/crocofied/corecontrol"> <a href="https://github.com/crocofied/corecontrol" className="transition-all hover:opacity-80">
<Image src="/logo.png" width={48} height={48} alt="Logo"/> <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"> <div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">CoreControl</span> <span className="font-semibold text-base">CoreControl</span>
<span className="">v{packageJson.version}</span> <span className="text-xs text-sidebar-foreground/70">v{packageJson.version}</span>
</div> </div>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent className="flex flex-col h-full"> <SidebarContent className="flex flex-col h-full py-4">
<SidebarGroup className="flex-grow"> <SidebarGroup className="flex-grow">
<SidebarMenu> <SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/60 uppercase tracking-wider px-4 mb-2">
{data.navMain.map((item) => ( Main Navigation
<SidebarMenuItem key={item.title}> </SidebarGroupLabel>
<SidebarMenuButton asChild> <SidebarGroupContent>
<Link href={item.url} className="font-medium"> <SidebarMenu>
{item.icon && <item.icon className="mr-2" />} {data.navMain.map((item) =>
{item.title} item.items?.length ? (
</Link> <Collapsible key={item.title} defaultOpen={hasActiveChild(item.items)} className="group/collapsible">
</SidebarMenuButton> <SidebarMenuItem>
{item.items?.length && ( <CollapsibleTrigger asChild>
<SidebarMenuSub> <SidebarMenuButton
{item.items.map((subItem) => ( className={cn(
<SidebarMenuSubItem key={subItem.title}> "font-medium transition-all",
<SidebarMenuSubButton (hasActiveChild(item.items) || isActive(item.url)) &&
asChild "text-sidebar-accent-foreground bg-sidebar-accent/50",
isActive={subItem.isActive ?? false} )}
> >
<Link href={subItem.url}> {item.icon && <item.icon className="h-4 w-4" />}
{subItem.icon && <subItem.icon className="mr-2" />} <span>{item.title}</span>
{subItem.title} <ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
</Link> </SidebarMenuButton>
</SidebarMenuSubButton> </CollapsibleTrigger>
</SidebarMenuSubItem> <CollapsibleContent>
))} <SidebarMenuSub>
</SidebarMenuSub> {item.items.map((subItem) => (
)} <SidebarMenuSubItem key={subItem.title}>
</SidebarMenuItem> <SidebarMenuSubButton asChild isActive={isActive(subItem.url)} className="transition-all">
))} <Link href={subItem.url} className="flex items-center">
</SidebarMenu> {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> </SidebarGroup>
<div className="p-4"> <SidebarFooter className="border-t border-sidebar-border/30 pt-4 mt-auto">
<Button variant="destructive" className="w-full" onClick={logout}> <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 Logout
</Button> </Button>
</div> </SidebarFooter>
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
) )
} }

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,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 image: haedlessdev/corecontrol-agent:latest
environment: environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres" DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
db:
condition: service_healthy
db: db:
image: postgres:17 image: postgres:17
@@ -24,6 +27,11 @@ services:
POSTGRES_DB: postgres POSTGRES_DB: postgres
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
timeout: 2s
retries: 10
volumes: volumes:
postgres_data: postgres_data:

68
package-lock.json generated
View File

@@ -1,20 +1,23 @@
{ {
"name": "corecontrol", "name": "corecontrol",
"version": "0.0.4", "version": "0.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "corecontrol", "name": "corecontrol",
"version": "0.0.4", "version": "0.0.6",
"dependencies": { "dependencies": {
"@prisma/client": "^6.6.0", "@prisma/client": "^6.6.0",
"@prisma/extension-accelerate": "^1.3.0", "@prisma/extension-accelerate": "^1.3.0",
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7", "@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-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3", "@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7", "@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
@@ -1240,6 +1243,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": { "node_modules/@radix-ui/react-collapsible": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.4.tgz",
@@ -1688,6 +1721,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": { "node_modules/@radix-ui/react-select": {
"version": "2.1.7", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.7.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "corecontrol", "name": "corecontrol",
"version": "0.0.4", "version": "0.0.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -13,9 +13,12 @@
"@prisma/extension-accelerate": "^1.3.0", "@prisma/extension-accelerate": "^1.3.0",
"@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7", "@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-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7", "@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-label": "^2.1.3", "@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7", "@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3", "@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slot": "^1.2.0", "@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

@@ -34,6 +34,8 @@ model uptime_history {
model server { model server {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
host Boolean @default(false)
hostServer Int?
name String name String
os String? os String?
ip String? ip String?
@@ -47,10 +49,27 @@ model server {
model settings { model settings {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
uptime_checks Boolean @default(true) uptime_checks Boolean @default(true)
notification_text String?
} }
model user { model user {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
password String 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?
} }