107 Commits

Author SHA1 Message Date
headlessdev
b70d17d844 v0.0.6 notifications->main
v0.0.6
2025-04-18 16:54:16 +02:00
headlessdev
35fae815cf Merge branch 'main' into notifications 2025-04-18 16:54:00 +02:00
headlessdev
8c087792bf DB Healthcheck 2025-04-18 16:52:49 +02:00
headlessdev
8627c54be9 Hotfix 2025-04-18 16:52:14 +02:00
headlessdev
41647a2e6c Updated Application Images 2025-04-18 16:04:41 +02:00
headlessdev
a73a98ddac small ui improvement 2025-04-18 16:01:03 +02:00
headlessdev
3e49932382 Enhance URL validation and error handling in checkAndUpdateStatus function 2025-04-18 15:55:57 +02:00
headlessdev
406091fdcb Basic VM functionality 2025-04-18 14:35:36 +02:00
headlessdev
c266296c4f Add host and hostServer fields to AddRequest and EditRequest interfaces 2025-04-18 12:26:13 +02:00
headlessdev
01470f5ce1 Change hostServer column type from String to Int in server model and migration 2025-04-18 12:11:12 +02:00
headlessdev
82cee64860 Add host and hostServer columns to server model and migration 2025-04-18 11:33:20 +02:00
headlessdev
60dd711856 Fix uptime check crash error 2025-04-18 11:29:14 +02:00
headlessdev
bcbc17d3fe Hotfix 2025-04-18 11:24:42 +02:00
headlessdev
547212cd9e Sidebar Design Update 2025-04-18 00:07:21 +02:00
headlessdev
dec15c0ce0 Mark Notifications as completed in the roadmap 2025-04-17 19:03:05 +02:00
headlessdev
e96ee56aa1 Add dynamic notification message template retrieval 2025-04-17 17:36:58 +02:00
headlessdev
6e1f4eeddd Remove unused Accordion imports from Settings component 2025-04-17 17:27:44 +02:00
headlessdev
70010ce8ee Set Notification Text Functionality 2025-04-17 17:27:01 +02:00
headlessdev
c1b62d8108 Implement endpoint to retrieve notification text from settings 2025-04-17 17:17:14 +02:00
headlessdev
c690a1cb37 Add customizable notification text feature in settings 2025-04-17 17:15:39 +02:00
headlessdev
88d99cee43 Add endpoint for managing notification text in settings 2025-04-17 17:03:06 +02:00
headlessdev
b0c7b813e6 Bump version to 0.0.6 in package.json 2025-04-17 16:51:27 +02:00
headlessdev
88f7f6a9d1 Add notification_text column to settings model in Prisma schema and migration 2025-04-17 16:51:15 +02:00
headlessdev
b9fac8ddb6 Enhance Application struct and update getApplications to include Name; improve HTTP request handling in checkAndUpdateStatus 2025-04-17 16:20:24 +02:00
headlessdev
dacde7153f Update Notifications List after adding Notification 2025-04-17 16:14:52 +02:00
headlessdev
8f647d3489 Implement notification reload mechanism and improve thread safety with mutex 2025-04-17 16:13:29 +02:00
headlessdev
e925f37b19 Notification Agent System 2025-04-17 16:12:10 +02:00
headlessdev
155a0af883 Type Error Fix getNotifications 2025-04-17 15:39:21 +02:00
headlessdev
6fd360b594 Change HTTP request method from HEAD to GET in checkAndUpdateStatus function 2025-04-17 15:34:45 +02:00
headlessdev
e9aba02d5f Add Notification SMTP Layout Fix 2025-04-17 15:29:43 +02:00
headlessdev
4a8759f627 Notifications Display & Delete Notifications 2025-04-17 15:25:43 +02:00
headlessdev
d00ec93133 Fix type annotation for smtpSecure checkbox onChange handler 2025-04-17 15:17:03 +02:00
headlessdev
e7e873c75c Implement endpoint to retrieve notifications 2025-04-17 15:16:38 +02:00
headlessdev
631c5b0c3b Add Notifications System 2025-04-17 15:14:27 +02:00
headlessdev
f024b0166f v0.0.5 uptime_fix->main
v0.0.5
2025-04-17 14:53:40 +02:00
headlessdev
d6889a27b5 Fix: Update online status check to account for HTTP 405 response 2025-04-17 14:41:50 +02:00
headlessdev
edbc72a7c9 Enhance application status check logging and error handling 2025-04-17 14:18:50 +02:00
headlessdev
a51f8c2a3c Add @next/swc-win32-x64-msvc package to package-lock.json 2025-04-17 14:17:51 +02:00
headlessdev
346b79ca22 Implement notification creation and deletion endpoints 2025-04-17 14:13:21 +02:00
headlessdev
2fd8e50f7f Add notification settings with SMTP, Telegram, and Discord options in Settings component 2025-04-17 14:02:54 +02:00
headlessdev
cecc5e0bab Add notification settings with alert dialog in Settings component 2025-04-17 13:17:22 +02:00
headlessdev
2325f9b042 Version to 0.0.5 2025-04-17 13:08:20 +02:00
headlessdev
4b29f7cbed Prisma notification model 2025-04-17 13:07:19 +02:00
headlessdev
7e82b42b29 HOTFIX: Agent context deadline fix 2025-04-17 11:36:35 +02:00
headlessdev
f5c835b5d9 Hotfix Docker file 'bcrypt' error 2025-04-16 14:55:09 +02:00
headlessdev
676354f53c v0.0.4 dev->main
v0.0.4
2025-04-16 14:17:20 +02:00
headlessdev
288bb4cc9e GITBOOK-1: No subject 2025-04-16 12:09:35 +00:00
headlessdev
7b86de86e3 GitBook: No commit message 2025-04-16 11:58:29 +00:00
headlessdev
3e43c01e5a Images fixes 2025-04-15 21:18:39 +02:00
headlessdev
43c09e7a09 Readme.md Fix 2025-04-15 21:17:08 +02:00
headlessdev
bdce02ac2b Update login section header style in README 2025-04-15 21:16:37 +02:00
headlessdev
eee74c9df9 Update Uptime Page screenshot 2025-04-15 16:34:47 +02:00
headlessdev
f21dae5646 Remove application count display from Uptime component 2025-04-15 16:33:45 +02:00
headlessdev
c8a21797c9 Uptime Pagination Fix 2025-04-15 16:33:12 +02:00
headlessdev
32d99a09a6 Update screenshots in README for improved visual representation 2025-04-15 16:29:28 +02:00
headlessdev
7660f8bb5c Enhance header styling and increase font size for better visibility across multiple dashboard components 2025-04-15 16:25:01 +02:00
headlessdev
8ff112e86e Update padding in CardContent for User and Theme Settings sections 2025-04-15 16:19:18 +02:00
headlessdev
7eafdef288 Settings Page Style Update 2025-04-15 16:18:35 +02:00
headlessdev
724a634985 Login page Styling Update 2025-04-15 16:14:10 +02:00
headlessdev
1e8682646d Dashboard Styling Update 2025-04-15 16:11:31 +02:00
headlessdev
39ba85dcf4 Clarify JWT_SECRET placeholder in configuration files 2025-04-15 16:04:06 +02:00
headlessdev
a055d72246 Fix formatting of default login credentials in README.md 2025-04-15 15:11:55 +02:00
headlessdev
aef0d6f812 Add default login credentials to README.md 2025-04-15 15:11:03 +02:00
headlessdev
b6e17e5d39 Updated Screenshots in Readme.md 2025-04-15 15:08:42 +02:00
headlessdev
879ce1e6f1 Language issue 2025-04-15 15:05:01 +02:00
headlessdev
c02b057f8e Readme.md Update 2025-04-15 15:04:07 +02:00
headlessdev
b0d7271d34 Enhance login page card width for improved responsiveness 2025-04-15 15:01:11 +02:00
headlessdev
6ca1b2ac97 Refactor login page header for improved layout 2025-04-15 14:59:23 +02:00
headlessdev
8059b0e541 Read.md Update & Cover Image 2025-04-15 14:51:57 +02:00
headlessdev
9c9b556124 Dashboard Update 2025-04-15 14:49:07 +02:00
headlessdev
871ab74576 Update online status calculation to check if failure rate is below 50% 2025-04-15 14:30:29 +02:00
headlessdev
a3b47bd314 responsive Uptime Page 2025-04-15 14:21:51 +02:00
headlessdev
1088806921 Delete Uptime History from deleted Applications 2025-04-15 14:10:16 +02:00
headlessdev
a320c04b92 Update Uptime Pagination 2025-04-15 14:09:36 +02:00
headlessdev
e34407539a Remove comment 2025-04-15 13:59:02 +02:00
headlessdev
f340543625 Uptime Pagination 2025-04-15 13:58:53 +02:00
headlessdev
4aaefe4c55 Remove unnecessary Code comments 2025-04-15 13:53:32 +02:00
headlessdev
12abe9c0d7 Uptime.tsx Type Error Fix 2025-04-15 13:52:46 +02:00
headlessdev
2f6957a45d Uptime functionality 2025-04-15 13:46:25 +02:00
headlessdev
ed46598c27 Uptime History Page 2025-04-15 12:32:49 +02:00
headlessdev
1a80c61c34 Uptime Page 2025-04-15 12:22:15 +02:00
headlessdev
52f3c0432f Uptime SIdebar Fix 2025-04-15 12:20:03 +02:00
headlessdev
faad5198a6 Activity Tab Sidebar 2025-04-15 12:17:14 +02:00
headlessdev
3f1f7b730e uptime_history for agent 2025-04-15 11:37:30 +02:00
headlessdev
19ef051e1e Docker Compose Update 2025-04-14 23:36:33 +02:00
headlessdev
75d1bd59f4 Readme.md Roadmap Update 2025-04-14 22:31:41 +02:00
headlessdev
7da6501ca7 Uptime History DB Model 2025-04-14 22:16:22 +02:00
headlessdev
d779355c4c Prevent deletions of servers with associated applications 2025-04-14 21:54:25 +02:00
headlessdev
e844712c29 Network Chart Styling Update 2025-04-14 21:48:09 +02:00
headlessdev
36beeb8a2c Pagination Button Improvements 2025-04-14 21:36:15 +02:00
headlessdev
e51600016b Pagination padding bottom 2025-04-14 21:28:09 +02:00
headlessdev
8fc4fea687 Servers ITEMS_PER_PAGE 2025-04-14 21:26:12 +02:00
headlessdev
0a8ea98dae Application Description Improvement 2025-04-14 21:12:12 +02:00
headlessdev
7c86483d48 Generate icon url tooltip 2025-04-14 21:10:21 +02:00
headlessdev
ca31a0b6b3 Login Page Update 2025-04-14 20:59:59 +02:00
headlessdev
6661b1e711 Change Email & password Function 2025-04-14 20:59:41 +02:00
headlessdev
5413dbf948 Edit Password API Route 2025-04-14 20:20:42 +02:00
headlessdev
49fec67996 Edit Email API Route 2025-04-14 20:15:44 +02:00
headlessdev
a1d9839bcc Settings accordion 2025-04-14 20:02:28 +02:00
headlessdev
246f6b594c DB managed user 2025-04-14 19:44:16 +02:00
headlessdev
7549d8c8c0 Readme.md: selfh.st shout-out 2025-04-14 18:54:31 +02:00
headlessdev
fdb8b5073f Version Update 2025-04-14 18:53:34 +02:00
headlessdev
07e7a60163 Agent .dockerignore Update 2025-04-14 18:51:47 +02:00
headlessdev
460c9103f7 v0.0.3 dev->main
v0.0.3
2025-04-14 17:21:20 +02:00
headlessdev
14574e370c Version Update 2025-04-14 17:20:25 +02:00
headlessdev
b915e0066d Hotfix 2025-04-14 17:19:13 +02:00
headlessdev
489c73ef77 Readme.md Buymeacoffee widget 2025-04-14 16:06:56 +02:00
52 changed files with 4248 additions and 583 deletions

View File

@@ -4,32 +4,44 @@ FROM node:20-alpine AS builder
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
# Production Stage
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV production
# Copy package files
COPY package.json package-lock.json* ./
RUN npm install --production
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
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js* ./
EXPOSE 3000
CMD ["npm", "start"]
# Run migrations and start
CMD ["sh", "-c", "npx prisma migrate deploy && npm start"]

View File

@@ -6,6 +6,8 @@
The only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance.
<a href="https://buymeacoffee.com/corecontrol" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>
## Features
- Dashboard: A clear screen with all the important information about your servers (WIP)
@@ -15,25 +17,33 @@ The only dashboard you'll ever need to manage your entire server infrastructure.
## Screenshots
Login Page:
![Login Page](https://i.ibb.co/QvvJvHxY/image.png)
![Login Page](https://i.ibb.co/DfS7BJdX/image.png)
Dashboard Page:
![Dashboard Page](https://i.ibb.co/G3FW5mVX/image.png)
![Dashboard Page](https://i.ibb.co/wFMb7StT/image.png)
Servers Page:
![Servers Page](https://i.ibb.co/v6Z79wJY/image.png)
![Servers Page](https://i.ibb.co/HLMD9HPZ/image.png)
VM Display:
![VM Display](https://i.ibb.co/My45mv8k/image.png)
Applications Page:
![Applications Page](https://i.ibb.co/zC1f6s9/image.png)
![Applications Page](https://i.ibb.co/qMwrKwn3/image.png)
Uptime Page:
![Uptime Page](https://i.ibb.co/jvGcL9Y6/image.png)
Network Page:
![Network Page](https://i.ibb.co/XkKYrGQX/image.png)
![Network Page](https://i.ibb.co/qYcL2Fws/image.png)
Settings Page:
![Settings Page](https://i.ibb.co/rRQB9Hcz/image.png)
## Roadmap
- [ ] Edit Applications, Applications searchbar
- [ ] Customizable Dashboard
- [ ] Notifications
- [ ] Uptime History
- [X] Edit Applications, Applications searchbar
- [X] Uptime History
- [X] Notifications
- [ ] Simple Server Monitoring
- [ ] Improved Network Flowchart with custom elements (like Network switches)
- [ ] Advanced Settings (Disable Uptime Tracking & more)
@@ -48,10 +58,7 @@ services:
ports:
- "3000:3000"
environment:
LOGIN_EMAIL: "mail@example.com"
LOGIN_PASSWORD: "SecretPassword"
JWT_SECRET: RANDOM_SECRET
ACCOUNT_SECRET: RANDOM_SECRET
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
@@ -76,6 +83,10 @@ volumes:
postgres_data:
```
#### Default Login
__E-Mail:__ admin@example.com\
__Password:__ admin
## Tech Stack & Credits
The application is build with:
@@ -85,6 +96,7 @@ The application is build with:
- PostgreSQL with [Prisma ORM](https://www.prisma.io/)
- Icons by [Lucide](https://lucide.dev/)
- Flowcharts by [React Flow](https://reactflow.dev/)
- Application icons by [selfh.st/icons](selfh.st/icons)
- and a lot of love ❤️
## Star History

View File

@@ -1 +1 @@
.env.local
.env

View File

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

View File

@@ -171,9 +171,13 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,22 +2,51 @@ package main
import (
"context"
"crypto/x509"
"database/sql"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
_ "github.com/jackc/pgx/v4/stdlib"
"github.com/joho/godotenv"
"gopkg.in/gomail.v2"
)
type Application struct {
ID int
Name string
PublicURL string
Online bool
}
type Notification struct {
ID int
Enabled bool
Type string
SMTPHost sql.NullString
SMTPPort sql.NullInt64
SMTPFrom sql.NullString
SMTPUser sql.NullString
SMTPPass sql.NullString
SMTPSecure sql.NullBool
SMTPTo sql.NullString
TelegramChatID sql.NullString
TelegramToken sql.NullString
DiscordWebhook sql.NullString
}
var (
notifications []Notification
notifMutex sync.RWMutex
)
func main() {
if err := godotenv.Load(); err != nil {
fmt.Println("No env vars found")
@@ -34,25 +63,121 @@ func main() {
}
defer db.Close()
ticker := time.NewTicker(5 * time.Second)
// 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() {
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 {
if err := deleteOldEntries(db); err != nil {
fmt.Printf("Error deleting old entries: %v\n", err)
}
}
}()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
client := &http.Client{
Timeout: 4 * time.Second,
}
for range ticker.C {
for now := range ticker.C {
if now.Second()%10 != 0 {
continue
}
apps := getApplications(db)
checkAndUpdateStatus(db, client, apps)
}
}
// helper to safely copy slice
func notifMutexCopy(src []Notification) []Notification {
copyDst := make([]Notification, len(src))
copy(copyDst, src)
return copyDst
}
func isIPAddress(host string) bool {
ip := net.ParseIP(host)
return ip != nil
}
func loadNotifications(db *sql.DB) ([]Notification, error) {
rows, err := db.Query(
`SELECT id, enabled, type, "smtpHost", "smtpPort", "smtpFrom", "smtpUser", "smtpPass", "smtpSecure", "smtpTo",
"telegramChatId", "telegramToken", "discordWebhook"
FROM notification
WHERE enabled = true`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var configs []Notification
for rows.Next() {
var n Notification
if err := rows.Scan(
&n.ID, &n.Enabled, &n.Type,
&n.SMTPHost, &n.SMTPPort, &n.SMTPFrom, &n.SMTPUser, &n.SMTPPass, &n.SMTPSecure, &n.SMTPTo,
&n.TelegramChatID, &n.TelegramToken, &n.DiscordWebhook,
); err != nil {
fmt.Printf("Error scanning notification: %v\n", err)
continue
}
configs = append(configs, n)
}
return configs, nil
}
func deleteOldEntries(db *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
res, err := db.ExecContext(ctx,
`DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`,
)
if err != nil {
return err
}
affected, _ := res.RowsAffected()
fmt.Printf("Deleted %d old entries from uptime_history\n", affected)
return nil
}
func getApplications(db *sql.DB) []Application {
rows, err := db.Query(`
SELECT id, "publicURL", online
FROM application
WHERE "publicURL" IS NOT NULL
`)
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
@@ -62,8 +187,7 @@ func getApplications(db *sql.DB) []Application {
var apps []Application
for rows.Next() {
var app Application
err := rows.Scan(&app.ID, &app.PublicURL, &app.Online)
if err != nil {
if err := rows.Scan(&app.ID, &app.Name, &app.PublicURL, &app.Online); err != nil {
fmt.Printf("Error scanning row: %v\n", err)
continue
}
@@ -73,29 +197,177 @@ 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(&notificationTemplate)
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 {
isOnline = true
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)
}
_, err = db.ExecContext(ctx,
"UPDATE application SET online = $1 WHERE id = $2",
isOnline,
app.ID,
var isOnline bool
if err == nil && resp != nil {
isOnline = (resp.StatusCode >= 200 && resp.StatusCode < 300) || resp.StatusCode == 405
resp.Body.Close()
} else {
if err != nil {
fmt.Printf("%s HTTP error: %v\n", logPrefix, err)
// Sonderbehandlung für IP-Adressen + TLS-Zertifikatfehler
if hostIsIP {
var urlErr *url.Error
if errors.As(err, &urlErr) {
var certErr x509.HostnameError
var unknownAuthErr x509.UnknownAuthorityError
if errors.As(urlErr.Err, &certErr) || errors.As(urlErr.Err, &unknownAuthErr) {
fmt.Printf("%s Ignoring TLS error for IP, marking as online.\n", logPrefix)
isOnline = true
}
}
}
}
}
httpCancel()
if isOnline != app.Online {
status := "offline"
if isOnline {
status = "online"
}
message := notificationTemplate
message = strings.ReplaceAll(message, "!name", app.Name)
message = strings.ReplaceAll(message, "!url", app.PublicURL)
message = strings.ReplaceAll(message, "!status", status)
sendNotifications(message)
}
dbCtx, dbCancel := context.WithTimeout(context.Background(), 5*time.Second)
_, err = db.ExecContext(dbCtx,
`UPDATE application SET online = $1 WHERE id = $2`,
isOnline, app.ID,
)
if err != nil {
fmt.Printf("Update failed for app %d: %v\n", app.ID, err)
fmt.Printf("%s DB update failed: %v\n", logPrefix, err)
}
dbCancel()
dbCtx2, dbCancel2 := context.WithTimeout(context.Background(), 5*time.Second)
_, err = db.ExecContext(dbCtx2,
`INSERT INTO uptime_history("applicationId", online, "createdAt") VALUES ($1, $2, now())`,
app.ID, isOnline,
)
if err != nil {
fmt.Printf("%s Insert into history failed: %v\n", logPrefix, err)
}
dbCancel2()
}
}
func sendNotifications(message string) {
notifMutex.RLock()
notifs := notifMutexCopy(notifications)
notifMutex.RUnlock()
for _, n := range notifs {
switch n.Type {
case "email":
if n.SMTPHost.Valid && n.SMTPTo.Valid {
sendEmail(n, message)
}
case "telegram":
if n.TelegramToken.Valid && n.TelegramChatID.Valid {
sendTelegram(n, message)
}
case "discord":
if n.DiscordWebhook.Valid {
sendDiscord(n, message)
}
}
}
}
func sendEmail(n Notification, body string) {
// Initialize SMTP dialer with host, port, user, pass
d := gomail.NewDialer(
n.SMTPHost.String,
int(n.SMTPPort.Int64),
n.SMTPUser.String,
n.SMTPPass.String,
)
if n.SMTPSecure.Valid && n.SMTPSecure.Bool {
d.SSL = true
}
m := gomail.NewMessage()
m.SetHeader("From", n.SMTPFrom.String)
m.SetHeader("To", n.SMTPTo.String)
m.SetHeader("Subject", "Uptime Notification")
m.SetBody("text/plain", body)
if err := d.DialAndSend(m); err != nil {
fmt.Printf("Email send failed: %v\n", err)
}
}
func sendTelegram(n Notification, message string) {
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage?chat_id=%s&text=%s",
n.TelegramToken.String,
n.TelegramChatID.String,
message,
)
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Telegram send failed: %v\n", err)
return
}
resp.Body.Close()
}
func sendDiscord(n Notification, message string) {
payload := fmt.Sprintf(`{"content": "%s"}`, message)
req, err := http.NewRequest("POST", n.DiscordWebhook.String, strings.NewReader(payload))
if err != nil {
fmt.Printf("Discord request creation failed: %v\n", err)
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
fmt.Printf("Discord send failed: %v\n", err)
return
}
resp.Body.Close()
}

View File

@@ -14,6 +14,10 @@ export async function POST(request: NextRequest) {
where: { id: id }
});
await prisma.uptime_history.deleteMany({
where: { applicationId: id }
});
return NextResponse.json({ success: true });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });

View File

@@ -0,0 +1,162 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface RequestBody {
timespan?: number;
page?: number;
}
const getTimeRange = (timespan: number) => {
const now = new Date();
switch (timespan) {
case 1:
return {
start: new Date(now.getTime() - 30 * 60 * 1000),
interval: 'minute'
};
case 2:
return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
interval: '3hour'
};
case 3:
return {
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
interval: 'day'
};
default:
return {
start: new Date(now.getTime() - 30 * 60 * 1000),
interval: 'minute'
};
}
};
const generateIntervals = (timespan: number) => {
const now = new Date();
now.setSeconds(0, 0);
switch (timespan) {
case 1:
return Array.from({ length: 30 }, (_, i) => {
const d = new Date(now);
d.setMinutes(d.getMinutes() - i);
d.setSeconds(0, 0);
return d;
});
case 2:
return Array.from({ length: 56 }, (_, i) => {
const d = new Date(now);
d.setHours(d.getHours() - (i * 3));
d.setMinutes(0, 0, 0);
return d;
});
case 3:
return Array.from({ length: 30 }, (_, i) => {
const d = new Date(now);
d.setDate(d.getDate() - i);
d.setHours(0, 0, 0, 0);
return d;
});
default:
return [];
}
};
const getIntervalKey = (date: Date, timespan: number) => {
const d = new Date(date);
switch (timespan) {
case 1:
d.setSeconds(0, 0);
return d.toISOString();
case 2:
d.setHours(Math.floor(d.getHours() / 3) * 3);
d.setMinutes(0, 0, 0);
return d.toISOString();
case 3:
d.setHours(0, 0, 0, 0);
return d.toISOString();
default:
return d.toISOString();
}
};
export async function POST(request: NextRequest) {
try {
const { timespan = 1, page = 1 }: RequestBody = await request.json();
const itemsPerPage = 5;
const skip = (page - 1) * itemsPerPage;
// Get paginated and sorted applications
const [applications, totalCount] = await Promise.all([
prisma.application.findMany({
skip,
take: itemsPerPage,
orderBy: { name: 'asc' }
}),
prisma.application.count()
]);
const applicationIds = applications.map(app => app.id);
// Get time range and intervals
const { start } = getTimeRange(timespan);
const intervals = generateIntervals(timespan);
// Get uptime history for the filtered applications
const uptimeHistory = await prisma.uptime_history.findMany({
where: {
applicationId: { in: applicationIds },
createdAt: { gte: start }
},
orderBy: { createdAt: "desc" }
});
// Process data for each application
const result = applications.map(app => {
const appChecks = uptimeHistory.filter(check => check.applicationId === app.id);
const checksMap = new Map<string, { failed: number; total: number }>();
for (const check of appChecks) {
const intervalKey = getIntervalKey(check.createdAt, timespan);
const current = checksMap.get(intervalKey) || { failed: 0, total: 0 };
current.total++;
if (!check.online) current.failed++;
checksMap.set(intervalKey, current);
}
const uptimeSummary = intervals.map(interval => {
const intervalKey = getIntervalKey(interval, timespan);
const stats = checksMap.get(intervalKey);
return {
timestamp: intervalKey,
missing: !stats,
online: stats ? (stats.failed / stats.total) <= 0.5 : null
};
});
return {
appName: app.name,
appId: app.id,
uptimeSummary
};
});
return NextResponse.json({
data: result,
pagination: {
currentPage: page,
totalPages: Math.ceil(totalCount / itemsPerPage),
totalItems: totalCount
}
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,56 @@
import { NextResponse, NextRequest } from "next/server";
import jwt from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
interface EditEmailRequest {
newEmail: string;
jwtToken: string;
}
export async function POST(request: NextRequest) {
try {
const body: EditEmailRequest = await request.json();
const { newEmail, jwtToken } = body;
// Ensure JWT_SECRET is defined
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is not defined');
}
// Verify JWT
const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET) as { account_secret: string };
if (!decoded.account_secret) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
// Get the user by account id
const user = await prisma.user.findUnique({
where: { id: decoded.account_secret },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if the new email is already in use
const existingUser = await prisma.user.findUnique({
where: { email: newEmail },
});
if (existingUser) {
return NextResponse.json({ error: 'Email already in use' }, { status: 400 });
}
// Update the user's email
await prisma.user.update({
where: { id: user.id },
data: { email: newEmail },
});
return NextResponse.json({ message: 'Email updated successfully' });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,55 @@
import { NextResponse, NextRequest } from "next/server";
import jwt from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
import bcrypt from 'bcrypt';
interface EditEmailRequest {
oldPassword: string;
newPassword: string;
jwtToken: string;
}
export async function POST(request: NextRequest) {
try {
const body: EditEmailRequest = await request.json();
const { oldPassword, newPassword, jwtToken } = body;
// Ensure JWT_SECRET is defined
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is not defined');
}
// Verify JWT
const decoded = jwt.verify(jwtToken, process.env.JWT_SECRET) as { account_secret: string };
if (!decoded.account_secret) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
// Get the user by account id
const user = await prisma.user.findUnique({
where: { id: decoded.account_secret },
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Check if the old password is correct
const isOldPasswordValid = await bcrypt.compare(oldPassword, user.password);
if (!isOldPasswordValid) {
return NextResponse.json({ error: 'Old password is incorrect' }, { status: 401 });
}
// Hash the new password
const hashedNewPassword = await bcrypt.hash(newPassword, 10);
// Update the user's password
await prisma.user.update({
where: { id: user.id },
data: { password: hashedNewPassword },
});
return NextResponse.json({ message: 'Password updated successfully' });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -1,5 +1,7 @@
import { NextResponse, NextRequest } from "next/server";
import jwt from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
import bcrypt from 'bcrypt';
interface LoginRequest {
username: string;
@@ -11,17 +13,50 @@ export async function POST(request: NextRequest) {
const body: LoginRequest = await request.json();
const { username, password } = body;
if(username !== process.env.LOGIN_EMAIL || password !== process.env.LOGIN_PASSWORD) {
throw new Error('Invalid credentials');
}
// Ensure JWT_SECRET is defined
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET is not defined');
}
let accountId: string = '';
// Check if there are any entries in user
const userCount = await prisma.user.count();
if (userCount === 0) {
if(username=== "admin@example.com" && password === "admin") {
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Create the first user with hashed password
const user = await prisma.user.create({
data: {
email: username,
password: hashedPassword,
},
});
// Get the account id
accountId = user.id;
} else {
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
}
} else {
// Get the user by username
const user = await prisma.user.findUnique({
where: { email: username },
});
if (!user) {
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
}
// Check if the password is correct
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return NextResponse.json({ error: "Wrong credentials" }, { status: 401 });
}
// Get the account id
accountId = user.id;
}
// Create JWT
const token = jwt.sign({ account_secret: process.env.ACCOUNT_SECRET }, process.env.JWT_SECRET, { expiresIn: '7d' });
const token = jwt.sign({ account_secret: accountId }, process.env.JWT_SECRET, { expiresIn: '7d' });
return NextResponse.json({ token });
} catch (error: any) {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import jwt, { JwtPayload } from 'jsonwebtoken';
import { prisma } from "@/lib/prisma";
interface ValidateRequest {
token: string;
@@ -16,6 +16,14 @@ export async function POST(request: NextRequest) {
throw new Error('JWT_SECRET is not defined');
}
// Get the account id
const user = await prisma.user.findFirst({
where: {},
});
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
// Verify JWT
const decoded = jwt.verify(token, process.env.JWT_SECRET) as JwtPayload & { id: string };
@@ -23,7 +31,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
if(decoded.account_secret !== process.env.ACCOUNT_SECRET) {
if(decoded.account_secret !== user.id) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}

View File

@@ -10,6 +10,9 @@ interface Node {
};
position: { x: number; y: number };
style: React.CSSProperties;
draggable?: boolean;
selectable?: boolean;
zIndex?: number;
}
interface Edge {
@@ -38,10 +41,13 @@ interface Application {
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 = 80;
const VERTICAL_SPACING = 60;
const START_Y = 120;
const ROOT_NODE_WIDTH = 300;
const CONTAINER_PADDING = 40;
export async function GET() {
try {
@@ -54,11 +60,12 @@ export async function GET() {
}) as Promise<Application[]>,
]);
// Root Node
const rootNode: Node = {
id: "root",
type: "infrastructure",
data: { label: "My Infrastructure" },
position: { x: 0, y: 20 },
position: { x: 0, y: 0 },
style: {
background: "#ffffff",
color: "#0f0f0f",
@@ -72,6 +79,7 @@ export async function GET() {
},
};
// Server Nodes
const serverNodes: Node[] = servers.map((server, index) => {
const xPos =
index * HORIZONTAL_SPACING -
@@ -100,11 +108,12 @@ export async function GET() {
};
});
// Application Nodes
const appNodes: Node[] = [];
servers.forEach((server) => {
const serverX =
serverNodes.find((n) => n.id === `server-${server.id}`)?.position.x || 0;
const serverY = START_Y;
const serverNode = serverNodes.find((n) => n.id === `server-${server.id}`);
const serverX = serverNode?.position.x || 0;
const xOffset = (NODE_WIDTH - APP_NODE_WIDTH) / 2;
applications
.filter((app) => app.serverId === server.id)
@@ -117,25 +126,26 @@ export async function GET() {
...app,
},
position: {
x: serverX,
y: serverY + NODE_HEIGHT + 40 + appIndex * VERTICAL_SPACING,
x: serverX + xOffset,
y: START_Y + NODE_HEIGHT + 30 + appIndex * VERTICAL_SPACING,
},
style: {
background: "#ffffff",
background: "#f5f5f5",
color: "#0f0f0f",
border: "2px solid #e6e4e1",
borderRadius: "4px",
padding: "8px",
width: NODE_WIDTH,
height: NODE_HEIGHT,
fontSize: "0.9rem",
lineHeight: "1.2",
padding: "6px",
width: APP_NODE_WIDTH,
height: APP_NODE_HEIGHT,
fontSize: "0.8rem",
lineHeight: "1.1",
whiteSpace: "pre-wrap",
},
});
});
});
// Connections
const connections: Edge[] = [
...servers.map((server) => ({
id: `conn-root-${server.id}`,
@@ -159,8 +169,46 @@ export async function GET() {
})),
];
// Container Box
const allNodes = [rootNode, ...serverNodes, ...appNodes];
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
allNodes.forEach((node) => {
const width = parseInt(node.style.width?.toString() || "0", 10);
const height = parseInt(node.style.height?.toString() || "0", 10);
minX = Math.min(minX, node.position.x);
maxX = Math.max(maxX, node.position.x + width);
minY = Math.min(minY, node.position.y);
maxY = Math.max(maxY, node.position.y + height);
});
const containerNode: Node = {
id: 'container',
type: 'container',
data: { label: '' },
position: {
x: minX - CONTAINER_PADDING,
y: minY - CONTAINER_PADDING
},
style: {
width: maxX - minX + 2 * CONTAINER_PADDING,
height: maxY - minY + 2 * CONTAINER_PADDING,
background: 'transparent',
border: '2px dashed #e2e8f0',
borderRadius: '8px',
zIndex: 0,
},
draggable: false,
selectable: false,
zIndex: -1,
};
return NextResponse.json({
nodes: [rootNode, ...serverNodes, ...appNodes],
nodes: [containerNode, ...allNodes],
edges: connections,
});
} catch (error: unknown) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,14 @@ export async function POST(request: NextRequest) {
if (!id) {
return NextResponse.json({ error: "Missing ID" }, { status: 400 });
}
// Check if there are any applications associated with the server
const applications = await prisma.application.findMany({
where: { serverId: id }
});
if (applications.length > 0) {
return NextResponse.json({ error: "Cannot delete server with associated applications" }, { status: 400 });
}
await prisma.server.delete({
where: { id: id }

View File

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

View File

@@ -2,27 +2,41 @@ import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface GetRequest {
page: number;
page?: number;
ITEMS_PER_PAGE?: number;
}
const ITEMS_PER_PAGE = 5;
export async function POST(request: NextRequest) {
try {
const body: GetRequest = await request.json();
const page = Math.max(1, body.page || 1);
const servers = await prisma.server.findMany({
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
const hosts = await prisma.server.findMany({
where: { hostServer: 0 },
skip: (page - 1) * ITEMS_PER_PAGE,
take: ITEMS_PER_PAGE,
orderBy: { name: 'asc' }
});
const totalCount = await prisma.server.count();
const maxPage = Math.ceil(totalCount / ITEMS_PER_PAGE);
const hostsWithVms = await Promise.all(
hosts.map(async (host) => ({
...host,
hostedVMs: await prisma.server.findMany({
where: { hostServer: host.id },
orderBy: { name: 'asc' }
})
}))
);
const totalHosts = await prisma.server.count({
where: { hostServer: null }
});
const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
return NextResponse.json({
servers,
servers: hostsWithVms,
maxPage
});
} catch (error: any) {

View File

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

View File

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

View File

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

View File

@@ -1,62 +1,61 @@
import { AppSidebar } from "@/components/app-sidebar";
"use client"
import { useEffect, useState } from "react"
import axios from "axios"
import Link from "next/link"
import { Activity, Layers, Network, Server } from "lucide-react"
import { AppSidebar } from "@/components/app-sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useEffect, useState } from "react";
import axios from "axios"; // Korrekter Import
import { Card, CardHeader } from "@/components/ui/card";
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface StatsResponse {
serverCount: number;
applicationCount: number;
onlineApplicationsCount: number;
serverCount: number
applicationCount: number
onlineApplicationsCount: number
}
export default function Dashboard() {
const [serverCount, setServerCount] = useState<number>(0);
const [applicationCount, setApplicationCount] = useState<number>(0);
const [onlineApplicationsCount, setOnlineApplicationsCount] = useState<number>(0);
const [serverCount, setServerCount] = 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);
setApplicationCount(response.data.applicationCount);
setOnlineApplicationsCount(response.data.onlineApplicationsCount);
const response = await axios.post<StatsResponse>("/api/dashboard/get", {})
setServerCount(response.data.serverCount)
setApplicationCount(response.data.applicationCount)
setOnlineApplicationsCount(response.data.onlineApplicationsCount)
} catch (error: any) {
console.log("Axios error:", error.response?.data);
console.log("Axios error:", error.response?.data)
}
};
}
useEffect(() => {
getStats();
}, []);
getStats()
}, [])
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbPage>
/
</BreadcrumbPage>
<BreadcrumbPage>/</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
@@ -66,51 +65,105 @@ export default function Dashboard() {
</Breadcrumb>
</div>
</header>
<div className="pl-4 pr-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card className="w-full mb-4 relative">
<CardHeader>
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center">
<span className="text-2xl font-bold">{serverCount}</span>
<span className="text-md">Servers</span>
</div>
<div className="p-6">
<h1 className="text-3xl font-bold tracking-tight mb-6">Dashboard</h1>
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
<Card className="overflow-hidden border-t-4 border-t-rose-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<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>
<CardDescription>Manage your server infrastructure</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active servers</p>
</CardContent>
<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>
</Button>
</CardFooter>
</Card>
<Card className="w-full mb-4 relative">
<CardHeader>
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center">
<span className="text-2xl font-bold">{applicationCount}</span>
<span className="text-md">Applications</span>
</div>
<Card className="overflow-hidden border-t-4 border-t-amber-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Applications</CardTitle>
<Layers className="h-6 w-6 text-amber-500" />
</div>
<CardDescription>Manage your deployed applications</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{applicationCount}</div>
<p className="text-sm text-muted-foreground mt-2">Running applications</p>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/applications">View all applications</Link>
</Button>
</CardFooter>
</Card>
<Card className="w-full mb-4 relative">
<CardHeader>
<div className="flex items-center justify-center w-full">
<div className="flex flex-col items-center justify-center">
<span className="text-2xl font-bold">
<Card className="overflow-hidden border-t-4 border-t-emerald-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Uptime</CardTitle>
<Activity className="h-6 w-6 text-emerald-500" />
</div>
<CardDescription>Monitor your service availability</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="flex flex-col">
<div className="text-4xl font-bold flex items-center justify-between">
<span>
{onlineApplicationsCount}/{applicationCount}
</span>
<span className="text-md">Applications are online</span>
<div className="flex items-center bg-emerald-100 text-emerald-700 px-2 py-1 rounded-md text-lg font-semibold">
{applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5 mt-3">
<div
className="bg-emerald-500 h-2.5 rounded-full"
style={{
width: `${applicationCount > 0 ? Math.round((onlineApplicationsCount / applicationCount) * 100) : 0}%`,
}}
></div>
</div>
<p className="text-sm text-muted-foreground mt-2">Online applications</p>
</div>
</CardHeader>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/uptime">View uptime metrics</Link>
</Button>
</CardFooter>
</Card>
<Card className="overflow-hidden border-t-4 border-t-sky-500 shadow-sm transition-all hover:shadow-md">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xl font-medium">Network</CardTitle>
<Network className="h-6 w-6 text-sky-500" />
</div>
<CardDescription>Manage network configuration</CardDescription>
</CardHeader>
<CardContent className="pt-2 pb-4">
<div className="text-4xl font-bold">{serverCount + applicationCount}</div>
<p className="text-sm text-muted-foreground mt-2">Active connections</p>
</CardContent>
<CardFooter className="border-t bg-muted/20 p-4">
<Button variant="ghost" size="default" className="w-full hover:bg-background font-medium" asChild>
<Link href="/dashboard/network">View network details</Link>
</Button>
</CardFooter>
</Card>
</div>
<div className="h-72 w-full rounded-xl flex items-center justify-center bg-muted">
<span className="text-gray-400 text-2xl">COMING SOON</span>
</div>
<div className="pt-4">
<div className="h-72 w-full rounded-xl flex items-center justify-center bg-muted">
<span className="text-gray-400 text-2xl">COMING SOON</span>
</div>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
)
}

View File

@@ -67,6 +67,12 @@ import {
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import axios from "axios";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
interface Application {
id: number;
@@ -254,7 +260,7 @@ export default function Dashboard() {
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -275,9 +281,9 @@ export default function Dashboard() {
</Breadcrumb>
</div>
</header>
<div className="pl-4 pr-4">
<div className="p-6">
<div className="flex justify-between items-center">
<span className="text-2xl font-semibold">Your Applications</span>
<span className="text-3xl font-bold">Your Applications</span>
<div className="flex gap-2">
<Button
variant="outline"
@@ -358,9 +364,18 @@ export default function Dashboard() {
placeholder="https://example.com/icon.png"
onChange={(e) => setIcon(e.target.value)}
/>
<Button variant="outline" size="icon" onClick={generateIconURL}>
<Zap />
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button variant="outline" size="icon" onClick={generateIconURL}>
<Zap />
</Button>
</TooltipTrigger>
<TooltipContent>
Generate Icon URL
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="grid w-full items-center gap-1.5">
@@ -457,7 +472,9 @@ export default function Dashboard() {
</CardTitle>
<CardDescription className="text-md">
{app.description}
<br />
{app.description && (
<br className="hidden md:block" />
)}
Server: {app.server || "No server"}
</CardDescription>
</div>
@@ -664,14 +681,14 @@ export default function Dashboard() {
</div>
</div>
)}
<div className="pt-4">
<div className="pt-4 pb-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={handlePrevious}
isActive={currentPage > 1}
style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
/>
</PaginationItem>
<PaginationItem>
@@ -679,9 +696,9 @@ export default function Dashboard() {
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
onClick={handleNext}
isActive={currentPage < maxPage}
style={{ cursor: currentPage === maxPage ? 'not-allowed' : 'pointer' }}
/>
</PaginationItem>
</PaginationContent>

View File

@@ -41,7 +41,7 @@ export default function Dashboard() {
<SidebarProvider>
<AppSidebar />
<SidebarInset className="flex flex-col h-screen">
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1 dark:text-white" />
<Separator

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ import {
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Card, CardHeader } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { useTheme } from "next-themes";
import {
Select,
@@ -21,15 +21,234 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input"
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, Bell } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
interface NotificationsResponse {
notifications: any[];
}
interface NotificationResponse {
notification_text?: string;
}
export default function Settings() {
const { theme, setTheme } = useTheme();
const [email, setEmail] = useState<string>("")
const [password, setPassword] = useState<string>("")
const [confirmPassword, setConfirmPassword] = useState<string>("")
const [oldPassword, setOldPassword] = useState<string>("")
const [emailError, setEmailError] = useState<string>("")
const [passwordError, setPasswordError] = useState<string>("")
const [emailErrorVisible, setEmailErrorVisible] = useState<boolean>(false)
const [passwordErrorVisible, setPasswordErrorVisible] = useState<boolean>(false)
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 [notifications, setNotifications] = useState<any[]>([])
const [notificationText, setNotificationText] = useState<string>("")
const changeEmail = async () => {
setEmailErrorVisible(false);
setEmailSuccess(false);
setEmailError("");
if (!email) {
setEmailError("Email is required");
setEmailErrorVisible(true);
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}
, 3000);
return;
}
try {
await axios.post('/api/auth/edit_email', {
newEmail: email,
jwtToken: Cookies.get('token')
});
setEmailSuccess(true);
setEmail("");
setTimeout(() => {
setEmailSuccess(false);
}, 3000);
} catch (error: any) {
setEmailError(error.response.data.error);
setEmailErrorVisible(true);
setTimeout(() => {
setEmailErrorVisible(false);
setEmailError("");
}, 3000);
}
}
const changePassword = async () => {
try {
if (password !== confirmPassword) {
setPasswordError("Passwords do not match");
setPasswordErrorVisible(true);
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
}
if (!oldPassword || !password || !confirmPassword) {
setPasswordError("All fields are required");
setPasswordErrorVisible(true);
setTimeout(() => {
setPasswordErrorVisible(false);
setPasswordError("");
}, 3000);
return;
}
const response = await axios.post('/api/auth/edit_password', {
oldPassword: oldPassword,
newPassword: password,
jwtToken: Cookies.get('token')
});
if (response.status === 200) {
setPasswordSuccess(true);
setPassword("");
setOldPassword("");
setConfirmPassword("");
setTimeout(() => {
setPasswordSuccess(false);
}, 3000);
}
} catch (error: any) {
setPasswordErrorVisible(true);
setPasswordError(error.response.data.error);
setTimeout(() => {
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
});
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 />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
@@ -50,32 +269,349 @@ export default function Settings() {
</Breadcrumb>
</div>
</header>
<div className="pl-4 pr-4">
<span className="text-2xl font-semibold">Settings</span>
<div className="pt-4">
<Card className="w-full mb-4 relative">
<CardHeader>
<span className="text-xl font-bold">Theme</span>
<Select
value={theme}
onValueChange={(value: string) => setTheme(value)}
>
<SelectTrigger className="w-full [&_svg]:hidden">
<SelectValue>
{(theme ?? 'system').charAt(0).toUpperCase() + (theme ?? 'system').slice(1)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
<div className="p-6">
<div className="pb-4">
<span className="text-3xl font-bold">Settings</span>
</div>
<div className="grid gap-6">
<Card className="overflow-hidden border-2 border-muted/20 shadow-sm">
<CardHeader className="bg-muted/10 px-6 py-4 border-b">
<div className="flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold">User Settings</h2>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="text-sm text-muted-foreground mb-6">
Manage your user settings here. You can change your email, password, and other account settings.
</div>
<div className="grid md:grid-cols-2 gap-8">
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-semibold text-lg">Change Email</h3>
</div>
{emailErrorVisible && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{emailError}</AlertDescription>
</Alert>
)}
{emailSuccess && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-300 animate-in fade-in-50">
<Check className="h-4 w-4" />
<AlertTitle>Success</AlertTitle>
<AlertDescription>Email changed successfully.</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<Input
type="email"
placeholder="Enter new email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11"
/>
<Button onClick={changeEmail} className="w-full h-11">
Change Email
</Button>
</div>
</div>
<div className="space-y-4">
<div className="border-b pb-2">
<h3 className="font-semibold text-lg">Change Password</h3>
</div>
{passwordErrorVisible && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{passwordError}</AlertDescription>
</Alert>
)}
{passwordSuccess && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-300 animate-in fade-in-50">
<Check className="h-4 w-4" />
<AlertTitle>Success</AlertTitle>
<AlertDescription>Password changed successfully.</AlertDescription>
</Alert>
)}
<div className="space-y-3">
<Input
type="password"
placeholder="Enter old password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="h-11"
/>
<Input
type="password"
placeholder="Enter new password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-11"
/>
<Input
type="password"
placeholder="Confirm new password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-11"
/>
<Button onClick={changePassword} className="w-full h-11">
Change Password
</Button>
</div>
</div>
</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-2">
<Palette className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold">Theme Settings</h2>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="text-sm text-muted-foreground mb-6">
Select a theme for the application. You can choose between light, dark, or system theme.
</div>
<div className="max-w-md">
<Select value={theme} onValueChange={(value: string) => setTheme(value)}>
<SelectTrigger className="w-full h-11">
<SelectValue>
{(theme ?? "system").charAt(0).toUpperCase() + (theme ?? "system").slice(1)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</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-2">
<Bell className="h-5 w-5 text-primary" />
<h2 className="text-xl font-semibold">Notifications</h2>
</div>
</CardHeader>
<CardContent className="pb-6">
<div className="text-sm text-muted-foreground mb-6">
Set up Notifications to get notified when an application goes offline or online.
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full">
Add Notification
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Add Notification</AlertDialogTitle>
<AlertDialogDescription>
<Select value={notificationType} onValueChange={(value: string) => setNotificationType(value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Notification Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="smtp">SMTP</SelectItem>
<SelectItem value="telegram">Telegram</SelectItem>
<SelectItem value="discord">Discord</SelectItem>
</SelectContent>
{notificationType === "smtp" && (
<div className="mt-4 space-y-4">
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpHost">SMTP Host</Label>
<Input
type="text"
id="smtpHost"
placeholder="smtp.example.com"
onChange={(e) => setSmtpHost(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpPort">SMTP Port</Label>
<Input
type="number"
id="smtpPort"
placeholder="587"
onChange={(e) => setSmtpPort(Number(e.target.value))}
/>
</div>
</div>
<div className="flex items-center space-x-2 pt-2 pb-4">
<Checkbox
id="smtpSecure"
onCheckedChange={(checked: any) => setSmtpSecure(checked)}
/>
<Label htmlFor="smtpSecure" className="text-sm font-medium leading-none">
Secure Connection (TLS/SSL)
</Label>
</div>
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpUser">SMTP Username</Label>
<Input
type="text"
id="smtpUser"
placeholder="user@example.com"
onChange={(e) => setSmtpUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpPass">SMTP Password</Label>
<Input
type="password"
id="smtpPass"
placeholder="••••••••"
onChange={(e) => setSmtpPassword(e.target.value)}
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="smtpFrom">From Address</Label>
<Input
type="email"
id="smtpFrom"
placeholder="noreply@example.com"
onChange={(e) => setSmtpFrom(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="smtpTo">To Address</Label>
<Input
type="email"
id="smtpTo"
placeholder="admin@example.com"
onChange={(e) => setSmtpTo(e.target.value)}
/>
</div>
</div>
</div>
</div>
)}
{notificationType === "telegram" && (
<div className="mt-4 space-y-2">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="telegramToken">Bot Token</Label>
<Input type="text" id="telegramToken" placeholder="" onChange={(e) => setTelegramToken(e.target.value)} />
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="telegramChatId">Chat ID</Label>
<Input type="text" id="telegramChatId" placeholder="" onChange={(e) => setTelegramChatId(e.target.value)} />
</div>
</div>
)}
{notificationType === "discord" && (
<div className="mt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="discordWebhook">Webhook URL</Label>
<Input type="text" id="discordWebhook" placeholder="" onChange={(e) => setDiscordWebhook(e.target.value)} />
</div>
</div>
)}
</Select>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={addNotification}>
Add
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<div className="pt-4 pb-2">
<Button className="w-full" variant="secondary">
Customize Notification Text
</Button>
</div>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>Customize Notification Text</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="text">Notification Text</Label>
<Textarea id="text" placeholder="Type here..." value={notificationText} onChange={(e) => setNotificationText(e.target.value)} rows={4} />
</div>
</div>
<div className="pt-4 text-sm text-muted-foreground">
You can use the following placeholders in the text:
<ul className="list-disc list-inside space-y-1 pt-2">
<li><strong>!name</strong> - Application name</li>
<li><strong>!url</strong> - Application URL</li>
<li><strong>!status</strong> - Application status (online/offline)</li>
</ul>
</div>
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={editNotificationText}>
Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="mt-6 space-y-4">
{notifications.length > 0 ? (
notifications.map((notification) => (
<div
key={notification.id}
className="flex items-center justify-between p-4 bg-muted/10 rounded-lg border"
>
<div className="space-y-1">
<h3 className="font-medium capitalize">{notification.type}</h3>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => deleteNotification(notification.id)}
>
Delete
</Button>
</div>
))
) : (
<div className="text-center text-muted-foreground py-6">
No notifications configured
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}
)
}

View File

@@ -0,0 +1,296 @@
import { AppSidebar } from "@/components/app-sidebar";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useEffect, useState } from "react";
import axios from "axios";
import { Card, CardHeader } from "@/components/ui/card";
import * as Tooltip from "@radix-ui/react-tooltip";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
const timeFormats = {
1: (timestamp: string) =>
new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false
}),
2: (timestamp: string) => {
const start = new Date(timestamp);
const end = new Date(start.getTime() + 3 * 60 * 60 * 1000);
return `${start.toLocaleDateString([], { day: '2-digit', month: 'short' })}
${start.getHours().toString().padStart(2, '0')}:00 -
${end.getHours().toString().padStart(2, '0')}:00`;
},
3: (timestamp: string) =>
new Date(timestamp).toLocaleDateString([], {
day: '2-digit',
month: 'short'
})
};
const minBoxWidths = {
1: 24,
2: 24,
3: 24
};
interface UptimeData {
appName: string;
appId: number;
uptimeSummary: {
timestamp: string;
missing: boolean;
online: boolean | null;
}[];
}
interface PaginationData {
currentPage: number;
totalPages: number;
totalItems: number;
}
export default function Uptime() {
const [data, setData] = useState<UptimeData[]>([]);
const [timespan, setTimespan] = useState<1 | 2 | 3>(1);
const [pagination, setPagination] = useState<PaginationData>({
currentPage: 1,
totalPages: 1,
totalItems: 0
});
const [isLoading, setIsLoading] = useState(false);
const getData = async (selectedTimespan: number, page: number) => {
setIsLoading(true);
try {
const response = await axios.post<{
data: UptimeData[];
pagination: PaginationData;
}>("/api/applications/uptime", {
timespan: selectedTimespan,
page
});
setData(response.data.data);
setPagination(response.data.pagination);
} catch (error) {
console.error("Error:", error);
setData([]);
setPagination({
currentPage: 1,
totalPages: 1,
totalItems: 0
});
} finally {
setIsLoading(false);
}
};
const handlePrevious = () => {
const newPage = Math.max(1, pagination.currentPage - 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
};
const handleNext = () => {
const newPage = Math.min(pagination.totalPages, pagination.currentPage + 1);
setPagination(prev => ({...prev, currentPage: newPage}));
getData(timespan, newPage);
};
useEffect(() => {
getData(timespan, 1);
}, [timespan]);
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbPage>/</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>My Infrastructure</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Uptime</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="p-6">
<div className="flex justify-between items-center">
<span className="text-3xl font-bold">Uptime</span>
<Select
value={String(timespan)}
onValueChange={(v) => {
setTimespan(Number(v) as 1 | 2 | 3);
setPagination(prev => ({...prev, currentPage: 1}));
}}
disabled={isLoading}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select timespan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 30 minutes</SelectItem>
<SelectItem value="2">Last 7 days</SelectItem>
<SelectItem value="3">Last 30 days</SelectItem>
</SelectContent>
</Select>
</div>
<div className="pt-4 space-y-4">
{isLoading ? (
<div className="text-center py-8">Loading...</div>
) : (
data.map((app) => {
const reversedSummary = [...app.uptimeSummary].reverse();
const startTime = reversedSummary[0]?.timestamp;
const endTime = reversedSummary[reversedSummary.length - 1]?.timestamp;
return (
<Card key={app.appId}>
<CardHeader>
<div className="flex flex-col gap-4">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold">{app.appName}</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>{startTime ? timeFormats[timespan](startTime) : ""}</span>
<span>{endTime ? timeFormats[timespan](endTime) : ""}</span>
</div>
<Tooltip.Provider>
<div
className="grid gap-0.5 w-full pb-2"
style={{
gridTemplateColumns: `repeat(auto-fit, minmax(${minBoxWidths[timespan]}px, 1fr))`
}}
>
{reversedSummary.map((entry) => (
<Tooltip.Root key={entry.timestamp}>
<Tooltip.Trigger asChild>
<div
className={`h-8 w-full rounded-sm border transition-colors ${
entry.missing
? "bg-gray-300 border-gray-400"
: entry.online
? "bg-green-500 border-green-600"
: "bg-red-500 border-red-600"
}`}
/>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="rounded bg-gray-900 px-2 py-1 text-white text-xs shadow-lg"
side="top"
>
<div className="flex flex-col gap-1">
<p className="font-medium">
{timespan === 2 ? (
timeFormats[2](entry.timestamp)
) : (
new Date(entry.timestamp).toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: timespan === 3 ? undefined : '2-digit',
hour12: false
})
)}
</p>
<p>
{entry.missing
? "No data"
: entry.online
? "Online"
: "Offline"}
</p>
</div>
<Tooltip.Arrow className="fill-gray-900" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
))}
</div>
</Tooltip.Provider>
</div>
</div>
</CardHeader>
</Card>
);
})
)}
</div>
{pagination.totalItems > 0 && !isLoading && (
<div className="pt-4 pb-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={handlePrevious}
aria-disabled={pagination.currentPage === 1 || isLoading}
className={
pagination.currentPage === 1 || isLoading
? "opacity-50 cursor-not-allowed"
: "hover:cursor-pointer"
}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink isActive>{pagination.currentPage}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={handleNext}
aria-disabled={pagination.currentPage === pagination.totalPages || isLoading}
className={
pagination.currentPage === pagination.totalPages || isLoading
? "opacity-50 cursor-not-allowed"
: "hover:cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

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

View File

@@ -1,108 +1,148 @@
"use client";
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import Cookies from "js-cookie"
import { useRouter } from "next/navigation"
import axios from "axios"
import { AlertCircle, KeyRound, Mail, User } from "lucide-react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { AlertCircle } from "lucide-react"
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { useState, useEffect } from "react";
import Cookies from "js-cookie";
import { useRouter } from "next/navigation";
import axios from "axios";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
export default function Home() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const [error, setError] = useState('');
const [errorVisible, setErrorVisible] = useState(false);
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [rememberMe, setRememberMe] = useState(false)
const router = useRouter()
const [error, setError] = useState("")
const [errorVisible, setErrorVisible] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const token = Cookies.get('token');
const token = Cookies.get("token")
if (token) {
router.push('/dashboard');
router.push("/dashboard")
}
}, [router]);
}, [router])
interface LoginResponse {
token: string;
token: string
}
const login = async () => {
try {
const response = await axios.post('/api/auth/login', { username, password });
const { token } = response.data as LoginResponse;
Cookies.set('token', token);
router.push('/dashboard');
} catch (error: any) {
setError(error.response.data.error);
if (!username || !password) {
setError("Please enter both email and password")
setErrorVisible(true)
return
}
try {
setIsLoading(true)
const response = await axios.post("/api/auth/login", { username, password })
const { token } = response.data as LoginResponse
const cookieOptions = rememberMe ? { expires: 7 } : {}
Cookies.set("token", token, cookieOptions)
router.push("/dashboard")
} catch (error: any) {
setError(error.response?.data?.error || "Login failed. Please try again.")
setErrorVisible(true)
} finally {
setIsLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
login()
}
}
return (
<div className="flex flex-col min-h-screen items-center justify-center gap-6 ">
<Card className="w-1/3">
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your Login data of the compose.yml file below to access
</CardDescription>
</CardHeader>
<CardContent>
{errorVisible && (
<>
<div className="pb-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error}
</AlertDescription>
</Alert>
</div>
</>
)}
<div>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="mail@example.com"
required
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
</div>
<Input id="password" type="password" required placeholder="* * * * * * *" onChange={(e) => setPassword(e.target.value)}/>
</div>
<Button className="w-full" onClick={login}>
Login
</Button>
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-muted/30 p-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center space-y-2">
<div className="flex justify-center">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-8 w-8 text-primary" />
</div>
</div>
</CardContent>
</Card>
<h1 className="text-4xl font-bold tracking-tight text-foreground">CoreControl</h1>
<p className="text-muted-foreground">Sign in to access your dashboard</p>
</div>
<Card className="border-muted/40 shadow-lg">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-semibold">Login</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{errorVisible && (
<Alert variant="destructive" className="animate-in fade-in-50 slide-in-from-top-5">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Authentication Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
Email
</Label>
<div className="relative">
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="mail@example.com"
className="pl-10"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-sm font-medium">
Password
</Label>
</div>
<div className="relative">
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
required
/>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button className="w-full" onClick={login} disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</CardFooter>
</Card>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

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

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

View File

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

View File

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

View File

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

View File

@@ -4,10 +4,7 @@ services:
ports:
- "3000:3000"
environment:
LOGIN_EMAIL: "mail@example.com"
LOGIN_PASSWORD: "SecretPassword"
JWT_SECRET: RANDOM_SECRET
ACCOUNT_SECRET: RANDOM_SECRET
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
@@ -17,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
@@ -27,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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

27
docs/README.md Normal file
View File

@@ -0,0 +1,27 @@
---
icon: gear
layout:
title:
visible: true
description:
visible: true
tableOfContents:
visible: true
outline:
visible: false
pagination:
visible: true
---
# CoreControl
<figure><img src=".gitbook/assets/image.png" alt=""><figcaption></figcaption></figure>
CoreControl is the only dashboard you'll ever need to manage your entire server infrastructure. Keep all your server data organized in one central place, easily add your self-hosted applications with quick access links, and monitor their availability in real-time with built-in uptime tracking. Designed for simplicity and control, it gives you a clear overview of your entire self-hosted setup at a glance.
## Features
* Dashboard: A clear screen with all the important information about your servers (WIP)
* Servers: This allows you to add all your servers (including Hardware Information), with Quicklinks to their Management Panels
* Applications: Add all your self-hosted services to a clear list and track their up and down time
* Networks: Generate visually stunning network flowcharts with ease.

4
docs/SUMMARY.md Normal file
View File

@@ -0,0 +1,4 @@
# Table of contents
* [CoreControl](README.md)
* [Installation](installation.md)

56
docs/installation.md Normal file
View File

@@ -0,0 +1,56 @@
---
icon: down
---
# Installation
To install the application using Docker Compose, first, ensure that Docker and Docker Compose are installed on your system.&#x20;
You can then simply install and start the following Docker compose. Remember that you have to generate a JWT\_SECRET beforehand.
```yaml
services:
web:
image: haedlessdev/corecontrol:latest
ports:
- "3000:3000"
environment:
JWT_SECRET: RANDOM_SECRET # Replace with a secure random string
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
depends_on:
- db
- agent
agent:
image: haedlessdev/corecontrol-agent:latest
environment:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/postgres"
db:
image: postgres:17
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
```
Now start the application:
```sh
docker compose up -d
```
**The default login is:**
E-Mail: [admin@example.com](mailto:admin@example.com)\
Password: admin
_Be sure to set your own password and customize the e-mail, otherwise this poses a security risk!_

782
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "corecontrol",
"version": "0.0.2",
"version": "0.0.6",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -11,20 +11,26 @@
"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-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",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-tooltip": "^1.2.0",
"@types/axios": "^0.9.36",
"@types/bcrypt": "^5.0.2",
"@types/js-cookie": "^3.0.6",
"@types/jsonwebtoken": "^9.0.9",
"@xyflow/react": "^12.5.5",
"axios": "^1.8.4",
"bcrypt": "^5.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"fuse.js": "^7.1.0",

View File

@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");

View File

@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "uptime_history" (
"id" SERIAL NOT NULL,
"applicationId" INTEGER NOT NULL DEFAULT 1,
"online" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "uptime_history_pkey" PRIMARY KEY ("id")
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,8 +25,17 @@ model application {
online Boolean @default(true)
}
model uptime_history {
id Int @id @default(autoincrement())
applicationId Int @default(1)
online Boolean @default(true)
createdAt DateTime @default(now())
}
model server {
id Int @id @default(autoincrement())
host Boolean @default(false)
hostServer Int?
name String
os String?
ip String?
@@ -38,6 +47,29 @@ model server {
}
model settings {
id Int @id @default(autoincrement())
uptime_checks Boolean @default(true)
id Int @id @default(autoincrement())
uptime_checks Boolean @default(true)
notification_text String?
}
model user {
id String @id @default(uuid())
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?
}

BIN
public/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB