v0.0.9->main
v0.0.9
@ -4,3 +4,4 @@ npm-debug.log
|
|||||||
agent/
|
agent/
|
||||||
.next
|
.next
|
||||||
docs/
|
docs/
|
||||||
|
screenshots/
|
||||||
21
README.md
@ -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>
|
[](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:
|
||||||

|

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

|

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

|

|
||||||
|
|
||||||
VM Display:
|
Server Detail Page
|
||||||

|

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

|

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

|

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

|

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

|

|
||||||
|
|
||||||
## 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)
|
||||||
|
|
||||||
|
|||||||
108
agent/main.go
@ -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, ¬ificationId); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
23
app/api/notifications/test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@ -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">
|
||||||
|
|||||||
735
app/dashboard/servers/[server_id]/Server.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
app/dashboard/servers/[server_id]/page.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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
@ -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 }
|
||||||
@ -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' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
4
docs/.vitepress/dist/404.html
vendored
@ -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>
|
||||||
|
|||||||
@ -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};
|
||||||
1
docs/.vitepress/dist/assets/chunks/@localSearchIndexroot.CP7uK6Pq.js
vendored
Normal 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}");
|
|
||||||
1
docs/.vitepress/dist/assets/chunks/metadata.b7f24d28.js
vendored
Normal 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}");
|
||||||
1
docs/.vitepress/dist/assets/notifications_Pushover.md.lZwGAQ0A.js
vendored
Normal 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};
|
||||||
1
docs/.vitepress/dist/assets/notifications_Pushover.md.lZwGAQ0A.lean.js
vendored
Normal 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};
|
||||||
BIN
docs/.vitepress/dist/assets/notifications_pushover.CeUzFKPr.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
8
docs/.vitepress/dist/general/Dashboard.html
vendored
8
docs/.vitepress/dist/general/Network.html
vendored
8
docs/.vitepress/dist/general/Servers.html
vendored
8
docs/.vitepress/dist/general/Settings.html
vendored
8
docs/.vitepress/dist/general/Uptime.html
vendored
2
docs/.vitepress/dist/hashmap.json
vendored
@ -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"}
|
||||||
|
|||||||
8
docs/.vitepress/dist/index.html
vendored
8
docs/.vitepress/dist/installation.html
vendored
8
docs/.vitepress/dist/notifications/Ntfy.html
vendored
26
docs/.vitepress/dist/notifications/Pushover.html
vendored
Normal file
2
docs/.vitepress/dist/vp-icons.css
vendored
@ -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")}
|
||||||
BIN
docs/assets/screenshots/notifications_pushover.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
3
docs/notifications/Pushover.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Pushover
|
||||||
|
|
||||||
|

|
||||||
@ -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
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "test_notification" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"notificationId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "test_notification_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@ -98,3 +98,8 @@ model notification {
|
|||||||
pushoverToken String?
|
pushoverToken String?
|
||||||
pushoverUser String?
|
pushoverUser String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model test_notification {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
notificationId Int
|
||||||
|
}
|
||||||
|
|||||||
BIN
screenshots/applications.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
screenshots/dashboard.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
screenshots/login.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
screenshots/network.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
screenshots/server.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
screenshots/servers.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
screenshots/settings.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
screenshots/uptime.png
Normal file
|
After Width: | Height: | Size: 83 KiB |