mirror of
https://github.com/community-scripts/ProxmoxVE.git
synced 2025-11-22 05:25:15 +00:00
feat: enhance DataPage with improved data fetching, visualization, and UI components
This commit is contained in:
@@ -1,9 +1,67 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import {
|
||||||
import "react-datepicker/dist/react-datepicker.css";
|
ArrowUpDown,
|
||||||
|
Box,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
List,
|
||||||
|
Loader2,
|
||||||
|
Trophy,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
LabelList,
|
||||||
|
XAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import ApplicationChart from "../../components/application-chart";
|
import type { ChartConfig } from "@/components/ui/chart";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import { formattedBadge } from "@/components/command-menu";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
type DataModel = {
|
type DataModel = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -30,42 +88,76 @@ type SummaryData = {
|
|||||||
nsapp_count: Record<string, number>;
|
nsapp_count: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DataFetcher: React.FC = () => {
|
// Chart colors optimized for both light and dark modes
|
||||||
|
// Medium-toned colors that are visible and not too flashy in both themes
|
||||||
|
const CHART_COLORS = [
|
||||||
|
"#5B8DEF", // blue - medium tone
|
||||||
|
"#4ECDC4", // teal - medium tone
|
||||||
|
"#FF8C42", // orange - medium tone
|
||||||
|
"#A78BFA", // purple - medium tone
|
||||||
|
"#F472B6", // pink - medium tone
|
||||||
|
"#38BDF8", // cyan - medium tone
|
||||||
|
"#4ADE80", // green - medium tone
|
||||||
|
"#FBBF24", // yellow - medium tone
|
||||||
|
"#818CF8", // indigo - medium tone
|
||||||
|
"#FB7185", // rose - medium tone
|
||||||
|
"#2DD4BF", // turquoise - medium tone
|
||||||
|
"#C084FC", // violet - medium tone
|
||||||
|
"#60A5FA", // sky blue - medium tone
|
||||||
|
"#84CC16", // lime - medium tone
|
||||||
|
"#F59E0B", // amber - medium tone
|
||||||
|
"#A855F7", // purple - medium tone
|
||||||
|
"#10B981", // emerald - medium tone
|
||||||
|
"#EAB308", // gold - medium tone
|
||||||
|
"#3B82F6", // royal blue - medium tone
|
||||||
|
"#EF4444", // red - medium tone
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartConfigApps = {
|
||||||
|
count: {
|
||||||
|
label: "Installations",
|
||||||
|
color: "hsl(var(--chart-1))",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export default function DataPage() {
|
||||||
const [data, setData] = useState<DataModel[]>([]);
|
const [data, setData] = useState<DataModel[]>([]);
|
||||||
const [summary, setSummary] = useState<SummaryData | null>(null);
|
const [summary, setSummary] = useState<SummaryData | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(25);
|
const [itemsPerPage, setItemsPerPage] = useState(25);
|
||||||
const [sortConfig, setSortConfig] = useState<{ key: string; direction: "ascending" | "descending" } | null>(null);
|
const [sortConfig, setSortConfig] = useState<{
|
||||||
|
key: string;
|
||||||
|
direction: "ascending" | "descending";
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
const nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSummary = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
|
||||||
const response = await fetch("https://api.htl-braunau.at/data/summary");
|
|
||||||
if (!response.ok)
|
|
||||||
throw new Error(`Failed to fetch summary: ${response.statusText}`);
|
|
||||||
const result: SummaryData = await response.json();
|
|
||||||
setSummary(result);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
setError((err as Error).message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSummary();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchPaginatedData = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? "" : itemsPerPage}`);
|
const [summaryRes, dataRes] = await Promise.all([
|
||||||
if (!response.ok)
|
fetch("https://api.htl-braunau.at/data/summary"),
|
||||||
throw new Error(`Failed to fetch data: ${response.statusText}`);
|
fetch(
|
||||||
const result: DataModel[] = await response.json();
|
`https://api.htl-braunau.at/data/paginated?page=${currentPage}&limit=${itemsPerPage === 0 ? "" : itemsPerPage
|
||||||
setData(result);
|
}`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!summaryRes.ok) {
|
||||||
|
throw new Error(`Failed to fetch summary: ${summaryRes.statusText}`);
|
||||||
|
}
|
||||||
|
if (!dataRes.ok) {
|
||||||
|
throw new Error(`Failed to fetch data: ${dataRes.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryData: SummaryData = await summaryRes.json();
|
||||||
|
const pageData: DataModel[] = await dataRes.json();
|
||||||
|
|
||||||
|
setSummary(summaryData);
|
||||||
|
setData(pageData);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -75,13 +167,13 @@ const DataFetcher: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPaginatedData();
|
fetchData();
|
||||||
}, [currentPage, itemsPerPage]);
|
}, [currentPage, itemsPerPage]);
|
||||||
|
|
||||||
const sortedData = React.useMemo(() => {
|
const sortedData = useMemo(() => {
|
||||||
if (!sortConfig)
|
if (!sortConfig)
|
||||||
return data;
|
return data;
|
||||||
const sorted = [...data].sort((a, b) => {
|
return [...data].sort((a, b) => {
|
||||||
if (a[sortConfig.key] < b[sortConfig.key]) {
|
if (a[sortConfig.key] < b[sortConfig.key]) {
|
||||||
return sortConfig.direction === "ascending" ? -1 : 1;
|
return sortConfig.direction === "ascending" ? -1 : 1;
|
||||||
}
|
}
|
||||||
@@ -90,23 +182,15 @@ const DataFetcher: React.FC = () => {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
return sorted;
|
|
||||||
}, [data, sortConfig]);
|
}, [data, sortConfig]);
|
||||||
|
|
||||||
if (loading)
|
|
||||||
return <p>Loading...</p>;
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<p>
|
|
||||||
Error:
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestSort = (key: string) => {
|
const requestSort = (key: string) => {
|
||||||
let direction: "ascending" | "descending" = "ascending";
|
let direction: "ascending" | "descending" = "ascending";
|
||||||
if (sortConfig && sortConfig.key === key && sortConfig.direction === "ascending") {
|
if (
|
||||||
|
sortConfig
|
||||||
|
&& sortConfig.key === key
|
||||||
|
&& sortConfig.direction === "ascending"
|
||||||
|
) {
|
||||||
direction = "descending";
|
direction = "descending";
|
||||||
}
|
}
|
||||||
setSortConfig({ key, direction });
|
setSortConfig({ key, direction });
|
||||||
@@ -114,135 +198,447 @@ const DataFetcher: React.FC = () => {
|
|||||||
|
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
const year = date.getFullYear();
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
const month = date.getMonth() + 1;
|
dateStyle: "medium",
|
||||||
const day = date.getDate();
|
timeStyle: "short",
|
||||||
const hours = String(date.getHours()).padStart(2, "0");
|
}).format(date);
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
const timezoneOffset = dateString.slice(-6);
|
|
||||||
return `${day}.${month}.${year} ${hours}:${minutes} ${timezoneOffset} GMT`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const getTypeBadge = (type: string) => {
|
||||||
<div className="p-6 mt-20">
|
if (type === "lxc")
|
||||||
<h1 className="text-2xl font-bold mb-4 text-center">Created LXCs</h1>
|
return formattedBadge("ct");
|
||||||
<ApplicationChart data={summary} />
|
if (type === "vm")
|
||||||
<p className="text-lg font-bold mt-4"> </p>
|
return formattedBadge("vm");
|
||||||
<div className="mb-4 flex justify-between items-center">
|
return null;
|
||||||
<p className="text-lg font-bold">
|
};
|
||||||
{nf.format(
|
|
||||||
summary?.total_entries ?? 0,
|
// Stats calculations
|
||||||
)}
|
const successCount = summary?.status_count.done ?? 0;
|
||||||
{" "}
|
const failureCount = summary?.status_count.failed ?? 0;
|
||||||
results found
|
const totalCount = summary?.total_entries ?? 0;
|
||||||
</p>
|
const successRate = totalCount > 0 ? (successCount / totalCount) * 100 : 0;
|
||||||
<p className="text-lg font">
|
|
||||||
Status Legend: 🔄 installing
|
const allApps = useMemo(() => {
|
||||||
{" "}
|
if (!summary?.nsapp_count)
|
||||||
{nf.format(summary?.status_count.installing ?? 0)}
|
return [];
|
||||||
{" "}
|
return Object.entries(summary.nsapp_count).sort(([, a], [, b]) => b - a);
|
||||||
| ✔️ completed
|
}, [summary]);
|
||||||
{" "}
|
|
||||||
{nf.format(summary?.status_count.done ?? 0)}
|
const topApps = useMemo(() => {
|
||||||
{" "}
|
return allApps.slice(0, 15);
|
||||||
| ❌ failed
|
}, [allApps]);
|
||||||
{" "}
|
|
||||||
{nf.format(summary?.status_count.failed ?? 0)}
|
const mostPopularApp = topApps[0];
|
||||||
{" "}
|
|
||||||
| ❓ unknown
|
// Chart Data
|
||||||
|
const appsChartData = topApps.map(([name, count], index) => ({
|
||||||
|
app: name,
|
||||||
|
count,
|
||||||
|
fill: CHART_COLORS[index % CHART_COLORS.length],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-center text-red-500">
|
||||||
|
<p>
|
||||||
|
Error loading data:
|
||||||
|
{error}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
);
|
||||||
<div className="overflow-y-auto lg:overflow-y-visible">
|
}
|
||||||
<table className="min-w-full table-auto border-collapse">
|
|
||||||
<thead>
|
return (
|
||||||
<tr>
|
<div className="mb-3">
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("status")}>Status</th>
|
<div className="mt-20 flex sm:px-4 xl:px-0">
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("type")}>Type</th>
|
<div className="mx-4 w-full sm:mx-0 space-y-8">
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("nsapp")}>Application</th>
|
{/* Header */}
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("os_type")}>OS</th>
|
<div>
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("os_version")}>OS Version</th>
|
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("disk_size")}>Disk Size</th>
|
<p className="text-muted-foreground">
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("core_count")}>Core Count</th>
|
Overview of container installations and system statistics.
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("ram_size")}>RAM Size</th>
|
</p>
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("method")}>Method</th>
|
</div>
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("pve_version")}>PVE Version</th>
|
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("error")}>Error Message</th>
|
{/* Widgets */}
|
||||||
<th className="px-4 py-2 border-b cursor-pointer" onClick={() => requestSort("created_at")}>Created At</th>
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
</tr>
|
<Card>
|
||||||
</thead>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<tbody>
|
<CardTitle className="text-sm font-medium">Total Created</CardTitle>
|
||||||
{sortedData.map((item, index) => (
|
<Box className="h-4 w-4 text-muted-foreground" />
|
||||||
<tr key={index}>
|
</CardHeader>
|
||||||
<td className="px-4 py-2 border-b">
|
<CardContent>
|
||||||
{item.status === "done"
|
<div className="text-2xl font-bold">{nf.format(totalCount)}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Total LXC/VM entries found
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{successRate.toFixed(1)}
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{nf.format(successCount)}
|
||||||
|
{" "}
|
||||||
|
successful installations
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Failures</CardTitle>
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{nf.format(failureCount)}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Installations encountered errors
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Most Popular</CardTitle>
|
||||||
|
<Trophy className="h-4 w-4 text-yellow-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="truncate text-2xl font-bold">
|
||||||
|
{mostPopularApp ? mostPopularApp[0] : "N/A"}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{mostPopularApp ? nf.format(mostPopularApp[1]) : 0}
|
||||||
|
{" "}
|
||||||
|
installations
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graphs */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<CardTitle>Top Applications</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
The most frequently installed applications.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="ml-auto">
|
||||||
|
<List className="mr-2 h-4 w-4" />
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[80vh] sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Application Statistics</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Installation counts for all
|
||||||
|
{" "}
|
||||||
|
{allApps.length}
|
||||||
|
{" "}
|
||||||
|
applications.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="h-[60vh] w-full rounded-md border p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{allApps.map(([name, count], index) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-8 font-mono text-muted-foreground">
|
||||||
|
{index + 1}
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono">{nf.format(count)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-2">
|
||||||
|
<div className="h-[300px] w-full">
|
||||||
|
{loading
|
||||||
|
? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<ChartContainer config={chartConfigApps} className="h-full w-full">
|
||||||
|
<BarChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={appsChartData}
|
||||||
|
margin={{
|
||||||
|
top: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="app"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={value => (value.length > 8 ? `${value.slice(0, 8)}...` : value)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent nameKey="app" />}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="count" radius={8}>
|
||||||
|
{appsChartData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
<LabelList
|
||||||
|
position="top"
|
||||||
|
offset={12}
|
||||||
|
className="fill-foreground"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Installation Log</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Detailed records of all container creation attempts.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(itemsPerPage)}
|
||||||
|
onValueChange={val => setItemsPerPage(Number(val))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[80px]">
|
||||||
|
<SelectValue placeholder="Limit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="25">25</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead
|
||||||
|
className="w-[100px] cursor-pointer"
|
||||||
|
onClick={() => requestSort("status")}
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
{sortConfig?.key === "status" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => requestSort("type")}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
{sortConfig?.key === "type" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => requestSort("nsapp")}
|
||||||
|
>
|
||||||
|
Application
|
||||||
|
{sortConfig?.key === "nsapp" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="hidden cursor-pointer md:table-cell"
|
||||||
|
onClick={() => requestSort("os_type")}
|
||||||
|
>
|
||||||
|
OS
|
||||||
|
{sortConfig?.key === "os_type" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="hidden cursor-pointer md:table-cell"
|
||||||
|
onClick={() => requestSort("disk_size")}
|
||||||
|
>
|
||||||
|
Disk Size
|
||||||
|
{sortConfig?.key === "disk_size" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="hidden cursor-pointer lg:table-cell"
|
||||||
|
onClick={() => requestSort("core_count")}
|
||||||
|
>
|
||||||
|
Core Count
|
||||||
|
{sortConfig?.key === "core_count" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="hidden cursor-pointer lg:table-cell"
|
||||||
|
onClick={() => requestSort("ram_size")}
|
||||||
|
>
|
||||||
|
RAM Size
|
||||||
|
{sortConfig?.key === "ram_size" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="cursor-pointer text-right"
|
||||||
|
onClick={() => requestSort("created_at")}
|
||||||
|
>
|
||||||
|
Created At
|
||||||
|
{sortConfig?.key === "created_at" && (
|
||||||
|
<ArrowUpDown className="ml-2 inline h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading
|
||||||
? (
|
? (
|
||||||
"✔️"
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="h-24 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{" "}
|
||||||
|
Loading data...
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
)
|
)
|
||||||
: item.status === "failed"
|
: sortedData.length > 0
|
||||||
? (
|
? (
|
||||||
"❌"
|
sortedData.map((item, idx) => (
|
||||||
)
|
<TableRow key={`${item.id}-${idx}`}>
|
||||||
: item.status === "installing"
|
<TableCell>
|
||||||
? (
|
{item.status === "done"
|
||||||
"🔄"
|
? (
|
||||||
)
|
<Badge className="text-green-500/75 border-green-500/75">
|
||||||
: (
|
Success
|
||||||
item.status
|
</Badge>
|
||||||
)}
|
)
|
||||||
</td>
|
: item.status === "failed"
|
||||||
<td className="px-4 py-2 border-b">
|
? (
|
||||||
{item.type === "lxc"
|
<Badge className="text-red-500/75 border-red-500/75">
|
||||||
? (
|
Failed
|
||||||
"📦"
|
</Badge>
|
||||||
)
|
)
|
||||||
: item.type === "vm"
|
: item.status === "installing"
|
||||||
? (
|
? (
|
||||||
"🖥️"
|
<Badge className="text-blue-500/75 border-blue-500/75">
|
||||||
|
Installing
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{getTypeBadge(item.type) || (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.type}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.nsapp}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{item.os_type}
|
||||||
|
{" "}
|
||||||
|
{item.os_version}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
{item.disk_size}
|
||||||
|
MB
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
{item.core_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">
|
||||||
|
{item.ram_size}
|
||||||
|
MB
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatDate(item.created_at)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
item.type
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="h-24 text-center">
|
||||||
|
No results found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableBody>
|
||||||
<td className="px-4 py-2 border-b">{item.nsapp}</td>
|
</Table>
|
||||||
<td className="px-4 py-2 border-b">{item.os_type}</td>
|
</div>
|
||||||
<td className="px-4 py-2 border-b">{item.os_version}</td>
|
|
||||||
<td className="px-4 py-2 border-b">{item.disk_size}</td>
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
<td className="px-4 py-2 border-b">{item.core_count}</td>
|
<Button
|
||||||
<td className="px-4 py-2 border-b">{item.ram_size}</td>
|
variant="outline"
|
||||||
<td className="px-4 py-2 border-b">{item.method}</td>
|
size="sm"
|
||||||
<td className="px-4 py-2 border-b">{item.pve_version}</td>
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
<td className="px-4 py-2 border-b">{item.error}</td>
|
disabled={currentPage === 1 || loading}
|
||||||
<td className="px-4 py-2 border-b">{formatDate(item.created_at)}</td>
|
>
|
||||||
</tr>
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||||
))}
|
Previous
|
||||||
</tbody>
|
</Button>
|
||||||
</table>
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Page
|
||||||
|
{" "}
|
||||||
|
{currentPage}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(prev => prev + 1)}
|
||||||
|
disabled={loading || sortedData.length < itemsPerPage}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex justify-between items-center">
|
|
||||||
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border">Previous</button>
|
|
||||||
<span>
|
|
||||||
Page
|
|
||||||
{currentPage}
|
|
||||||
</span>
|
|
||||||
<button onClick={() => setCurrentPage(prev => prev + 1)} className="p-2 border">Next</button>
|
|
||||||
<select
|
|
||||||
value={itemsPerPage}
|
|
||||||
onChange={e => setItemsPerPage(Number(e.target.value))}
|
|
||||||
className="p-2 border"
|
|
||||||
>
|
|
||||||
<option value={10}>10</option>
|
|
||||||
<option value={20}>20</option>
|
|
||||||
<option value={50}>50</option>
|
|
||||||
<option value={100}>100</option>
|
|
||||||
<option value={250}>250</option>
|
|
||||||
<option value={500}>500</option>
|
|
||||||
<option value={5000}>5000</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default DataFetcher;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user