diff --git a/.dockerignore b/.dockerignore index cb3396c..0d1e926 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ node_modules npm-debug.log .env agent/ -.next \ No newline at end of file +.next +docs/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ef6a52..878f5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Vitepress +docs/.vitepress/dist +docs/.vitepress/cache +docs/node_modules/ + # dependencies /node_modules /.pnp diff --git a/README.md b/README.md index d13c9e7..5e92acd 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Login Page: ![Login Page](https://i.ibb.co/DfS7BJdX/image.png) Dashboard Page: -![Dashboard Page](https://i.ibb.co/wFMb7StT/image.png) +![Dashboard Page](https://i.ibb.co/mVgGJN6H/image.png) Servers Page: ![Servers Page](https://i.ibb.co/HLMD9HPZ/image.png) @@ -29,13 +29,13 @@ VM Display: ![VM Display](https://i.ibb.co/My45mv8k/image.png) Applications Page: -![Applications Page](https://i.ibb.co/qMwrKwn3/image.png) +![Applications Page](https://i.ibb.co/Hc2HQpV/image.png) Uptime Page: ![Uptime Page](https://i.ibb.co/jvGcL9Y6/image.png) Network Page: -![Network Page](https://i.ibb.co/qYcL2Fws/image.png) +![Network Page](https://i.ibb.co/ymLHcNqM/image.png) Settings Page: ![Settings Page](https://i.ibb.co/rRQB9Hcz/image.png) @@ -44,7 +44,8 @@ Settings Page: - [X] Edit Applications, Applications searchbar - [X] Uptime History - [X] Notifications -- [ ] Simple Server Monitoring +- [X] Simple Server Monitoring +- [ ] Simple Server Monitoring History - [ ] Improved Network Flowchart with custom elements (like Network switches) - [ ] Advanced Settings (Disable Uptime Tracking & more) @@ -60,9 +61,6 @@ services: 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 @@ -105,6 +103,7 @@ The application is build with: - Icons by [Lucide](https://lucide.dev/) - Flowcharts by [React Flow](https://reactflow.dev/) - Application icons by [selfh.st/icons](selfh.st/icons) +- Monitoring Tool by [Glances](https://github.com/nicolargo/glances) - and a lot of love ❤️ ## Star History diff --git a/agent/main.go b/agent/main.go index 48391bd..cfc190c 100644 --- a/agent/main.go +++ b/agent/main.go @@ -28,6 +28,31 @@ type Application struct { Online bool } +type Server struct { + ID int + Name string + Monitoring bool + MonitoringURL sql.NullString + Online bool + CpuUsage sql.NullFloat64 + RamUsage sql.NullFloat64 + DiskUsage sql.NullFloat64 +} + +type CPUResponse struct { + Total float64 `json:"total"` +} + +type MemoryResponse struct { + Percent float64 `json:"percent"` +} + +type FSResponse []struct { + DeviceName string `json:"device_name"` + MntPoint string `json:"mnt_point"` + Percent float64 `json:"percent"` +} + type Notification struct { ID int Enabled bool @@ -46,6 +71,9 @@ type Notification struct { GotifyToken sql.NullString NtfyUrl sql.NullString NtfyToken sql.NullString + PushoverUrl sql.NullString + PushoverToken sql.NullString + PushoverUser sql.NullString } var ( @@ -108,20 +136,34 @@ func main() { } }() - ticker := time.NewTicker(time.Second) - defer ticker.Stop() - - client := &http.Client{ + appClient := &http.Client{ Timeout: 4 * time.Second, } + // Server monitoring every 5 seconds + go func() { + serverClient := &http.Client{ + Timeout: 5 * time.Second, + } + serverTicker := time.NewTicker(5 * time.Second) + defer serverTicker.Stop() + + for range serverTicker.C { + servers := getServers(db) + checkAndUpdateServerStatus(db, serverClient, servers) + } + }() + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for now := range ticker.C { if now.Second()%10 != 0 { continue } apps := getApplications(db) - checkAndUpdateStatus(db, client, apps) + checkAndUpdateStatus(db, appClient, apps) } } @@ -169,6 +211,7 @@ func deleteOldEntries(db *sql.DB) error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() + // Delete old uptime history entries res, err := db.ExecContext(ctx, `DELETE FROM uptime_history WHERE "createdAt" < now() - interval '30 days'`, ) @@ -177,6 +220,17 @@ func deleteOldEntries(db *sql.DB) error { } affected, _ := res.RowsAffected() fmt.Printf("Deleted %d old entries from uptime_history\n", affected) + + // Delete old server history entries + res, err = db.ExecContext(ctx, + `DELETE FROM server_history WHERE "createdAt" < now() - interval '30 days'`, + ) + if err != nil { + return err + } + affected, _ = res.RowsAffected() + fmt.Printf("Deleted %d old entries from server_history\n", affected) + return nil } @@ -202,9 +256,35 @@ func getApplications(db *sql.DB) []Application { return apps } +func getServers(db *sql.DB) []Server { + rows, err := db.Query( + `SELECT id, name, monitoring, "monitoringURL", online, "cpuUsage", "ramUsage", "diskUsage" + FROM server WHERE monitoring = true`, + ) + if err != nil { + fmt.Printf("Error fetching servers: %v\n", err) + return nil + } + defer rows.Close() + + var servers []Server + for rows.Next() { + var server Server + if err := rows.Scan( + &server.ID, &server.Name, &server.Monitoring, &server.MonitoringURL, + &server.Online, &server.CpuUsage, &server.RamUsage, &server.DiskUsage, + ); err != nil { + fmt.Printf("Error scanning server row: %v\n", err) + continue + } + servers = append(servers, server) + } + return servers +} + func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) { var notificationTemplate string - err := db.QueryRow("SELECT notification_text FROM settings LIMIT 1").Scan(¬ificationTemplate) + err := db.QueryRow("SELECT notification_text_application FROM settings LIMIT 1").Scan(¬ificationTemplate) if err != nil || notificationTemplate == "" { notificationTemplate = "The application !name (!url) went !status!" } @@ -301,6 +381,154 @@ func checkAndUpdateStatus(db *sql.DB, client *http.Client, apps []Application) { dbCancel2() } } + +func checkAndUpdateServerStatus(db *sql.DB, client *http.Client, servers []Server) { + var notificationTemplate string + err := db.QueryRow("SELECT notification_text_server FROM settings LIMIT 1").Scan(¬ificationTemplate) + if err != nil || notificationTemplate == "" { + notificationTemplate = "The server !name is now !status!" + } + + for _, server := range servers { + if !server.Monitoring || !server.MonitoringURL.Valid { + continue + } + + logPrefix := fmt.Sprintf("[Server %s]", server.Name) + fmt.Printf("%s Checking...\n", logPrefix) + + baseURL := strings.TrimSuffix(server.MonitoringURL.String, "/") + var cpuUsage, ramUsage, diskUsage float64 + var online = true + + // Get CPU usage + cpuResp, err := client.Get(fmt.Sprintf("%s/api/4/cpu", baseURL)) + if err != nil { + fmt.Printf("%s CPU request failed: %v\n", logPrefix, err) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + defer cpuResp.Body.Close() + + if cpuResp.StatusCode != http.StatusOK { + fmt.Printf("%s Bad CPU status code: %d\n", logPrefix, cpuResp.StatusCode) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + var cpuData CPUResponse + if err := json.NewDecoder(cpuResp.Body).Decode(&cpuData); err != nil { + fmt.Printf("%s Failed to parse CPU JSON: %v\n", logPrefix, err) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + cpuUsage = cpuData.Total + } + } + } + + if online { + // Get Memory usage + memResp, err := client.Get(fmt.Sprintf("%s/api/4/mem", baseURL)) + if err != nil { + fmt.Printf("%s Memory request failed: %v\n", logPrefix, err) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + defer memResp.Body.Close() + + if memResp.StatusCode != http.StatusOK { + fmt.Printf("%s Bad memory status code: %d\n", logPrefix, memResp.StatusCode) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + var memData MemoryResponse + if err := json.NewDecoder(memResp.Body).Decode(&memData); err != nil { + fmt.Printf("%s Failed to parse memory JSON: %v\n", logPrefix, err) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + ramUsage = memData.Percent + } + } + } + } + + if online { + // Get Disk usage + fsResp, err := client.Get(fmt.Sprintf("%s/api/4/fs", baseURL)) + if err != nil { + fmt.Printf("%s Filesystem request failed: %v\n", logPrefix, err) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + defer fsResp.Body.Close() + + if fsResp.StatusCode != http.StatusOK { + fmt.Printf("%s Bad filesystem status code: %d\n", logPrefix, fsResp.StatusCode) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else { + var fsData FSResponse + if err := json.NewDecoder(fsResp.Body).Decode(&fsData); err != nil { + fmt.Printf("%s Failed to parse filesystem JSON: %v\n", logPrefix, err) + updateServerStatus(db, server.ID, false, 0, 0, 0) + online = false + } else if len(fsData) > 0 { + diskUsage = fsData[0].Percent + } + } + } + } + + // Check if status changed and send notification if needed + if online != server.Online { + status := "offline" + if online { + status = "online" + } + + message := notificationTemplate + message = strings.ReplaceAll(message, "!name", server.Name) + message = strings.ReplaceAll(message, "!status", status) + + sendNotifications(message) + } + + // Update server status with metrics + updateServerStatus(db, server.ID, online, cpuUsage, ramUsage, diskUsage) + + // Add entry to server history + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _, err = db.ExecContext(ctx, + `INSERT INTO server_history( + "serverId", online, "cpuUsage", "ramUsage", "diskUsage", "createdAt" + ) VALUES ($1, $2, $3, $4, $5, now())`, + server.ID, online, fmt.Sprintf("%.2f", cpuUsage), fmt.Sprintf("%.2f", ramUsage), fmt.Sprintf("%.2f", diskUsage), + ) + cancel() + if err != nil { + fmt.Printf("%s Failed to insert history: %v\n", logPrefix, err) + } + + fmt.Printf("%s Updated - CPU: %.2f%%, RAM: %.2f%%, Disk: %.2f%%\n", + logPrefix, cpuUsage, ramUsage, diskUsage) + } +} + +func updateServerStatus(db *sql.DB, serverID int, online bool, cpuUsage, ramUsage, diskUsage float64) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := db.ExecContext(ctx, + `UPDATE server SET online = $1, "cpuUsage" = $2::float8, "ramUsage" = $3::float8, "diskUsage" = $4::float8 + WHERE id = $5`, + online, cpuUsage, ramUsage, diskUsage, serverID, + ) + if err != nil { + fmt.Printf("Failed to update server status (ID: %d): %v\n", serverID, err) + } +} + func sendNotifications(message string) { notifMutex.RLock() notifs := notifMutexCopy(notifications) @@ -328,6 +556,10 @@ func sendNotifications(message string) { if n.NtfyUrl.Valid && n.NtfyToken.Valid { sendNtfy(n, message) } + case "pushover": + if n.PushoverUrl.Valid && n.PushoverToken.Valid && n.PushoverUser.Valid { + sendPushover(n, message) + } } } } @@ -451,3 +683,30 @@ func sendNtfy(n Notification, message string) { fmt.Printf("Ntfy: ERROR status code: %d\n", resp.StatusCode) } } + +func sendPushover(n Notification, message string) { + form := url.Values{} + form.Add("token", n.PushoverToken.String) + form.Add("user", n.PushoverUser.String) + form.Add("message", message) + + req, err := http.NewRequest("POST", n.PushoverUrl.String, strings.NewReader(form.Encode())) + if err != nil { + fmt.Printf("Pushover: ERROR creating request: %v\n", err) + return + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Pushover: ERROR sending request: %v\n", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + fmt.Printf("Pushover: ERROR status code: %d\n", resp.StatusCode) + } +} diff --git a/app/api/notifications/add/route.ts b/app/api/notifications/add/route.ts index 286bb84..be69c1c 100644 --- a/app/api/notifications/add/route.ts +++ b/app/api/notifications/add/route.ts @@ -17,12 +17,15 @@ interface AddRequest { gotifyToken?: string; ntfyUrl?: string; ntfyToken?: string; + pushoverUrl?: string; + pushoverToken?: string; + pushoverUser?: string; } export async function POST(request: NextRequest) { try { const body: AddRequest = await request.json(); - const { type, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken } = body; + const { type, smtpHost, smtpPort, smtpSecure, smtpUsername, smtpPassword, smtpFrom, smtpTo, telegramToken, telegramChatId, discordWebhook, gotifyUrl, gotifyToken, ntfyUrl, ntfyToken, pushoverUrl, pushoverToken, pushoverUser } = body; const notification = await prisma.notification.create({ data: { @@ -41,6 +44,9 @@ export async function POST(request: NextRequest) { gotifyToken: gotifyToken, ntfyUrl: ntfyUrl, ntfyToken: ntfyToken, + pushoverUrl: pushoverUrl, + pushoverToken: pushoverToken, + pushoverUser: pushoverUser, } }); diff --git a/app/api/servers/add/route.ts b/app/api/servers/add/route.ts index e29924c..440d4d0 100644 --- a/app/api/servers/add/route.ts +++ b/app/api/servers/add/route.ts @@ -13,13 +13,15 @@ interface AddRequest { gpu: string; ram: string; disk: string; + monitoring: boolean; + monitoringURL: string; } export async function POST(request: NextRequest) { try { const body: AddRequest = await request.json(); - const { host, hostServer, name, icon, os, ip, url, cpu, gpu, ram, disk } = body; + const { host, hostServer, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body; const server = await prisma.server.create({ data: { @@ -33,7 +35,9 @@ export async function POST(request: NextRequest) { cpu, gpu, ram, - disk + disk, + monitoring, + monitoringURL } }); diff --git a/app/api/servers/delete/route.ts b/app/api/servers/delete/route.ts index ed7b852..9561f5e 100644 --- a/app/api/servers/delete/route.ts +++ b/app/api/servers/delete/route.ts @@ -18,6 +18,12 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Cannot delete server with associated applications" }, { status: 400 }); } + // Delete all server history records for this server + await prisma.server_history.deleteMany({ + where: { serverId: id } + }); + + // Delete the server await prisma.server.delete({ where: { id: id } }); diff --git a/app/api/servers/edit/route.ts b/app/api/servers/edit/route.ts index e0ef2f6..c857d68 100644 --- a/app/api/servers/edit/route.ts +++ b/app/api/servers/edit/route.ts @@ -14,12 +14,14 @@ interface EditRequest { gpu: string; ram: string; disk: string; + monitoring: boolean; + monitoringURL: string; } export async function PUT(request: NextRequest) { try { const body: EditRequest = await request.json(); - const { host, hostServer, id, name, icon, os, ip, url, cpu, gpu, ram, disk } = body; + const { host, hostServer, id, name, icon, os, ip, url, cpu, gpu, ram, disk, monitoring, monitoringURL } = body; const existingServer = await prisma.server.findUnique({ where: { id } }); if (!existingServer) { @@ -46,7 +48,9 @@ export async function PUT(request: NextRequest) { cpu, gpu, ram, - disk + disk, + monitoring, + monitoringURL } }); diff --git a/app/api/servers/monitoring/route.ts b/app/api/servers/monitoring/route.ts new file mode 100644 index 0000000..e7c0f45 --- /dev/null +++ b/app/api/servers/monitoring/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma"; + + +export async function GET() { + try { + const servers = await prisma.server.findMany({ + select: { + id: true, + online: true, + cpuUsage: true, + ramUsage: true, + diskUsage: true, + } + }); + + const monitoringData = servers.map((server: { + id: number; + online: boolean; + cpuUsage: string | null; + ramUsage: string | null; + diskUsage: string | null; + }) => ({ + id: server.id, + online: server.online, + cpuUsage: server.cpuUsage ? parseInt(server.cpuUsage) : 0, + ramUsage: server.ramUsage ? parseInt(server.ramUsage) : 0, + diskUsage: server.diskUsage ? parseInt(server.diskUsage) : 0 + })); + + return NextResponse.json(monitoringData) + } catch (error) { + return new NextResponse("Internal Error", { status: 500 }) + } +} \ No newline at end of file diff --git a/app/api/settings/get_notification_text/route.ts b/app/api/settings/get_notification_text/route.ts index 65f3a1d..0c210e2 100644 --- a/app/api/settings/get_notification_text/route.ts +++ b/app/api/settings/get_notification_text/route.ts @@ -7,7 +7,7 @@ export async function POST(request: NextRequest) { // Check if there are any settings entries const existingSettings = await prisma.settings.findFirst(); if (!existingSettings) { - return NextResponse.json({ "notification_text": "" }); + return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" }); } // If settings entry exists, fetch it @@ -15,10 +15,10 @@ export async function POST(request: NextRequest) { where: { id: existingSettings.id }, }); if (!settings) { - return NextResponse.json({ "notification_text": "" }); + return NextResponse.json({ "notification_text_application": "", "notification_text_server": "" }); } // Return the settings entry - return NextResponse.json({ "notification_text": settings.notification_text }); + return NextResponse.json({ "notification_text_application": settings.notification_text_application, "notification_text_server": settings.notification_text_server }); } catch (error: any) { return NextResponse.json({ error: error.message }, { status: 500 }); } diff --git a/app/api/settings/notification_text/route.ts b/app/api/settings/notification_text/route.ts index 0a37722..16ae26b 100644 --- a/app/api/settings/notification_text/route.ts +++ b/app/api/settings/notification_text/route.ts @@ -2,13 +2,14 @@ import { NextResponse, NextRequest } from "next/server"; import { prisma } from "@/lib/prisma"; interface AddRequest { - text: string; + text_application: string; + text_server: string; } export async function POST(request: NextRequest) { try { const body: AddRequest = await request.json(); - const { text } = body; + const { text_application, text_server } = body; // Check if there is already a settings entry const existingSettings = await prisma.settings.findFirst(); @@ -16,14 +17,15 @@ export async function POST(request: NextRequest) { // Update the existing settings entry const updatedSettings = await prisma.settings.update({ where: { id: existingSettings.id }, - data: { notification_text: text }, + data: { notification_text_application: text_application, notification_text_server: text_server }, }); 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, + notification_text_application: text_application, + notification_text_server: text_server, } }); diff --git a/app/dashboard/Dashboard.tsx b/app/dashboard/Dashboard.tsx index 3a38444..1275bbf 100644 --- a/app/dashboard/Dashboard.tsx +++ b/app/dashboard/Dashboard.tsx @@ -72,83 +72,94 @@ export default function Dashboard() {

Dashboard

- - -
-
- Servers - Physical and virtual servers overview -
- -
-
- -
- {/* Physical Servers */} -
-
- -
-
-
{serverCountNoVMs}
-

Physical Servers

-
-
- - {/* Virtual Machines */} -
-
- -
-
-
{serverCountOnlyVMs}
-

Virtual Servers

-
-
-
-
- - - -
- - - + +
- Applications - +
+ Servers + Physical and virtual servers overview +
+
- Manage your deployed applications
- -
{applicationCount}
-

Running applications

+ +
+ {/* Physical Servers */} +
+
+ +
+
+
{serverCountNoVMs}
+

Physical Servers

+
+
+ + {/* Virtual Machines */} +
+
+ +
+
+
{serverCountOnlyVMs}
+

Virtual Servers

+
+
+
- -
- - + +
- Uptime - +
+ Applications + Manage your deployed applications +
+
- Monitor your service availability
- + +
{applicationCount}
+

Running applications

+
+ + + +
+ + + +
+
+ Uptime + Monitor your service availability +
+ +
+
+
@@ -169,28 +180,44 @@ export default function Dashboard() {

Online applications

- - - - + +
- Network - +
+ Network + Manage network configuration +
+
- Manage network configuration
- +
{serverCountNoVMs + serverCountOnlyVMs + applicationCount}

Active connections

- -
diff --git a/app/dashboard/applications/Applications.tsx b/app/dashboard/applications/Applications.tsx index 24f1fd2..1b55709 100644 --- a/app/dashboard/applications/Applications.tsx +++ b/app/dashboard/applications/Applications.tsx @@ -73,6 +73,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip" +import { StatusIndicator } from "@/components/status-indicator"; interface Application { id: number; @@ -115,21 +116,19 @@ export default function Dashboard() { const [currentPage, setCurrentPage] = useState(1); const [maxPage, setMaxPage] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(5); const [applications, setApplications] = useState([]); const [servers, setServers] = useState([]); - const [isGridLayout, setIsGridLayout] = useState(false); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(""); const [isSearching, setIsSearching] = useState(false); - useEffect(() => { - const savedLayout = Cookies.get("layoutPreference-app"); - const layout_bool = savedLayout === "grid"; - setIsGridLayout(layout_bool); - setItemsPerPage(layout_bool ? 15 : 5); - }, []); + const savedLayout = Cookies.get("layoutPreference-app"); + const initialIsGridLayout = savedLayout === "grid"; + const initialItemsPerPage = initialIsGridLayout ? 15 : 5; + + const [isGridLayout, setIsGridLayout] = useState(initialIsGridLayout); + const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); const toggleLayout = () => { const newLayout = !isGridLayout; @@ -440,20 +439,9 @@ export default function Dashboard() { >
-
-
-
+
-
+
{app.icon ? ( diff --git a/app/dashboard/servers/Servers.tsx b/app/dashboard/servers/Servers.tsx index 3d1077f..a11faef 100644 --- a/app/dashboard/servers/Servers.tsx +++ b/app/dashboard/servers/Servers.tsx @@ -24,7 +24,8 @@ import { MicroscopeIcon as Microchip, MemoryStick, HardDrive, - Server, + LucideServer, + Copy, } from "lucide-react" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { @@ -57,7 +58,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { DynamicIcon } from "lucide-react/dynamic" - +import { StatusIndicator } from "@/components/status-indicator" interface Server { id: number name: string @@ -73,6 +74,12 @@ interface Server { disk?: string hostedVMs?: Server[] isVM?: boolean + monitoring?: boolean + monitoringURL?: string + online?: boolean + cpuUsage?: number + ramUsage?: number + diskUsage?: number } interface GetServersResponse { @@ -80,6 +87,14 @@ interface GetServersResponse { maxPage: number } +interface MonitoringData { + id: number + online: boolean + cpuUsage: number + ramUsage: number + diskUsage: number +} + export default function Dashboard() { const [host, setHost] = useState(false) const [hostServer, setHostServer] = useState(0) @@ -92,12 +107,16 @@ export default function Dashboard() { const [gpu, setGpu] = useState("") const [ram, setRam] = useState("") const [disk, setDisk] = useState("") + const [monitoring, setMonitoring] = useState(false) + const [monitoringURL, setMonitoringURL] = useState("") + const [online, setOnline] = useState(false) + const [cpuUsage, setCpuUsage] = useState(0) + const [ramUsage, setRamUsage] = useState(0) + const [diskUsage, setDiskUsage] = useState(0) const [currentPage, setCurrentPage] = useState(1) const [maxPage, setMaxPage] = useState(1) - const [itemsPerPage, setItemsPerPage] = useState(4) const [servers, setServers] = useState([]) - const [isGridLayout, setIsGridLayout] = useState(false) const [loading, setLoading] = useState(true) const [editId, setEditId] = useState(null) @@ -112,6 +131,8 @@ export default function Dashboard() { const [editGpu, setEditGpu] = useState("") const [editRam, setEditRam] = useState("") const [editDisk, setEditDisk] = useState("") + const [editMonitoring, setEditMonitoring] = useState(false) + const [editMonitoringURL, setEditMonitoringURL] = useState("") const [searchTerm, setSearchTerm] = useState("") const [isSearching, setIsSearching] = useState(false) @@ -119,23 +140,25 @@ export default function Dashboard() { const [hostServers, setHostServers] = useState([]) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) - useEffect(() => { - const savedLayout = Cookies.get("layoutPreference-servers") - const layout_bool = savedLayout === "grid" - setIsGridLayout(layout_bool) - setItemsPerPage(layout_bool ? 6 : 4) - }, []) + const [monitoringInterval, setMonitoringInterval] = useState(null); + + const savedLayout = Cookies.get("layoutPreference-servers"); + const initialIsGridLayout = savedLayout === "grid"; + const initialItemsPerPage = initialIsGridLayout ? 6 : 4; + + const [isGridLayout, setIsGridLayout] = useState(initialIsGridLayout); + const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage); const toggleLayout = () => { - const newLayout = !isGridLayout - setIsGridLayout(newLayout) + const newLayout = !isGridLayout; + setIsGridLayout(newLayout); Cookies.set("layoutPreference-servers", newLayout ? "grid" : "standard", { expires: 365, path: "/", sameSite: "strict", - }) - setItemsPerPage(newLayout ? 6 : 4) - } + }); + setItemsPerPage(newLayout ? 6 : 4); // Update itemsPerPage based on new layout + }; const add = async () => { try { @@ -151,6 +174,8 @@ export default function Dashboard() { gpu, ram, disk, + monitoring, + monitoringURL, }) setIsAddDialogOpen(false) setHost(false) @@ -164,6 +189,8 @@ export default function Dashboard() { setGpu("") setRam("") setDisk("") + setMonitoring(false) + setMonitoringURL("") getServers() } catch (error: any) { console.log(error.response.data) @@ -223,6 +250,8 @@ export default function Dashboard() { setEditGpu(server.gpu || "") setEditRam(server.ram || "") setEditDisk(server.disk || "") + setEditMonitoring(server.monitoring || false) + setEditMonitoringURL(server.monitoringURL || "") } const edit = async () => { @@ -242,6 +271,8 @@ export default function Dashboard() { gpu: editGpu, ram: editRam, disk: editDisk, + monitoring: editMonitoring, + monitoringURL: editMonitoringURL, }) getServers() setEditId(null) @@ -321,6 +352,78 @@ export default function Dashboard() { // Flatten icons for search const allIcons = Object.values(iconCategories).flat() + const copyServerDetails = (sourceServer: Server) => { + // First clear all fields + setName("") + setIcon("") + setOs("") + setIp("") + setUrl("") + setCpu("") + setGpu("") + setRam("") + setDisk("") + setMonitoring(false) + setMonitoringURL("") + setHost(false) + setHostServer(0) + + // Then copy the new server details + setTimeout(() => { + setName(sourceServer.name + " (Copy)") + setIcon(sourceServer.icon || "") + setOs(sourceServer.os || "") + setIp(sourceServer.ip || "") + setUrl(sourceServer.url || "") + setCpu(sourceServer.cpu || "") + setGpu(sourceServer.gpu || "") + setRam(sourceServer.ram || "") + setDisk(sourceServer.disk || "") + setMonitoring(sourceServer.monitoring || false) + setMonitoringURL(sourceServer.monitoringURL || "") + setHost(sourceServer.host) + setHostServer(sourceServer.hostServer || 0) + }, 0) + } + + const updateMonitoringData = async () => { + try { + const response = await axios.get("/api/servers/monitoring"); + const monitoringData = response.data; + + setServers(prevServers => + prevServers.map(server => { + const serverMonitoring = monitoringData.find(m => m.id === server.id); + if (serverMonitoring) { + return { + ...server, + online: serverMonitoring.online, + cpuUsage: serverMonitoring.cpuUsage, + ramUsage: serverMonitoring.ramUsage, + diskUsage: serverMonitoring.diskUsage + }; + } + return server; + }) + ); + } catch (error) { + console.error("Error updating monitoring data:", error); + } + }; + + // Set up monitoring interval + useEffect(() => { + updateMonitoringData(); + const interval = setInterval(updateMonitoringData, 5000); + setMonitoringInterval(interval); + + return () => { + if (monitoringInterval) { + clearInterval(monitoringInterval); + } + }; + }, []); + return ( @@ -368,13 +471,67 @@ export default function Dashboard() { - Add an server + + Add a server + + General Hardware Virtualization + Monitoring
@@ -448,6 +605,7 @@ export default function Dashboard() { id="name" type="text" placeholder="e.g. Server1" + value={name} onChange={(e) => setName(e.target.value)} />
@@ -455,7 +613,7 @@ export default function Dashboard() { - setOs(value)}> @@ -467,13 +625,14 @@ export default function Dashboard() {
-
@@ -495,6 +654,7 @@ export default function Dashboard() { id="publicURL" type="text" placeholder="e.g. https://proxmox.server1.com" + value={url} onChange={(e) => setUrl(e.target.value)} />
@@ -503,46 +663,50 @@ export default function Dashboard() {
-
-
-
-
@@ -563,12 +727,19 @@ export default function Dashboard() { setMonitoringURL(e.target.value)} + /> +
+
+

Required Server Setup

+

+ To enable monitoring, you need to install Glances on your server. Here's an example Docker Compose configuration: +

+
+                                    {`services:
+  glances:
+    image: nicolargo/glances:latest
+    container_name: glances
+    restart: unless-stopped
+    ports:
+      - "61208:61208"
+    pid: "host"
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+    environment:
+      - GLANCES_OPT=-w --disable-webui`}
+                                  
+
+ + )} +
+ @@ -599,6 +816,7 @@ export default function Dashboard() { onChange={(e) => setSearchTerm(e.target.value)} />
+
{!loading ? (
@@ -607,16 +825,23 @@ export default function Dashboard() { .map((server) => ( + {server.monitoring && ( +
+ +
+ )}
-
-
+
+
{server.icon && } - {server.icon && "・"} {server.name} + + {server.icon && "・"} {server.name} +
{server.isVM && ( VM @@ -642,7 +867,7 @@ export default function Dashboard() { {server.isVM && server.hostServer && (
- + Host: {getHostServerName(server.hostServer)} @@ -653,6 +878,10 @@ export default function Dashboard() {
+
+

Hardware Information

+
+
@@ -677,10 +906,72 @@ export default function Dashboard() { Disk: {server.disk || "-"}
+ + {server.monitoring && server.hostServer === 0 && ( + <> +
+ +
+ +
+

Resource Usage

+
+
+
+
+ + CPU +
+ {server.cpuUsage || 0}% +
+
+
80 ? "bg-destructive" : server.cpuUsage && server.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`} + style={{ width: `${server.cpuUsage || 0}%` }} + /> +
+
+ +
+
+
+ + RAM +
+ {server.ramUsage || 0}% +
+
+
80 ? "bg-destructive" : server.ramUsage && server.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`} + style={{ width: `${server.ramUsage || 0}%` }} + /> +
+
+ +
+
+
+ + Disk +
+ {server.diskUsage || 0}% +
+
+
80 ? "bg-destructive" : server.diskUsage && server.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`} + style={{ width: `${server.diskUsage || 0}%` }} + /> +
+
+
+
+ + )}
-
+
+
@@ -690,7 +981,7 @@ export default function Dashboard() { className="gap-2" onClick={() => window.open(server.url, "_blank")} > - + )}
-
@@ -905,12 +1196,19 @@ export default function Dashboard() { setEditMonitoringURL(e.target.value)} + /> +
+
+

Required Server Setup

+

+ To enable monitoring, you need to install Glances on your server. Here's an example Docker Compose configuration: +

+
+                                                      {`services:
+  glances:
+    image: nicolargo/glances:latest
+    container_name: glances
+    restart: unless-stopped
+    ports:
+      - "61208:61208"
+    pid: "host"
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock:ro
+    environment:
+      - GLANCES_OPT=-w --disable-webui`}
+                                                    
+
+ + )} +
+
@@ -939,7 +1283,7 @@ export default function Dashboard() { @@ -965,7 +1309,10 @@ export default function Dashboard() { size={24} /> )} -
{hostedVM.icon && "・ "}{hostedVM.name}
+
+ {hostedVM.icon && "・ "} + {hostedVM.name} +
+ +
+ + setPushoverToken(e.target.value)} + /> +
+ +
+ + setPushoverUser(e.target.value)} + /> +
+
+ )} @@ -613,12 +662,22 @@ export default function Settings() {
- +