mirror of
https://github.com/crocofied/CoreControl.git
synced 2025-12-29 16:14:43 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e8744e78 | ||
|
|
e39402ff70 | ||
|
|
7d49baee6b | ||
|
|
ceb10a2ffe | ||
|
|
7986737e0e | ||
|
|
f6debb1629 | ||
|
|
8259563c33 | ||
|
|
3580f7f640 | ||
|
|
b655b7fe2d | ||
|
|
b42c1a45cc | ||
|
|
86d48bc082 | ||
|
|
44817d6685 | ||
|
|
62c27118d6 | ||
|
|
016c9a2562 | ||
|
|
b835ded157 | ||
|
|
016d52fa1b | ||
|
|
2b8f7a95d2 | ||
|
|
300547e59e | ||
|
|
93bffa29cc | ||
|
|
2a910c165e | ||
|
|
6412cbaf1c | ||
|
|
d9304001fe | ||
|
|
7d7897c3f6 | ||
|
|
1ae55da3f9 | ||
|
|
113bb3bfb4 | ||
|
|
83ea20545d | ||
|
|
965f79f31a | ||
|
|
f1c0cc9deb | ||
|
|
f2535cd2b9 | ||
|
|
42e584a381 | ||
|
|
0e1f9edaab | ||
|
|
c3fe3bc03d | ||
|
|
67097725d7 | ||
|
|
61468a359d | ||
|
|
b70d17d844 | ||
|
|
35fae815cf | ||
|
|
8c087792bf | ||
|
|
8627c54be9 | ||
|
|
41647a2e6c | ||
|
|
a73a98ddac | ||
|
|
3e49932382 | ||
|
|
406091fdcb | ||
|
|
c266296c4f | ||
|
|
01470f5ce1 | ||
|
|
82cee64860 | ||
|
|
60dd711856 | ||
|
|
bcbc17d3fe | ||
|
|
547212cd9e | ||
|
|
dec15c0ce0 | ||
|
|
e96ee56aa1 | ||
|
|
6e1f4eeddd | ||
|
|
70010ce8ee | ||
|
|
c1b62d8108 | ||
|
|
c690a1cb37 | ||
|
|
88d99cee43 | ||
|
|
b0c7b813e6 | ||
|
|
88f7f6a9d1 | ||
|
|
b9fac8ddb6 | ||
|
|
dacde7153f | ||
|
|
8f647d3489 | ||
|
|
e925f37b19 | ||
|
|
155a0af883 | ||
|
|
6fd360b594 | ||
|
|
e9aba02d5f | ||
|
|
4a8759f627 | ||
|
|
d00ec93133 | ||
|
|
e7e873c75c | ||
|
|
631c5b0c3b | ||
|
|
f024b0166f | ||
|
|
d6889a27b5 | ||
|
|
edbc72a7c9 | ||
|
|
a51f8c2a3c | ||
|
|
346b79ca22 | ||
|
|
2fd8e50f7f | ||
|
|
cecc5e0bab | ||
|
|
2325f9b042 | ||
|
|
4b29f7cbed | ||
|
|
7e82b42b29 | ||
|
|
f5c835b5d9 |
19
Dockerfile
19
Dockerfile
@@ -6,9 +6,13 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY ./prisma ./prisma
|
||||
|
||||
# Install all dependencies (including devDependencies)
|
||||
RUN npm install
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
@@ -19,13 +23,16 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
# Install production dependencies INCLUDING prisma
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production --ignore-scripts
|
||||
|
||||
# Copy needed Prisma files
|
||||
COPY --from=builder /app/node_modules/.prisma /app/node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma /app/node_modules/@prisma
|
||||
# Copy node_modules from builder
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Remove dev dependencies
|
||||
RUN npm prune --production
|
||||
|
||||
# Copy Prisma files
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
# Copy built application
|
||||
@@ -36,5 +43,5 @@ COPY --from=builder /app/next.config.js* ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Run migrations first, then start app
|
||||
# Run migrations and start
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]
|
||||
25
README.md
25
README.md
@@ -20,27 +20,30 @@ Login Page:
|
||||

|
||||
|
||||
Dashboard Page:
|
||||

|
||||

|
||||
|
||||
Servers Page:
|
||||

|
||||

|
||||
|
||||
VM Display:
|
||||

|
||||
|
||||
Applications Page:
|
||||

|
||||

|
||||
|
||||
Uptime Page:
|
||||

|
||||

|
||||
|
||||
Network Page:
|
||||

|
||||

|
||||
|
||||
Settings Page:
|
||||

|
||||

|
||||
|
||||
## Roadmap
|
||||
- [X] Edit Applications, Applications searchbar
|
||||
- [X] Uptime History
|
||||
- [ ] Notifications
|
||||
- [X] Notifications
|
||||
- [ ] Simple Server Monitoring
|
||||
- [ ] Improved Network Flowchart with custom elements (like Network switches)
|
||||
- [ ] Advanced Settings (Disable Uptime Tracking & more)
|
||||
@@ -65,6 +68,9 @@ services:
|
||||
image: haedlessdev/corecontrol-agent:latest
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
@@ -75,6 +81,11 @@ services:
|
||||
POSTGRES_DB: postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
374
agent/main.go
374
agent/main.go
@@ -1,23 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v4/stdlib"
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
ID int
|
||||
Name string
|
||||
PublicURL string
|
||||
Online bool
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID int
|
||||
Enabled bool
|
||||
Type string
|
||||
SMTPHost sql.NullString
|
||||
SMTPPort sql.NullInt64
|
||||
SMTPFrom sql.NullString
|
||||
SMTPUser sql.NullString
|
||||
SMTPPass sql.NullString
|
||||
SMTPSecure sql.NullBool
|
||||
SMTPTo sql.NullString
|
||||
TelegramChatID sql.NullString
|
||||
TelegramToken sql.NullString
|
||||
DiscordWebhook sql.NullString
|
||||
GotifyUrl sql.NullString
|
||||
GotifyToken sql.NullString
|
||||
NtfyUrl sql.NullString
|
||||
NtfyToken sql.NullString
|
||||
}
|
||||
|
||||
var (
|
||||
notifications []Notification
|
||||
notifMutex sync.RWMutex
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
fmt.Println("No env vars found")
|
||||
@@ -34,8 +69,36 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// initial load
|
||||
notifs, err := loadNotifications(db)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to load notifications: %v", err))
|
||||
}
|
||||
notifMutex.Lock()
|
||||
notifications = notifMutexCopy(notifs)
|
||||
notifMutex.Unlock()
|
||||
|
||||
// reload notification configs every minute
|
||||
go func() {
|
||||
deletionTicker := time.NewTicker(1 * time.Hour)
|
||||
reloadTicker := time.NewTicker(time.Minute)
|
||||
defer reloadTicker.Stop()
|
||||
|
||||
for range reloadTicker.C {
|
||||
newNotifs, err := loadNotifications(db)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to reload notifications: %v\n", err)
|
||||
continue
|
||||
}
|
||||
notifMutex.Lock()
|
||||
notifications = notifMutexCopy(newNotifs)
|
||||
notifMutex.Unlock()
|
||||
fmt.Println("Reloaded notification configurations")
|
||||
}
|
||||
}()
|
||||
|
||||
// clean up old entries hourly
|
||||
go func() {
|
||||
deletionTicker := time.NewTicker(time.Hour)
|
||||
defer deletionTicker.Stop()
|
||||
|
||||
for range deletionTicker.C {
|
||||
@@ -45,7 +108,7 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
client := &http.Client{
|
||||
@@ -62,12 +125,53 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// helper to safely copy slice
|
||||
func notifMutexCopy(src []Notification) []Notification {
|
||||
copyDst := make([]Notification, len(src))
|
||||
copy(copyDst, src)
|
||||
return copyDst
|
||||
}
|
||||
|
||||
func isIPAddress(host string) bool {
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil
|
||||
}
|
||||
|
||||
func loadNotifications(db *sql.DB) ([]Notification, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo",
|
||||
"telegramChatId", "telegramToken", "discordWebhook", "gotifyUrl", "gotifyToken", "ntfyUrl", "ntfyToken"
|
||||
FROM notification
|
||||
WHERE enabled = true`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var configs []Notification
|
||||
for rows.Next() {
|
||||
var n Notification
|
||||
if err := rows.Scan(
|
||||
&n.ID, &n.Enabled, &n.Type,
|
||||
&n.SMTPHost, &n.SMTPPort, &n.SMTPFrom, &n.SMTPUser, &n.SMTPPass, &n.SMTPSecure, &n.SMTPTo,
|
||||
&n.TelegramChatID, &n.TelegramToken, &n.DiscordWebhook, &n.GotifyUrl, &n.GotifyToken, &n.NtfyUrl, &n.NtfyToken,
|
||||
); err != nil {
|
||||
fmt.Printf("Error scanning notification: %v\n", err)
|
||||
continue
|
||||
}
|
||||
configs = append(configs, n)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func deleteOldEntries(db *sql.DB) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`)
|
||||
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -77,11 +181,9 @@ func deleteOldEntries(db *sql.DB) error {
|
||||
}
|
||||
|
||||
func getApplications(db *sql.DB) []Application {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, "publicURL", online
|
||||
FROM application
|
||||
WHERE "publicURL" IS NOT NULL
|
||||
`)
|
||||
rows, err := db.Query(
|
||||
`SELECT id, name, "publicURL", online FROM application WHERE "publicURL" IS NOT NULL`,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("Error fetching applications: %v\n", err)
|
||||
return nil
|
||||
@@ -91,8 +193,7 @@ func getApplications(db *sql.DB) []Application {
|
||||
var apps []Application
|
||||
for rows.Next() {
|
||||
var app Application
|
||||
err := rows.Scan(&app.ID, &app.PublicURL, &app.Online)
|
||||
if err != nil {
|
||||
if err := rows.Scan(&app.ID, &app.Name, &app.PublicURL, &app.Online); err != nil {
|
||||
fmt.Printf("Error scanning row: %v\n", err)
|
||||
continue
|
||||
}
|
||||
@@ -102,38 +203,251 @@ func getApplications(db *sql.DB) []Application {
|
||||
}
|
||||
|
||||
func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) {
|
||||
for _, app := range apps {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
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!"
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Printf("Error creating request: %v\n", err)
|
||||
fmt.Printf("%s Request creation failed: %v\n", logPrefix, err)
|
||||
httpCancel()
|
||||
continue
|
||||
}
|
||||
|
||||
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)) {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx,
|
||||
`UPDATE application SET online = $1 WHERE id = $2`,
|
||||
isOnline,
|
||||
app.ID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("Update failed for app %d: %v\n", app.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx,
|
||||
`INSERT INTO uptime_history ("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
|
||||
app.ID,
|
||||
isOnline,
|
||||
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`,
|
||||
isOnline, app.ID,
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("Insert into uptime_history failed for app %d: %v\n", app.ID, err)
|
||||
fmt.Printf("%s DB update failed: %v\n", logPrefix, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
case "gotify":
|
||||
if n.GotifyUrl.Valid && n.GotifyToken.Valid {
|
||||
sendGotify(n, message)
|
||||
}
|
||||
case "ntfy":
|
||||
if n.NtfyUrl.Valid && n.NtfyToken.Valid {
|
||||
sendNtfy(n, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendEmail(n Notification, body string) {
|
||||
// Initialize SMTP dialer with host, port, user, pass
|
||||
d := gomail.NewDialer(
|
||||
n.SMTPHost.String,
|
||||
int(n.SMTPPort.Int64),
|
||||
n.SMTPUser.String,
|
||||
n.SMTPPass.String,
|
||||
)
|
||||
if n.SMTPSecure.Valid && n.SMTPSecure.Bool {
|
||||
d.SSL = true
|
||||
}
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", n.SMTPFrom.String)
|
||||
m.SetHeader("To", n.SMTPTo.String)
|
||||
m.SetHeader("Subject", "Uptime Notification")
|
||||
m.SetBody("text/plain", body)
|
||||
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
fmt.Printf("Email send failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func sendTelegram(n Notification, message string) {
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&text=%s",
|
||||
n.TelegramToken.String,
|
||||
n.TelegramChatID.String,
|
||||
message,
|
||||
)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
fmt.Printf("Telegram send failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func sendDiscord(n Notification, message string) {
|
||||
payload := fmt.Sprintf(`{"content": "%s"}`, message)
|
||||
req, err := http.NewRequest("POST", n.DiscordWebhook.String, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
fmt.Printf("Discord request creation failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Discord send failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func sendGotify(n Notification, message string) {
|
||||
baseURL := strings.TrimSuffix(n.GotifyUrl.String, "/")
|
||||
targetURL := fmt.Sprintf("%s/message", baseURL)
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("message", message)
|
||||
form.Add("priority", "5")
|
||||
|
||||
req, err := http.NewRequest("POST", targetURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
fmt.Printf("Gotify: ERROR creating request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("X-Gotify-Key", n.GotifyToken.String)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Gotify: ERROR sending request: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("Gotify: ERROR status code: %d\n", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func sendNtfy(n Notification, message string) {
|
||||
baseURL := strings.TrimSuffix(n.NtfyUrl.String, "/")
|
||||
topic := "corecontrol"
|
||||
requestURL := fmt.Sprintf("%s/%s", baseURL, topic)
|
||||
|
||||
payload := map[string]string{"message": message}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
fmt.Printf("Ntfy: ERROR marshaling JSON: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", requestURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
fmt.Printf("Ntfy: ERROR creating request: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if n.NtfyToken.Valid {
|
||||
req.Header.Set("Authorization", "Bearer "+n.NtfyToken.String)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("Ntfy: ERROR sending request: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("Ntfy: ERROR status code: %d\n", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,19 @@ import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const serverCount = await prisma.server.count();
|
||||
const serverCountNoVMs = await prisma.server.count({
|
||||
where: {
|
||||
hostServer: 0
|
||||
}
|
||||
});
|
||||
|
||||
const serverCountOnlyVMs = await prisma.server.count({
|
||||
where: {
|
||||
hostServer: {
|
||||
not: 0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const applicationCount = await prisma.application.count();
|
||||
|
||||
@@ -12,7 +24,8 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
serverCount,
|
||||
serverCountNoVMs,
|
||||
serverCountOnlyVMs,
|
||||
applicationCount,
|
||||
onlineApplicationsCount
|
||||
});
|
||||
|
||||
@@ -30,6 +30,8 @@ interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
host: boolean;
|
||||
hostServer: number | null;
|
||||
}
|
||||
|
||||
interface Application {
|
||||
@@ -43,11 +45,15 @@ const NODE_WIDTH = 220;
|
||||
const NODE_HEIGHT = 60;
|
||||
const APP_NODE_WIDTH = 160;
|
||||
const APP_NODE_HEIGHT = 40;
|
||||
const HORIZONTAL_SPACING = 280;
|
||||
const VERTICAL_SPACING = 60;
|
||||
const HORIZONTAL_SPACING = 700;
|
||||
const VERTICAL_SPACING = 80;
|
||||
const START_Y = 120;
|
||||
const ROOT_NODE_WIDTH = 300;
|
||||
const CONTAINER_PADDING = 40;
|
||||
const COLUMN_SPACING = 220;
|
||||
const VM_APP_SPACING = 220;
|
||||
const MIN_VM_SPACING = 10;
|
||||
const APP_ROW_SPACING = 15;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -60,30 +66,13 @@ export async function GET() {
|
||||
}) as Promise<Application[]>,
|
||||
]);
|
||||
|
||||
// Root Node
|
||||
const rootNode: Node = {
|
||||
id: "root",
|
||||
type: "infrastructure",
|
||||
data: { label: "My Infrastructure" },
|
||||
position: { x: 0, y: 0 },
|
||||
style: {
|
||||
background: "#ffffff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
width: ROOT_NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
fontSize: "1.2rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
};
|
||||
|
||||
// Server Nodes
|
||||
const serverNodes: Node[] = servers.map((server, index) => {
|
||||
// Level 2: Physical Servers
|
||||
const serverNodes: Node[] = servers
|
||||
.filter(server => !server.hostServer)
|
||||
.map((server, index, filteredServers) => {
|
||||
const xPos =
|
||||
index * HORIZONTAL_SPACING -
|
||||
((servers.length - 1) * HORIZONTAL_SPACING) / 2;
|
||||
((filteredServers.length - 1) * HORIZONTAL_SPACING) / 2;
|
||||
|
||||
return {
|
||||
id: `server-${server.id}`,
|
||||
@@ -108,26 +97,107 @@ export async function GET() {
|
||||
};
|
||||
});
|
||||
|
||||
// Application Nodes
|
||||
const appNodes: Node[] = [];
|
||||
// Level 3: Services and VMs
|
||||
const serviceNodes: Node[] = [];
|
||||
const vmNodes: Node[] = [];
|
||||
|
||||
servers.forEach((server) => {
|
||||
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
|
||||
const serverX = serverNode?.position.x || 0;
|
||||
const xOffset = (NODE_WIDTH - APP_NODE_WIDTH) / 2;
|
||||
if (serverNode) {
|
||||
const serverX = serverNode.position.x;
|
||||
|
||||
// Services (left column)
|
||||
applications
|
||||
.filter((app) => app.serverId === server.id)
|
||||
.filter(app => app.serverId === server.id)
|
||||
.forEach((app, appIndex) => {
|
||||
appNodes.push({
|
||||
id: `app-${app.id}`,
|
||||
serviceNodes.push({
|
||||
id: `service-${app.id}`,
|
||||
type: "service",
|
||||
data: {
|
||||
label: `${app.name}\n${app.localURL}`,
|
||||
...app,
|
||||
},
|
||||
position: {
|
||||
x: serverX - COLUMN_SPACING,
|
||||
y: START_Y + NODE_HEIGHT + VERTICAL_SPACING + appIndex * (APP_NODE_HEIGHT + 20),
|
||||
},
|
||||
style: {
|
||||
background: "#f0f9ff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #60a5fa",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// VMs (middle column) mit dynamischem Abstand
|
||||
const hostVMs = servers.filter(vm => vm.hostServer === server.id);
|
||||
let currentY = START_Y + NODE_HEIGHT + VERTICAL_SPACING;
|
||||
|
||||
hostVMs.forEach(vm => {
|
||||
const appCount = applications.filter(app => app.serverId === vm.id).length;
|
||||
|
||||
vmNodes.push({
|
||||
id: `vm-${vm.id}`,
|
||||
type: "vm",
|
||||
data: {
|
||||
label: `${vm.name}\n${vm.ip}`,
|
||||
...vm,
|
||||
},
|
||||
position: {
|
||||
x: serverX,
|
||||
y: currentY,
|
||||
},
|
||||
style: {
|
||||
background: "#fef2f2",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #fecaca",
|
||||
borderRadius: "4px",
|
||||
padding: "6px",
|
||||
width: APP_NODE_WIDTH,
|
||||
height: APP_NODE_HEIGHT,
|
||||
fontSize: "0.8rem",
|
||||
lineHeight: "1.1",
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
});
|
||||
|
||||
// Dynamischer Abstand basierend auf Anzahl Apps
|
||||
const requiredSpace = appCount > 0
|
||||
? (appCount * (APP_NODE_HEIGHT + APP_ROW_SPACING))
|
||||
: 0;
|
||||
|
||||
currentY += Math.max(
|
||||
requiredSpace + MIN_VM_SPACING,
|
||||
MIN_VM_SPACING + APP_NODE_HEIGHT
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Level 4: VM Applications (right column)
|
||||
const vmAppNodes: Node[] = [];
|
||||
vmNodes.forEach((vm) => {
|
||||
const vmX = vm.position.x;
|
||||
applications
|
||||
.filter(app => app.serverId === vm.data.id)
|
||||
.forEach((app, appIndex) => {
|
||||
vmAppNodes.push({
|
||||
id: `vm-app-${app.id}`,
|
||||
type: "application",
|
||||
data: {
|
||||
label: `${app.name}\n${app.localURL}`,
|
||||
...app,
|
||||
},
|
||||
position: {
|
||||
x: serverX + xOffset,
|
||||
y: START_Y + NODE_HEIGHT + 30 + appIndex * VERTICAL_SPACING,
|
||||
x: vmX + VM_APP_SPACING,
|
||||
y: vm.position.y + appIndex * (APP_NODE_HEIGHT + 20),
|
||||
},
|
||||
style: {
|
||||
background: "#f5f5f5",
|
||||
@@ -145,38 +215,14 @@ export async function GET() {
|
||||
});
|
||||
});
|
||||
|
||||
// Connections
|
||||
const connections: Edge[] = [
|
||||
...servers.map((server) => ({
|
||||
id: `conn-root-${server.id}`,
|
||||
source: "root",
|
||||
target: `server-${server.id}`,
|
||||
type: "straight",
|
||||
style: {
|
||||
stroke: "#94a3b8",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
})),
|
||||
...applications.map((app) => ({
|
||||
id: `conn-${app.serverId}-${app.id}`,
|
||||
source: `server-${app.serverId}`,
|
||||
target: `app-${app.id}`,
|
||||
type: "straight",
|
||||
style: {
|
||||
stroke: "#60a5fa",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
// Container Box
|
||||
const allNodes = [rootNode, ...serverNodes, ...appNodes];
|
||||
// Calculate dimensions for root node positioning
|
||||
const tempNodes = [...serverNodes, ...serviceNodes, ...vmNodes, ...vmAppNodes];
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
allNodes.forEach((node) => {
|
||||
tempNodes.forEach((node) => {
|
||||
const width = parseInt(node.style.width?.toString() || "0", 10);
|
||||
const height = parseInt(node.style.height?.toString() || "0", 10);
|
||||
|
||||
@@ -186,17 +232,47 @@ export async function GET() {
|
||||
maxY = Math.max(maxY, node.position.y + height);
|
||||
});
|
||||
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const rootX = centerX - ROOT_NODE_WIDTH / 2;
|
||||
|
||||
// Level 1: Root Node (centered at top)
|
||||
const rootNode: Node = {
|
||||
id: "root",
|
||||
type: "infrastructure",
|
||||
data: { label: "My Infrastructure" },
|
||||
position: { x: rootX, y: 0 },
|
||||
style: {
|
||||
background: "#ffffff",
|
||||
color: "#0f0f0f",
|
||||
border: "2px solid #e6e4e1",
|
||||
borderRadius: "8px",
|
||||
padding: "16px",
|
||||
width: ROOT_NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
fontSize: "1.2rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
};
|
||||
|
||||
// Update dimensions with root node
|
||||
const allNodes = [rootNode, ...tempNodes];
|
||||
let newMinX = Math.min(minX, rootNode.position.x);
|
||||
let newMaxX = Math.max(maxX, rootNode.position.x + ROOT_NODE_WIDTH);
|
||||
let newMinY = Math.min(minY, rootNode.position.y);
|
||||
let newMaxY = Math.max(maxY, rootNode.position.y + NODE_HEIGHT);
|
||||
|
||||
// Container Node
|
||||
const containerNode: Node = {
|
||||
id: 'container',
|
||||
type: 'container',
|
||||
data: { label: '' },
|
||||
position: {
|
||||
x: minX - CONTAINER_PADDING,
|
||||
y: minY - CONTAINER_PADDING
|
||||
x: newMinX - CONTAINER_PADDING,
|
||||
y: newMinY - CONTAINER_PADDING
|
||||
},
|
||||
style: {
|
||||
width: maxX - minX + 2 * CONTAINER_PADDING,
|
||||
height: maxY - minY + 2 * CONTAINER_PADDING,
|
||||
width: newMaxX - newMinX + 2 * CONTAINER_PADDING,
|
||||
height: newMaxY - newMinY + 2 * CONTAINER_PADDING,
|
||||
background: 'transparent',
|
||||
border: '2px dashed #e2e8f0',
|
||||
borderRadius: '8px',
|
||||
@@ -207,6 +283,116 @@ export async function GET() {
|
||||
zIndex: -1,
|
||||
};
|
||||
|
||||
// Connections with hierarchical chaining
|
||||
const connections: Edge[] = [];
|
||||
|
||||
// Root to Servers
|
||||
serverNodes.forEach((server) => {
|
||||
connections.push({
|
||||
id: `conn-root-${server.id}`,
|
||||
source: "root",
|
||||
target: server.id,
|
||||
type: "straight",
|
||||
style: {
|
||||
stroke: "#94a3b8",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Services chaining
|
||||
const servicesByServer = new Map<number, Node[]>();
|
||||
serviceNodes.forEach(service => {
|
||||
const serverId = service.data.serverId;
|
||||
if (!servicesByServer.has(serverId)) servicesByServer.set(serverId, []);
|
||||
servicesByServer.get(serverId)!.push(service);
|
||||
});
|
||||
servicesByServer.forEach((services, serverId) => {
|
||||
services.sort((a, b) => a.position.y - b.position.y);
|
||||
services.forEach((service, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-service-${service.id}`,
|
||||
source: `server-${serverId}`,
|
||||
target: service.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#60a5fa", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevService = services[index - 1];
|
||||
connections.push({
|
||||
id: `conn-service-${service.id}-${prevService.id}`,
|
||||
source: prevService.id,
|
||||
target: service.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#60a5fa", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// VMs chaining
|
||||
const vmsByHost = new Map<number, Node[]>();
|
||||
vmNodes.forEach(vm => {
|
||||
const hostId = vm.data.hostServer;
|
||||
if (!vmsByHost.has(hostId)) vmsByHost.set(hostId, []);
|
||||
vmsByHost.get(hostId)!.push(vm);
|
||||
});
|
||||
vmsByHost.forEach((vms, hostId) => {
|
||||
vms.sort((a, b) => a.position.y - b.position.y);
|
||||
vms.forEach((vm, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-vm-${vm.id}`,
|
||||
source: `server-${hostId}`,
|
||||
target: vm.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevVm = vms[index - 1];
|
||||
connections.push({
|
||||
id: `conn-vm-${vm.id}-${prevVm.id}`,
|
||||
source: prevVm.id,
|
||||
target: vm.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// VM Applications chaining
|
||||
const appsByVM = new Map<number, Node[]>();
|
||||
vmAppNodes.forEach(app => {
|
||||
const vmId = app.data.serverId;
|
||||
if (!appsByVM.has(vmId)) appsByVM.set(vmId, []);
|
||||
appsByVM.get(vmId)!.push(app);
|
||||
});
|
||||
appsByVM.forEach((apps, vmId) => {
|
||||
apps.sort((a, b) => a.position.y - b.position.y);
|
||||
apps.forEach((app, index) => {
|
||||
if (index === 0) {
|
||||
connections.push({
|
||||
id: `conn-vm-app-${app.id}`,
|
||||
source: `vm-${vmId}`,
|
||||
target: app.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
} else {
|
||||
const prevApp = apps[index - 1];
|
||||
connections.push({
|
||||
id: `conn-vm-app-${app.id}-${prevApp.id}`,
|
||||
source: prevApp.id,
|
||||
target: app.id,
|
||||
type: "straight",
|
||||
style: { stroke: "#f87171", strokeWidth: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
nodes: [containerNode, ...allNodes],
|
||||
edges: connections,
|
||||
|
||||
51
app/api/notifications/add/route.ts
Normal file
51
app/api/notifications/add/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
type: string;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpSecure?: boolean;
|
||||
smtpUsername?: string;
|
||||
smtpPassword?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTo?: string;
|
||||
telegramToken?: string;
|
||||
telegramChatId?: string;
|
||||
discordWebhook?: string;
|
||||
gotifyUrl?: string;
|
||||
gotifyToken?: string;
|
||||
ntfyUrl?: string;
|
||||
ntfyToken?: string;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { type, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken } = body;
|
||||
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
type: type,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpFrom: smtpFrom,
|
||||
smtpUser: smtpUsername,
|
||||
smtpPass: smtpPassword,
|
||||
smtpSecure: smtpSecure,
|
||||
smtpTo: smtpTo,
|
||||
telegramChatId: telegramChatId,
|
||||
telegramToken: telegramToken,
|
||||
discordWebhook: discordWebhook,
|
||||
gotifyUrl: gotifyUrl,
|
||||
gotifyToken: gotifyToken,
|
||||
ntfyUrl: ntfyUrl,
|
||||
ntfyToken: ntfyToken,
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Success", notification });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
21
app/api/notifications/delete/route.ts
Normal file
21
app/api/notifications/delete/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
app/api/notifications/get/route.ts
Normal file
16
app/api/notifications/get/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface AddRequest {
|
||||
host: boolean;
|
||||
hostServer: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
url: string;
|
||||
@@ -16,11 +19,14 @@ interface AddRequest {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: AddRequest = await request.json();
|
||||
const { name, os, ip, url, cpu, gpu, ram, disk } = body;
|
||||
const { host, hostServer, name, icon, os, ip, url, cpu, gpu, ram, disk } = body;
|
||||
|
||||
const server = await prisma.server.create({
|
||||
data: {
|
||||
host,
|
||||
hostServer,
|
||||
name,
|
||||
icon,
|
||||
os,
|
||||
ip,
|
||||
url,
|
||||
|
||||
@@ -2,8 +2,11 @@ import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
interface EditRequest {
|
||||
host: boolean;
|
||||
hostServer: number;
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
os: string;
|
||||
ip: string;
|
||||
url: string;
|
||||
@@ -16,17 +19,27 @@ interface EditRequest {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body: EditRequest = await request.json();
|
||||
const { id, name, os, ip, url, cpu, gpu, ram, disk } = body;
|
||||
const { host, hostServer, id, name, icon, os, ip, url, cpu, gpu, ram, disk } = body;
|
||||
|
||||
const existingServer = await prisma.server.findUnique({ where: { id } });
|
||||
if (!existingServer) {
|
||||
return NextResponse.json({ error: "Server not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let newHostServer = hostServer;
|
||||
if (hostServer === null) {
|
||||
newHostServer = 0;
|
||||
} else {
|
||||
newHostServer = hostServer;
|
||||
}
|
||||
|
||||
const updatedServer = await prisma.server.update({
|
||||
where: { id },
|
||||
data: {
|
||||
host,
|
||||
hostServer: newHostServer,
|
||||
name,
|
||||
icon,
|
||||
os,
|
||||
ip,
|
||||
url,
|
||||
|
||||
@@ -6,24 +6,49 @@ interface GetRequest {
|
||||
ITEMS_PER_PAGE?: number;
|
||||
}
|
||||
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: GetRequest = await request.json();
|
||||
const page = Math.max(1, body.page || 1);
|
||||
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
|
||||
|
||||
const servers = await prisma.server.findMany({
|
||||
const hosts = await prisma.server.findMany({
|
||||
where: { hostServer: 0 },
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
take: ITEMS_PER_PAGE,
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
const totalCount = await prisma.server.count();
|
||||
const maxPage = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
const hostsWithVms = await Promise.all(
|
||||
hosts.map(async (host) => {
|
||||
const vms = await prisma.server.findMany({
|
||||
where: { hostServer: host.id },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
// Add isVM flag to VMs
|
||||
const vmsWithFlag = vms.map(vm => ({
|
||||
...vm,
|
||||
isVM: true,
|
||||
hostedVMs: [] // Initialize empty hostedVMs array for VMs
|
||||
}));
|
||||
|
||||
return {
|
||||
...host,
|
||||
isVM: false, // Mark as physical server/not a VM
|
||||
hostedVMs: vmsWithFlag
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const totalHosts = await prisma.server.count({
|
||||
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
|
||||
});
|
||||
|
||||
const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
|
||||
|
||||
return NextResponse.json({
|
||||
servers,
|
||||
servers: hostsWithVms,
|
||||
maxPage
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
21
app/api/servers/hosts/route.ts
Normal file
21
app/api/servers/hosts/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const servers = await prisma.server.findMany({
|
||||
where: { host: true },
|
||||
});
|
||||
|
||||
// Add required properties to ensure consistency
|
||||
const serversWithProps = servers.map(server => ({
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: [] // Initialize empty hostedVMs array
|
||||
}));
|
||||
|
||||
return NextResponse.json({ servers: serversWithProps });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,51 @@ export async function POST(request: NextRequest) {
|
||||
const body: SearchRequest = await request.json();
|
||||
const { searchterm } = body;
|
||||
|
||||
// Fetch all servers
|
||||
const servers = await prisma.server.findMany({});
|
||||
|
||||
// Create a map of host servers with their hosted VMs
|
||||
const serverMap = new Map();
|
||||
servers.forEach(server => {
|
||||
if (server.host) {
|
||||
serverMap.set(server.id, {
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add VMs to their host servers and mark them as VMs
|
||||
const serversWithType = servers.map(server => {
|
||||
// If not a host and has a hostServer, it's a VM
|
||||
if (!server.host && server.hostServer) {
|
||||
const hostServer = serverMap.get(server.hostServer);
|
||||
if (hostServer) {
|
||||
hostServer.hostedVMs.push({
|
||||
...server,
|
||||
isVM: true
|
||||
});
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
isVM: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
isVM: false,
|
||||
hostedVMs: serverMap.get(server.id)?.hostedVMs || []
|
||||
};
|
||||
});
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk'],
|
||||
keys: ['name', 'description', 'cpu', 'gpu', 'ram', 'disk', 'os'],
|
||||
threshold: 0.3,
|
||||
includeScore: true,
|
||||
};
|
||||
|
||||
const fuse = new Fuse(servers, fuseOptions);
|
||||
const fuse = new Fuse(serversWithType, fuseOptions);
|
||||
|
||||
const searchResults = fuse.search(searchterm);
|
||||
|
||||
|
||||
25
app/api/settings/get_notification_text/route.ts
Normal file
25
app/api/settings/get_notification_text/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
app/api/settings/notification_text/route.ts
Normal file
34
app/api/settings/notification_text/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -19,20 +19,23 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface StatsResponse {
|
||||
serverCount: number
|
||||
serverCountNoVMs: number
|
||||
serverCountOnlyVMs: number
|
||||
applicationCount: number
|
||||
onlineApplicationsCount: number
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [serverCount, setServerCount] = useState<number>(0)
|
||||
const [serverCountNoVMs, setServerCountNoVMs] = useState<number>(0)
|
||||
const [serverCountOnlyVMs, setServerCountOnlyVMs] = useState<number>(0)
|
||||
const [applicationCount, setApplicationCount] = useState<number>(0)
|
||||
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0)
|
||||
|
||||
const getStats = async () => {
|
||||
try {
|
||||
const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
|
||||
setServerCount(response.data.serverCount)
|
||||
setServerCountNoVMs(response.data.serverCountNoVMs)
|
||||
setServerCountOnlyVMs(response.data.serverCountOnlyVMs)
|
||||
setApplicationCount(response.data.applicationCount)
|
||||
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
|
||||
} catch (error: any) {
|
||||
@@ -69,21 +72,51 @@ export default function Dashboard() {
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
|
||||
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-sm transition-all hover:shadow-md">
|
||||
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-lg transition-all hover:shadow-xl hover:border-t-rose-600">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl font-medium">Servers</CardTitle>
|
||||
<Server className="h-6 w-6 text-rose-500" />
|
||||
<div>
|
||||
<CardTitle className="text-2xl font-semibold">Servers</CardTitle>
|
||||
<CardDescription className="mt-1">Physical and virtual servers overview</CardDescription>
|
||||
</div>
|
||||
<Server className="h-8 w-8 text-rose-500 p-1.5 rounded-lg" />
|
||||
</div>
|
||||
<CardDescription>Manage your server infrastructure</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2 pb-4">
|
||||
<div className="text-4xl font-bold">{serverCount}</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">Active servers</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Physical Servers */}
|
||||
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
|
||||
<div className="bg-rose-100 p-2 rounded-full">
|
||||
<Server className="h-6 w-6 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{serverCountNoVMs}</div>
|
||||
<p className="text-sm text-muted-foreground">Physical Servers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Virtual Machines */}
|
||||
<div className="flex items-center space-x-4 border border-gray-background p-4 rounded-lg">
|
||||
<div className="bg-violet-100 p-2 rounded-full">
|
||||
<Network className="h-6 w-6 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{serverCountOnlyVMs}</div>
|
||||
<p className="text-sm text-muted-foreground">Virtual Servers</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/20 p-4">
|
||||
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
|
||||
<Link href="/dashboard/servers">View all servers</Link>
|
||||
<CardFooter className="border-t bg-muted/10 p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="w-full hover:bg-rose-50font-semibold transition-colors"
|
||||
asChild
|
||||
>
|
||||
<Link href="/dashboard/servers" className="flex items-center justify-between">
|
||||
<span>Manage Servers</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -152,7 +185,7 @@ export default function Dashboard() {
|
||||
<CardDescription>Manage network configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2 pb-4">
|
||||
<div className="text-4xl font-bold">{serverCount + applicationCount}</div>
|
||||
<div className="text-4xl font-bold">{serverCountNoVMs + serverCountOnlyVMs + applicationCount}</div>
|
||||
<p className="text-sm text-muted-foreground mt-2">Active connections</p>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t bg-muted/20 p-4">
|
||||
|
||||
@@ -479,7 +479,7 @@ export default function Dashboard() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end justify-start space-y-2 w-[270px]">
|
||||
<div className="flex flex-col items-end justify-start space-y-2 w-[190px]">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex flex-col space-y-2 flex-grow">
|
||||
<Button
|
||||
@@ -490,7 +490,7 @@ export default function Dashboard() {
|
||||
}
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
Open Public URL
|
||||
Public URL
|
||||
</Button>
|
||||
{app.localURL && (
|
||||
<Button
|
||||
@@ -501,7 +501,7 @@ export default function Dashboard() {
|
||||
}
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
Open Local URL
|
||||
Local URL
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,42 +1,49 @@
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
"use client"
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useState } from "react";
|
||||
import axios from "axios";
|
||||
import Cookies from "js-cookie";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEffect, useState } from "react"
|
||||
import axios from "axios"
|
||||
import Cookies from "js-cookie"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { AlertCircle, Check, Palette, User } from "lucide-react";
|
||||
import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2 } from "lucide-react"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
interface NotificationsResponse {
|
||||
notifications: any[]
|
||||
}
|
||||
interface NotificationResponse {
|
||||
notification_text?: string
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const [email, setEmail] = useState<string>("")
|
||||
const [password, setPassword] = useState<string>("")
|
||||
@@ -51,86 +58,188 @@ export default function Settings() {
|
||||
const [passwordSuccess, setPasswordSuccess] = useState<boolean>(false)
|
||||
const [emailSuccess, setEmailSuccess] = useState<boolean>(false)
|
||||
|
||||
const [notificationType, setNotificationType] = useState<string>("")
|
||||
const [smtpHost, setSmtpHost] = useState<string>("")
|
||||
const [smtpPort, setSmtpPort] = useState<number>(0)
|
||||
const [smtpSecure, setSmtpSecure] = useState<boolean>(false)
|
||||
const [smtpUsername, setSmtpUsername] = useState<string>("")
|
||||
const [smtpPassword, setSmtpPassword] = useState<string>("")
|
||||
const [smtpFrom, setSmtpFrom] = useState<string>("")
|
||||
const [smtpTo, setSmtpTo] = useState<string>("")
|
||||
const [telegramToken, setTelegramToken] = useState<string>("")
|
||||
const [telegramChatId, setTelegramChatId] = useState<string>("")
|
||||
const [discordWebhook, setDiscordWebhook] = useState<string>("")
|
||||
const [gotifyUrl, setGotifyUrl] = useState<string>("")
|
||||
const [gotifyToken, setGotifyToken] = useState<string>("")
|
||||
const [ntfyUrl, setNtfyUrl] = useState<string>("")
|
||||
const [ntfyToken, setNtfyToken] = useState<string>("")
|
||||
|
||||
const [notifications, setNotifications] = useState<any[]>([])
|
||||
|
||||
const [notificationText, setNotificationText] = useState<string>("")
|
||||
|
||||
const changeEmail = async () => {
|
||||
setEmailErrorVisible(false);
|
||||
setEmailSuccess(false);
|
||||
setEmailError("");
|
||||
setEmailErrorVisible(false)
|
||||
setEmailSuccess(false)
|
||||
setEmailError("")
|
||||
|
||||
if (!email) {
|
||||
setEmailError("Email is required");
|
||||
setEmailErrorVisible(true);
|
||||
setEmailError("Email is required")
|
||||
setEmailErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setEmailErrorVisible(false);
|
||||
setEmailError("");
|
||||
}
|
||||
, 3000);
|
||||
return;
|
||||
setEmailErrorVisible(false)
|
||||
setEmailError("")
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await axios.post('/api/auth/edit_email', {
|
||||
await axios.post("/api/auth/edit_email", {
|
||||
newEmail: email,
|
||||
jwtToken: Cookies.get('token')
|
||||
});
|
||||
setEmailSuccess(true);
|
||||
setEmail("");
|
||||
jwtToken: Cookies.get("token"),
|
||||
})
|
||||
setEmailSuccess(true)
|
||||
setEmail("")
|
||||
setTimeout(() => {
|
||||
setEmailSuccess(false);
|
||||
}, 3000);
|
||||
setEmailSuccess(false)
|
||||
}, 3000)
|
||||
} catch (error: any) {
|
||||
setEmailError(error.response.data.error);
|
||||
setEmailErrorVisible(true);
|
||||
setEmailError(error.response.data.error)
|
||||
setEmailErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setEmailErrorVisible(false);
|
||||
setEmailError("");
|
||||
}, 3000);
|
||||
setEmailErrorVisible(false)
|
||||
setEmailError("")
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const changePassword = async () => {
|
||||
try {
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordError("Passwords do not match");
|
||||
setPasswordErrorVisible(true);
|
||||
setPasswordError("Passwords do not match")
|
||||
setPasswordErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setPasswordErrorVisible(false);
|
||||
setPasswordError("");
|
||||
}, 3000);
|
||||
return;
|
||||
setPasswordErrorVisible(false)
|
||||
setPasswordError("")
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
if (!oldPassword || !password || !confirmPassword) {
|
||||
setPasswordError("All fields are required");
|
||||
setPasswordErrorVisible(true);
|
||||
setPasswordError("All fields are required")
|
||||
setPasswordErrorVisible(true)
|
||||
setTimeout(() => {
|
||||
setPasswordErrorVisible(false);
|
||||
setPasswordError("");
|
||||
}, 3000);
|
||||
return;
|
||||
setPasswordErrorVisible(false)
|
||||
setPasswordError("")
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/auth/edit_password', {
|
||||
const response = await axios.post("/api/auth/edit_password", {
|
||||
oldPassword: oldPassword,
|
||||
newPassword: password,
|
||||
jwtToken: Cookies.get('token')
|
||||
});
|
||||
jwtToken: Cookies.get("token"),
|
||||
})
|
||||
|
||||
if (response.status === 200) {
|
||||
setPasswordSuccess(true);
|
||||
setPassword("");
|
||||
setOldPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordSuccess(true)
|
||||
setPassword("")
|
||||
setOldPassword("")
|
||||
setConfirmPassword("")
|
||||
setTimeout(() => {
|
||||
setPasswordSuccess(false);
|
||||
}, 3000);
|
||||
setPasswordSuccess(false)
|
||||
}, 3000)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setPasswordErrorVisible(true);
|
||||
setPasswordError(error.response.data.error);
|
||||
setPasswordErrorVisible(true)
|
||||
setPasswordError(error.response.data.error)
|
||||
setTimeout(() => {
|
||||
setPasswordErrorVisible(false);
|
||||
setPasswordError("");
|
||||
}, 3000);
|
||||
setPasswordErrorVisible(false)
|
||||
setPasswordError("")
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const addNotification = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/add", {
|
||||
type: notificationType,
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpSecure: smtpSecure,
|
||||
smtpUsername: smtpUsername,
|
||||
smtpPassword: smtpPassword,
|
||||
smtpFrom: smtpFrom,
|
||||
smtpTo: smtpTo,
|
||||
telegramToken: telegramToken,
|
||||
telegramChatId: telegramChatId,
|
||||
discordWebhook: discordWebhook,
|
||||
gotifyUrl: gotifyUrl,
|
||||
gotifyToken: gotifyToken,
|
||||
ntfyUrl: ntfyUrl,
|
||||
ntfyToken: ntfyToken,
|
||||
})
|
||||
getNotifications()
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNotification = async (id: number) => {
|
||||
try {
|
||||
const response = await axios.post("/api/notifications/delete", {
|
||||
id: id,
|
||||
})
|
||||
if (response.status === 200) {
|
||||
getNotifications()
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const getNotifications = async () => {
|
||||
try {
|
||||
const response = await axios.post<NotificationsResponse>("/api/notifications/get", {})
|
||||
if (response.status === 200 && response.data) {
|
||||
setNotifications(response.data.notifications)
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getNotifications()
|
||||
}, [])
|
||||
|
||||
const getNotificationText = async () => {
|
||||
try {
|
||||
const response = await axios.post<NotificationResponse>("/api/settings/get_notification_text", {})
|
||||
if (response.status === 200) {
|
||||
if (response.data.notification_text) {
|
||||
setNotificationText(response.data.notification_text)
|
||||
} else {
|
||||
setNotificationText("The application !name (!url) is now !status.")
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
const editNotificationText = async () => {
|
||||
try {
|
||||
const response = await axios.post("/api/settings/notification_text", {
|
||||
text: notificationText,
|
||||
})
|
||||
} catch (error: any) {
|
||||
alert(error.response.data.error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationText()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
@@ -289,6 +398,328 @@ export default function Settings() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
|
||||
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Notifications</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="text-sm text-muted-foreground mb-6">
|
||||
Set up notifications to get instantly alerted when an application changes status.
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full h-11 flex items-center gap-2">
|
||||
Add Notification Channel
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>Add Notification</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<Select value={notificationType} onValueChange={(value: string) => setNotificationType(value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Notification Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">SMTP</SelectItem>
|
||||
<SelectItem value="telegram">Telegram</SelectItem>
|
||||
<SelectItem value="discord">Discord</SelectItem>
|
||||
<SelectItem value="gotify">Gotify</SelectItem>
|
||||
<SelectItem value="ntfy">Ntfy</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
{notificationType === "smtp" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtpHost">SMTP Host</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="smtpHost"
|
||||
placeholder="smtp.example.com"
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtpPort">SMTP Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="smtpPort"
|
||||
placeholder="587"
|
||||
onChange={(e) => setSmtpPort(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2 pb-4">
|
||||
<Checkbox id="smtpSecure" onCheckedChange={(checked: any) => setSmtpSecure(checked)} />
|
||||
<Label htmlFor="smtpSecure" className="text-sm font-medium leading-none">
|
||||
Secure Connection (TLS/SSL)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtpUser">SMTP Username</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="smtpUser"
|
||||
placeholder="user@example.com"
|
||||
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtpPass">SMTP Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
id="smtpPass"
|
||||
placeholder="••••••••"
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtpFrom">From Address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="smtpFrom"
|
||||
placeholder="noreply@example.com"
|
||||
onChange={(e) => setSmtpFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtpTo">To Address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
id="smtpTo"
|
||||
placeholder="admin@example.com"
|
||||
onChange={(e) => setSmtpTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "telegram" && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="telegramToken">Bot Token</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="telegramToken"
|
||||
placeholder=""
|
||||
onChange={(e) => setTelegramToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="telegramChatId">Chat ID</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="telegramChatId"
|
||||
placeholder=""
|
||||
onChange={(e) => setTelegramChatId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "discord" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="discordWebhook">Webhook URL</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="discordWebhook"
|
||||
placeholder=""
|
||||
onChange={(e) => setDiscordWebhook(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "gotify" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="gotifyUrl">Gotify URL</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="gotifyUrl"
|
||||
placeholder=""
|
||||
onChange={(e) => setGotifyUrl(e.target.value)}
|
||||
/>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="gotifyToken">Gotify Token</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="gotifyToken"
|
||||
placeholder=""
|
||||
onChange={(e) => setGotifyToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationType === "ntfy" && (
|
||||
<div className="mt-4">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="ntfyUrl">Ntfy URL</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="ntfyUrl"
|
||||
placeholder=""
|
||||
onChange={(e) => setNtfyUrl(e.target.value)}
|
||||
/>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="ntfyToken">Ntfy Token</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="ntfyToken"
|
||||
placeholder=""
|
||||
onChange={(e) => setNtfyToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Select>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={addNotification}>Add</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="w-full h-11" variant="outline">
|
||||
Customize Notification Text
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogTitle>Customize Notification Text</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="text">Notification Text</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
placeholder="Type here..."
|
||||
value={notificationText}
|
||||
onChange={(e) => setNotificationText(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-4 text-sm text-muted-foreground">
|
||||
You can use the following placeholders in the text:
|
||||
<ul className="list-disc list-inside space-y-1 pt-2">
|
||||
<li>
|
||||
<strong>!name</strong> - Application name
|
||||
</li>
|
||||
<li>
|
||||
<strong>!url</strong> - Application URL
|
||||
</li>
|
||||
<li>
|
||||
<strong>!status</strong> - Application status (online/offline)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={editNotificationText}>Save</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-4">Active Notification Channels</h3>
|
||||
<div className="space-y-3">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card transition-all hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{notification.type === "smtp" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<AtSign className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "telegram" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Send className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "discord" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "gotify" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{notification.type === "ntfy" && (
|
||||
<div className="bg-muted/20 p-2 rounded-full">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium capitalize">{notification.type}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{notification.type === "smtp" && "Email notifications"}
|
||||
{notification.type === "telegram" && "Telegram bot alerts"}
|
||||
{notification.type === "discord" && "Discord webhook alerts"}
|
||||
{notification.type === "gotify" && "Gotify notifications"}
|
||||
{notification.type === "ntfy" && "Ntfy notifications"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:bg-muted/20"
|
||||
onClick={() => deleteNotification(notification.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 border rounded-lg bg-muted/5">
|
||||
<div className="flex justify-center mb-3">
|
||||
<div className="bg-muted/20 p-3 rounded-full">
|
||||
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-1">No notifications configured</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Add a notification channel to get alerted when your applications change status.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import * as React from "react"
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { AppWindow, Settings, LayoutDashboardIcon, Briefcase, Server, Network, Activity } from "lucide-react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
AppWindow,
|
||||
Settings,
|
||||
LayoutDashboardIcon,
|
||||
Briefcase,
|
||||
Server,
|
||||
Network,
|
||||
Activity,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
@@ -13,12 +28,15 @@ import {
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarFooter,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
import Cookies from "js-cookie"
|
||||
import { useRouter } from "next/navigation"
|
||||
import packageJson from "@/package.json"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
@@ -33,7 +51,7 @@ const data: { navMain: NavItem[] } = {
|
||||
{
|
||||
title: "Dashboard",
|
||||
icon: LayoutDashboardIcon,
|
||||
url: "/dashboard"
|
||||
url: "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "My Infrastructure",
|
||||
@@ -72,23 +90,38 @@ const data: { navMain: NavItem[] } = {
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const logout = async () => {
|
||||
Cookies.remove('token')
|
||||
Cookies.remove("token")
|
||||
router.push("/")
|
||||
}
|
||||
|
||||
// Check if a path is active (exact match or starts with path for parent items)
|
||||
const isActive = (path: string) => {
|
||||
if (path === "#") return false
|
||||
return pathname === path || (path !== "/dashboard" && pathname?.startsWith(path))
|
||||
}
|
||||
|
||||
// Check if any child item is active
|
||||
const hasActiveChild = (items?: NavItem[]) => {
|
||||
if (!items) return false
|
||||
return items.some((item) => isActive(item.url))
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarHeader className="border-b border-sidebar-border/30 pb-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<a href="https://github.com/crocofied/corecontrol">
|
||||
<Image src="/logo.png" width={48} height={48} alt="Logo"/>
|
||||
<SidebarMenuButton size="lg" asChild className="gap-3">
|
||||
<a href="https://github.com/crocofied/corecontrol" className="transition-all hover:opacity-80">
|
||||
<div className="flex items-center justify-center rounded-lg overflow-hidden bg-gradient-to-br from-teal-500 to-emerald-600 shadow-sm">
|
||||
<Image src="/logo.png" width={48} height={48} alt="CoreControl Logo" className="object-cover" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold">CoreControl</span>
|
||||
<span className="">v{packageJson.version}</span>
|
||||
<span className="font-semibold text-base">CoreControl</span>
|
||||
<span className="text-xs text-sidebar-foreground/70">v{packageJson.version}</span>
|
||||
</div>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
@@ -96,44 +129,77 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent className="flex flex-col h-full">
|
||||
<SidebarContent className="flex flex-col h-full py-4">
|
||||
<SidebarGroup className="flex-grow">
|
||||
<SidebarGroupLabel className="text-xs font-medium text-sidebar-foreground/60 uppercase tracking-wider px-4 mb-2">
|
||||
Main Navigation
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{data.navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href={item.url} className="font-medium">
|
||||
{item.icon && <item.icon className="mr-2" />}
|
||||
{item.title}
|
||||
</Link>
|
||||
{data.navMain.map((item) =>
|
||||
item.items?.length ? (
|
||||
<Collapsible key={item.title} defaultOpen={hasActiveChild(item.items)} className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className={cn(
|
||||
"font-medium transition-all",
|
||||
(hasActiveChild(item.items) || isActive(item.url)) &&
|
||||
"text-sidebar-accent-foreground bg-sidebar-accent/50",
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
{item.items?.length && (
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={subItem.isActive ?? false}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
{subItem.icon && <subItem.icon className="mr-2" />}
|
||||
{subItem.title}
|
||||
<SidebarMenuSubButton asChild isActive={isActive(subItem.url)} className="transition-all">
|
||||
<Link href={subItem.url} className="flex items-center">
|
||||
{subItem.icon && <subItem.icon className="h-3.5 w-3.5 mr-2" />}
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className={cn(
|
||||
"font-medium transition-all",
|
||||
isActive(item.url) && "text-sidebar-accent-foreground bg-sidebar-accent/50",
|
||||
)}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <item.icon className="h-4 w-4" />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
),
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<div className="p-4">
|
||||
<Button variant="destructive" className="w-full" onClick={logout}>
|
||||
<SidebarFooter className="border-t border-sidebar-border/30 pt-4 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10 border-none shadow-none"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
|
||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal 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 }
|
||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal 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 }
|
||||
33
components/ui/collapsible.tsx
Normal file
33
components/ui/collapsible.tsx
Normal 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 }
|
||||
31
components/ui/progress.tsx
Normal file
31
components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal 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 }
|
||||
@@ -14,6 +14,9 @@ services:
|
||||
image: haedlessdev/corecontrol-agent:latest
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
@@ -24,6 +27,11 @@ services:
|
||||
POSTGRES_DB: postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 2s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
116
package-lock.json
generated
116
package-lock.json
generated
@@ -1,20 +1,24 @@
|
||||
{
|
||||
"name": "corecontrol",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "corecontrol",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.7",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.6.0",
|
||||
"@prisma/extension-accelerate": "^1.3.0",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-progress": "^1.1.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
@@ -1240,6 +1244,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-checkbox": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.5.tgz",
|
||||
"integrity": "sha512-B0gYIVxl77KYDR25AY9EGe/G//ef85RVBIxQvK+m5pxAC7XihAc/8leMHhDvjvhDu02SBSb6BuytlWr/G7F3+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-presence": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.0.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.4.tgz",
|
||||
@@ -1657,6 +1691,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz",
|
||||
"integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
|
||||
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz",
|
||||
@@ -1688,6 +1769,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.4.tgz",
|
||||
"integrity": "sha512-G9rdWTQjOR4sk76HwSdROhPU0jZWpfozn9skU1v4N0/g9k7TmswrJn8W8WMU+aYktnLLpk5LX6fofj2bGe5NFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.0.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.7.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "corecontrol",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -13,9 +13,13 @@
|
||||
"@prisma/extension-accelerate": "^1.3.0",
|
||||
"@radix-ui/react-accordion": "^1.2.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-checkbox": "^1.1.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-label": "^2.1.3",
|
||||
"@radix-ui/react-progress": "^1.1.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.3",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "settings" ADD COLUMN "notification_text" TEXT;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "server" ADD COLUMN "host" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "hostServer" TEXT;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "notification" ADD COLUMN "gotifyToken" TEXT,
|
||||
ADD COLUMN "gotifyUrl" TEXT,
|
||||
ADD COLUMN "ntfyToken" TEXT,
|
||||
ADD COLUMN "ntfyUrl" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "server" ADD COLUMN "icon" TEXT;
|
||||
@@ -34,7 +34,10 @@ model uptime_history {
|
||||
|
||||
model server {
|
||||
id Int @id @default(autoincrement())
|
||||
host Boolean @default(false)
|
||||
hostServer Int?
|
||||
name String
|
||||
icon String?
|
||||
os String?
|
||||
ip String?
|
||||
url String?
|
||||
@@ -47,6 +50,7 @@ model server {
|
||||
model settings {
|
||||
id Int @id @default(autoincrement())
|
||||
uptime_checks Boolean @default(true)
|
||||
notification_text String?
|
||||
}
|
||||
|
||||
model user {
|
||||
@@ -54,3 +58,23 @@ model user {
|
||||
email String @unique
|
||||
password String
|
||||
}
|
||||
|
||||
model notification {
|
||||
id Int @id @default(autoincrement())
|
||||
enabled Boolean @default(true)
|
||||
type String
|
||||
smtpHost String?
|
||||
smtpPort Int?
|
||||
smtpFrom String?
|
||||
smtpUser String?
|
||||
smtpPass String?
|
||||
smtpSecure Boolean?
|
||||
smtpTo String?
|
||||
telegramChatId String?
|
||||
telegramToken String?
|
||||
discordWebhook String?
|
||||
gotifyUrl String?
|
||||
gotifyToken String?
|
||||
ntfyUrl String?
|
||||
ntfyToken String?
|
||||
}
|
||||
Reference in New Issue
Block a user