add api frontend
This commit is contained in:
parent
4b646a40ee
commit
3e9b3e6fe9
193
frontend/src/app/data/new.tsx
Normal file
193
frontend/src/app/data/new.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import FilterComponent from "../../components/FilterComponent";
|
||||||
|
|
||||||
|
interface DataModel {
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
nsapp: string;
|
||||||
|
os_type: string;
|
||||||
|
disk_size: number;
|
||||||
|
core_count: number;
|
||||||
|
ram_size: number;
|
||||||
|
method: string;
|
||||||
|
pve_version: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataFetcher: React.FC = () => {
|
||||||
|
const [data, setData] = useState<DataModel[]>([]);
|
||||||
|
const [filteredData, setFilteredData] = useState<DataModel[]>([]);
|
||||||
|
const [filters, setFilters] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
const response = await fetch("https://api.htl-braunau.at/data/json");
|
||||||
|
const result: DataModel[] = await response.json();
|
||||||
|
setData(result);
|
||||||
|
setFilteredData(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyFilters = async (column: string, operator: string, value: any) => {
|
||||||
|
setFilters((prev) => {
|
||||||
|
const updatedFilters = { ...prev };
|
||||||
|
if (!updatedFilters[column]) updatedFilters[column] = [];
|
||||||
|
|
||||||
|
// Prevent duplicate filters
|
||||||
|
const alreadyExists = updatedFilters[column].some((filter: { operator: string; value: any }) =>
|
||||||
|
filter.operator === operator && filter.value === value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
updatedFilters[column].push({ operator, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedFilters;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFilter = (column: string, index: number) => {
|
||||||
|
setFilters((prev) => {
|
||||||
|
const updatedFilters = { ...prev };
|
||||||
|
updatedFilters[column] = updatedFilters[column].filter((_, i) => i !== index);
|
||||||
|
|
||||||
|
// If no filters remain, remove the column entry
|
||||||
|
if (updatedFilters[column].length === 0) delete updatedFilters[column];
|
||||||
|
|
||||||
|
return updatedFilters;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let filtered = [...data];
|
||||||
|
|
||||||
|
Object.keys(filters).forEach((key) => {
|
||||||
|
if (!filters[key] || filters[key].length === 0) return;
|
||||||
|
|
||||||
|
filtered = filtered.filter((item) => {
|
||||||
|
const itemValue = item[key as keyof DataModel];
|
||||||
|
|
||||||
|
return filters[key].some(({ operator, value }: { operator: string; value: any }) => {
|
||||||
|
if (typeof itemValue === "number") {
|
||||||
|
value = parseFloat(value);
|
||||||
|
if (operator === "greater") return itemValue > value;
|
||||||
|
if (operator === "greater or equal") return itemValue >= value;
|
||||||
|
if (operator === "less") return itemValue < value;
|
||||||
|
if (operator === "less or equal") return itemValue <= value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof itemValue === "string") {
|
||||||
|
if (operator === "equals") return itemValue.toLowerCase() === value.toLowerCase();
|
||||||
|
if (operator === "not equals") return itemValue.toLowerCase() !== value.toLowerCase();
|
||||||
|
if (operator === "contains") return itemValue.toLowerCase().includes(value.toLowerCase());
|
||||||
|
if (operator === "does not contain") return !itemValue.toLowerCase().includes(value.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredData(filtered);
|
||||||
|
}, [filters, data]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "status", type: "text" },
|
||||||
|
{ key: "type", type: "text" },
|
||||||
|
{ key: "nsapp", type: "text" },
|
||||||
|
{ key: "os_type", type: "text" },
|
||||||
|
{ key: "disk_size", type: "number" },
|
||||||
|
{ key: "core_count", type: "number" },
|
||||||
|
{ key: "ram_size", type: "number" },
|
||||||
|
{ key: "method", type: "text" },
|
||||||
|
{ key: "pve_version", type: "text" },
|
||||||
|
{ key: "created_at", type: "text" }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 mt-20">
|
||||||
|
<h1 className="text-2xl font-bold mb-4 text-center">Created LXCs</h1>
|
||||||
|
|
||||||
|
<table className="min-w-full table-auto border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(({ key, type }) => (
|
||||||
|
<th key={key} className="px-4 py-2 border-b text-left">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-semibold">{key}</span>
|
||||||
|
<FilterComponent
|
||||||
|
column={key}
|
||||||
|
type={type}
|
||||||
|
activeFilters={filters[key] || []}
|
||||||
|
onApplyFilter={applyFilters}
|
||||||
|
onRemoveFilter={removeFilter}
|
||||||
|
allData={data}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
{/* Filters Row - Displays below headers */}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(({ key }) => (
|
||||||
|
<th key={key} className="px-4 py-2 border-b text-left">
|
||||||
|
{filters[key] && filters[key].length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{filters[key].map((filter: { operator: string; value: any }, index: number) => (
|
||||||
|
<div key={`${key}-${filter.value}-${index}`} className="bg-gray-800 text-white px-2 py-1 rounded flex items-center">
|
||||||
|
<span className="text-sm italic">
|
||||||
|
{filter.operator} <b>"{filter.value}"</b>
|
||||||
|
</span>
|
||||||
|
<button className="text-red-500 ml-2" onClick={() => removeFilter(key, index)}>
|
||||||
|
✖
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">—</span>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{filteredData.length > 0 ? (
|
||||||
|
filteredData.map((item, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="px-4 py-2 border-b">{item.status}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.type}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.nsapp}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.os_type}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.disk_size}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.core_count}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.ram_size}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.method}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.pve_version}</td>
|
||||||
|
<td className="px-4 py-2 border-b">{item.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="px-4 py-2 text-center text-gray-500">
|
||||||
|
No results found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataFetcher;
|
@ -25,16 +25,12 @@ import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from "c
|
|||||||
import ChartDataLabels from "chartjs-plugin-datalabels";
|
import ChartDataLabels from "chartjs-plugin-datalabels";
|
||||||
import { BarChart3, PieChart } from "lucide-react";
|
import { BarChart3, PieChart } from "lucide-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Pie, Bar } from "react-chartjs-2";
|
import { Pie } from "react-chartjs-2";
|
||||||
|
|
||||||
ChartJS.register(ArcElement, ChartTooltip, Legend, ChartDataLabels);
|
ChartJS.register(ArcElement, ChartTooltip, Legend, ChartDataLabels);
|
||||||
|
|
||||||
interface SummaryData {
|
|
||||||
nsapp_count: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApplicationChartProps {
|
interface ApplicationChartProps {
|
||||||
data: SummaryData | null;
|
data: { nsapp: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
@ -61,9 +57,13 @@ export default function ApplicationChart({ data }: ApplicationChartProps) {
|
|||||||
const [chartStartIndex, setChartStartIndex] = useState(0);
|
const [chartStartIndex, setChartStartIndex] = useState(0);
|
||||||
const [tableLimit, setTableLimit] = useState(ITEMS_PER_PAGE);
|
const [tableLimit, setTableLimit] = useState(ITEMS_PER_PAGE);
|
||||||
|
|
||||||
if (!data) return null;
|
// Calculate application counts
|
||||||
|
const appCounts = data.reduce((acc, item) => {
|
||||||
|
acc[item.nsapp] = (acc[item.nsapp] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
const sortedApps = Object.entries(data.nsapp_count)
|
const sortedApps = Object.entries(appCounts)
|
||||||
.sort(([, a], [, b]) => b - a);
|
.sort(([, a], [, b]) => b - a);
|
||||||
|
|
||||||
const chartApps = sortedApps.slice(
|
const chartApps = sortedApps.slice(
|
||||||
|
185
frontend/src/components/FilterComponent.tsx
Normal file
185
frontend/src/components/FilterComponent.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface FilterProps {
|
||||||
|
column: string;
|
||||||
|
type: "text" | "number";
|
||||||
|
activeFilters: { operator: string; value: any }[];
|
||||||
|
onApplyFilter: (column: string, operator: string, value: any) => Promise<void>;
|
||||||
|
onRemoveFilter: (column: string, index: number) => void;
|
||||||
|
allData: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterComponent: React.FC<FilterProps> = ({ column, type, activeFilters, onApplyFilter, onRemoveFilter, allData }) => {
|
||||||
|
const [filters, setFilters] = useState<{ operator: string; value: string | number }[]>([
|
||||||
|
{ operator: "equals", value: "" }
|
||||||
|
]);
|
||||||
|
const [showFilter, setShowFilter] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const operators = {
|
||||||
|
text: ["equals", "not equals", "contains", "does not contain", "is empty"],
|
||||||
|
number: ["equals", "not equals", "greater", "greater or equal", "less", "less or equal"]
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilters(activeFilters.length > 0 ? activeFilters : [{ operator: "equals", value: "" }]);
|
||||||
|
}, [activeFilters]);
|
||||||
|
|
||||||
|
const updateFilter = (index: number, key: "operator" | "value", newValue: string | number) => {
|
||||||
|
setFilters((prevFilters) => {
|
||||||
|
const updatedFilters = [...prevFilters];
|
||||||
|
updatedFilters[index][key] = newValue;
|
||||||
|
|
||||||
|
if (key === "value" && type === "text") {
|
||||||
|
handleAutocomplete(newValue as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedFilters;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (key === "value") {
|
||||||
|
setTimeout(() => setShowSuggestions(false), 100); // Vorschläge ausblenden, sobald Wert gesetzt wird
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutocomplete = (input: string) => {
|
||||||
|
let filteredSuggestions: string[] = [];
|
||||||
|
|
||||||
|
const uniqueValues = [...new Set(allData.map((item) => item[column]?.toString()))];
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
filteredSuggestions = uniqueValues;
|
||||||
|
} else {
|
||||||
|
filteredSuggestions = uniqueValues.filter((value) =>
|
||||||
|
value && value.toLowerCase().includes(input.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuggestions(filteredSuggestions.slice(0, 5));
|
||||||
|
setShowSuggestions(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const applyFilters = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
for (const filter of filters) {
|
||||||
|
await onApplyFilter(column, filter.operator, filter.value);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
setShowFilter(false);
|
||||||
|
setSuggestions([]); // Close suggestions after applying filter
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setFilters([{ operator: "equals", value: "" }]);
|
||||||
|
setShowFilter(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block text-left">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilter(!showFilter)}
|
||||||
|
className="ml-2 p-1 rounded bg-gray-800 hover:bg-gray-600 transition text-white"
|
||||||
|
>
|
||||||
|
🔽
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showFilter && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute left-0 mt-2 bg-white dark:bg-gray-900 text-black dark:text-white border border-gray-300 dark:border-gray-700 shadow-lg rounded-lg w-56 p-4 z-50"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<label className="text-sm font-medium">Filter by {column}</label>
|
||||||
|
<button onClick={resetFilters} className="text-red-500 hover:text-red-700 transition">
|
||||||
|
✖
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filters.map((filter, index) => (
|
||||||
|
<div key={index} className="mb-2 p-2 border rounded relative">
|
||||||
|
<select
|
||||||
|
value={filter.operator}
|
||||||
|
onChange={(e) => updateFilter(index, "operator", e.target.value)}
|
||||||
|
className="w-full p-1 border rounded bg-gray-100 dark:bg-gray-800 text-black dark:text-white"
|
||||||
|
>
|
||||||
|
{operators[type].map((op) => (
|
||||||
|
<option key={op} value={op}>
|
||||||
|
{op}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<input
|
||||||
|
type={type === "number" ? "number" : "text"}
|
||||||
|
value={filters[index].value}
|
||||||
|
onChange={(e) => updateFilter(index, "value", e.target.value)}
|
||||||
|
className="w-full mt-2 p-1 border rounded"
|
||||||
|
onFocus={() => handleAutocomplete("")} // Zeige Vorschläge an
|
||||||
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 200)} // Verhindert sofortiges Schließen
|
||||||
|
/>
|
||||||
|
|
||||||
|
{type === "text" && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutocomplete("")}
|
||||||
|
className="ml-2 bg-gray-300 dark:bg-gray-600 px-2 py-1 rounded text-gray-800 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
🔽
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
|
<ul className="absolute top-full left-0 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 mt-1 rounded shadow-lg z-50">
|
||||||
|
{suggestions.map((suggestion, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
className="p-2 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // Verhindert, dass das Input-Feld sofort das Blur-Event auslöst
|
||||||
|
updateFilter(index, "value", suggestion);
|
||||||
|
setSuggestions([]); // Vorschläge ausblenden
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}}
|
||||||
|
onClick={() => setFilters([{ operator: filters[index].operator, value: suggestion }])} // Setzt den Wert im Input zurück
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button onClick={() => setFilters([...filters, { operator: "equals", value: "" }])}
|
||||||
|
className="w-full bg-gray-500 hover:bg-gray-600 text-white p-1 rounded"
|
||||||
|
>
|
||||||
|
+ Add Another Filter
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={applyFilters}
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full p-2 rounded-md font-semibold mt-3 transition ${loading
|
||||||
|
? "bg-blue-300 text-gray-700 cursor-not-allowed"
|
||||||
|
: "bg-blue-500 hover:bg-blue-600 text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? "Applying..." : "Apply"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterComponent;
|
@ -37,6 +37,10 @@ export default function CodeCopyButton({
|
|||||||
);
|
);
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toast.success(`copied ${type} to clipboard`, {
|
||||||
|
// icon: <ClipboardCheck className="h-4 w-4" />,
|
||||||
|
// });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -45,17 +49,17 @@ export default function CodeCopyButton({
|
|||||||
<div className="overflow-x-auto whitespace-pre-wrap text-nowrap break-all pr-4 text-sm">
|
<div className="overflow-x-auto whitespace-pre-wrap text-nowrap break-all pr-4 text-sm">
|
||||||
{!isMobile && children ? children : "Copy install command"}
|
{!isMobile && children ? children : "Copy install command"}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div
|
||||||
|
className={cn(" right-0 cursor-pointer bg-muted px-3 py-4")}
|
||||||
onClick={() => handleCopy("install command", children)}
|
onClick={() => handleCopy("install command", children)}
|
||||||
className={cn("bg-muted px-3 py-4")}
|
|
||||||
title="Copy"
|
|
||||||
>
|
>
|
||||||
{hasCopied ? (
|
{hasCopied ? (
|
||||||
<CheckIcon className="h-4 w-4" />
|
<CheckIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ClipboardIcon className="h-4 w-4" />
|
<ClipboardIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
<span className="sr-only">Copy</span>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user