Fix VM View

This commit is contained in:
headlessdev 2025-04-23 20:14:06 +02:00
parent 6ced93722c
commit c8247a8ee0
3 changed files with 500 additions and 52 deletions

View File

@ -64,7 +64,7 @@ const getIntervals = (timeRange: '1h' | '7d' | '30d' = '1h') => {
const parseUsageValue = (value: string | null): number => { const parseUsageValue = (value: string | null): number => {
if (!value) return 0; if (!value) return 0;
return parseFloat(value.replace('%', '')); return Math.round(parseFloat(value.replace('%', '')) * 100) / 100;
}; };
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -173,7 +173,7 @@ export async function POST(request: NextRequest) {
}; };
const average = (arr: number[]) => const average = (arr: number[]) =>
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : null; arr.length ? Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 100) / 100 : null;
return { return {
timestamp: key, timestamp: key,
@ -202,15 +202,15 @@ export async function POST(request: NextRequest) {
datasets: { datasets: {
cpu: intervals.map(d => { cpu: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.cpu || []; const data = historyMap.get(d.toISOString())?.cpu || [];
return data.length ? data.reduce((a, b) => a + b) / data.length : null; return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}), }),
ram: intervals.map(d => { ram: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.ram || []; const data = historyMap.get(d.toISOString())?.ram || [];
return data.length ? data.reduce((a, b) => a + b) / data.length : null; return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}), }),
disk: intervals.map(d => { disk: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.disk || []; const data = historyMap.get(d.toISOString())?.disk || [];
return data.length ? data.reduce((a, b) => a + b) / data.length : null; return data.length ? Math.round((data.reduce((a, b) => a + b) / data.length) * 100) / 100 : null;
}), }),
online: intervals.map(d => { online: intervals.map(d => {
const data = historyMap.get(d.toISOString())?.online || []; const data = historyMap.get(d.toISOString())?.online || [];

View File

@ -1023,6 +1023,469 @@ export default function Dashboard() {
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
{server.host && server.hostedVMs && server.hostedVMs.length > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="icon">
<LucideServer className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hosted VMs</AlertDialogTitle>
<AlertDialogDescription>
{server.host && (
<div className="mt-4">
<ScrollArea className="h-[500px] w-full pr-3">
<div className="space-y-2 mt-2">
{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}
/>
)}
<div className="text-base font-extrabold">
{hostedVM.icon && "・ "}
{hostedVM.name}
</div>
</div>
<div className="flex items-center gap-2 text-foreground/80">
<Button
variant="outline"
className="gap-2"
onClick={() => window.open(hostedVM.url, "_blank")}
>
<LinkIcon className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="icon"
className="h-9 w-9"
onClick={() => deleteApplication(hostedVM.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
className="h-9 w-9"
onClick={() => openEditDialog(hostedVM)}
>
<Pencil className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Edit VM</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>
</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-vm-edit-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-vm-edit-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"
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>
</Tabs>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button onClick={edit}>Save</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</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>
</ScrollArea>
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Close</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>View VMs ({server.hostedVMs.length})</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
@ -1168,45 +1631,33 @@ export default function Dashboard() {
<TabsContent value="hardware"> <TabsContent value="hardware">
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-4">
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="editCpu"> <Label htmlFor="editCpu">CPU</Label>
CPU <span className="text-stone-600">(optional)</span>
</Label>
<Input <Input
id="editCpu" id="editCpu"
type="text"
value={editCpu} value={editCpu}
onChange={(e) => setEditCpu(e.target.value)} onChange={(e) => setEditCpu(e.target.value)}
/> />
</div> </div>
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="editGpu"> <Label htmlFor="editGpu">GPU</Label>
GPU <span className="text-stone-600">(optional)</span>
</Label>
<Input <Input
id="editGpu" id="editGpu"
type="text"
value={editGpu} value={editGpu}
onChange={(e) => setEditGpu(e.target.value)} onChange={(e) => setEditGpu(e.target.value)}
/> />
</div> </div>
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="editRam"> <Label htmlFor="editRam">RAM</Label>
RAM <span className="text-stone-600">(optional)</span>
</Label>
<Input <Input
id="editRam" id="editRam"
type="text"
value={editRam} value={editRam}
onChange={(e) => setEditRam(e.target.value)} onChange={(e) => setEditRam(e.target.value)}
/> />
</div> </div>
<div className="grid w-full items-center gap-1.5"> <div className="grid w-full items-center gap-1.5">
<Label htmlFor="editDisk"> <Label htmlFor="editDisk">Disk</Label>
Disk <span className="text-stone-600">(optional)</span>
</Label>
<Input <Input
id="editDisk" id="editDisk"
type="text"
value={editDisk} value={editDisk}
onChange={(e) => setEditDisk(e.target.value)} onChange={(e) => setEditDisk(e.target.value)}
/> />
@ -1219,7 +1670,9 @@ export default function Dashboard() {
<Checkbox <Checkbox
id="editHostCheckbox" id="editHostCheckbox"
checked={editHost} checked={editHost}
onCheckedChange={(checked) => setEditHost(checked === true)} onCheckedChange={(checked) =>
setEditHost(checked === true)
}
/> />
<Label htmlFor="editHostCheckbox">Mark as host server</Label> <Label htmlFor="editHostCheckbox">Mark as host server</Label>
</div> </div>
@ -1241,47 +1694,27 @@ export default function Dashboard() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="0">No host server</SelectItem> <SelectItem value="0">No host server</SelectItem>
{hostServers.map((server) => ( {hostServers
<SelectItem key={server.id} value={server.id.toString()}> .filter(
{server.name} (server) => server.id !== editId,
</SelectItem> )
))} .map((server) => (
<SelectItem key={server.id} value={server.id.toString()}>
{server.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)} )}
</div> </div>
</TabsContent> </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>
</TabsContent>
</Tabs> </Tabs>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={edit}>Save Changes</AlertDialogAction> <Button onClick={edit}>Save</Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

15
package-lock.json generated
View File

@ -5062,6 +5062,21 @@
"optional": true "optional": true
} }
} }
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.0.tgz",
"integrity": "sha512-vHUQS4YVGJPmpjn7r5lEZuMhK5UQBNBRSB+iGDvJjaNk649pTIcRluDWNb9siunyLLiu/LDPHfvxBtNamyuLTw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }