v0.0.9->main

v0.0.9
This commit is contained in:
headlessdev 2025-04-24 14:42:36 +02:00 committed by GitHub
commit f3eabb9297
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2390 additions and 3425 deletions

View File

@ -4,3 +4,4 @@ npm-debug.log
agent/ agent/
.next .next
docs/ docs/
screenshots/

View File

@ -6,7 +6,7 @@
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. 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> [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/corecontrol)
## Features ## Features
@ -17,35 +17,34 @@ The only dashboard you'll ever need to manage your entire server infrastructure.
## Screenshots ## Screenshots
Login Page: Login Page:
![Login Page](https://i.ibb.co/DfS7BJdX/image.png) ![Login Page](/screenshots/login.png)
Dashboard Page: Dashboard Page:
![Dashboard Page](https://i.ibb.co/SYwrFw8/image.png) ![Dashboard Page](/screenshots/dashboard.png)
Servers Page: Servers Page:
![Servers Page](https://i.ibb.co/HLMD9HPZ/image.png) ![Servers Page](/screenshots/servers.png)
VM Display: Server Detail Page
![VM Display](https://i.ibb.co/My45mv8k/image.png) ![Server Detail Page](/screenshots/server.png)
Applications Page: Applications Page:
![Applications Page](https://i.ibb.co/Hc2HQpV/image.png) ![Applications Page](/screenshots/applications.png)
Uptime Page: Uptime Page:
![Uptime Page](https://i.ibb.co/jvGcL9Y6/image.png) ![Uptime Page](/screenshots/uptime.png)
Network Page: Network Page:
![Network Page](https://i.ibb.co/ymLHcNqM/image.png) ![Network Page](/screenshots/network.png)
Settings Page: Settings Page:
![Settings Page](https://i.ibb.co/rRQB9Hcz/image.png) ![Settings Page](/screenshots/settings.png)
## Roadmap ## Roadmap
- [X] Edit Applications, Applications searchbar - [X] Edit Applications, Applications searchbar
- [X] Uptime History - [X] Uptime History
- [X] Notifications - [X] Notifications
- [X] Simple Server Monitoring - [X] Simple Server Monitoring
- [ ] Simple Server Monitoring History
- [ ] Improved Network Flowchart with custom elements (like Network switches) - [ ] Improved Network Flowchart with custom elements (like Network switches)
- [ ] Advanced Settings (Disable Uptime Tracking & more) - [ ] Advanced Settings (Disable Uptime Tracking & more)

View File

@ -44,7 +44,16 @@ type CPUResponse struct {
} }
type MemoryResponse struct { type MemoryResponse struct {
Active int64 `json:"active"`
Available int64 `json:"available"`
Buffers int64 `json:"buffers"`
Cached int64 `json:"cached"`
Free int64 `json:"free"`
Inactive int64 `json:"inactive"`
Percent float64 `json:"percent"` Percent float64 `json:"percent"`
Shared int64 `json:"shared"`
Total int64 `json:"total"`
Used int64 `json:"used"`
} }
type FSResponse []struct { type FSResponse []struct {
@ -136,6 +145,16 @@ func main() {
} }
}() }()
// Check for test notifications every 10 seconds
go func() {
testNotifTicker := time.NewTicker(10 * time.Second)
defer testNotifTicker.Stop()
for range testNotifTicker.C {
checkAndSendTestNotifications(db)
}
}()
appClient := &http.Client{ appClient := &http.Client{
Timeout: 4 * time.Second, Timeout: 4 * time.Second,
} }
@ -447,7 +466,23 @@ func checkAndUpdateServerStatus(db *sql.DB, client *http.Client, servers []Serve
updateServerStatus(db, server.ID, false, 0, 0, 0) updateServerStatus(db, server.ID, false, 0, 0, 0)
online = false online = false
} else { } else {
// Calculate actual RAM usage excluding swap, cache, and buffers
// Formula: (total - free - cached - buffers) / total * 100
// This is the most accurate representation of actual used RAM
actualUsedRam := memData.Total - memData.Free - memData.Cached - memData.Buffers
if actualUsedRam < 0 {
actualUsedRam = 0 // Safeguard against negative values
}
if memData.Total > 0 {
ramUsage = float64(actualUsedRam) / float64(memData.Total) * 100
fmt.Printf("%s Calculated RAM usage: %.2f%% (Used: %d MB, Total: %d MB)\n",
logPrefix, ramUsage, actualUsedRam/1024/1024, memData.Total/1024/1024)
} else {
// Fallback to the provided percentage if calculation fails
ramUsage = memData.Percent ramUsage = memData.Percent
fmt.Printf("%s Using provided memory percentage because total is zero\n", logPrefix)
}
} }
} }
} }
@ -710,3 +745,76 @@ func sendPushover(n Notification, message string) {
fmt.Printf("Pushover: ERROR status code: %d\n", resp.StatusCode) fmt.Printf("Pushover: ERROR status code: %d\n", resp.StatusCode)
} }
} }
func checkAndSendTestNotifications(db *sql.DB) {
// Query for test notifications
rows, err := db.Query(`SELECT tn.id, tn."notificationId" FROM test_notification tn`)
if err != nil {
fmt.Printf("Error fetching test notifications: %v\n", err)
return
}
defer rows.Close()
// Process each test notification
var testIds []int
for rows.Next() {
var id, notificationId int
if err := rows.Scan(&id, &notificationId); err != nil {
fmt.Printf("Error scanning test notification: %v\n", err)
continue
}
// Add to list of IDs to delete
testIds = append(testIds, id)
// Find the notification configuration
notifMutex.RLock()
for _, n := range notifications {
if n.ID == notificationId {
// Send test notification
fmt.Printf("Sending test notification to notification ID %d\n", notificationId)
sendSpecificNotification(n, "Test notification from CoreControl")
}
}
notifMutex.RUnlock()
}
// Delete processed test notifications
if len(testIds) > 0 {
for _, id := range testIds {
_, err := db.Exec(`DELETE FROM test_notification WHERE id = $1`, id)
if err != nil {
fmt.Printf("Error deleting test notification (ID: %d): %v\n", id, err)
}
}
}
}
func sendSpecificNotification(n Notification, message string) {
switch n.Type {
case "email":
if n.SMTPHost.Valid && n.SMTPTo.Valid {
sendEmail(n, message)
}
case "telegram":
if n.TelegramToken.Valid && n.TelegramChatID.Valid {
sendTelegram(n, message)
}
case "discord":
if n.DiscordWebhook.Valid {
sendDiscord(n, message)
}
case "gotify":
if n.GotifyUrl.Valid && n.GotifyToken.Valid {
sendGotify(n, message)
}
case "ntfy":
if n.NtfyUrl.Valid && n.NtfyToken.Valid {
sendNtfy(n, message)
}
case "pushover":
if n.PushoverUrl.Valid && n.PushoverToken.Valid && n.PushoverUser.Valid {
sendPushover(n, message)
}
}
}

View File

