mirror of
https://github.com/crocofied/CoreControl.git
synced 2025-12-17 15:36:50 +00:00
Server History Page
This commit is contained in:
parent
4381f20146
commit
4e58dc5a0b
@ -1,23 +1,102 @@
|
|||||||
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' | '7d' | '30d';
|
||||||
|
serverId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTimeRange = (timeRange: '1h' | '7d' | '30d' = '1h') => {
|
||||||
|
const now = new Date();
|
||||||
|
switch (timeRange) {
|
||||||
|
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' | '7d' | '30d' = '1h') => {
|
||||||
|
const { start, end, intervalMinutes } = getTimeRange(timeRange);
|
||||||
|
|
||||||
|
let intervalCount: number;
|
||||||
|
switch (timeRange) {
|
||||||
|
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 parseFloat(value.replace('%', ''));
|
||||||
|
};
|
||||||
|
|
||||||
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
|
||||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
? { id: serverId }
|
||||||
take: ITEMS_PER_PAGE,
|
: { hostServer: 0 };
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
let hosts;
|
||||||
|
if (!serverId) {
|
||||||
|
hosts = await prisma.server.findMany({
|
||||||
|
where: hostsQuery,
|
||||||
|
orderBy: { name: 'asc' as Prisma.SortOrder },
|
||||||
|
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||||
|
take: ITEMS_PER_PAGE,
|
||||||
|
});
|
||||||
|
} 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 +105,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 ? arr.reduce((a, b) => a + b, 0) / arr.length : 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 +195,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 ? data.reduce((a, b) => a + b) / data.length : null;
|
||||||
|
}),
|
||||||
|
ram: intervals.map(d => {
|
||||||
|
const data = historyMap.get(d.toISOString())?.ram || [];
|
||||||
|
return data.length ? data.reduce((a, b) => a + b) / data.length : null;
|
||||||
|
}),
|
||||||
|
disk: intervals.map(d => {
|
||||||
|
const data = historyMap.get(d.toISOString())?.disk || [];
|
||||||
|
return data.length ? data.reduce((a, b) => a + b) / data.length : null;
|
||||||
|
}),
|
||||||
|
online: intervals.map(d => {
|
||||||
|
const data = historyMap.get(d.toISOString())?.online || [];
|
||||||
|
return data.length ? data.filter(Boolean).length / data.length >= 0.5 : null;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalHosts = await prisma.server.count({
|
// Only calculate maxPage when not requesting a specific server
|
||||||
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
|
let maxPage = 1;
|
||||||
});
|
if (!serverId) {
|
||||||
|
const totalHosts = await prisma.server.count({
|
||||||
const maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
|
where: { OR: [{ hostServer: 0 }, { hostServer: null }] }
|
||||||
|
});
|
||||||
|
maxPage = Math.ceil(totalHosts / ITEMS_PER_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
servers: hostsWithVms,
|
servers: hostsWithVms,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
591
app/dashboard/servers/[server_id]/Server.tsx
Normal file
591
app/dashboard/servers/[server_id]/Server.tsx
Normal file
@ -0,0 +1,591 @@
|
|||||||
|
"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 } 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' | '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 === '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 === '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' })})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest' as const,
|
||||||
|
axis: 'x' as const,
|
||||||
|
intersect: false
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: getRangeTitle(),
|
||||||
|
font: {
|
||||||
|
size: 14
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: function(tooltipItems: any) {
|
||||||
|
return timeLabels[tooltipItems[0].dataIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 100,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Usage %'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
point: {
|
||||||
|
radius: 0
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
tension: 0.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add individual chart configs
|
||||||
|
const cpuChartConfig = {
|
||||||
|
...chartConfig,
|
||||||
|
plugins: {
|
||||||
|
...chartConfig.plugins,
|
||||||
|
title: {
|
||||||
|
...chartConfig.plugins.title,
|
||||||
|
text: 'CPU Usage History'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ramChartConfig = {
|
||||||
|
...chartConfig,
|
||||||
|
plugins: {
|
||||||
|
...chartConfig.plugins,
|
||||||
|
title: {
|
||||||
|
...chartConfig.plugins.title,
|
||||||
|
text: 'RAM Usage History'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const diskChartConfig = {
|
||||||
|
...chartConfig,
|
||||||
|
plugins: {
|
||||||
|
...chartConfig.plugins,
|
||||||
|
title: {
|
||||||
|
...chartConfig.plugins.title,
|
||||||
|
text: 'Disk Usage History'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.filter(value => value !== null),
|
||||||
|
borderColor: 'rgb(75, 192, 192)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: cpuChartConfig
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.filter(value => value !== null),
|
||||||
|
borderColor: 'rgb(153, 102, 255)',
|
||||||
|
backgroundColor: 'rgba(153, 102, 255, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: ramChartConfig
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.filter(value => value !== null),
|
||||||
|
borderColor: 'rgb(255, 159, 64)',
|
||||||
|
backgroundColor: 'rgba(255, 159, 64, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: diskChartConfig
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 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>
|
||||||
|
<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}
|
||||||
|
{server.monitoring && (
|
||||||
|
<StatusIndicator isOnline={server.online} />
|
||||||
|
)}
|
||||||
|
</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 className="flex gap-2">
|
||||||
|
<Select value={timeRange} onValueChange={(value: '1h' | '7d' | '30d') => setTimeRange(value)}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Time range" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1h">Last Hour</SelectItem>
|
||||||
|
<SelectItem value="7d">Last 7 Days (Hourly)</SelectItem>
|
||||||
|
<SelectItem value="30d">Last 30 Days (4h Intervals)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" onClick={refreshData}>Refresh Data</Button>
|
||||||
|
</div>
|
||||||
|
</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 || "N/A"}</div>
|
||||||
|
<div className="text-muted-foreground">GPU:</div>
|
||||||
|
<div>{server.gpu || "N/A"}</div>
|
||||||
|
<div className="text-muted-foreground">RAM:</div>
|
||||||
|
<div>{server.ram || "N/A"}</div>
|
||||||
|
<div className="text-muted-foreground">Disk:</div>
|
||||||
|
<div>{server.disk || "N/A"}</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 || "N/A"}</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>
|
||||||
|
) : (
|
||||||
|
"N/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-24 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-24 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-24 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 === '7d'
|
||||||
|
? 'Last 7 days, hourly intervals'
|
||||||
|
: 'Last 30 days, 4-hour intervals'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[600px] pr-4">
|
||||||
|
<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>
|
||||||
|
</ScrollArea>
|
||||||
|
</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 lg:grid-cols-3 gap-4">
|
||||||
|
{server.hostedVMs.map(vm => (
|
||||||
|
<Card key={vm.id} className="hover:shadow-md transition-all duration-200">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{vm.icon && <DynamicIcon name={vm.icon as any} size={20} />}
|
||||||
|
<NextLink href={`/dashboard/servers/${vm.id}`}>
|
||||||
|
<CardTitle className="text-lg hover:underline">{vm.name}</CardTitle>
|
||||||
|
</NextLink>
|
||||||
|
</div>
|
||||||
|
{vm.monitoring && (
|
||||||
|
<StatusIndicator isOnline={vm.online} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{vm.os || "No OS specified"}
|
||||||
|
</div>
|
||||||
|
{vm.monitoring && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">CPU</div>
|
||||||
|
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${vm.cpuUsage > 80 ? "bg-destructive" : vm.cpuUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||||
|
style={{ width: `${vm.cpuUsage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">RAM</div>
|
||||||
|
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${vm.ramUsage > 80 ? "bg-destructive" : vm.ramUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||||
|
style={{ width: `${vm.ramUsage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Disk</div>
|
||||||
|
<div className="h-1 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${vm.diskUsage > 80 ? "bg-destructive" : vm.diskUsage > 60 ? "bg-amber-500" : "bg-emerald-500"}`}
|
||||||
|
style={{ width: `${vm.diskUsage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</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
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;
|
||||||
|
}
|
||||||
2420
package-lock.json
generated
2420
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user