From c8829bedddb90f7cc43f1390ec180caa84568e7d Mon Sep 17 00:00:00 2001 From: Bram Suurd <78373894+BramSuurdje@users.noreply.github.com> Date: Fri, 31 Jan 2025 14:09:28 +0100 Subject: [PATCH] feat: enhance DataFetcher with better UI components and add reactive data fetching intervals (#1901) (#1902) --- frontend/src/app/data/page.tsx | 425 ++++++++++--------- frontend/src/components/ApplicationChart.tsx | 259 ++++++----- frontend/src/components/ui/table.tsx | 120 ++++++ 3 files changed, 509 insertions(+), 295 deletions(-) create mode 100644 frontend/src/components/ui/table.tsx diff --git a/frontend/src/app/data/page.tsx b/frontend/src/app/data/page.tsx index baa0612c..3f1d7ace 100644 --- a/frontend/src/app/data/page.tsx +++ b/frontend/src/app/data/page.tsx @@ -1,9 +1,33 @@ "use client"; -import React, { useEffect, useState } from "react"; -import DatePicker from 'react-datepicker'; -import 'react-datepicker/dist/react-datepicker.css'; -import ApplicationChart from "../../components/ApplicationChart"; +import ApplicationChart from "@/components/ApplicationChart"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { format } from "date-fns"; +import { Calendar as CalendarIcon } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; interface DataModel { id: number; @@ -40,23 +64,41 @@ const DataFetcher: React.FC = () => { const [reloadInterval, setReloadInterval] = useState(null); - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch("https://api.htl-braunau.at/data/json"); - if (!response.ok) throw new Error("Failed to fetch data: ${response.statusText}"); - const result: DataModel[] = await response.json(); - setData(result); - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); - } - }; - - fetchData(); + const fetchData = useCallback(async () => { + try { + const response = await fetch("https://api.htl-braunau.at/data/json"); + if (!response.ok) throw new Error(`Failed to fetch data: ${response.statusText}`); + const result: DataModel[] = await response.json(); + setData(result); + setLoading(false); + } catch (err) { + setError((err as Error).message); + setLoading(false); + } }, []); + useEffect(() => { + fetchData(); + const storedInterval = localStorage.getItem('reloadInterval'); + if (storedInterval) { + setIntervalTime(Number(storedInterval)); + } + }, [fetchData]); + + useEffect(() => { + let intervalId: NodeJS.Timeout | null = null; + + if (interval > 0) { + intervalId = setInterval(fetchData, Math.max(interval, 10) * 1000); + localStorage.setItem('reloadInterval', interval.toString()); + } else { + localStorage.removeItem('reloadInterval'); + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [interval, fetchData]); const filteredData = data.filter(item => { const matchesSearchQuery = Object.values(item).some(value => @@ -111,203 +153,194 @@ const DataFetcher: React.FC = () => { return `${day}.${month}.${year} ${hours}:${minutes} ${timezoneOffset} GMT`; }; - const handleItemsPerPageChange = (event: React.ChangeEvent) => { - setItemsPerPage(Number(event.target.value)); - setCurrentPage(1); - }; - const paginatedData = sortedData.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); - useEffect(() => { - const storedInterval = localStorage.getItem('reloadInterval'); - if (storedInterval) { - setIntervalTime(Number(storedInterval)); - } - }, []); + const statusCounts = data.reduce((acc, item) => { + const status = item.status; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {} as Record); - - useEffect(() => { - if (interval <= 10) { - const newInterval = setInterval(() => { - window.location.reload(); - }, 10000); - - - return () => clearInterval(newInterval); - } else { - const newInterval = setInterval(() => { - window.location.reload(); - }, interval * 1000); - } - - }, [interval]); - - - useEffect(() => { - if (interval > 0) { - localStorage.setItem('reloadInterval', interval.toString()); - } else { - localStorage.removeItem('reloadInterval'); - } - }, [interval]); - - if (loading) return

Loading...

; - if (error) return

Error: {error}

; - - var installingCounts: number = 0; - var failedCounts: number = 0; - var doneCounts: number = 0 - var unknownCounts: number = 0; - data.forEach((item) => { - if (item.status === "installing") { - installingCounts += 1; - } else if (item.status === "failed") { - failedCounts += 1; - } - else if (item.status === "done") { - doneCounts += 1; - } - else { - unknownCounts += 1; - } - }); + if (loading) return
Loading...
; + if (error) return
Error: {error}
; return ( -
-

Created LXCs

-
-
- setSearchQuery(e.target.value)} - className="p-2 border" - /> - -
-
- setStartDate(date)} - selectsStart - startDate={startDate} - endDate={endDate} - placeholderText="Start date" - className="p-2 border" - /> - -
+
+

Created LXCs

+ +
+ + + Search + + + setSearchQuery(e.target.value)} + /> + + -
- setEndDate(date)} - selectsEnd - startDate={startDate} - endDate={endDate} - placeholderText="End date" - className="p-2 border" - /> - -
- -
-
- setIntervalTime(Number(e.target.value))} - className="p-2 border" - placeholder="Interval (seconds)" - /> - -
-
+ + + Start Date + + + + + + + + setStartDate(date || null)} + initialFocus + /> + + + + + + + + End Date + + + + + + + + setEndDate(date || null)} + initialFocus + /> + + + + + + + + Reload Interval + + + setIntervalTime(Number(e.target.value))} + placeholder="Interval (seconds)" + /> + +
+ -
-

{filteredData.length} results found

-

Status Legend: 🔄 installing {installingCounts} | ✔️ completetd {doneCounts} | ❌ failed {failedCounts} | ❓ unknown {unknownCounts}

- -
-
-
- - - - - - - - - - - - - - - - - - - - - {paginatedData.map((item, index) => ( - - - - - - - - - - - - - - - - - ))} - -
requestSort('status')}>Status requestSort('nsapp')}>Application requestSort('os_type')}>OS requestSort('os_version')}>OS Version requestSort('disk_size')}>Disk Size requestSort('core_count')}>Core Count requestSort('ram_size')}>RAM Size requestSort('hn')}>Hostname requestSort('ssh')}>SSH requestSort('verbose')}>Verb requestSort('tags')}>Tags requestSort('method')}>Method requestSort('pve_version')}>PVE Version requestSort('created_at')}>Created At
- {item.status === "done" ? ( - "✔️" - ) : item.status === "failed" ? ( - "❌" - ) : item.status === "installing" ? ( - "🔄" - ) : ( - item.status - )} - {item.nsapp}{item.os_type}{item.os_version}{item.disk_size}{item.core_count}{item.ram_size}{item.hn}{item.ssh}{item.verbose}{item.tags.replace(/;/g, ' ')}{item.method}{item.pve_version}{formatDate(item.created_at)}
+ +
+

{filteredData.length} results found

+
+ 🔄 Installing: {statusCounts.installing || 0} + ✔️ Completed: {statusCounts.done || 0} + ❌ Failed: {statusCounts.failed || 0} + ❓ Unknown: {statusCounts.unknown || 0}
+
-
-
+ +
+ - Page {currentPage} - +
); }; + export default DataFetcher; diff --git a/frontend/src/components/ApplicationChart.tsx b/frontend/src/components/ApplicationChart.tsx index 526505cd..e62c1096 100644 --- a/frontend/src/components/ApplicationChart.tsx +++ b/frontend/src/components/ApplicationChart.tsx @@ -1,132 +1,193 @@ "use client"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from "chart.js"; +import ChartDataLabels from "chartjs-plugin-datalabels"; +import { BarChart3, PieChart } from "lucide-react"; import React, { useState } from "react"; import { Pie } from "react-chartjs-2"; -import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js"; -import ChartDataLabels from "chartjs-plugin-datalabels"; -import Modal from "@/components/Modal"; -ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels); +ChartJS.register(ArcElement, ChartTooltip, Legend, ChartDataLabels); interface ApplicationChartProps { data: { nsapp: string }[]; } -const ApplicationChart: React.FC = ({ data }) => { +const ITEMS_PER_PAGE = 20; +const CHART_COLORS = [ + "#ff6384", + "#36a2eb", + "#ffce56", + "#4bc0c0", + "#9966ff", + "#ff9f40", + "#4dc9f6", + "#f67019", + "#537bc4", + "#acc236", + "#166a8f", + "#00a950", + "#58595b", + "#8549ba", +]; + +export default function ApplicationChart({ data }: ApplicationChartProps) { const [isChartOpen, setIsChartOpen] = useState(false); const [isTableOpen, setIsTableOpen] = useState(false); const [chartStartIndex, setChartStartIndex] = useState(0); - const [tableLimit, setTableLimit] = useState(20); + const [tableLimit, setTableLimit] = useState(ITEMS_PER_PAGE); - const appCounts: Record = {}; - data.forEach((item) => { - appCounts[item.nsapp] = (appCounts[item.nsapp] || 0) + 1; - }); + // Calculate application counts + const appCounts = data.reduce((acc, item) => { + acc[item.nsapp] = (acc[item.nsapp] || 0) + 1; + return acc; + }, {} as Record); - const sortedApps = Object.entries(appCounts).sort(([, a], [, b]) => b - a); - const chartApps = sortedApps.slice(chartStartIndex, chartStartIndex + 20); + const sortedApps = Object.entries(appCounts) + .sort(([, a], [, b]) => b - a); + + const chartApps = sortedApps.slice( + chartStartIndex, + chartStartIndex + ITEMS_PER_PAGE + ); const chartData = { labels: chartApps.map(([name]) => name), datasets: [ { - label: "Applications", data: chartApps.map(([, count]) => count), - backgroundColor: [ - "#ff6384", - "#36a2eb", - "#ffce56", - "#4bc0c0", - "#9966ff", - "#ff9f40", - ], + backgroundColor: CHART_COLORS, }, ], }; + const chartOptions = { + plugins: { + legend: { display: false }, + datalabels: { + color: "white", + font: { weight: "bold" as const }, + formatter: (value: number, context: any) => { + const label = context.chart.data.labels?.[context.dataIndex]; + return `${label}\n(${value})`; + }, + }, + }, + responsive: true, + maintainAspectRatio: false, + }; + return ( -
- - +
+ + + + + + Open Chart View + - setIsChartOpen(false)}> -

Top Applications (Chart)

-
- - context.chart.data.labels?.[context.dataIndex] || "", - }, - }, - }} - /> -
-
- - -
-
+ + + + + Open Table View + +
- setIsTableOpen(false)}> -

Application Count Table

- - - - - - - - - {sortedApps.slice(0, tableLimit).map(([name, count]) => ( - - - - - ))} - -
ApplicationCount
{name}{count}
+ + + + Applications Distribution + +
+ +
+
+ + +
+
+
- {tableLimit < sortedApps.length && ( -
- -
- )} -
+ + )} + +
); -}; - -export default ApplicationChart; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/components/ui/table.tsx b/frontend/src/components/ui/table.tsx new file mode 100644 index 00000000..c0df655c --- /dev/null +++ b/frontend/src/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}