@ -12,22 +12,27 @@ const getTimeRange = (timespan: number) => {
switch (timespan) { switch (timespan) {
case 1: case 1:
return { return {
start: new Date(now.getTime() - 30 * 60 * 1000), start: new Date(now.getTime() - 60 * 60 * 1000),
interval: 'minute' interval: 'minute'
}; };
case 2: case 2:
return { return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
interval: '3hour' interval: 'hour'
}; };
case 3: case 3:
return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
interval: 'day'
};
case 4:
return { return {
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
interval: 'day' interval: 'day'
}; };
default: default:
return { return {
start: new Date(now.getTime() - 30 * 60 * 1000), start: new Date(now.getTime() - 60 * 60 * 1000),
interval: 'minute' interval: 'minute'
}; };
} }
@ -38,23 +43,31 @@ const generateIntervals = (timespan: number) => {
now.setSeconds(0, 0); now.setSeconds(0, 0);
switch (timespan) { switch (timespan) {
case 1: case 1: // 1 hour - 60 one-minute intervals
return Array.from({ length: 30 }, (_, i) => { return Array.from({ length: 60 }, (_, i) => {
const d = new Date(now); const d = new Date(now);
d.setMinutes(d.getMinutes() - i); d.setMinutes(d.getMinutes() - i);
d.setSeconds(0, 0); d.setSeconds(0, 0);
return d; return d;
}); });
case 2: case 2: // 1 day - 24 one-hour intervals
return Array.from({ length: 56 }, (_, i) => { return Array.from({ length: 24 }, (_, i) => {
const d = new Date(now); const d = new Date(now);
d.setHours(d.getHours() - (i * 3)); d.setHours(d.getHours() - i);
d.setMinutes(0, 0, 0); d.setMinutes(0, 0, 0);
return d; return d;
}); });
case 3: case 3: // 7 days
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(now);
d.setDate(d.getDate() - i);
d.setHours(0, 0, 0, 0);
return d;
});
case 4: // 30 days
return Array.from({ length: 30 }, (_, i) => { return Array.from({ length: 30 }, (_, i) => {
const d = new Date(now); const d = new Date(now);
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
@ -70,14 +83,14 @@ const generateIntervals = (timespan: number) => {
const getIntervalKey = (date: Date, timespan: number) => { const getIntervalKey = (date: Date, timespan: number) => {
const d = new Date(date); const d = new Date(date);
switch (timespan) { switch (timespan) {
case 1: case 1: // 1 hour - minute intervals
d.setSeconds(0, 0); d.setSeconds(0, 0);
return d.toISOString(); return d.toISOString();
case 2: case 2: // 1 day - hour intervals
d.setHours(Math.floor(d.getHours() / 3) * 3);
d.setMinutes(0, 0, 0); d.setMinutes(0, 0, 0);
return d.toISOString(); return d.toISOString();
case 3: case 3: // 7 days - day intervals
case 4: // 30 days - day intervals
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
return d.toISOString(); return d.toISOString();
default: default:

View File

@ -0,0 +1,23 @@
import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
interface AddRequest {
notificationId: number;
}
export async function POST(request: NextRequest) {
try {
const body: AddRequest = await request.json();
const { notificationId } = body;
const notification = await prisma.test_notification.create({
data: {
notificationId: notificationId,
}
});
return NextResponse.json({ message: "Success", notification });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -1,23 +1,111 @@
import { NextResponse, NextRequest } from "next/server"; import { NextResponse, NextRequest } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Prisma } from "@prisma/client";
interface GetRequest { interface GetRequest {
page?: number; page?: number;
ITEMS_PER_PAGE?: number; ITEMS_PER_PAGE?: number;
timeRange?: '1h' | '1d' | '7d' | '30d';
serverId?: number;
} }
const getTimeRange = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
const now = new Date();
switch (timeRange) {
case '1d':
return {
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
end: now,
intervalMinutes: 15 // 15 minute intervals
};
case '7d':
return {
start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
end: now,
intervalMinutes: 60 // 1 hour intervals
};
case '30d':
return {
start: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000),
end: now,
intervalMinutes: 240 // 4 hour intervals
};
case '1h':
default:
return {
start: new Date(now.getTime() - 60 * 60 * 1000),
end: now,
intervalMinutes: 1 // 1 minute intervals
};
}
};
const getIntervals = (timeRange: '1h' | '1d' | '7d' | '30d' = '1h') => {
const { start, end, intervalMinutes } = getTimeRange(timeRange);
let intervalCount: number;
switch (timeRange) {
case '1d':
intervalCount = 96; // 24 hours * 4 (15-minute intervals)
break;
case '7d':
intervalCount = 168; // 7 days * 24 hours
break;
case '30d':
intervalCount = 180; // 30 days * 6 (4-hour intervals)
break;
case '1h':
default:
intervalCount = 60;
break;
}
// Calculate the total time span in minutes
const totalMinutes = Math.floor((end.getTime() - start.getTime()) / (1000 * 60));
// Create equally spaced intervals
return Array.from({ length: intervalCount }, (_, i) => {
const minutesFromEnd = Math.floor(i * (totalMinutes / (intervalCount - 1)));
const d = new Date(end.getTime() - minutesFromEnd * 60 * 1000);
return d;
}).reverse(); // Return in chronological order
};
const parseUsageValue = (value: string | null): number => {
if (!value) return 0;
return Math.round(parseFloat(value.replace('%', '')) * 100) / 100;
};
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body: GetRequest = await request.json(); const body: GetRequest = await request.json();
const page = Math.max(1, body.page || 1); const page = Math.max(1, body.page || 1);
const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4; const ITEMS_PER_PAGE = body.ITEMS_PER_PAGE || 4;
const timeRange = body.timeRange || '1h';
const serverId = body.serverId;
const hosts = await prisma.server.findMany({ // If serverId is provided, only fetch that specific server
where: { hostServer: 0 }, const hostsQuery = serverId
? { id: serverId }
: { hostServer: 0 };
let hosts;
if (!serverId) {
hosts = await prisma.server.findMany({
where: hostsQuery,
orderBy: { name: 'asc' as Prisma.SortOrder },
skip: (page - 1) * ITEMS_PER_PAGE, skip: (page - 1) * ITEMS_PER_PAGE,
take: ITEMS_PER_PAGE, take: ITEMS_PER_PAGE,
orderBy: { name: 'asc' }
}); });
} else {
hosts = await prisma.server.findMany({
where: hostsQuery,
orderBy: { name: 'asc' as Prisma.SortOrder },
});
}
const { start } = getTimeRange(timeRange);
const intervals = getIntervals(timeRange);
const hostsWithVms = await Promise.all( const hostsWithVms = await Promise.all(
hosts.map(async (host) => { hosts.map(async (host) => {
@ -26,6 +114,87 @@ export async function POST(request: NextRequest) {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}); });
// Get server history for the host
const serverHistory = await prisma.server_history.findMany({
where: {
serverId: host.id,
createdAt: {
gte: start
}
},
orderBy: {
createdAt: 'asc'
}
});
// Process history data into intervals
const historyMap = new Map<string, {
cpu: number[],
ram: number[],
disk: number[],
online: boolean[]
}>();
// Initialize intervals
intervals.forEach(date => {
const key = date.toISOString();
historyMap.set(key, {
cpu: [],
ram: [],
disk: [],
online: []
});
});
// Group data by interval
serverHistory.forEach(record => {
const recordDate = new Date(record.createdAt);
let nearestInterval: Date = intervals[0];
let minDiff = Infinity;
// Find the nearest interval for this record
intervals.forEach(intervalDate => {
const diff = Math.abs(recordDate.getTime() - intervalDate.getTime());
if (diff < minDiff) {
minDiff = diff;
nearestInterval = intervalDate;
}
});
const key = nearestInterval.toISOString();
const interval = historyMap.get(key);
if (interval) {
interval.cpu.push(parseUsageValue(record.cpuUsage));
interval.ram.push(parseUsageValue(record.ramUsage));
interval.disk.push(parseUsageValue(record.diskUsage));
interval.online.push(record.online);
}
});
// Calculate averages for each interval
const historyData = intervals.map(date => {
const key = date.toISOString();
const data = historyMap.get(key) || {
cpu: [],
ram: [],
disk: [],
online: []
};
const average = (arr: number[]) =>
arr.length ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : null;
return {
timestamp: key,
cpu: average(data.cpu),
ram: average(data.ram),
disk: average(data.disk),
online: data.online.length ?
data.online.filter(Boolean).length / data.online.length >= 0.5
: null
};
});
// Add isVM flag to VMs // Add isVM flag to VMs
const vmsWithFlag = vms.map(vm => ({ const vmsWithFlag = vms.map(vm => ({
...vm, ...vm,
@ -35,17 +204,41 @@ export async function POST(request: NextRequest) {
return { return {
...host, ...host,
isVM: false, // Mark as physical server/not a VM isVM: false,
hostedVMs: vmsWithFlag hostedVMs: vmsWithFlag,
history: {
labels: intervals.map(d => d.toISOString()),
datasets: {
cpu: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.cpu || [];
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}),
ram: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.ram || [];
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}),
disk: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.disk || [];
return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}),
online: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.online || [];
return data.length ? data.filter(Boolean).length / data.length >= 0.5 : null;
})
}
}
}; };
}) })
); );
// Only calculate maxPage when not requesting a specific server
let maxPage = 1;
if (!serverId) {
const totalHosts = await prisma.server.count({ const totalHosts = await prisma.server.count({
where: { OR: [{ hostServer: 0 }, { hostServer: null }] } where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
}); });
maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE); }
return NextResponse.json({ return NextResponse.json({
servers: hostsWithVms, servers: hostsWithVms,

View File

@ -74,6 +74,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { StatusIndicator } from "@/components/status-indicator"; import { StatusIndicator } from "@/components/status-indicator";
import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner"
interface Application { interface Application {
id: number; id: number;
@ -152,8 +154,10 @@ export default function Dashboard() {
serverId, serverId,
}); });
getApplications(); getApplications();
toast.success("Application added successfully");
} catch (error: any) { } catch (error: any) {
console.log(error.response?.data); console.log(error.response?.data);
toast.error("Failed to add application");
} }
}; };
@ -170,6 +174,7 @@ export default function Dashboard() {
setLoading(false); setLoading(false);
} catch (error: any) { } catch (error: any) {
console.log(error.response?.data); console.log(error.response?.data);
toast.error("Failed to get applications");
} }
}; };
@ -185,8 +190,10 @@ export default function Dashboard() {
try { try {
await axios.post("/api/applications/delete", { id }); await axios.post("/api/applications/delete", { id });
getApplications(); getApplications();
toast.success("Application deleted successfully");
} catch (error: any) { } catch (error: any) {
console.log(error.response?.data); console.log(error.response?.data);
toast.error("Failed to delete application");
} }
}; };
@ -215,8 +222,10 @@ export default function Dashboard() {
}); });
getApplications(); getApplications();
setEditId(null); setEditId(null);
toast.success("Application edited successfully");
} catch (error: any) { } catch (error: any) {
console.log(error.response.data); console.log(error.response.data);
toast.error("Failed to edit application");
} }
}; };
@ -280,6 +289,7 @@ export default function Dashboard() {
</Breadcrumb> </Breadcrumb>
</div> </div>
</header> </header>
<Toaster />
<div className="p-6"> <div className="p-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-3xl font-bold">Your Applications</span> <span className="text-3xl font-bold">Your Applications</span>

View File

@ -13,7 +13,7 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Plus, Plus,
Link, Link as LinkIcon,
MonitorIcon as MonitorCog, MonitorIcon as MonitorCog,
FileDigit, FileDigit,
Trash2, Trash2,
@ -26,6 +26,7 @@ import {
HardDrive, HardDrive,
LucideServer, LucideServer,
Copy, Copy,
History,
} from "lucide-react" } from "lucide-react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { import {
@ -52,34 +53,51 @@ import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import Cookies from "js-cookie" import Cookies from "js-cookie"
import { useState, useEffect } from "react" import { useState, useEffect, useRef } from "react"
import axios from "axios" import axios from "axios"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { DynamicIcon } from "lucide-react/dynamic" import { DynamicIcon } from "lucide-react/dynamic"
import { StatusIndicator } from "@/components/status-indicator" import { StatusIndicator } from "@/components/status-indicator"
import Chart from 'chart.js/auto'
import NextLink from "next/link"
import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner"
interface ServerHistory {
labels: string[];
datasets: {
cpu: number[];
ram: number[];
disk: number[];
online: boolean[];
}
}
interface Server { interface Server {
id: number id: number;
name: string name: string;
icon: string icon: string;
host: boolean host: boolean;
hostServer: number | null hostServer: number | null;
os?: string os?: string;
ip?: string ip?: string;
url?: string url?: string;
cpu?: string cpu?: string;
gpu?: string gpu?: string;
ram?: string ram?: string;
disk?: string disk?: string;
hostedVMs?: Server[] hostedVMs?: Server[];
isVM?: boolean isVM?: boolean;
monitoring?: boolean monitoring?: boolean;
monitoringURL?: string monitoringURL?: string;
online?: boolean online?: boolean;
cpuUsage?: number cpuUsage: number;
ramUsage?: number ramUsage: number;
diskUsage?: number diskUsage: number;
history?: ServerHistory;
port: number;
} }
interface GetServersResponse { interface GetServersResponse {
@ -192,8 +210,10 @@ export default function Dashboard() {
setMonitoring(false) setMonitoring(false)
setMonitoringURL("") setMonitoringURL("")
getServers() getServers()
toast.success("Server added successfully");
} catch (error: any) { } catch (error: any) {
console.log(error.response.data) console.log(error.response.data)
toast.error("Failed to add server");
} }
} }
@ -209,10 +229,12 @@ export default function Dashboard() {
console.log("ID:" + server.id) console.log("ID:" + server.id)
} }
setServers(response.data.servers) setServers(response.data.servers)
console.log(response.data.servers)
setMaxPage(response.data.maxPage) setMaxPage(response.data.maxPage)
setLoading(false) setLoading(false)
} catch (error: any) { } catch (error: any) {
console.log(error.response) console.log(error.response)
toast.error("Failed to fetch servers");
} }
} }
@ -232,8 +254,10 @@ export default function Dashboard() {
try { try {
await axios.post("/api/servers/delete", { id }) await axios.post("/api/servers/delete", { id })
getServers() getServers()
toast.success("Server deleted successfully");
} catch (error: any) { } catch (error: any) {
console.log(error.response.data) console.log(error.response.data)
toast.error("Failed to delete server");
} }
} }
@ -276,8 +300,10 @@ export default function Dashboard() {
}) })
getServers() getServers()
setEditId(null) setEditId(null)
toast.success("Server edited successfully");
} catch (error: any) { } catch (error: any) {
console.log(error.response.data) console.log(error.response.data)
toast.error("Failed to edit server");
} }
} }
@ -408,6 +434,7 @@ export default function Dashboard() {
); );
} catch (error) { } catch (error) {
console.error("Error updating monitoring data:", error); console.error("Error updating monitoring data:", error);
toast.error("Failed to update monitoring data");
} }
}; };
@ -449,6 +476,7 @@ export default function Dashboard() {
</Breadcrumb> </Breadcrumb>
</div> </div>
</header> </header>
<Toaster />
<div className="p-6"> <div className="p-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-3xl font-bold">Your Servers</span> <span className="text-3xl font-bold">Your Servers</span>
@ -544,7 +572,7 @@ export default function Dashboard() {
<SelectValue placeholder="Select an icon"> <SelectValue placeholder="Select an icon">
{icon && ( {icon && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DynamicIcon name={icon as any} color="white" size={18} /> <DynamicIcon name={icon as any} size={18} />
<span>{icon}</span> <span>{icon}</span>
</div> </div>
)} )}
@ -581,7 +609,7 @@ export default function Dashboard() {
data-icon-name={iconName} data-icon-name={iconName}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DynamicIcon name={iconName as any} color="white" size={18} /> <DynamicIcon name={iconName as any} size={18} />
<span>{iconName}</span> <span>{iconName}</span>
</div> </div>
</SelectItem> </SelectItem>
@ -595,7 +623,7 @@ export default function Dashboard() {
<div className="grid w-[52px] items-center gap-1.5"> <div className="grid w-[52px] items-center gap-1.5">
<Label htmlFor="icon">Preview</Label> <Label htmlFor="icon">Preview</Label>
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
{icon && <DynamicIcon name={icon as any} color="white" size={36} />} {icon && <DynamicIcon name={icon as any} size={36} />}
</div> </div>
</div> </div>
</div> </div>
@ -822,26 +850,29 @@ export default function Dashboard() {
<div className={isGridLayout ? "grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4" : "space-y-4"}> <div className={isGridLayout ? "grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4" : "space-y-4"}>
{servers {servers
.filter((server) => (searchTerm ? true : server.hostServer === 0)) .filter((server) => (searchTerm ? true : server.hostServer === 0))
.map((server) => ( .map((server) => {
return (
<Card <Card
key={server.id} key={server.id}
className={`${isGridLayout ? "h-full flex flex-col justify-between" : "w-full mb-4"} hover:shadow-md transition-all duration-200 max-w-full relative`} className={`${isGridLayout ? "h-full flex flex-col justify-between" : "w-full mb-4"} hover:shadow-md transition-all duration-200 max-w-full relative`}
> >
<CardHeader> <CardHeader>
{server.monitoring && ( {server.monitoring && (
<div className="absolute top-2 right-2"> <div className="absolute top-4 right-4">
<StatusIndicator isOnline={server.online} /> <StatusIndicator isOnline={server.online} />
</div> </div>
)} )}
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center w-4/6"> <div className="flex items-center w-full">
<div className="ml-4 flex-grow"> <div className="ml-4 flex-grow">
<CardTitle className="text-2xl font-bold flex items-center gap-2"> <CardTitle className="text-2xl font-bold flex items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{server.icon && <DynamicIcon name={server.icon as any} color="white" size={24} />} {server.icon && <DynamicIcon name={server.icon as any} size={24} />}
<NextLink href={`/dashboard/servers/${server.id}`} className="hover:underline">
<span className="font-bold"> <span className="font-bold">
{server.icon && "・"} {server.name} {server.icon && "・"} {server.name}
</span> </span>
</NextLink>
</div> </div>
{server.isVM && ( {server.isVM && (
<span className="bg-blue-500 text-white text-xs px-2 py-1 rounded">VM</span> <span className="bg-blue-500 text-white text-xs px-2 py-1 rounded">VM</span>
@ -915,7 +946,7 @@ export default function Dashboard() {
<div className="col-span-full"> <div className="col-span-full">
<h4 className="text-sm font-semibold mb-3">Resource Usage</h4> <h4 className="text-sm font-semibold mb-3">Resource Usage</h4>
<div className={`${!isGridLayout ? "grid grid-cols-3 gap-4" : "space-y-3"}`}> <div className={`${!isGridLayout ? "grid grid-cols-[1fr_1fr_1fr_auto] gap-4" : "space-y-3"}`}>
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -970,321 +1001,65 @@ export default function Dashboard() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<div className="w-1/6" /> </div>
<div className="flex flex-col items-end justify-start space-y-2 w-1/6"> </CardHeader>
<div className="flex items-center justify-end gap-2 w-full"> <div className="px-6">
<div className="flex flex-col items-end gap-2"> <div className="flex gap-2 mt-2 mb-2">
<div className="flex gap-2"> <NextLink href={`/dashboard/servers/${server.id}`} className="flex-1">
<Button variant="outline" className="w-full">
<History className="h-4 w-4 mr-2" />
View Details
</Button>
</NextLink>
{server.url && ( {server.url && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="gap-2" size="icon"
onClick={() => window.open(server.url, "_blank")} onClick={() => window.open(server.url, "_blank")}
> >
<Link className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
</Button> </Button>
</TooltipTrigger>
<TooltipContent>Open Management URL</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="destructive" variant="outline"
size="icon" size="icon"
className="h-9 w-9" onClick={() => {
onClick={() => deleteApplication(server.id)} openEditDialog(server);
// Open the dialog after setting the values
const dialogTriggerButton = document.getElementById(`edit-dialog-trigger-${server.id}`);
if (dialogTriggerButton) {
dialogTriggerButton.click();
}
}}
> >
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" className="h-9 w-9" onClick={() => openEditDialog(server)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</AlertDialogTrigger> </TooltipTrigger>
<AlertDialogContent> <TooltipContent>Edit server</TooltipContent>
<AlertDialogHeader> </Tooltip>
<AlertDialogTitle>Edit Server</AlertDialogTitle> </TooltipProvider>
<AlertDialogDescription>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="hardware">Hardware</TabsTrigger>
<TabsTrigger value="virtualization">Virtualization</TabsTrigger>
{(!editHostServer || editHostServer === 0) && <TabsTrigger value="monitoring">Monitoring</TabsTrigger>}
</TabsList>
<TabsContent value="general">
<div className="space-y-4 pt-4">
<div className="flex items-center gap-2">
<div className="grid w-[calc(100%-52px)] items-center gap-1.5">
<Label htmlFor="icon">Icon</Label>
<div className="space-y-2">
<Select
value={editIcon}
onValueChange={(value) => setEditIcon(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an icon">
{editIcon && (
<div className="flex items-center gap-2">
<DynamicIcon
name={editIcon as any}
color="white"
size={18}
/>
<span>{editIcon}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<Input
placeholder="Search icons..."
className="mb-2"
onChange={(e) => {
const iconElements =
document.querySelectorAll("[data-edit-icon-item]")
const searchTerm = e.target.value.toLowerCase()
iconElements.forEach((el) => { {server.host && server.hostedVMs && server.hostedVMs.length > 0 && (
const iconName = <TooltipProvider>
el.getAttribute("data-icon-name")?.toLowerCase() || "" <Tooltip>
if (iconName.includes(searchTerm)) { <TooltipTrigger asChild>
;(el as HTMLElement).style.display = "flex"
} else {
;(el as HTMLElement).style.display = "none"
}
})
}}
/>
{Object.entries(iconCategories).map(
([category, categoryIcons]) => (
<div key={category} className="mb-2">
<div className="px-2 text-xs font-bold text-muted-foreground mb-1">
{category}
</div>
{categoryIcons.map((iconName) => (
<SelectItem
key={iconName}
value={iconName}
data-edit-icon-item
data-icon-name={iconName}
>
<div className="flex items-center gap-2">
<DynamicIcon
name={iconName as any}
color="white"
size={18}
/>
<span>{iconName}</span>
</div>
</SelectItem>
))}
</div>
),
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid w-[52px] items-center gap-1.5">
<Label htmlFor="icon">Preview</Label>
<div className="flex items-center justify-center">
{editIcon && (
<DynamicIcon name={editIcon as any} color="white" size={36} />
)}
</div>
</div>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editName">Name</Label>
<Input
id="editName"
type="text"
placeholder="e.g. Server1"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editOs">Operating System</Label>
<Select value={editOs} onValueChange={setEditOs}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Windows">Windows</SelectItem>
<SelectItem value="Linux">Linux</SelectItem>
<SelectItem value="MacOS">MacOS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editIp">IP Adress</Label>
<Input
id="editIp"
type="text"
placeholder="e.g. 192.168.100.2"
value={editIp}
onChange={(e) => setEditIp(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editUrl">Management URL</Label>
<Input
id="editUrl"
type="text"
placeholder="e.g. https://proxmox.server1.com"
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
/>
</div>
</div>
</TabsContent>
<TabsContent value="hardware">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editCpu">CPU</Label>
<Input
id="editCpu"
value={editCpu}
onChange={(e) => setEditCpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editGpu">GPU</Label>
<Input
id="editGpu"
value={editGpu}
onChange={(e) => setEditGpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editRam">RAM</Label>
<Input
id="editRam"
value={editRam}
onChange={(e) => setEditRam(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editDisk">Disk</Label>
<Input
id="editDisk"
value={editDisk}
onChange={(e) => setEditDisk(e.target.value)}
/>
</div>
</div>
</TabsContent>
<TabsContent value="virtualization">
<div className="space-y-4 pt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="editHostCheckbox"
checked={editHost}
onCheckedChange={(checked) => setEditHost(checked === true)}
disabled={server.hostedVMs && server.hostedVMs.length > 0}
/>
<Label htmlFor="editHostCheckbox">
Mark as host server
{server.hostedVMs && server.hostedVMs.length > 0 && (
<span className="text-muted-foreground text-sm ml-2">
(Cannot be disabled while hosting VMs)
</span>
)}
</Label>
</div>
{!editHost && (
<div className="grid w-full items-center gap-1.5">
<Label>Host Server</Label>
<Select
value={editHostServer?.toString()}
onValueChange={(value) => {
const newHostServer = Number(value);
setEditHostServer(newHostServer);
if (newHostServer !== 0) {
setEditMonitoring(false);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a host server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No host server</SelectItem>
{hostServers
.filter((server) => server.id !== editId)
.map((server) => (
<SelectItem key={server.id} value={server.id.toString()}>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="space-y-4 pt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="editMonitoringCheckbox"
checked={editMonitoring}
onCheckedChange={(checked) => setEditMonitoring(checked === true)}
/>
<Label htmlFor="editMonitoringCheckbox">Enable monitoring</Label>
</div>
{editMonitoring && (
<>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editMonitoringURL">Monitoring URL</Label>
<Input
id="editMonitoringURL"
type="text"
placeholder={`http://${editIp}:61208`}
value={editMonitoringURL}
onChange={(e) => setEditMonitoringURL(e.target.value)}
/>
</div>
<div className="mt-4 p-4 border rounded-lg bg-muted">
<h4 className="text-sm font-semibold mb-2">Required Server Setup</h4>
<p className="text-sm text-muted-foreground mb-3">
To enable monitoring, you need to install Glances on your server. Here's an example Docker Compose configuration:
</p>
<pre className="bg-background p-4 rounded-md text-sm">
<code>{`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`}</code>
</pre>
</div>
</>
)}
</div>
</TabsContent>
</Tabs>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button onClick={edit}>Save</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{server.hostedVMs && server.hostedVMs.length > 0 && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" className="h-9 flex items-center gap-2 px-3 w-full"> <Button variant="outline" size="icon">
<LucideServer className="h-4 w-4" /> <LucideServer className="h-4 w-4" />
<span>VMs</span>
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@ -1305,7 +1080,6 @@ export default function Dashboard() {
{hostedVM.icon && ( {hostedVM.icon && (
<DynamicIcon <DynamicIcon
name={hostedVM.icon as any} name={hostedVM.icon as any}
color="white"
size={24} size={24}
/> />
)} )}
@ -1315,13 +1089,15 @@ export default function Dashboard() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-foreground/80"> <div className="flex items-center gap-2 text-foreground/80">
{ hostedVM.url && (
<Button <Button
variant="outline" variant="outline"
className="gap-2" className="gap-2"
onClick={() => window.open(hostedVM.url, "_blank")} onClick={() => window.open(hostedVM.url, "_blank")}
> >
<Link className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
</Button> </Button>
)}
<Button <Button
variant="destructive" variant="destructive"
size="icon" size="icon"
@ -1371,7 +1147,6 @@ export default function Dashboard() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DynamicIcon <DynamicIcon
name={editIcon as any} name={editIcon as any}
color="white"
size={18} size={18}
/> />
<span>{editIcon}</span> <span>{editIcon}</span>
@ -1431,7 +1206,6 @@ export default function Dashboard() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DynamicIcon <DynamicIcon
name={iconName as any} name={iconName as any}
color="white"
size={18} size={18}
/> />
<span>{iconName}</span> <span>{iconName}</span>
@ -1451,7 +1225,6 @@ export default function Dashboard() {
{editIcon && ( {editIcon && (
<DynamicIcon <DynamicIcon
name={editIcon as any} name={editIcon as any}
color="white"
size={36} size={36}
/> />
)} )}
@ -1614,7 +1387,7 @@ export default function Dashboard() {
</div> </div>
</div> </div>
<div className="col-span-fullpb-2"> <div className="col-span-full pb-2">
<Separator /> <Separator />
</div> </div>
@ -1738,14 +1511,327 @@ export default function Dashboard() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</TooltipTrigger>
<TooltipContent>View VMs ({server.hostedVMs.length})</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
id={`edit-dialog-trigger-${server.id}`}
className="hidden"
>
Hidden Trigger
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Edit {server.name}</AlertDialogTitle>
<AlertDialogDescription>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="hardware">Hardware</TabsTrigger>
<TabsTrigger value="virtualization">Virtualization</TabsTrigger>
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
</TabsList>
<TabsContent value="general">
<div className="space-y-4 pt-4">
<div className="flex items-center gap-2">
<div className="grid w-[calc(100%-52px)] items-center gap-1.5">
<Label htmlFor="editIcon">Icon</Label>
<div className="space-y-2">
<Select
value={editIcon}
onValueChange={(value) => setEditIcon(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an icon">
{editIcon && (
<div className="flex items-center gap-2">
<DynamicIcon name={editIcon as any} size={18} />
<span>{editIcon}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<Input
placeholder="Search icons..."
className="mb-2"
onChange={(e) => {
const iconElements = document.querySelectorAll(
"[data-icon-item]"
)
const searchTerm = e.target.value.toLowerCase()
iconElements.forEach((el) => {
const iconName =
el.getAttribute("data-icon-name")?.toLowerCase() || ""
if (iconName.includes(searchTerm)) {
;(el as HTMLElement).style.display = "flex"
} else {
;(el as HTMLElement).style.display = "none"
}
})
}}
/>
{Object.entries(iconCategories).map(
([category, categoryIcons]) => (
<div key={category} className="mb-2">
<div className="px-2 text-xs font-bold text-muted-foreground mb-1">
{category}
</div>
{categoryIcons.map((iconName) => (
<SelectItem
key={iconName}
value={iconName}
data-icon-item
data-icon-name={iconName}
>
<div className="flex items-center gap-2">
<DynamicIcon name={iconName as any} size={18} />
<span>{iconName}</span>
</div>
</SelectItem>
))}
</div>
)
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid w-[52px] items-center gap-1.5">
<Label htmlFor="editIcon">Preview</Label>
<div className="flex items-center justify-center">
{editIcon && <DynamicIcon name={editIcon as any} size={36} />}
</div>
</div>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editName">Name</Label>
<Input
id="editName"
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editOs">
Operating System <span className="text-stone-600">(optional)</span>
</Label>
<Select value={editOs} onValueChange={(value) => setEditOs(value)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select OS" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Windows">Windows</SelectItem>
<SelectItem value="Linux">Linux</SelectItem>
<SelectItem value="MacOS">MacOS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editIp">
IP Address <span className="text-stone-600">(optional)</span>
</Label>
<Input
id="editIp"
type="text"
value={editIp}
onChange={(e) => setEditIp(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editUrl">
Management URL <span className="text-stone-600">(optional)</span>
</Label>
<Input
id="editUrl"
type="text"
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
/>
</div>
</div>
</TabsContent>
<TabsContent value="hardware">
<div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editCpu">CPU</Label>
<Input
id="editCpu"
value={editCpu}
onChange={(e) => setEditCpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editGpu">GPU</Label>
<Input
id="editGpu"
value={editGpu}
onChange={(e) => setEditGpu(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editRam">RAM</Label>
<Input
id="editRam"
value={editRam}
onChange={(e) => setEditRam(e.target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editDisk">Disk</Label>
<Input
id="editDisk"
value={editDisk}
onChange={(e) => setEditDisk(e.target.value)}
/>
</div>
</div>
</TabsContent>
<TabsContent value="virtualization">
<div className="space-y-4 pt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="editHostCheckbox"
checked={editHost}
onCheckedChange={(checked) =>
setEditHost(checked === true)
}
/>
<Label htmlFor="editHostCheckbox">Mark as host server</Label>
</div>
{!editHost && (
<div className="grid w-full items-center gap-1.5">
<Label>Host Server</Label>
<Select
value={editHostServer?.toString()}
onValueChange={(value) => {
const newHostServer = Number(value);
setEditHostServer(newHostServer);
if (newHostServer !== 0) {
setEditMonitoring(false);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a host server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No host server</SelectItem>
{hostServers
.filter(
(server) => server.id !== editId,
)
.map((server) => (
<SelectItem key={server.id} value={server.id.toString()}>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)} )}
</div> </div>
</TabsContent>
<TabsContent value="monitoring">
<div className="space-y-4 pt-4">
<div className="flex items-center space-x-2">
<Checkbox
id="editMonitoringCheckbox"
checked={editMonitoring}
onCheckedChange={(checked) => setEditMonitoring(checked === true)}
/>
<Label htmlFor="editMonitoringCheckbox">Enable monitoring</Label>
</div>
{editMonitoring && (
<>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="editMonitoringURL">Monitoring URL</Label>
<Input
id="editMonitoringURL"
type="text"
placeholder={`http://${editIp}:61208`}
value={editMonitoringURL}
onChange={(e) => setEditMonitoringURL(e.target.value)}
/>
</div>
<div className="mt-4 p-4 border rounded-lg bg-muted">
<h4 className="text-sm font-semibold mb-2">Required Server Setup</h4>
<p className="text-sm text-muted-foreground mb-3">
To enable monitoring, you need to install Glances on your server. Here's an example Docker Compose configuration:
</p>
<pre className="bg-background p-4 rounded-md text-sm">
<code>{`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`}</code>
</pre>
</div>
</>
)}
</div>
</TabsContent>
</Tabs>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button onClick={edit}>Save</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="icon">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {server.name}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this server? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteApplication(server.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete server</TooltipContent>
</Tooltip>
</TooltipProvider>
</div> </div>
</div> </div>
</div>
</CardHeader>
</Card> </Card>
))} )
})}
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">

View File

@ -0,0 +1,735 @@
"use client"
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import axios from "axios"
import Chart from 'chart.js/auto'
import { AppSidebar } from "@/components/app-sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { Separator } from "@/components/ui/separator"
import { Link, Cpu, MicroscopeIcon as Microchip, MemoryStick, HardDrive, MonitorIcon as MonitorCog, FileDigit, History } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusIndicator } from "@/components/status-indicator"
import { DynamicIcon } from "lucide-react/dynamic"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import NextLink from "next/link"
interface ServerHistory {
labels: string[];
datasets: {
cpu: (number | null)[];
ram: (number | null)[];
disk: (number | null)[];
online: (boolean | null)[];
}
}
interface Server {
id: number;
name: string;
icon: string;
host: boolean;
hostServer: number | null;
os?: string;
ip?: string;
url?: string;
cpu?: string;
gpu?: string;
ram?: string;
disk?: string;
hostedVMs?: Server[];
isVM?: boolean;
monitoring?: boolean;
monitoringURL?: string;
online?: boolean;
cpuUsage: number;
ramUsage: number;
diskUsage: number;
history?: ServerHistory;
port: number;
}
interface GetServersResponse {
servers: Server[];
maxPage: number;
}
export default function ServerDetail() {
const params = useParams()
const serverId = params.server_id as string
const [server, setServer] = useState<Server | null>(null)
const [timeRange, setTimeRange] = useState<'1h' | '1d' | '7d' | '30d'>('1h')
const [loading, setLoading] = useState(true)
// Chart references
const cpuChartRef = { current: null as Chart | null }
const ramChartRef = { current: null as Chart | null }
const diskChartRef = { current: null as Chart | null }
const fetchServerDetails = async () => {
try {
setLoading(true)
const response = await axios.post<GetServersResponse>("/api/servers/get", {
serverId: parseInt(serverId),
timeRange: timeRange
})
if (response.data.servers && response.data.servers.length > 0) {
setServer(response.data.servers[0])
}
setLoading(false)
} catch (error) {
console.error("Failed to fetch server details:", error)
setLoading(false)
}
}
useEffect(() => {
fetchServerDetails()
}, [serverId, timeRange])
useEffect(() => {
if (!server || !server.history) return;
// Clean up existing charts
if (cpuChartRef.current) cpuChartRef.current.destroy();
if (ramChartRef.current) ramChartRef.current.destroy();
if (diskChartRef.current) diskChartRef.current.destroy();
// Wait for DOM to be ready
const initTimer = setTimeout(() => {
const history = server.history as ServerHistory;
// Format time labels based on the selected time range
const timeLabels = history.labels.map((date: string) => {
const d = new Date(date)
if (timeRange === '1h') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
} else if (timeRange === '1d') {
// For 1 day, show hours and minutes
return d.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
} else if (timeRange === '7d') {
// For 7 days, show day and time
return d.toLocaleDateString([], {
weekday: 'short',
month: 'numeric',
day: 'numeric'
}) + ' ' + d.toLocaleTimeString([], {
hour: '2-digit'
})
} else {
// For 30 days
return d.toLocaleDateString([], {
month: 'numeric',
day: 'numeric'
})
}
})
// Create a time range title for the chart
const getRangeTitle = () => {
const now = new Date()
const startDate = new Date(history.labels[0])
if (timeRange === '1h') {
return `Last Hour (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
} else if (timeRange === '1d') {
return `Last 24 Hours (${startDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`
} else if (timeRange === '7d') {
return `Last 7 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
} else {
return `Last 30 Days (${startDate.toLocaleDateString([], { month: 'short', day: 'numeric' })} - ${now.toLocaleDateString([], { month: 'short', day: 'numeric' })})`
}
}
// Directly hardcode the y-axis maximum in each chart option
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'nearest' as const,
axis: 'x' as const,
intersect: false
},
scales: {
y: {
min: 0,
max: 100,
beginAtZero: true,
ticks: {
stepSize: 25,
autoSkip: false,
callback: function(value: any) {
return value + '%';
}
},
title: {
display: true,
text: 'Usage %'
}
},
x: {
grid: {
display: false
}
}
},
elements: {
point: {
radius: 0
},
line: {
tension: 0.4,
spanGaps: true
}
}
};
// Create charts with very explicit y-axis max values
const cpuCanvas = document.getElementById(`cpu-chart`) as HTMLCanvasElement
if (cpuCanvas) {
cpuChartRef.current = new Chart(cpuCanvas, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: 'CPU Usage',
data: history.datasets.cpu,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
fill: true,
spanGaps: false
}]
},
options: {
...commonOptions,
plugins: {
title: {
display: true,
text: 'CPU Usage History',
font: {
size: 14
}
},
tooltip: {
callbacks: {
title: function(tooltipItems: any) {
return timeLabels[tooltipItems[0].dataIndex];
}
}
},
legend: {
display: false
}
},
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
max: 100 // Force this to ensure it's applied
}
}
}
})
}
const ramCanvas = document.getElementById(`ram-chart`) as HTMLCanvasElement
if (ramCanvas) {
ramChartRef.current = new Chart(ramCanvas, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: 'RAM Usage',
data: history.datasets.ram,
borderColor: 'rgb(153, 102, 255)',
backgroundColor: 'rgba(153, 102, 255, 0.1)',
fill: true,
spanGaps: false
}]
},
options: {
...commonOptions,
plugins: {
title: {
display: true,
text: 'RAM Usage History',
font: {
size: 14
}
},
tooltip: {
callbacks: {
title: function(tooltipItems: any) {
return timeLabels[tooltipItems[0].dataIndex];
}
}
},
legend: {
display: false
}
},
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
max: 100 // Force this to ensure it's applied
}
}
}
})
}
const diskCanvas = document.getElementById(`disk-chart`) as HTMLCanvasElement
if (diskCanvas) {
diskChartRef.current = new Chart(diskCanvas, {
type: 'line',
data: {
labels: timeLabels,
datasets: [{
label: 'Disk Usage',
data: history.datasets.disk,
borderColor: 'rgb(255, 159, 64)',
backgroundColor: 'rgba(255, 159, 64, 0.1)',
fill: true,
spanGaps: false
}]
},
options: {
...commonOptions,
plugins: {
title: {
display: true,
text: 'Disk Usage History',
font: {
size: 14
}
},
tooltip: {
callbacks: {
title: function(tooltipItems: any) {
return timeLabels[tooltipItems[0].dataIndex];
}
}
},
legend: {
display: false
}
},
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
max: 100 // Force this to ensure it's applied
}
}
}
})
}
}, 100);
return () => {
clearTimeout(initTimer);
if (cpuChartRef.current) cpuChartRef.current.destroy();
if (ramChartRef.current) ramChartRef.current.destroy();
if (diskChartRef.current) diskChartRef.current.destroy();
};
}, [server, timeRange]);
// Function to refresh data
const refreshData = () => {
fetchServerDetails()
}
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>
<NextLink href="/dashboard/servers" className="hover:underline">
<BreadcrumbPage>Servers</BreadcrumbPage>
</NextLink>
</BreadcrumbItem>
{server && (
<>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>{server.name}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="p-6">
{loading ? (
<div className="flex items-center justify-center h-64">
<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 clipPath="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"
strokeWidth="1.4"
strokeLinecap="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>
) : server ? (
<div className="space-y-6">
{/* Server header card */}
<Card>
<CardHeader className="relative">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{server.icon && <DynamicIcon name={server.icon as any} size={32} />}
<div>
<CardTitle className="text-2xl flex items-center gap-2">
{server.name}
</CardTitle>
<CardDescription>
{server.os || "No OS specified"} {server.isVM ? "Virtual Machine" : "Physical Server"}
{server.isVM && server.hostServer && (
<> Hosted on {server.hostedVMs?.[0]?.name}</>
)}
</CardDescription>
</div>
</div>
</div>
{server.monitoring && (
<div className="absolute top-0 right-4">
<StatusIndicator isOnline={server.online} />
</div>
)}
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Hardware</h3>
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
<div className="text-muted-foreground">CPU:</div>
<div>{server.cpu || "-"}</div>
<div className="text-muted-foreground">GPU:</div>
<div>{server.gpu || "-"}</div>
<div className="text-muted-foreground">RAM:</div>
<div>{server.ram || "-"}</div>
<div className="text-muted-foreground">Disk:</div>
<div>{server.disk || "-"}</div>
</div>
</div>
<div className="space-y-2">
<h3 className="text-sm font-medium">Network</h3>
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
<div className="text-muted-foreground">IP Address:</div>
<div>{server.ip || "-"}</div>
<div className="text-muted-foreground">Management URL:</div>
<div>
{server.url ? (
<a href={server.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-500 hover:underline">
{server.url} <Link className="h-3 w-3" />
</a>
) : (
"-"
)}
</div>
</div>
</div>
{server.monitoring && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Current Usage</h3>
<div className="grid grid-cols-[120px_1fr] text-sm gap-1">
<div className="text-muted-foreground">CPU Usage:</div>
<div className="flex items-center gap-2">
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full ${server.cpuUsage > 80 ? "bg-destructive" : server.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${server.cpuUsage}%` }}
/>
</div>
<span>{server.cpuUsage}%</span>
</div>
<div className="text-muted-foreground">RAM Usage:</div>
<div className="flex items-center gap-2">
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full ${server.ramUsage > 80 ? "bg-destructive" : server.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${server.ramUsage}%` }}
/>
</div>
<span>{server.ramUsage}%</span>
</div>
<div className="text-muted-foreground">Disk Usage:</div>
<div className="flex items-center gap-2">
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full ${server.diskUsage > 80 ? "bg-destructive" : server.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${server.diskUsage}%` }}
/>
</div>
<span>{server.diskUsage}%</span>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Charts */}
{server.monitoring && server.history && (
<div className="grid grid-cols-1 gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Resource Usage History</CardTitle>
<CardDescription>
{timeRange === '1h'
? 'Last hour, per minute'
: timeRange === '1d'
? 'Last 24 hours, 15-minute intervals'
: timeRange === '7d'
? 'Last 7 days, hourly intervals'
: 'Last 30 days, 4-hour intervals'}
</CardDescription>
</div>
<div className="flex gap-2">
<Select value={timeRange} onValueChange={(value: '1h' | '1d' | '7d' | '30d') => setTimeRange(value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last Hour</SelectItem>
<SelectItem value="1d">Last 24 Hours</SelectItem>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={refreshData}>Refresh</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-8">
<div className="h-[200px] relative bg-background">
<canvas id="cpu-chart" />
</div>
<div className="h-[200px] relative bg-background">
<canvas id="ram-chart" />
</div>
<div className="h-[200px] relative bg-background">
<canvas id="disk-chart" />
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Virtual Machines */}
{server.hostedVMs && server.hostedVMs.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Virtual Machines</CardTitle>
<CardDescription>Virtual machines hosted on this server</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{server.hostedVMs.map((hostedVM) => (
<div
key={hostedVM.id}
className="flex flex-col gap-2 border border-muted py-2 px-4 rounded-md"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{hostedVM.icon && (
<DynamicIcon
name={hostedVM.icon as any}
size={24}
/>
)}
<NextLink href={`/dashboard/servers/${hostedVM.id}`} className="hover:underline">
<div className="text-base font-extrabold">
{hostedVM.icon && "・ "}
{hostedVM.name}
</div>
</NextLink>
</div>
{hostedVM.monitoring && (
<StatusIndicator isOnline={hostedVM.online} />
)}
</div>
<div className="col-span-full pb-2">
<Separator />
</div>
<div className="flex gap-5 pb-2">
<div className="flex items-center gap-2 text-foreground/80">
<MonitorCog className="h-4 w-4 text-muted-foreground" />
<span>
<b>OS:</b> {hostedVM.os || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<FileDigit className="h-4 w-4 text-muted-foreground" />
<span>
<b>IP:</b> {hostedVM.ip || "Not set"}
</span>
</div>
</div>
<div className="col-span-full mb-2">
<h4 className="text-sm font-semibold">Hardware Information</h4>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span>
<b>CPU:</b> {hostedVM.cpu || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<Microchip className="h-4 w-4 text-muted-foreground" />
<span>
<b>GPU:</b> {hostedVM.gpu || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span>
<b>RAM:</b> {hostedVM.ram || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span>
<b>Disk:</b> {hostedVM.disk || "-"}
</span>
</div>
{hostedVM.monitoring && (
<>
<div className="col-span-full pt-2 pb-2">
<Separator />
</div>
<div className="col-span-full grid grid-cols-3 gap-4">
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">CPU</span>
</div>
<span className="text-xs font-medium">
{hostedVM.cpuUsage || 0}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
<div
className={`h-full ${hostedVM.cpuUsage && hostedVM.cpuUsage > 80 ? "bg-destructive" : hostedVM.cpuUsage && hostedVM.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${hostedVM.cpuUsage || 0}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">RAM</span>
</div>
<span className="text-xs font-medium">
{hostedVM.ramUsage || 0}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
<div
className={`h-full ${hostedVM.ramUsage && hostedVM.ramUsage > 80 ? "bg-destructive" : hostedVM.ramUsage && hostedVM.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${hostedVM.ramUsage || 0}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Disk</span>
</div>
<span className="text-xs font-medium">
{hostedVM.diskUsage || 0}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary mt-1">
<div
className={`h-full ${hostedVM.diskUsage && hostedVM.diskUsage > 80 ? "bg-destructive" : hostedVM.diskUsage && hostedVM.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
style={{ width: `${hostedVM.diskUsage || 0}%` }}
/>
</div>
</div>
</div>
</>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
) : (
<div className="text-center p-12">
<h2 className="text-2xl font-bold">Server not found</h2>
<p className="text-muted-foreground mt-2">The requested server could not be found or you don't have permission to view it.</p>
</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 ServerDetail from "./Server"
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 clipPath='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' strokeWidth='1.4' strokeLinecap='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 ? <ServerDetail /> : null;
}

View File

@ -19,7 +19,9 @@ import axios from "axios"
import Cookies from "js-cookie" import Cookies from "js-cookie"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2 } from "lucide-react" import { AlertCircle, Check, Palette, User, Bell, AtSign, Send, MessageSquare, Trash2, Play } from "lucide-react"
import { Toaster } from "@/components/ui/sonner"
import { toast } from "sonner"
import { import {
AlertDialog, AlertDialog,
@ -254,6 +256,17 @@ export default function Settings() {
getNotificationText() getNotificationText()
}, []) }, [])
const testNotification = async (id: number) => {
try {
const response = await axios.post("/api/notifications/test", {
notificationId: id,
})
toast.success("Notification will be sent in a few seconds.")
} catch (error: any) {
toast.error(error.response.data.error)
}
}
return ( return (
<SidebarProvider> <SidebarProvider>
<AppSidebar /> <AppSidebar />
@ -763,6 +776,16 @@ export default function Settings() {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="hover:bg-muted/20"
onClick={() => testNotification(notification.id)}
>
<Play className="h-4 w-4 mr-1" />
Test
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -770,9 +793,9 @@ export default function Settings() {
onClick={() => deleteNotification(notification.id)} onClick={() => deleteNotification(notification.id)}
> >
<Trash2 className="h-4 w-4 mr-1" /> <Trash2 className="h-4 w-4 mr-1" />
Remove
</Button> </Button>
</div> </div>
</div>
)) ))
) : ( ) : (
<div className="text-center py-12 border rounded-lg bg-muted/5"> <div className="text-center py-12 border rounded-lg bg-muted/5">
@ -789,6 +812,7 @@ export default function Settings() {
)} )}
</div> </div>
</div> </div>
<Toaster />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -34,14 +34,18 @@ const timeFormats = {
minute: '2-digit', minute: '2-digit',
hour12: false hour12: false
}), }),
2: (timestamp: string) => { 2: (timestamp: string) =>
const start = new Date(timestamp); new Date(timestamp).toLocaleTimeString([], {
const end = new Date(start.getTime() + 3 * 60 * 60 * 1000); hour: '2-digit',
return `${start.toLocaleDateString([], { day: '2-digit', month: 'short' })} minute: '2-digit',
${start.getHours().toString().padStart(2, '0')}:00 - hour12: false
${end.getHours().toString().padStart(2, '0')}:00`; }),
},
3: (timestamp: string) => 3: (timestamp: string) =>
new Date(timestamp).toLocaleDateString([], {
day: '2-digit',
month: 'short'
}),
4: (timestamp: string) =>
new Date(timestamp).toLocaleDateString([], { new Date(timestamp).toLocaleDateString([], {
day: '2-digit', day: '2-digit',
month: 'short' month: 'short'
@ -49,9 +53,10 @@ const timeFormats = {
}; };
const minBoxWidths = { const minBoxWidths = {
1: 24, 1: 20,
2: 24, 2: 20,
3: 24 3: 24,
4: 24
}; };
interface UptimeData { interface UptimeData {
@ -72,7 +77,7 @@ interface PaginationData {
export default function Uptime() { export default function Uptime() {
const [data, setData] = useState<UptimeData[]>([]); const [data, setData] = useState<UptimeData[]>([]);
const [timespan, setTimespan] = useState<1 | 2 | 3>(1); const [timespan, setTimespan] = useState<1 | 2 | 3 | 4>(1);
const [pagination, setPagination] = useState<PaginationData>({ const [pagination, setPagination] = useState<PaginationData>({
currentPage: 1, currentPage: 1,
totalPages: 1, totalPages: 1,
@ -153,7 +158,7 @@ export default function Uptime() {
<Select <Select
value={String(timespan)} value={String(timespan)}
onValueChange={(v) => { onValueChange={(v) => {
setTimespan(Number(v) as 1 | 2 | 3); setTimespan(Number(v) as 1 | 2 | 3 | 4);
setPagination(prev => ({...prev, currentPage: 1})); setPagination(prev => ({...prev, currentPage: 1}));
}} }}
disabled={isLoading} disabled={isLoading}
@ -162,9 +167,10 @@ export default function Uptime() {
<SelectValue placeholder="Select timespan" /> <SelectValue placeholder="Select timespan" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">Last 30 minutes</SelectItem> <SelectItem value="1">Last 1 hour</SelectItem>
<SelectItem value="2">Last 7 days</SelectItem> <SelectItem value="2">Last 1 day</SelectItem>
<SelectItem value="3">Last 30 days</SelectItem> <SelectItem value="3">Last 7 days</SelectItem>
<SelectItem value="4">Last 30 days</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -219,18 +225,14 @@ export default function Uptime() {
> >
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="font-medium"> <p className="font-medium">
{timespan === 2 ? ( {new Date(entry.timestamp).toLocaleString([], {
timeFormats[2](entry.timestamp)
) : (
new Date(entry.timestamp).toLocaleString([], {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric', day: timespan > 2 ? 'numeric' : undefined,
hour: '2-digit', hour: '2-digit',
minute: timespan === 3 ? undefined : '2-digit', minute: timespan === 1 ? '2-digit' : undefined,
hour12: false hour12: false
}) })}
)}
</p> </p>
<p> <p>
{entry.missing {entry.missing

25
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -19,7 +19,7 @@ export default defineConfig({
footer: { footer: {
message: 'Released under the MIT License.', message: 'Released under the MIT License.',
copyright: 'Copyright © 2025-present CoreControl' copyright: 'Copyright © 2025-present CoreControl',
}, },
search: { search: {
@ -53,12 +53,14 @@ export default defineConfig({
{ text: 'Discord', link: '/notifications/Discord' }, { text: 'Discord', link: '/notifications/Discord' },
{ text: 'Gotify', link: '/notifications/Gotify' }, { text: 'Gotify', link: '/notifications/Gotify' },
{ text: 'Ntfy', link: '/notifications/Ntfy' }, { text: 'Ntfy', link: '/notifications/Ntfy' },
{ text: 'Pushover', link: '/notifications/Pushover' },
] ]
} }
], ],
socialLinks: [ socialLinks: [
{ icon: 'github', link: 'https://github.com/crocofied/corecontrol' } { icon: 'github', link: 'https://github.com/crocofied/corecontrol' },
{ icon: 'buymeacoffee', link: 'https://www.buymeacoffee.com/corecontrol' }
] ]
} }
}) })

View File

@ -8,8 +8,8 @@
<meta name="generator" content="VitePress v1.6.3"> <meta name="generator" content="VitePress v1.6.3">
<link rel="preload stylesheet" href="/assets/style.DEOyzpKL.css" as="style"> <link rel="preload stylesheet" href="/assets/style.DEOyzpKL.css" as="style">
<link rel="preload stylesheet" href="/vp-icons.css" as="style"> <link rel="preload stylesheet" href="/vp-icons.css" as="style">
<script type="module" src="/assets/chunks/metadata.87c7e30c.js"></script> <script type="module" src="/assets/chunks/metadata.b7f24d28.js"></script>
<script type="module" src="/assets/app.CZwi0YgD.js"></script> <script type="module" src="/assets/app.DQZaLSC2.js"></script>
<link rel="preload" href="/assets/inter-roman-latin.Di8DUHzh.woff2" as="font" type="font/woff2" crossorigin=""> <link rel="preload" href="/assets/inter-roman-latin.Di8DUHzh.woff2" as="font" type="font/woff2" crossorigin="">
<link rel="icon" type="image/png" href="/logo.png"> <link rel="icon" type="image/png" href="/logo.png">
<script id="check-dark-mode">(()=>{const e=localStorage.getItem("vitepress-theme-appearance")||"auto",a=window.matchMedia("(prefers-color-scheme: dark)").matches;(!e||e==="auto"?a:e==="dark")&&document.documentElement.classList.add("dark")})();</script> <script id="check-dark-mode">(()=>{const e=localStorage.getItem("vitepress-theme-appearance")||"auto",a=window.matchMedia("(prefers-color-scheme: dark)").matches;(!e||e==="auto"?a:e==="dark")&&document.documentElement.classList.add("dark")})();</script>

View File

@ -1 +1 @@
import{t as p}from"./chunks/theme.CkdfpqM_.js";import{R as s,a2 as i,a3 as u,a4 as c,a5 as l,a6 as f,a7 as d,a8 as m,a9 as h,aa as g,ab as A,d as v,u as y,v as C,s as P,ac as b,ad as w,ae as R,af as E}from"./chunks/framework.DPDPlp3K.js";function r(e){if(e.extends){const a=r(e.extends);return{...a,...e,async enhanceApp(t){a.enhanceApp&&await a.enhanceApp(t),e.enhanceApp&&await e.enhanceApp(t)}}}return e}const n=r(p),S=v({name:"VitePressApp",setup(){const{site:e,lang:a,dir:t}=y();return C(()=>{P(()=>{document.documentElement.lang=a.value,document.documentElement.dir=t.value})}),e.value.router.prefetchLinks&&b(),w(),R(),n.setup&&n.setup(),()=>E(n.Layout)}});async function T(){globalThis.__VITEPRESS__=!0;const e=_(),a=D();a.provide(u,e);const t=c(e.route);return a.provide(l,t),a.component("Content",f),a.component("ClientOnly",d),Object.defineProperties(a.config.globalProperties,{$frontmatter:{get(){return t.frontmatter.value}},$params:{get(){return t.page.value.params}}}),n.enhanceApp&&await n.enhanceApp({app:a,router:e,siteData:m}),{app:a,router:e,data:t}}function D(){return A(S)}function _(){let e=s;return h(a=>{let t=g(a),o=null;return t&&(e&&(t=t.replace(/\.js$/,".lean.js")),o=import(t)),s&&(e=!1),o},n.NotFound)}s&&T().then(({app:e,router:a,data:t})=>{a.go().then(()=>{i(a.route,t.site),e.mount("#app")})});export{T as createApp}; import{t as p}from"./chunks/theme.BTnOYcHU.js";import{R as s,a2 as i,a3 as u,a4 as c,a5 as l,a6 as f,a7 as d,a8 as m,a9 as h,aa as g,ab as A,d as v,u as y,v as C,s as P,ac as b,ad as w,ae as R,af as E}from"./chunks/framework.DPDPlp3K.js";function r(e){if(e.extends){const a=r(e.extends);return{...a,...e,async enhanceApp(t){a.enhanceApp&&await a.enhanceApp(t),e.enhanceApp&&await e.enhanceApp(t)}}}return e}const n=r(p),S=v({name:"VitePressApp",setup(){const{site:e,lang:a,dir:t}=y();return C(()=>{P(()=>{document.documentElement.lang=a.value,document.documentElement.dir=t.value})}),e.value.router.prefetchLinks&&b(),w(),R(),n.setup&&n.setup(),()=>E(n.Layout)}});async function T(){globalThis.__VITEPRESS__=!0;const e=_(),a=D();a.provide(u,e);const t=c(e.route);return a.provide(l,t),a.component("Content",f),a.component("ClientOnly",d),Object.defineProperties(a.config.globalProperties,{$frontmatter:{get(){return t.frontmatter.value}},$params:{get(){return t.page.value.params}}}),n.enhanceApp&&await n.enhanceApp({app:a,router:e,siteData:m}),{app:a,router:e,data:t}}function D(){return A(S)}function _(){let e=s;return h(a=>{let t=g(a),o=null;return t&&(e&&(t=t.replace(/\.js$/,".lean.js")),o=import(t)),s&&(e=!1),o},n.NotFound)}s&&T().then(({app:e,router:a,data:t})=>{a.go().then(()=>{i(a.route,t.site),e.mount("#app")})});export{T as createApp};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
window.__VP_HASH_MAP__=JSON.parse("{\"general_applications.md\":\"DFVqSlCw\",\"general_dashboard.md\":\"DW5yESFW\",\"general_network.md\":\"tbP8aEzX\",\"general_servers.md\":\"BaASA60T\",\"general_settings.md\":\"DrC2XV32\",\"general_uptime.md\":\"CKBdQg4u\",\"index.md\":\"_yXl4OkC\",\"installation.md\":\"Cz1eOHOr\",\"notifications_discord.md\":\"C0x5CxmR\",\"notifications_email.md\":\"Cugw2BRs\",\"notifications_general.md\":\"D7AVsSjD\",\"notifications_gotify.md\":\"vFHjr6ko\",\"notifications_ntfy.md\":\"CPMnGQVP\",\"notifications_telegram.md\":\"B6_EzaEX\"}");window.__VP_SITE_DATA__=JSON.parse("{\"lang\":\"en-US\",\"dir\":\"ltr\",\"title\":\"CoreControl\",\"description\":\"Dashboard to manage your entire server infrastructure\",\"base\":\"/\",\"head\":[],\"router\":{\"prefetchLinks\":true},\"appearance\":true,\"themeConfig\":{\"logo\":\"/logo.png\",\"nav\":[{\"text\":\"Home\",\"link\":\"/\"},{\"text\":\"Installation\",\"link\":\"/installation\"}],\"footer\":{\"message\":\"Released under the MIT License.\",\"copyright\":\"Copyright © 2025-present CoreControl\"},\"search\":{\"provider\":\"local\"},\"sidebar\":[{\"text\":\"Deploy\",\"items\":[{\"text\":\"Installation\",\"link\":\"/installation\"}]},{\"text\":\"General\",\"items\":[{\"text\":\"Dashboard\",\"link\":\"/general/Dashboard\"},{\"text\":\"Servers\",\"link\":\"/general/Servers\"},{\"text\":\"Applications\",\"link\":\"/general/Applications\"},{\"text\":\"Uptime\",\"link\":\"/general/Uptime\"},{\"text\":\"Network\",\"link\":\"/general/Network\"},{\"text\":\"Settings\",\"link\":\"/general/Settings\"}]},{\"text\":\"Notifications\",\"items\":[{\"text\":\"General\",\"link\":\"/notifications/General\"},{\"text\":\"Email\",\"link\":\"/notifications/Email\"},{\"text\":\"Telegram\",\"link\":\"/notifications/Telegram\"},{\"text\":\"Discord\",\"link\":\"/notifications/Discord\"},{\"text\":\"Gotify\",\"link\":\"/notifications/Gotify\"},{\"text\":\"Ntfy\",\"link\":\"/notifications/Ntfy\"}]}],\"socialLinks\":[{\"icon\":\"github\",\"link\":\"https://github.com/crocofied/corecontrol\"}]},\"locales\":{},\"scrollOffset\":134,\"cleanUrls\":true}");

View File

@ -0,0 +1 @@
window.__VP_HASH_MAP__=JSON.parse("{\"general_applications.md\":\"DFVqSlCw\",\"general_dashboard.md\":\"DW5yESFW\",\"general_network.md\":\"tbP8aEzX\",\"general_servers.md\":\"BaASA60T\",\"general_settings.md\":\"DrC2XV32\",\"general_uptime.md\":\"CKBdQg4u\",\"index.md\":\"_yXl4OkC\",\"installation.md\":\"Cz1eOHOr\",\"notifications_discord.md\":\"C0x5CxmR\",\"notifications_email.md\":\"Cugw2BRs\",\"notifications_general.md\":\"D7AVsSjD\",\"notifications_gotify.md\":\"vFHjr6ko\",\"notifications_ntfy.md\":\"CPMnGQVP\",\"notifications_pushover.md\":\"lZwGAQ0A\",\"notifications_telegram.md\":\"B6_EzaEX\"}");window.__VP_SITE_DATA__=JSON.parse("{\"lang\":\"en-US\",\"dir\":\"ltr\",\"title\":\"CoreControl\",\"description\":\"Dashboard to manage your entire server infrastructure\",\"base\":\"/\",\"head\":[],\"router\":{\"prefetchLinks\":true},\"appearance\":true,\"themeConfig\":{\"logo\":\"/logo.png\",\"nav\":[{\"text\":\"Home\",\"link\":\"/\"},{\"text\":\"Installation\",\"link\":\"/installation\"}],\"footer\":{\"message\":\"Released under the MIT License.\",\"copyright\":\"Copyright © 2025-present CoreControl\"},\"search\":{\"provider\":\"local\"},\"sidebar\":[{\"text\":\"Deploy\",\"items\":[{\"text\":\"Installation\",\"link\":\"/installation\"}]},{\"text\":\"General\",\"items\":[{\"text\":\"Dashboard\",\"link\":\"/general/Dashboard\"},{\"text\":\"Servers\",\"link\":\"/general/Servers\"},{\"text\":\"Applications\",\"link\":\"/general/Applications\"},{\"text\":\"Uptime\",\"link\":\"/general/Uptime\"},{\"text\":\"Network\",\"link\":\"/general/Network\"},{\"text\":\"Settings\",\"link\":\"/general/Settings\"}]},{\"text\":\"Notifications\",\"items\":[{\"text\":\"General\",\"link\":\"/notifications/General\"},{\"text\":\"Email\",\"link\":\"/notifications/Email\"},{\"text\":\"Telegram\",\"link\":\"/notifications/Telegram\"},{\"text\":\"Discord\",\"link\":\"/notifications/Discord\"},{\"text\":\"Gotify\",\"link\":\"/notifications/Gotify\"},{\"text\":\"Ntfy\",\"link\":\"/notifications/Ntfy\"},{\"text\":\"Pushover\",\"link\":\"/notifications/Pushover\"}]}],\"socialLinks\":[{\"icon\":\"github\",\"link\":\"https://github.com/crocofied/corecontrol\"},{\"icon\":\"buymeacoffee\",\"link\":\"https://www.buymeacoffee.com/corecontrol\"}]},\"locales\":{},\"scrollOffset\":134,\"cleanUrls\":true}");

View File

@ -0,0 +1 @@
import{_ as s,c as a,o,j as e,a as r}from"./chunks/framework.DPDPlp3K.js";const n="/assets/notifications_pushover.CeUzFKPr.png",m=JSON.parse('{"title":"Pushover","description":"","frontmatter":{},"headers":[],"relativePath":"notifications/Pushover.md","filePath":"notifications/Pushover.md","lastUpdated":1745496781000}'),i={name:"notifications/Pushover.md"};function c(p,t,d,l,u,h){return o(),a("div",null,t[0]||(t[0]=[e("h1",{id:"pushover",tabindex:"-1"},[r("Pushover "),e("a",{class:"header-anchor",href:"#pushover","aria-label":'Permalink to "Pushover"'},"")],-1),e("p",null,[e("img",{src:n,alt:"Set up"})],-1)]))}const v=s(i,[["render",c]]);export{m as __pageData,v as default};

View File

@ -0,0 +1 @@
import{_ as s,c as a,o,j as e,a as r}from"./chunks/framework.DPDPlp3K.js";const n="/assets/notifications_pushover.CeUzFKPr.png",m=JSON.parse('{"title":"Pushover","description":"","frontmatter":{},"headers":[],"relativePath":"notifications/Pushover.md","filePath":"notifications/Pushover.md","lastUpdated":1745496781000}'),i={name:"notifications/Pushover.md"};function c(p,t,d,l,u,h){return o(),a("div",null,t[0]||(t[0]=[e("h1",{id:"pushover",tabindex:"-1"},[r("Pushover "),e("a",{class:"header-anchor",href:"#pushover","aria-label":'Permalink to "Pushover"'},"")],-1),e("p",null,[e("img",{src:n,alt:"Set up"})],-1)]))}const v=s(i,[["render",c]]);export{m as __pageData,v as default};

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"general_applications.md":"DFVqSlCw","general_dashboard.md":"DW5yESFW","general_network.md":"tbP8aEzX","general_servers.md":"BaASA60T","general_settings.md":"DrC2XV32","general_uptime.md":"CKBdQg4u","index.md":"_yXl4OkC","installation.md":"Cz1eOHOr","notifications_discord.md":"C0x5CxmR","notifications_email.md":"Cugw2BRs","notifications_general.md":"D7AVsSjD","notifications_gotify.md":"vFHjr6ko","notifications_ntfy.md":"CPMnGQVP","notifications_telegram.md":"B6_EzaEX"} {"general_applications.md":"DFVqSlCw","general_dashboard.md":"DW5yESFW","general_network.md":"tbP8aEzX","general_servers.md":"BaASA60T","general_settings.md":"DrC2XV32","general_uptime.md":"CKBdQg4u","index.md":"_yXl4OkC","installation.md":"Cz1eOHOr","notifications_discord.md":"C0x5CxmR","notifications_email.md":"Cugw2BRs","notifications_general.md":"D7AVsSjD","notifications_gotify.md":"vFHjr6ko","notifications_ntfy.md":"CPMnGQVP","notifications_pushover.md":"lZwGAQ0A","notifications_telegram.md":"B6_EzaEX"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
.vpi-social-github{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")} .vpi-social-buymeacoffee{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='m20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379c-.197-.069-.42-.098-.57-.241c-.152-.143-.196-.366-.231-.572c-.065-.378-.125-.756-.192-1.133c-.057-.325-.102-.69-.25-.987c-.195-.4-.597-.634-.996-.788a6 6 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a26 26 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5c-.318.116-.646.256-.888.501c-.297.302-.393.77-.177 1.146c.154.267.415.456.692.58c.36.162.737.284 1.123.366c1.075.238 2.189.331 3.287.37q1.829.074 3.65-.118q.449-.05.896-.119c.352-.054.578-.513.474-.834c-.124-.383-.457-.531-.834-.473c-.466.074-.96.108-1.382.146q-1.767.12-3.536.006a22 22 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036q-.364-.055-.724-.13c-.111-.027-.111-.185 0-.212h.005q.416-.09.838-.147h.002c.131-.009.263-.032.394-.048a25 25 0 0 1 3.426-.12q1.011.029 2.017.144l.228.031q.4.06.798.145c.392.085.895.113 1.07.542c.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003l-.112.015a37 37 0 0 1-4.743.295a37 37 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06c-.326-.048-.649-.108-.973-.161c-.393-.065-.768-.032-1.123.161c-.29.16-.527.404-.675.701c-.154.316-.199.66-.267 1c-.069.34-.176.707-.135 1.056c.087.753.613 1.365 1.37 1.502a39.7 39.7 0 0 0 11.343.376a.483.483 0 0 1 .535.53l-.071.697l-1.018 9.907c-.041.41-.047.832-.125 1.237c-.122.637-.553 1.028-1.182 1.171q-.868.197-1.756.205c-.656.004-1.31-.025-1.966-.022c-.699.004-1.556-.06-2.095-.58c-.475-.458-.54-1.174-.605-1.793l-.731-7.013l-.322-3.094c-.037-.351-.286-.695-.678-.678c-.336.015-.718.3-.678.679l.228 2.185l.949 9.112c.147 1.344 1.174 2.068 2.446 2.272c.742.12 1.503.144 2.257.156c.966.016 1.942.053 2.892-.122c1.408-.258 2.465-1.198 2.616-2.657l1.024-9.995l.215-2.087a.48.48 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518c.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233c-2.416.359-4.866.54-7.308.46c-1.748-.06-3.477-.254-5.207-.498c-.17-.024-.353-.055-.47-.18c-.22-.236-.111-.71-.054-.995c.052-.26.152-.609.463-.646c.484-.057 1.046.148 1.526.22q.865.132 1.737.212c2.48.226 5.002.19 7.472-.14q.675-.09 1.345-.21c.399-.072.84-.206 1.08.206c.166.281.188.657.162.974a.54.54 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a6 6 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38c0 0 1.243.065 1.658.065c.447 0 1.786-.065 1.786-.065c.783 0 1.434-.6 1.499-1.38l.94-9.95a4 4 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613'/%3E%3C/svg%3E")}.vpi-social-github{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,3 @@
# Pushover
![Set up](../assets/screenshots/notifications_pushover.png)

View File

@ -1,6 +1,6 @@
{ {
"name": "docs", "name": "docs",
"version": "1.0.0", "version": "0.0.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

2414
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "corecontrol", "name": "corecontrol",
"version": "0.0.8", "version": "0.0.9",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@ -32,6 +32,7 @@
"@xyflow/react": "^12.5.5", "@xyflow/react": "^12.5.5",
"axios": "^1.8.4", "axios": "^1.8.4",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
@ -43,6 +44,7 @@
"postcss-loader": "^8.1.1", "postcss-loader": "^8.1.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.5" "tw-animate-css": "^1.2.5"
}, },

View File

@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "test_notification" (
"id" SERIAL NOT NULL,
"notificationId" INTEGER NOT NULL,
CONSTRAINT "test_notification_pkey" PRIMARY KEY ("id")
);

View File

@ -98,3 +98,8 @@ model notification {
pushoverToken String? pushoverToken String?
pushoverUser String? pushoverUser String?
} }
model test_notification {
id Int @id @default(autoincrement())
notificationId Int
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
screenshots/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
screenshots/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
screenshots/network.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

BIN
screenshots/server.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
screenshots/servers.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
screenshots/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
screenshots/uptime.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB