From b61ebc6011ccadb7cf66fe98b224a35898700df8 Mon Sep 17 00:00:00 2001 From: tom1145 Date: Fri, 13 Dec 2024 11:40:29 +0200 Subject: [PATCH 01/14] fix: active pagination button --- src/components/Table/CustomPagination.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Table/CustomPagination.tsx b/src/components/Table/CustomPagination.tsx index 15c2787..955b6aa 100644 --- a/src/components/Table/CustomPagination.tsx +++ b/src/components/Table/CustomPagination.tsx @@ -19,10 +19,10 @@ const StyledPagination = styled(Pagination)(({ theme }) => ({ '&:hover': { backgroundColor: '#CF1FB1' }, - maxWidth: '32px', - maxHeight: '32px', + minWidth: '32px', + height: '32px', borderRadius: '8px', - paddingTop: '3px' + padding: '3px 8px' } })) From 9919f4f5103d5d7339538d23b7f2eeb0e2f1fd6d Mon Sep 17 00:00:00 2001 From: tom1145 Date: Fri, 13 Dec 2024 12:24:32 +0200 Subject: [PATCH 02/14] fix: remove duplicate columns from column selector menu --- src/components/Table/index.tsx | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index 05e8518..ac6e751 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -342,6 +342,7 @@ export default function Table({ minWidth: 200, sortable: false, filterable: true, + hideable: false, filterOperators: [ { label: 'contains', @@ -380,6 +381,7 @@ export default function Table({ minWidth: 200, sortable: false, filterable: true, + hideable: false, filterOperators: [ { label: 'contains', @@ -404,6 +406,7 @@ export default function Table({ minWidth: 200, sortable: false, filterable: true, + hideable: false, filterOperators: [ { label: 'contains', @@ -968,23 +971,20 @@ export default function Table({ }} initialState={{ columns: { - columnVisibilityModel: - tableType === 'nodes' - ? { - network: false, - publicKey: false, - version: false, - http: false, - p2p: false, - supportedStorage: false, - platform: false, - codeHash: false, - allowedAdmins: false, - dnsFilter: false, - city: false, - country: false - } - : {} + columnVisibilityModel: { + network: false, + publicKey: false, + version: false, + http: false, + p2p: false, + supportedStorage: false, + platform: false, + codeHash: false, + allowedAdmins: false, + dnsFilter: false, + city: false, + country: false + } }, pagination: { paginationModel: { From 37d9e1ed3729be3927d72dae8f7eb05b25deed8a Mon Sep 17 00:00:00 2001 From: tom1145 Date: Mon, 16 Dec 2024 11:40:07 +0200 Subject: [PATCH 03/14] fix: extract csv values format --- src/components/Table/CustomToolbar.module.css | 13 - src/components/Table/CustomToolbar.tsx | 72 ------ src/components/Table/index.tsx | 227 +++++++++--------- src/components/Table/utils.ts | 167 +++++++++++++ src/components/Toolbar/index.tsx | 49 +++- 5 files changed, 323 insertions(+), 205 deletions(-) delete mode 100644 src/components/Table/CustomToolbar.module.css delete mode 100644 src/components/Table/CustomToolbar.tsx create mode 100644 src/components/Table/utils.ts diff --git a/src/components/Table/CustomToolbar.module.css b/src/components/Table/CustomToolbar.module.css deleted file mode 100644 index 4f3f72d..0000000 --- a/src/components/Table/CustomToolbar.module.css +++ /dev/null @@ -1,13 +0,0 @@ -.root { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px; - background-color: #f8f9fa; - border-bottom: 1px solid #e9ecef; -} - -.search { - display: flex; - align-items: center; -} diff --git a/src/components/Table/CustomToolbar.tsx b/src/components/Table/CustomToolbar.tsx deleted file mode 100644 index f06abcb..0000000 --- a/src/components/Table/CustomToolbar.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react' -import { GridToolbarContainer, GridToolbarProps } from '@mui/x-data-grid' -import { Button, IconButton, styled, TextField } from '@mui/material' -import ViewColumnIcon from '@mui/icons-material/ViewColumn' -import FilterListIcon from '@mui/icons-material/FilterList' -import DensityMediumIcon from '@mui/icons-material/DensityMedium' -import FileDownloadIcon from '@mui/icons-material/FileDownload' -import style from './CustomToolbar.module.css' -import { ClearIcon } from '@mui/x-date-pickers' - -const StyledGridToolbarContainer = styled(GridToolbarContainer)(({ theme }) => ({ - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(1, 2), - gap: theme.spacing(2) -})) - -const StyledButton = styled(Button)(() => ({ - color: '#CF1FB1', - textTransform: 'uppercase', - fontWeight: 'bold', - '&:hover': { - backgroundColor: 'transparent' - } -})) - -interface CustomToolbarProps extends GridToolbarProps { - searchTerm: string - onSearchChange: (value: string) => void - onSearch: () => void - onReset: () => void - tableType: 'nodes' | 'countries' -} - -const CustomToolbar: React.FC = ({ - searchTerm, - onSearchChange, - onSearch, - onReset -}) => { - return ( - - }>Columns - }>Filters - }>Density - }>Export - -
- onSearchChange(e.target.value)} - placeholder="Search..." - variant="outlined" - size="small" - InputProps={{ - endAdornment: ( - <> - - {searchTerm && ( - - - - )} - - ) - }} - /> -
-
- ) -} - -export default CustomToolbar diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index ac6e751..25250b9 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -3,9 +3,9 @@ import React, { useState, useMemo, useEffect, - useCallback + useCallback, + useRef } from 'react' -import axios from 'axios' import { DataGrid, GridColDef, @@ -15,7 +15,7 @@ import { GridValidRowModel, GridFilterInputValue, GridFilterModel, - GridValueGetter + useGridApiRef } from '@mui/x-data-grid' import styles from './index.module.css' @@ -29,8 +29,14 @@ import ReportIcon from '@mui/icons-material/Report' import CustomToolbar from '../Toolbar' import { styled } from '@mui/material/styles' import CustomPagination from './CustomPagination' -import { FilterOperator, CountryStatsFilters, NodeFilters } from '../../types/filters' +import { FilterOperator, NodeFilters } from '../../types/filters' import { debounce } from '../../shared/utils/debounce' +import { + getAllNetworks, + formatSupportedStorage, + formatPlatform, + formatUptimePercentage +} from './utils' const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ '& .MuiDataGrid-toolbarContainer': { @@ -95,72 +101,6 @@ const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ } })) -const getAllNetworks = (indexers: NodeData['indexer']): string => { - return indexers?.map((indexer) => indexer.network).join(', ') -} - -export const formatSupportedStorage = ( - supportedStorage: NodeData['supportedStorage'] -): string => { - const storageTypes = [] - - if (supportedStorage?.url) storageTypes.push('URL') - if (supportedStorage?.arwave) storageTypes.push('Arweave') - if (supportedStorage?.ipfs) storageTypes.push('IPFS') - - return storageTypes.join(', ') -} - -export const formatPlatform = (platform: NodeData['platform']): string => { - if (platform) { - const { cpus, arch, machine, platform: platformName, osType, node } = platform - return `CPUs: ${cpus}, Architecture: ${arch}, Machine: ${machine}, Platform: ${platformName}, OS Type: ${osType}, Node.js: ${node}` - } - return '' -} - -export const formatUptime = (uptimeInSeconds: number): string => { - const days = Math.floor(uptimeInSeconds / (3600 * 24)) - const hours = Math.floor((uptimeInSeconds % (3600 * 24)) / 3600) - const minutes = Math.floor((uptimeInSeconds % 3600) / 60) - - const dayStr = days > 0 ? `${days} day${days > 1 ? 's' : ''} ` : '' - const hourStr = hours > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ` : '' - const minuteStr = minutes > 0 ? `${minutes} minute${minutes > 1 ? 's' : ''}` : '' - - return `${dayStr}${hourStr}${minuteStr}`.trim() -} -const formatUptimePercentage = ( - uptimeInSeconds: number, - totalUptime: number | null -): string => { - if (totalUptime === null) return '0.00%' - - console.group('Uptime Calculation') - console.log('Input uptimeInSeconds:', uptimeInSeconds) - console.log('Input totalUptime:', totalUptime) - - const uptimePercentage = (uptimeInSeconds / totalUptime) * 100 - console.log('Calculated percentage:', uptimePercentage) - - const percentage = uptimePercentage > 100 ? 100 : uptimePercentage - console.log('Final percentage (capped at 100):', percentage) - console.groupEnd() - - return `${percentage.toFixed(2)}%` -} - -const UptimeCell: React.FC<{ - uptimeInSeconds: number - totalUptime: number | null -}> = ({ uptimeInSeconds, totalUptime }) => { - if (totalUptime === null) { - return Loading... - } - - return {formatUptimePercentage(uptimeInSeconds, totalUptime)} -} - const getEligibleCheckbox = (eligible: boolean): React.ReactElement => { return eligible ? ( @@ -169,6 +109,11 @@ const getEligibleCheckbox = (eligible: boolean): React.ReactElement => { ) } +interface DebouncedFunction { + (value: string): void + cancel: () => void +} + export default function Table({ tableType = 'nodes' }: { @@ -199,6 +144,8 @@ export default function Table({ const [selectedNode, setSelectedNode] = useState(null) const [searchTerm, setLocalSearchTerm] = useState('') const [searchTermCountry, setLocalSearchTermCountry] = useState('') + const apiRef = useGridApiRef() + const timeoutIdRef = useRef(null) useEffect(() => { setTableType(tableType) @@ -649,7 +596,7 @@ export default function Table({ minWidth: 200, align: 'left', headerAlign: 'left', - sortable: false, + sortable: true, filterable: true, filterOperators: [ { @@ -824,6 +771,17 @@ export default function Table({ } ] + const UptimeCell: React.FC<{ + uptimeInSeconds: number + totalUptime: number | null + }> = ({ uptimeInSeconds, totalUptime }) => { + if (totalUptime === null) { + return Loading... + } + + return {formatUptimePercentage(uptimeInSeconds, totalUptime)} + } + const handlePaginationChange = useCallback( (paginationModel: { page: number; pageSize: number }) => { const newPage = paginationModel.page + 1 @@ -919,30 +877,58 @@ export default function Table({ [setFilters, totalUptime] ) - const handleSearchChange = (searchValue: string) => { - if (tableType === 'countries') { - setLocalSearchTermCountry(searchValue) - } else { - setLocalSearchTerm(searchValue) + const debouncedSearchFn = useMemo(() => { + const debouncedFn = (value: string) => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + } + + timeoutIdRef.current = setTimeout(() => { + const setContextSearchTerm = + tableType === 'countries' ? setCountrySearchTerm : setSearchTerm + setContextSearchTerm(value) + timeoutIdRef.current = null + }, 1000) } - } - const handleSearch = () => { - if (tableType === 'countries') { - setCountrySearchTerm(searchTermCountry) - } else { - setSearchTerm(searchTerm) + debouncedFn.cancel = () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + timeoutIdRef.current = null + } } + + return debouncedFn + }, [tableType, setCountrySearchTerm, setSearchTerm]) + + const handleSearchChange = (searchValue: string) => { + const setLocalTerm = + tableType === 'countries' ? setLocalSearchTermCountry : setLocalSearchTerm + setLocalTerm(searchValue) + + if (!searchValue) { + debouncedSearchFn.cancel() + const setContextTerm = + tableType === 'countries' ? setCountrySearchTerm : setSearchTerm + setContextTerm('') + return + } + + debouncedSearchFn(searchValue) } const handleReset = () => { - if (tableType === 'countries') { - setCountrySearchTerm('') - } else { - setLocalSearchTerm('') - setLocalSearchTermCountry('') - setCountrySearchTerm('') - setSearchTerm('') + const currentSearchTerm = tableType === 'countries' ? searchTermCountry : searchTerm + + if (currentSearchTerm) { + const setLocalTerm = + tableType === 'countries' ? setLocalSearchTermCountry : setLocalSearchTerm + const setContextTerm = + tableType === 'countries' ? setCountrySearchTerm : setSearchTerm + + setLocalTerm('') + setContextTerm('') + debouncedSearchFn.cancel() } } @@ -964,32 +950,35 @@ export default function Table({ toolbar: { searchTerm: tableType === 'countries' ? searchTermCountry : searchTerm, onSearchChange: handleSearchChange, - onSearch: handleSearch, onReset: handleReset, - tableType: tableType + tableType: tableType, + apiRef: apiRef.current } }} initialState={{ columns: { - columnVisibilityModel: { - network: false, - publicKey: false, - version: false, - http: false, - p2p: false, - supportedStorage: false, - platform: false, - codeHash: false, - allowedAdmins: false, - dnsFilter: false, - city: false, - country: false - } + columnVisibilityModel: + tableType === 'nodes' + ? { + network: false, + publicKey: false, + version: false, + http: false, + p2p: false, + supportedStorage: false, + platform: false, + codeHash: false, + allowedAdmins: false, + dnsFilter: false, + city: false, + country: false + } + : {} }, pagination: { paginationModel: { - pageSize: pageSize, - page: currentPage - 1 + pageSize: tableType === 'countries' ? countryPageSize : pageSize, + page: (tableType === 'countries' ? countryCurrentPage : currentPage) - 1 } }, density: 'comfortable' @@ -1013,6 +1002,30 @@ export default function Table({ rowCount={totalItems} autoHeight={false} hideFooter={true} + processRowUpdate={( + newRow: GridValidRowModel, + oldRow: GridValidRowModel + ): GridValidRowModel => { + const processCell = (value: unknown) => { + if (typeof value === 'object' && value !== null) { + if ('dns' in value || 'ip' in value) { + const dnsIpObj = value as { dns?: string; ip?: string; port?: string } + return `${dnsIpObj.dns || dnsIpObj.ip}${dnsIpObj.port ? ':' + dnsIpObj.port : ''}` + } + + if ('city' in value || 'country' in value) { + const locationObj = value as { city?: string; country?: string } + return `${locationObj.city} ${locationObj.country}` + } + } + return value + } + + return Object.fromEntries( + Object.entries(newRow).map(([key, value]) => [key, processCell(value)]) + ) as GridValidRowModel + }} + apiRef={apiRef} /> +} + +interface CountryData { + id: string + country: string + totalNodes: number + citiesWithNodes: number +} + +export const getAllNetworks = (indexers: NodeData['indexer']): string => { + return indexers?.map((indexer) => indexer.network).join(', ') || '' +} + +export const formatSupportedStorage = ( + supportedStorage: NodeData['supportedStorage'] +): string => { + const storageTypes = [] + + if (supportedStorage?.url) storageTypes.push('URL') + if (supportedStorage?.arwave) storageTypes.push('Arweave') + if (supportedStorage?.ipfs) storageTypes.push('IPFS') + + return storageTypes.join(', ') +} + +export const formatPlatform = (platform: NodeData['platform']): string => { + if (platform) { + const { cpus, arch, machine, platform: platformName, osType, node } = platform + return `CPUs: ${cpus}, Architecture: ${arch}, Machine: ${machine}, Platform: ${platformName}, OS Type: ${osType}, Node.js: ${node}` + } + return '' +} + +export const formatUptime = (uptimeInSeconds: number): string => { + const days = Math.floor(uptimeInSeconds / (3600 * 24)) + const hours = Math.floor((uptimeInSeconds % (3600 * 24)) / 3600) + const minutes = Math.floor((uptimeInSeconds % 3600) / 60) + + const dayStr = days > 0 ? `${days} day${days > 1 ? 's' : ''} ` : '' + const hourStr = hours > 0 ? `${hours} hour${hours > 1 ? 's' : ''} ` : '' + const minuteStr = minutes > 0 ? `${minutes} minute${minutes > 1 ? 's' : ''}` : '' + + return `${dayStr}${hourStr}${minuteStr}`.trim() +} + +export const formatUptimePercentage = ( + uptimeInSeconds: number, + totalUptime: number | null +): string => { + if (totalUptime === null) return '0.00%' + + const uptimePercentage = (uptimeInSeconds / totalUptime) * 100 + const percentage = uptimePercentage > 100 ? 100 : uptimePercentage + return `${percentage.toFixed(2)}%` +} + +export const exportToCsv = (apiRef: GridApi, tableType: 'nodes' | 'countries') => { + if (!apiRef) return + + const columns = apiRef.getAllColumns().filter((col) => { + if (tableType === 'nodes') { + return col.field !== 'viewMore' && col.field !== 'location' + } + // For countries table, keep all columns + return true + }) + + const rows = apiRef.getRowModels() + + const formattedRows = Array.from(rows.values()).map((row) => { + const formattedRow: Record = {} + + columns.forEach((column) => { + const field = column.field + const value = row[field] + + if (field === 'weeklyUptime') { + formattedRow[column.headerName || field] = formatUptimePercentage( + value, + row.totalUptime + ) + } else if (field === 'dnsFilter') { + const ipAndDns = row.ipAndDns as { dns?: string; ip?: string; port?: string } + formattedRow[column.headerName || field] = + `${ipAndDns?.dns || ''} ${ipAndDns?.ip || ''} ${ipAndDns?.port ? ':' + ipAndDns?.port : ''}`.trim() + } else if (field === 'city') { + formattedRow[column.headerName || field] = row.location?.city || '' + } else if (field === 'country') { + formattedRow[column.headerName || field] = row.location?.country || '' + } else if (field === 'platform') { + formattedRow[column.headerName || field] = formatPlatform(value) + } else if (field === 'supportedStorage') { + formattedRow[column.headerName || field] = formatSupportedStorage(value) + } else if (field === 'indexer') { + formattedRow[column.headerName || field] = getAllNetworks(value) + } else if (field === 'lastCheck') { + formattedRow[column.headerName || field] = new Date(value).toLocaleString() + } else if (typeof value === 'boolean') { + formattedRow[column.headerName || field] = value ? 'Yes' : 'No' + } else if (Array.isArray(value)) { + formattedRow[column.headerName || field] = value.join(', ') + } else { + formattedRow[column.headerName || field] = String(value || '') + } + }) + + return formattedRow + }) + + console.group('CSV Export Debug') + console.log('Columns:', columns) + console.log('Raw rows:', Array.from(rows.values())) + + console.log('Final formatted rows:', formattedRows) + console.groupEnd() + + const headers = Object.keys(formattedRows[0]) + const csvRows = [ + headers.join(','), + ...formattedRows.map((row) => + headers + .map((header) => { + const value = row[header] + return value.includes(',') || value.includes('"') + ? `"${value.replace(/"/g, '""')}"` + : value + }) + .join(',') + ) + ].join('\n') + + const blob = new Blob(['\ufeff' + csvRows], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = `${tableType}_export_${new Date().toISOString()}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) +} diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index 69119c1..36275ad 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -5,12 +5,15 @@ import { GridToolbarColumnsButton, GridToolbarFilterButton, GridToolbarDensitySelector, - GridToolbarProps + GridToolbarProps, + GridApi } from '@mui/x-data-grid' -import { TextField, IconButton, styled } from '@mui/material' +import { TextField, IconButton, styled, Button } from '@mui/material' import SearchIcon from '@mui/icons-material/Search' import ClearIcon from '@mui/icons-material/Clear' import style from './style.module.css' +import { exportToCsv } from '../Table/utils' +import FileDownloadIcon from '@mui/icons-material/FileDownload' const StyledTextField = styled(TextField)({ '& .MuiOutlinedInput-root': { @@ -40,6 +43,8 @@ interface CustomToolbarProps extends GridToolbarProps { onSearchChange: (value: string) => void onSearch: () => void onReset: () => void + tableType: 'nodes' | 'countries' + apiRef?: GridApi } const CustomToolbar: React.FC = ({ @@ -47,26 +52,45 @@ const CustomToolbar: React.FC = ({ onSearchChange, onSearch, onReset, - ...props + apiRef, + tableType }) => { + const handleExport = () => { + console.log('Export clicked') + console.log('apiRef available:', !!apiRef) + if (apiRef) { + exportToCsv(apiRef, tableType) + } + } + + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && searchTerm) { + event.preventDefault() + onSearchChange(searchTerm) + } + } + return (
- +
onSearchChange(e.target.value)} - onKeyPress={(e) => { - if (e.key === 'Enter') { - onSearch() - } - }} + onKeyPress={handleKeyPress} placeholder="Search..." variant="outlined" size="small" @@ -74,16 +98,15 @@ const CustomToolbar: React.FC = ({ endAdornment: ( <> - + {searchTerm && ( - + )} - ), - style: { paddingRight: '8px' } + ) }} />
From 4368454be8313794d45317437a037f69517c5e7e Mon Sep 17 00:00:00 2001 From: tom1145 Date: Mon, 16 Dec 2024 11:44:06 +0200 Subject: [PATCH 04/14] fix: build --- src/components/Table/NodeDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Table/NodeDetails.tsx b/src/components/Table/NodeDetails.tsx index d4e8be0..2898d4a 100644 --- a/src/components/Table/NodeDetails.tsx +++ b/src/components/Table/NodeDetails.tsx @@ -2,7 +2,7 @@ import { FC } from 'react'; import { Card, CardContent, Grid, IconButton, Typography, Box } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import { NodeData } from '../../shared/types/RowDataType'; -import { formatPlatform, formatSupportedStorage, formatUptime } from './index'; +import { formatPlatform, formatSupportedStorage, formatUptime } from './utils' interface NodeDetailsProps { nodeData: NodeData; From 7c355c441197669f0ef80c309abc569638f2df9d Mon Sep 17 00:00:00 2001 From: tom1145 Date: Mon, 16 Dec 2024 14:07:46 +0200 Subject: [PATCH 05/14] fix: show Eligibility Issue at export --- src/components/Table/index.tsx | 3 ++- src/components/Table/utils.ts | 21 ++++++++++++++------- src/components/Toolbar/index.tsx | 6 ++++-- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index 25250b9..3800442 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -952,7 +952,8 @@ export default function Table({ onSearchChange: handleSearchChange, onReset: handleReset, tableType: tableType, - apiRef: apiRef.current + apiRef: apiRef.current, + totalUptime: totalUptime } }} initialState={{ diff --git a/src/components/Table/utils.ts b/src/components/Table/utils.ts index 303f7d6..38ed398 100644 --- a/src/components/Table/utils.ts +++ b/src/components/Table/utils.ts @@ -72,23 +72,28 @@ export const formatUptime = (uptimeInSeconds: number): string => { export const formatUptimePercentage = ( uptimeInSeconds: number, - totalUptime: number | null + totalUptime: number | null | undefined ): string => { - if (totalUptime === null) return '0.00%' + const defaultTotalUptime = 7 * 24 * 60 * 60 + + const actualTotalUptime = totalUptime || defaultTotalUptime - const uptimePercentage = (uptimeInSeconds / totalUptime) * 100 + const uptimePercentage = (uptimeInSeconds / actualTotalUptime) * 100 const percentage = uptimePercentage > 100 ? 100 : uptimePercentage return `${percentage.toFixed(2)}%` } -export const exportToCsv = (apiRef: GridApi, tableType: 'nodes' | 'countries') => { +export const exportToCsv = ( + apiRef: GridApi, + tableType: 'nodes' | 'countries', + totalUptime: number | null +) => { if (!apiRef) return const columns = apiRef.getAllColumns().filter((col) => { if (tableType === 'nodes') { return col.field !== 'viewMore' && col.field !== 'location' } - // For countries table, keep all columns return true }) @@ -101,10 +106,10 @@ export const exportToCsv = (apiRef: GridApi, tableType: 'nodes' | 'countries') = const field = column.field const value = row[field] - if (field === 'weeklyUptime') { + if (field === 'uptime') { formattedRow[column.headerName || field] = formatUptimePercentage( value, - row.totalUptime + totalUptime ) } else if (field === 'dnsFilter') { const ipAndDns = row.ipAndDns as { dns?: string; ip?: string; port?: string } @@ -126,6 +131,8 @@ export const exportToCsv = (apiRef: GridApi, tableType: 'nodes' | 'countries') = formattedRow[column.headerName || field] = value ? 'Yes' : 'No' } else if (Array.isArray(value)) { formattedRow[column.headerName || field] = value.join(', ') + } else if (field === 'eligibilityCauseStr') { + formattedRow[column.headerName || field] = value || 'none' } else { formattedRow[column.headerName || field] = String(value || '') } diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index 36275ad..05717c7 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -45,6 +45,7 @@ interface CustomToolbarProps extends GridToolbarProps { onReset: () => void tableType: 'nodes' | 'countries' apiRef?: GridApi + totalUptime: number | null } const CustomToolbar: React.FC = ({ @@ -53,13 +54,14 @@ const CustomToolbar: React.FC = ({ onSearch, onReset, apiRef, - tableType + tableType, + totalUptime }) => { const handleExport = () => { console.log('Export clicked') console.log('apiRef available:', !!apiRef) if (apiRef) { - exportToCsv(apiRef, tableType) + exportToCsv(apiRef, tableType, totalUptime) } } From 5a7fc6d0c36e7548a3afc3065e5412dae674399d Mon Sep 17 00:00:00 2001 From: tom1145 Date: Mon, 16 Dec 2024 14:16:51 +0200 Subject: [PATCH 06/14] fix: networks --- src/components/Table/index.tsx | 11 ++++++----- src/components/Table/utils.ts | 5 +++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index 3800442..311d5cc 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -490,12 +490,13 @@ export default function Table({ field: 'network', headerName: 'Network', flex: 1, - minWidth: 150, + minWidth: 200, sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => ( - {getAllNetworks(params.row.indexer)} - ) + filterable: true, + renderCell: (params: GridRenderCellParams) => { + const networks = params.row.provider?.map((p) => p.network).join(', ') || '' + return {networks} + } }, { field: 'viewMore', diff --git a/src/components/Table/utils.ts b/src/components/Table/utils.ts index 38ed398..095aaaf 100644 --- a/src/components/Table/utils.ts +++ b/src/components/Table/utils.ts @@ -25,6 +25,7 @@ type NodeData = { codeHash: string allowedAdmins: string[] indexer?: Array<{ network: string }> + provider?: Array<{ network: string }> } interface CountryData { @@ -133,6 +134,10 @@ export const exportToCsv = ( formattedRow[column.headerName || field] = value.join(', ') } else if (field === 'eligibilityCauseStr') { formattedRow[column.headerName || field] = value || 'none' + } else if (field === 'network') { + const networks = + row.provider?.map((p: { network: string }) => p.network).join(', ') || '' + formattedRow[column.headerName || field] = networks } else { formattedRow[column.headerName || field] = String(value || '') } From 1c4f7b6b16cea2612cecf2a68ddaf07a929884ff Mon Sep 17 00:00:00 2001 From: tom1145 Date: Mon, 16 Dec 2024 14:41:08 +0200 Subject: [PATCH 07/14] fix: export formatting for countries table and code cleanup --- src/components/Table/utils.ts | 82 ++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/src/components/Table/utils.ts b/src/components/Table/utils.ts index 095aaaf..475a799 100644 --- a/src/components/Table/utils.ts +++ b/src/components/Table/utils.ts @@ -107,52 +107,54 @@ export const exportToCsv = ( const field = column.field const value = row[field] - if (field === 'uptime') { - formattedRow[column.headerName || field] = formatUptimePercentage( - value, - totalUptime - ) - } else if (field === 'dnsFilter') { - const ipAndDns = row.ipAndDns as { dns?: string; ip?: string; port?: string } - formattedRow[column.headerName || field] = - `${ipAndDns?.dns || ''} ${ipAndDns?.ip || ''} ${ipAndDns?.port ? ':' + ipAndDns?.port : ''}`.trim() - } else if (field === 'city') { - formattedRow[column.headerName || field] = row.location?.city || '' - } else if (field === 'country') { - formattedRow[column.headerName || field] = row.location?.country || '' - } else if (field === 'platform') { - formattedRow[column.headerName || field] = formatPlatform(value) - } else if (field === 'supportedStorage') { - formattedRow[column.headerName || field] = formatSupportedStorage(value) - } else if (field === 'indexer') { - formattedRow[column.headerName || field] = getAllNetworks(value) - } else if (field === 'lastCheck') { - formattedRow[column.headerName || field] = new Date(value).toLocaleString() - } else if (typeof value === 'boolean') { - formattedRow[column.headerName || field] = value ? 'Yes' : 'No' - } else if (Array.isArray(value)) { - formattedRow[column.headerName || field] = value.join(', ') - } else if (field === 'eligibilityCauseStr') { - formattedRow[column.headerName || field] = value || 'none' - } else if (field === 'network') { - const networks = - row.provider?.map((p: { network: string }) => p.network).join(', ') || '' - formattedRow[column.headerName || field] = networks + if (tableType === 'countries') { + if (field === 'cityWithMostNodes') { + const cityName = row.cityWithMostNodes || '' + const nodeCount = row.cityWithMostNodesCount || 0 + formattedRow[column.headerName || field] = `${cityName} (${nodeCount})` + } else { + formattedRow[column.headerName || field] = String(value || '') + } } else { - formattedRow[column.headerName || field] = String(value || '') + if (field === 'uptime') { + formattedRow[column.headerName || field] = formatUptimePercentage( + value, + totalUptime + ) + } else if (field === 'network') { + const networks = + row.provider?.map((p: { network: string }) => p.network).join(', ') || '' + formattedRow[column.headerName || field] = networks + } else if (field === 'dnsFilter') { + const ipAndDns = row.ipAndDns as { dns?: string; ip?: string; port?: string } + formattedRow[column.headerName || field] = + `${ipAndDns?.dns || ''} ${ipAndDns?.ip || ''} ${ipAndDns?.port ? ':' + ipAndDns?.port : ''}`.trim() + } else if (field === 'city') { + formattedRow[column.headerName || field] = row.location?.city || '' + } else if (field === 'country') { + formattedRow[column.headerName || field] = row.location?.country || '' + } else if (field === 'platform') { + formattedRow[column.headerName || field] = formatPlatform(value) + } else if (field === 'supportedStorage') { + formattedRow[column.headerName || field] = formatSupportedStorage(value) + } else if (field === 'indexer') { + formattedRow[column.headerName || field] = getAllNetworks(value) + } else if (field === 'lastCheck') { + formattedRow[column.headerName || field] = new Date(value).toLocaleString() + } else if (typeof value === 'boolean') { + formattedRow[column.headerName || field] = value ? 'Yes' : 'No' + } else if (Array.isArray(value)) { + formattedRow[column.headerName || field] = value.join(', ') + } else if (field === 'eligibilityCauseStr') { + formattedRow[column.headerName || field] = value || 'none' + } else { + formattedRow[column.headerName || field] = String(value || '') + } } }) - return formattedRow }) - console.group('CSV Export Debug') - console.log('Columns:', columns) - console.log('Raw rows:', Array.from(rows.values())) - - console.log('Final formatted rows:', formattedRows) - console.groupEnd() - const headers = Object.keys(formattedRows[0]) const csvRows = [ headers.join(','), From 0566ac4772943e5ca8ef3c8c9d4f0ee871398b87 Mon Sep 17 00:00:00 2001 From: tom1145 Date: Tue, 17 Dec 2024 11:20:46 +0200 Subject: [PATCH 08/14] refactor: table --- src/components/Table/columns.tsx | 650 ++++++++++++++++++ src/components/Table/hooks/useTable.ts | 259 +++++++ src/components/Table/index.tsx | 908 ++----------------------- src/components/Table/tableConfig.ts | 61 ++ src/components/Table/utils.ts | 7 +- src/components/Toolbar/index.tsx | 4 +- src/context/DataContext.tsx | 2 +- src/shared/enums/TableTypeEnum.ts | 4 + src/{ => shared}/types/filters.ts | 0 src/shared/types/tableTypes.ts | 76 +++ src/shared/utils/urlBuilder.ts | 2 +- 11 files changed, 1106 insertions(+), 867 deletions(-) create mode 100644 src/components/Table/columns.tsx create mode 100644 src/components/Table/hooks/useTable.ts create mode 100644 src/components/Table/tableConfig.ts create mode 100644 src/shared/enums/TableTypeEnum.ts rename src/{ => shared}/types/filters.ts (100%) create mode 100644 src/shared/types/tableTypes.ts diff --git a/src/components/Table/columns.tsx b/src/components/Table/columns.tsx new file mode 100644 index 0000000..a8fd97e --- /dev/null +++ b/src/components/Table/columns.tsx @@ -0,0 +1,650 @@ +import { GridColDef, GridFilterInputValue, GridRenderCellParams } from '@mui/x-data-grid' +import { Button, Tooltip } from '@mui/material' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import ReportIcon from '@mui/icons-material/Report' +import { NodeData } from '../../shared/types/RowDataType' +import { formatSupportedStorage, formatPlatform, formatUptimePercentage } from './utils' +import styles from './index.module.css' + +const getEligibleCheckbox = (eligible: boolean): React.ReactElement => { + return eligible ? ( + + ) : ( + + ) +} + +const UptimeCell: React.FC<{ + uptimeInSeconds: number + totalUptime: number | null +}> = ({ uptimeInSeconds, totalUptime }) => { + if (totalUptime === null) { + return Loading... + } + + return {formatUptimePercentage(uptimeInSeconds, totalUptime)} +} + +export const nodeColumns = ( + totalUptime: number | null, + setSelectedNode: (node: NodeData) => void +): GridColDef[] => [ + { + field: 'index', + headerName: 'Index', + width: 70, + align: 'center', + headerAlign: 'center', + sortable: false, + filterable: false + }, + { + field: 'id', + headerName: 'Node ID', + flex: 1, + minWidth: 300, + sortable: false, + filterable: true, + filterOperators: [ + { + label: 'contains', + value: 'contains', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + return params.value?.toLowerCase().includes(filterItem.value.toLowerCase()) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'text' } + } + ] + }, + { + field: 'uptime', + headerName: 'Weekly Uptime', + sortable: true, + flex: 1, + minWidth: 150, + filterable: true, + headerClassName: styles.headerTitle, + filterOperators: [ + { + label: 'equals', + value: 'eq', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + const filterValue = Number(filterItem.value) / 100 + const uptimePercentage = params.value / params.row.totalUptime + return Math.abs(uptimePercentage - filterValue) <= 0.001 + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { + type: 'number', + step: '0.01', + min: '0', + max: '100', + placeholder: 'Enter percentage (0-100)', + error: !totalUptime, + helperText: !totalUptime ? 'Loading uptime data...' : undefined + } + }, + { + label: 'greater than', + value: 'gt', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + const filterValue = Number(filterItem.value) / 100 + const uptimePercentage = params.value / params.row.totalUptime + return uptimePercentage > filterValue + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { + type: 'number', + step: '0.01', + min: '0', + max: '100', + placeholder: 'Enter percentage (0-100)', + error: !totalUptime, + helperText: !totalUptime ? 'Loading uptime data...' : undefined + } + }, + { + label: 'less than', + value: 'lt', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + const filterValue = Number(filterItem.value) / 100 + const uptimePercentage = params.value / params.row.totalUptime + return uptimePercentage < filterValue + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { + type: 'number', + step: '0.01', + min: '0', + max: '100', + placeholder: 'Enter percentage (0-100)', + error: !totalUptime, + helperText: !totalUptime ? 'Loading uptime data...' : undefined + } + } + ], + renderCell: (params: GridRenderCellParams) => ( + + ), + renderHeader: () => ( + + Weekly Uptime + + ) + }, + { + field: 'dns', + headerName: 'DNS / IP', + flex: 1, + minWidth: 200, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + {(params.row.ipAndDns?.dns || params.row.ipAndDns?.ip || '') + + (params.row.ipAndDns?.port ? ':' + params.row.ipAndDns?.port : '')} + + ) + }, + { + field: 'dnsFilter', + headerName: 'DNS / IP', + flex: 1, + minWidth: 200, + sortable: false, + filterable: true, + hideable: false, + filterOperators: [ + { + label: 'contains', + value: 'contains', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + const dnsIpString = + (params.row.ipAndDns?.dns || params.row.ipAndDns?.ip || '') + + (params.row.ipAndDns?.port ? ':' + params.row.ipAndDns?.port : '') + return dnsIpString.includes(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'text' } + } + ] + }, + { + field: 'location', + headerName: 'Location', + flex: 1, + minWidth: 200, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + {`${params.row.location?.city || ''} ${params.row.location?.country || ''}`} + + ) + }, + { + field: 'city', + headerName: 'City', + flex: 1, + minWidth: 200, + sortable: false, + filterable: true, + hideable: false, + filterOperators: [ + { + label: 'contains', + value: 'contains', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + return (params.row.location?.city || '') + .toLowerCase() + .includes(filterItem.value.toLowerCase()) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'text' } + } + ] + }, + { + field: 'country', + headerName: 'Country', + flex: 1, + minWidth: 200, + sortable: false, + filterable: true, + hideable: false, + filterOperators: [ + { + label: 'contains', + value: 'contains', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + return (params.row.location?.country || '') + .toLowerCase() + .includes(filterItem.value.toLowerCase()) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'text' } + } + ] + }, + { + field: 'address', + headerName: 'Address', + flex: 1, + minWidth: 150, + sortable: false, + filterable: false + }, + { + field: 'eligible', + headerName: 'Last Check Eligibility', + flex: 1, + width: 80, + filterable: false, + sortable: true, + renderHeader: () => ( + + Last Check Eligibility + + ), + filterOperators: [ + { + label: 'equals', + value: 'eq', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + return params.value === (filterItem.value === 'true') + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { + type: 'singleSelect', + valueOptions: [ + { value: 'true', label: 'Eligible' }, + { value: 'false', label: 'Not Eligible' } + ] + } + } + ], + renderCell: (params: GridRenderCellParams) => ( +
+ {getEligibleCheckbox(params.row.eligible)} +
+ ) + }, + { + field: 'eligibilityCauseStr', + headerName: 'Eligibility Issue', + flex: 1, + width: 100, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + {params.row.eligibilityCauseStr || 'none'} + ) + }, + { + field: 'lastCheck', + headerName: 'Last Check', + flex: 1, + minWidth: 140, + filterable: true, + renderCell: (params: GridRenderCellParams) => ( + + {new Date(params?.row?.lastCheck)?.toLocaleString(undefined, { + timeZoneName: 'short' + })} + + ), + filterOperators: [ + { + label: 'equals', + value: 'eq', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + const filterDate = new Date(filterItem.value).getTime() + const cellDate = new Date(params.value).getTime() + return cellDate === filterDate + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'datetime-local' } + }, + { + label: 'after', + value: 'gt', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + const filterDate = new Date(filterItem.value).getTime() + const cellDate = new Date(params.value).getTime() + return cellDate > filterDate + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'datetime-local' } + }, + { + label: 'before', + value: 'lt', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + const filterDate = new Date(filterItem.value).getTime() + const cellDate = new Date(params.value).getTime() + return cellDate < filterDate + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'datetime-local' } + } + ] + }, + { + field: 'network', + headerName: 'Network', + flex: 1, + minWidth: 200, + sortable: false, + filterable: true, + renderCell: (params: GridRenderCellParams) => { + const networks = params.row.provider?.map((p) => p.network).join(', ') || '' + return {networks} + } + }, + { + field: 'viewMore', + headerName: '', + width: 120, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + + ) + }, + { + field: 'publicKey', + headerName: 'Public Key', + flex: 1, + sortable: false, + minWidth: 200, + filterable: false + }, + { + field: 'version', + headerName: 'Version', + flex: 1, + minWidth: 100, + sortable: false, + filterable: false + }, + { + field: 'http', + headerName: 'HTTP Enabled', + flex: 1, + minWidth: 100, + sortable: false, + filterable: false + }, + { + field: 'p2p', + headerName: 'P2P Enabled', + flex: 1, + minWidth: 100, + sortable: false, + filterable: false + }, + { + field: 'supportedStorage', + headerName: 'Supported Storage', + flex: 1, + minWidth: 200, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + {formatSupportedStorage(params.row.supportedStorage)} + ) + }, + { + field: 'platform', + headerName: 'Platform', + flex: 1, + minWidth: 200, + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => ( + {formatPlatform(params.row.platform)} + ) + }, + { + field: 'codeHash', + headerName: 'Code Hash', + flex: 1, + minWidth: 200, + sortable: false, + filterable: false + }, + { + field: 'allowedAdmins', + headerName: 'Allowed Admins', + flex: 1, + minWidth: 200, + sortable: false, + filterable: false + } +] + +export const countryColumns: GridColDef[] = [ + { + field: 'index', + headerName: 'Index', + width: 70, + align: 'center', + headerAlign: 'center', + sortable: false, + filterable: false + }, + { + field: 'country', + headerName: 'Country', + flex: 1, + minWidth: 200, + align: 'left', + headerAlign: 'left', + sortable: true, + filterable: true, + filterOperators: [ + { + label: 'contains', + value: 'contains', + getApplyFilterFn: (filterItem) => { + return (params) => { + if (!filterItem.value) return true + return params.value?.toLowerCase().includes(filterItem.value.toLowerCase()) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'text' } + }, + { + label: 'equals', + value: 'eq', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value === filterItem.value + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'text' } + } + ] + }, + { + field: 'totalNodes', + headerName: 'Total Nodes', + flex: 1, + minWidth: 150, + type: 'number', + align: 'left', + headerAlign: 'left', + sortable: true, + filterable: true, + filterOperators: [ + { + label: 'equals', + value: 'eq', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value === Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + }, + { + label: 'greater than', + value: 'gt', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value > Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + }, + { + label: 'less than', + value: 'lt', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value < Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + } + ] + }, + { + field: 'citiesWithNodes', + headerName: 'Cities with Nodes', + flex: 1, + minWidth: 200, + type: 'number', + align: 'left', + headerAlign: 'left', + sortable: true, + filterable: true, + filterOperators: [ + { + label: 'equals', + value: 'eq', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value === Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + }, + { + label: 'greater than', + value: 'gt', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value > Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + }, + { + label: 'less than', + value: 'lt', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value < Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + } + ] + }, + { + field: 'cityWithMostNodes', + headerName: 'City with Most Nodes', + flex: 1, + minWidth: 200, + align: 'left', + headerAlign: 'left', + sortable: true, + filterable: true, + valueGetter: (params: { row: any }) => { + return params.row?.cityWithMostNodesCount || 0 + }, + filterOperators: [ + { + label: 'equals', + value: 'eq', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value === Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + }, + { + label: 'greater than', + value: 'gt', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value > Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + }, + { + label: 'less than', + value: 'lt', + getApplyFilterFn: (filterItem) => { + return (params) => { + return params.value < Number(filterItem.value) + } + }, + InputComponent: GridFilterInputValue, + InputComponentProps: { type: 'number' } + } + ], + renderCell: (params: GridRenderCellParams) => ( + + {params.row.cityWithMostNodes} ({params.row.cityWithMostNodesCount} nodes) + + ) + } +] diff --git a/src/components/Table/hooks/useTable.ts b/src/components/Table/hooks/useTable.ts new file mode 100644 index 0000000..564f6ca --- /dev/null +++ b/src/components/Table/hooks/useTable.ts @@ -0,0 +1,259 @@ +import { useCallback, useMemo, useRef, useState, useEffect } from 'react' +import { GridFilterModel, GridSortModel, GridValidRowModel } from '@mui/x-data-grid' +import { useDataContext } from '@/context/DataContext' +import { TableTypeEnum } from '../../../shared/enums/TableTypeEnum' +import { NodeData } from '../../../shared/types/RowDataType' +import { + DebouncedFunction, + CountrySortFields, + NodeSortFields, + NodeFilters, + FilterOperator, + TableHookReturn +} from '../../../shared/types/tableTypes' +import { debounce } from '../../../shared/utils/debounce' +import { TABLE_CONFIG } from '../tableConfig' + +export function useTable(tableType: TableTypeEnum): TableHookReturn { + const { + data: nodeData, + countryStats, + loading, + currentPage, + pageSize, + totalItems, + setCurrentPage, + setPageSize, + setTableType, + filters, + setFilters, + setSortModel, + setSearchTerm, + countryCurrentPage, + setCountryCurrentPage, + countryPageSize, + setCountryPageSize, + setCountrySearchTerm, + totalUptime + } = useDataContext() + + const [selectedNode, setSelectedNode] = useState(null) + const [searchTerm, setLocalSearchTerm] = useState('') + const [searchTermCountry, setLocalSearchTermCountry] = useState('') + const timeoutIdRef = useRef(null) + + useEffect(() => { + setTableType(tableType) + }, [tableType, setTableType]) + + const data = useMemo(() => { + return tableType === TableTypeEnum.COUNTRIES ? countryStats : nodeData + }, [tableType, nodeData, countryStats]) + + const handlePaginationChange = useCallback( + (paginationModel: { page: number; pageSize: number }) => { + const newPage = paginationModel.page + 1 + const newPageSize = paginationModel.pageSize + + if (tableType === TableTypeEnum.COUNTRIES) { + setCountryCurrentPage(newPage) + if (newPageSize !== countryPageSize) { + setCountryPageSize(newPageSize) + } + } else { + setCurrentPage(newPage) + if (newPageSize !== pageSize) { + setPageSize(newPageSize) + } + } + }, + [ + tableType, + setCurrentPage, + setPageSize, + setCountryCurrentPage, + setCountryPageSize, + countryPageSize, + pageSize + ] + ) + + const handleSortModelChange = useCallback( + (newSortModel: GridSortModel) => { + if (newSortModel.length > 0) { + const { field, sort } = newSortModel[0] + if (tableType === TableTypeEnum.COUNTRIES) { + const sortField = + field === 'cityWithMostNodes' ? 'cityWithMostNodesCount' : field + if ( + TABLE_CONFIG.SORT_FIELDS[TableTypeEnum.COUNTRIES].includes( + sortField as CountrySortFields + ) + ) { + setSortModel({ [sortField]: sort as 'asc' | 'desc' }) + } + } else if (tableType === TableTypeEnum.NODES) { + if ( + TABLE_CONFIG.SORT_FIELDS[TableTypeEnum.NODES].includes( + field as NodeSortFields + ) + ) { + setSortModel({ [field]: sort as 'asc' | 'desc' }) + } + } + } else { + setSortModel({}) + } + }, + [tableType, setSortModel] + ) + + const handleFilterChange = useCallback( + (filterModel: GridFilterModel) => { + const debouncedFilter = debounce((model: GridFilterModel) => { + if (!model.items.some((item) => item.value)) { + setFilters({}) + return + } + + const newFilters: NodeFilters = {} + + model.items.forEach((item) => { + if (item.value && item.field) { + if (item.field === 'dnsFilter') { + newFilters.dns = { + value: String(item.value), + operator: 'contains' as FilterOperator + } + } else if (item.field === 'uptime' && totalUptime !== null) { + const percentageValue = Number(item.value) + const rawSeconds = (percentageValue / 100) * totalUptime + newFilters.uptime = { + value: rawSeconds.toString(), + operator: item.operator as FilterOperator + } + } else if (item.field === 'city' || item.field === 'country') { + newFilters[item.field] = { + value: String(item.value), + operator: item.operator as FilterOperator + } + } else { + newFilters[item.field as keyof NodeFilters] = { + value: String(item.value), + operator: item.operator as FilterOperator + } + } + } + }) + + setFilters(newFilters) + }, TABLE_CONFIG.DEBOUNCE_DELAY) + + debouncedFilter(filterModel) + }, + [setFilters, totalUptime] + ) + + const debouncedSearchFn = useMemo(() => { + const debouncedFn = (value: string) => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + } + + timeoutIdRef.current = setTimeout(() => { + const setContextSearchTerm = + tableType === TableTypeEnum.COUNTRIES ? setCountrySearchTerm : setSearchTerm + setContextSearchTerm(value) + timeoutIdRef.current = null + }, TABLE_CONFIG.DEBOUNCE_DELAY) + } + + debouncedFn.cancel = () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + timeoutIdRef.current = null + } + } + + return debouncedFn + }, [tableType, setCountrySearchTerm, setSearchTerm]) + + const handleSearchChange = useCallback( + (searchValue: string) => { + const setLocalTerm = + tableType === TableTypeEnum.COUNTRIES + ? setLocalSearchTermCountry + : setLocalSearchTerm + setLocalTerm(searchValue) + + if (!searchValue) { + debouncedSearchFn.cancel() + const setContextTerm = + tableType === TableTypeEnum.COUNTRIES ? setCountrySearchTerm : setSearchTerm + setContextTerm('') + return + } + + debouncedSearchFn(searchValue) + }, + [ + tableType, + setLocalSearchTermCountry, + setLocalSearchTerm, + debouncedSearchFn, + setCountrySearchTerm, + setSearchTerm + ] + ) + + const handleReset = useCallback(() => { + const currentSearchTerm = + tableType === TableTypeEnum.COUNTRIES ? searchTermCountry : searchTerm + + if (currentSearchTerm) { + const setLocalTerm = + tableType === TableTypeEnum.COUNTRIES + ? setLocalSearchTermCountry + : setLocalSearchTerm + const setContextTerm = + tableType === TableTypeEnum.COUNTRIES ? setCountrySearchTerm : setSearchTerm + + setLocalTerm('') + setContextTerm('') + debouncedSearchFn.cancel() + } + }, [ + tableType, + searchTermCountry, + searchTerm, + setLocalSearchTermCountry, + setLocalSearchTerm, + setCountrySearchTerm, + setSearchTerm, + debouncedSearchFn + ]) + + return { + data, + loading, + selectedNode, + setSelectedNode, + searchTerm, + searchTermCountry, + currentPage, + pageSize, + countryCurrentPage, + countryPageSize, + setCurrentPage, + setPageSize, + setCountryCurrentPage, + setCountryPageSize, + totalItems, + totalUptime, + handlePaginationChange, + handleSortModelChange, + handleFilterChange, + handleSearchChange, + handleReset + } +} diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx index 311d5cc..019048d 100644 --- a/src/components/Table/index.tsx +++ b/src/components/Table/index.tsx @@ -1,42 +1,21 @@ -import React, { - JSXElementConstructor, - useState, - useMemo, - useEffect, - useCallback, - useRef -} from 'react' +import React, { JSXElementConstructor, useMemo } from 'react' import { DataGrid, GridColDef, - GridRenderCellParams, - GridSortModel, GridToolbarProps, GridValidRowModel, - GridFilterInputValue, - GridFilterModel, useGridApiRef } from '@mui/x-data-grid' import styles from './index.module.css' -import { NodeData } from '../../shared/types/RowDataType' -import { useDataContext } from '@/context/DataContext' import NodeDetails from './NodeDetails' -import { Button, Tooltip } from '@mui/material' -import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' -import ReportIcon from '@mui/icons-material/Report' import CustomToolbar from '../Toolbar' import { styled } from '@mui/material/styles' import CustomPagination from './CustomPagination' -import { FilterOperator, NodeFilters } from '../../types/filters' -import { debounce } from '../../shared/utils/debounce' -import { - getAllNetworks, - formatSupportedStorage, - formatPlatform, - formatUptimePercentage -} from './utils' +import { nodeColumns, countryColumns } from './columns' +import { TableTypeEnum } from '../../shared/enums/TableTypeEnum' +import { useTable } from './hooks/useTable' const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ '& .MuiDataGrid-toolbarContainer': { @@ -101,843 +80,41 @@ const StyledDataGrid = styled(DataGrid)(({ theme }) => ({ } })) -const getEligibleCheckbox = (eligible: boolean): React.ReactElement => { - return eligible ? ( - - ) : ( - - ) -} - -interface DebouncedFunction { - (value: string): void - cancel: () => void -} - -export default function Table({ - tableType = 'nodes' -}: { - tableType?: 'nodes' | 'countries' -}) { +export default function Table({ tableType = TableTypeEnum.NODES }: { tableType?: TableTypeEnum }) { const { - data: nodeData, - countryStats, + data, loading, + selectedNode, + setSelectedNode, + searchTerm, + searchTermCountry, currentPage, pageSize, - totalItems, + countryCurrentPage, + countryPageSize, setCurrentPage, setPageSize, - setTableType, - filters, - setFilters, - setSortModel, - setSearchTerm, - countryCurrentPage, setCountryCurrentPage, - countryPageSize, setCountryPageSize, - setCountrySearchTerm, - totalUptime - } = useDataContext() + totalItems, + totalUptime, + handlePaginationChange, + handleSortModelChange, + handleFilterChange, + handleSearchChange, + handleReset + } = useTable(tableType) - const [selectedNode, setSelectedNode] = useState(null) - const [searchTerm, setLocalSearchTerm] = useState('') - const [searchTermCountry, setLocalSearchTermCountry] = useState('') const apiRef = useGridApiRef() - const timeoutIdRef = useRef(null) - - useEffect(() => { - setTableType(tableType) - }, [tableType, setTableType]) - - const nodeColumns: GridColDef[] = [ - { - field: 'index', - headerName: 'Index', - width: 70, - align: 'center', - headerAlign: 'center', - sortable: false, - filterable: false - }, - { - field: 'id', - headerName: 'Node ID', - flex: 1, - minWidth: 300, - sortable: false, - filterable: true, - filterOperators: [ - { - label: 'contains', - value: 'contains', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - return params.value?.toLowerCase().includes(filterItem.value.toLowerCase()) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'text' } - } - ] - }, - { - field: 'uptime', - headerName: 'Weekly Uptime', - sortable: true, - flex: 1, - minWidth: 150, - filterable: true, - headerClassName: styles.headerTitle, - filterOperators: [ - { - label: 'equals', - value: 'eq', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - const filterValue = Number(filterItem.value) / 100 - const uptimePercentage = params.value / params.row.totalUptime - return Math.abs(uptimePercentage - filterValue) <= 0.001 - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { - type: 'number', - step: '0.01', - min: '0', - max: '100', - placeholder: 'Enter percentage (0-100)', - error: !totalUptime, - helperText: !totalUptime ? 'Loading uptime data...' : undefined - } - }, - { - label: 'greater than', - value: 'gt', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - const filterValue = Number(filterItem.value) / 100 - const uptimePercentage = params.value / params.row.totalUptime - return uptimePercentage > filterValue - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { - type: 'number', - step: '0.01', - min: '0', - max: '100', - placeholder: 'Enter percentage (0-100)', - error: !totalUptime, - helperText: !totalUptime ? 'Loading uptime data...' : undefined - } - }, - { - label: 'less than', - value: 'lt', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - const filterValue = Number(filterItem.value) / 100 - const uptimePercentage = params.value / params.row.totalUptime - return uptimePercentage < filterValue - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { - type: 'number', - step: '0.01', - min: '0', - max: '100', - placeholder: 'Enter percentage (0-100)', - error: !totalUptime, - helperText: !totalUptime ? 'Loading uptime data...' : undefined - } - } - ], - renderCell: (params: GridRenderCellParams) => ( - - ), - renderHeader: () => ( - - Weekly Uptime - - ) - }, - { - field: 'dns', - headerName: 'DNS / IP', - flex: 1, - minWidth: 200, - sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => ( - - {(params.row.ipAndDns?.dns || params.row.ipAndDns?.ip || '') + - (params.row.ipAndDns?.port ? ':' + params.row.ipAndDns?.port : '')} - - ) - }, - { - field: 'dnsFilter', - headerName: 'DNS / IP', - flex: 1, - minWidth: 200, - sortable: false, - filterable: true, - hideable: false, - filterOperators: [ - { - label: 'contains', - value: 'contains', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - const dnsIpString = - (params.row.ipAndDns?.dns || params.row.ipAndDns?.ip || '') + - (params.row.ipAndDns?.port ? ':' + params.row.ipAndDns?.port : '') - return dnsIpString.includes(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'text' } - } - ] - }, - { - field: 'location', - headerName: 'Location', - flex: 1, - minWidth: 200, - sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => ( - - {`${params.row.location?.city || ''} ${params.row.location?.country || ''}`} - - ) - }, - { - field: 'city', - headerName: 'City', - flex: 1, - minWidth: 200, - sortable: false, - filterable: true, - hideable: false, - filterOperators: [ - { - label: 'contains', - value: 'contains', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - return (params.row.location?.city || '') - .toLowerCase() - .includes(filterItem.value.toLowerCase()) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'text' } - } - ] - }, - { - field: 'country', - headerName: 'Country', - flex: 1, - minWidth: 200, - sortable: false, - filterable: true, - hideable: false, - filterOperators: [ - { - label: 'contains', - value: 'contains', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - return (params.row.location?.country || '') - .toLowerCase() - .includes(filterItem.value.toLowerCase()) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'text' } - } - ] - }, - { - field: 'address', - headerName: 'Address', - flex: 1, - minWidth: 150, - sortable: false, - filterable: false - }, - { - field: 'eligible', - headerName: 'Last Check Eligibility', - flex: 1, - width: 80, - filterable: false, - sortable: true, - renderHeader: () => ( - - Last Check Eligibility - - ), - filterOperators: [ - { - label: 'equals', - value: 'eq', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - return params.value === (filterItem.value === 'true') - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { - type: 'singleSelect', - valueOptions: [ - { value: 'true', label: 'Eligible' }, - { value: 'false', label: 'Not Eligible' } - ] - } - } - ], - renderCell: (params: GridRenderCellParams) => ( -
- {getEligibleCheckbox(params.row.eligible)} -
- ) - }, - { - field: 'eligibilityCauseStr', - headerName: 'Eligibility Issue', - flex: 1, - width: 100, - sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => ( - {params.row.eligibilityCauseStr || 'none'} - ) - }, - { - field: 'lastCheck', - headerName: 'Last Check', - flex: 1, - minWidth: 140, - filterable: true, - renderCell: (params: GridRenderCellParams) => ( - - {new Date(params?.row?.lastCheck)?.toLocaleString(undefined, { - timeZoneName: 'short' - })} - - ), - filterOperators: [ - { - label: 'equals', - value: 'eq', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - const filterDate = new Date(filterItem.value).getTime() - const cellDate = new Date(params.value).getTime() - return cellDate === filterDate - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'datetime-local' } - }, - { - label: 'after', - value: 'gt', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - const filterDate = new Date(filterItem.value).getTime() - const cellDate = new Date(params.value).getTime() - return cellDate > filterDate - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'datetime-local' } - }, - { - label: 'before', - value: 'lt', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - const filterDate = new Date(filterItem.value).getTime() - const cellDate = new Date(params.value).getTime() - return cellDate < filterDate - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'datetime-local' } - } - ] - }, - { - field: 'network', - headerName: 'Network', - flex: 1, - minWidth: 200, - sortable: false, - filterable: true, - renderCell: (params: GridRenderCellParams) => { - const networks = params.row.provider?.map((p) => p.network).join(', ') || '' - return {networks} - } - }, - { - field: 'viewMore', - headerName: '', - width: 120, - sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => ( - - ) - }, - { - field: 'publicKey', - headerName: 'Public Key', - flex: 1, - sortable: false, - minWidth: 200, - filterable: false - }, - { - field: 'version', - headerName: 'Version', - flex: 1, - minWidth: 100, - sortable: false, - filterable: false - }, - { - field: 'http', - headerName: 'HTTP Enabled', - flex: 1, - minWidth: 100, - sortable: false, - filterable: false - }, - { - field: 'p2p', - headerName: 'P2P Enabled', - flex: 1, - minWidth: 100, - sortable: false, - filterable: false - }, - { - field: 'supportedStorage', - headerName: 'Supported Storage', - flex: 1, - minWidth: 200, - sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => ( - {formatSupportedStorage(params.row.supportedStorage)} - ) - }, - { - field: 'platform', - headerName: 'Platform', - flex: 1, - minWidth: 200, - sortable: false, - filterable: false, - renderCell: (params: GridRenderCellParams) => ( - {formatPlatform(params.row.platform)} - ) - }, - { - field: 'codeHash', - headerName: 'Code Hash', - flex: 1, - minWidth: 200, - sortable: false, - filterable: false - }, - { - field: 'allowedAdmins', - headerName: 'Allowed Admins', - flex: 1, - minWidth: 200, - sortable: false, - filterable: false - } - ] - - const countryColumns: GridColDef[] = [ - { - field: 'index', - headerName: 'Index', - width: 70, - align: 'center', - headerAlign: 'center', - sortable: false, - filterable: false - }, - { - field: 'country', - headerName: 'Country', - flex: 1, - minWidth: 200, - align: 'left', - headerAlign: 'left', - sortable: true, - filterable: true, - filterOperators: [ - { - label: 'contains', - value: 'contains', - getApplyFilterFn: (filterItem) => { - return (params) => { - if (!filterItem.value) return true - return params.value?.toLowerCase().includes(filterItem.value.toLowerCase()) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'text' } - }, - { - label: 'equals', - value: 'eq', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value === filterItem.value - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'text' } - } - ] - }, - { - field: 'totalNodes', - headerName: 'Total Nodes', - flex: 1, - minWidth: 150, - type: 'number', - align: 'left', - headerAlign: 'left', - sortable: true, - filterable: true, - filterOperators: [ - { - label: 'equals', - value: 'eq', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value === Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - }, - { - label: 'greater than', - value: 'gt', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value > Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - }, - { - label: 'less than', - value: 'lt', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value < Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - } - ] - }, - { - field: 'citiesWithNodes', - headerName: 'Cities with Nodes', - flex: 1, - minWidth: 200, - type: 'number', - align: 'left', - headerAlign: 'left', - sortable: true, - filterable: true, - filterOperators: [ - { - label: 'equals', - value: 'eq', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value === Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - }, - { - label: 'greater than', - value: 'gt', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value > Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - }, - { - label: 'less than', - value: 'lt', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value < Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - } - ] - }, - { - field: 'cityWithMostNodes', - headerName: 'City with Most Nodes', - flex: 1, - minWidth: 200, - align: 'left', - headerAlign: 'left', - sortable: true, - filterable: true, - valueGetter: (params: { row: any }) => { - return params.row?.cityWithMostNodesCount || 0 - }, - filterOperators: [ - { - label: 'equals', - value: 'eq', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value === Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - }, - { - label: 'greater than', - value: 'gt', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value > Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - }, - { - label: 'less than', - value: 'lt', - getApplyFilterFn: (filterItem) => { - return (params) => { - return params.value < Number(filterItem.value) - } - }, - InputComponent: GridFilterInputValue, - InputComponentProps: { type: 'number' } - } - ], - renderCell: (params: GridRenderCellParams) => ( - - {params.row.cityWithMostNodes} ({params.row.cityWithMostNodesCount} nodes) - - ) - } - ] - const UptimeCell: React.FC<{ - uptimeInSeconds: number - totalUptime: number | null - }> = ({ uptimeInSeconds, totalUptime }) => { - if (totalUptime === null) { - return Loading... - } - - return {formatUptimePercentage(uptimeInSeconds, totalUptime)} - } - - const handlePaginationChange = useCallback( - (paginationModel: { page: number; pageSize: number }) => { - const newPage = paginationModel.page + 1 - const newPageSize = paginationModel.pageSize - - if (tableType === 'countries') { - setCountryCurrentPage(newPage) - if (newPageSize !== countryPageSize) { - setCountryPageSize(newPageSize) - } - } else { - setCurrentPage(newPage) - if (newPageSize !== pageSize) { - setPageSize(newPageSize) - } - } - }, - [ - tableType, - setCurrentPage, - setPageSize, - setCountryCurrentPage, - setCountryPageSize, - countryPageSize, - pageSize - ] + const columns = useMemo( + () => + tableType === TableTypeEnum.NODES + ? nodeColumns(totalUptime, setSelectedNode) + : countryColumns, + [tableType, totalUptime, setSelectedNode] ) - const handleSortModelChange = (newSortModel: GridSortModel) => { - if (newSortModel.length > 0) { - const { field, sort } = newSortModel[0] - if (tableType === 'countries') { - const sortField = field === 'cityWithMostNodes' ? 'cityWithMostNodesCount' : field - const allowedSortFields = [ - 'totalNodes', - 'citiesWithNodes', - 'cityWithMostNodesCount' - ] - if (allowedSortFields.includes(sortField)) { - setSortModel({ [sortField]: sort as 'asc' | 'desc' }) - } - } else if (tableType === 'nodes') { - setSortModel({ [field]: sort as 'asc' | 'desc' }) - } - } else { - setSortModel({}) - } - } - - const debouncedHandleFilterChange = useCallback( - (filterModel: GridFilterModel) => { - const debouncedFilter = debounce((model: GridFilterModel) => { - if (!model.items.some((item) => item.value)) { - setFilters({}) - return - } - - const newFilters: NodeFilters = {} - - model.items.forEach((item) => { - if (item.value && item.field) { - if (item.field === 'dnsFilter') { - newFilters.dns = { - value: String(item.value), - operator: 'contains' as FilterOperator - } - } else if (item.field === 'uptime' && totalUptime !== null) { - const percentageValue = Number(item.value) - const rawSeconds = (percentageValue / 100) * totalUptime - newFilters.uptime = { - value: rawSeconds.toString(), - operator: item.operator as FilterOperator - } - } else if (item.field === 'city' || item.field === 'country') { - newFilters[item.field] = { - value: String(item.value), - operator: item.operator as FilterOperator - } - } else { - newFilters[item.field as keyof NodeFilters] = { - value: String(item.value), - operator: item.operator as FilterOperator - } - } - } - }) - - setFilters(newFilters) - }, 1000) - - debouncedFilter(filterModel) - }, - [setFilters, totalUptime] - ) - - const debouncedSearchFn = useMemo(() => { - const debouncedFn = (value: string) => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current) - } - - timeoutIdRef.current = setTimeout(() => { - const setContextSearchTerm = - tableType === 'countries' ? setCountrySearchTerm : setSearchTerm - setContextSearchTerm(value) - timeoutIdRef.current = null - }, 1000) - } - - debouncedFn.cancel = () => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current) - timeoutIdRef.current = null - } - } - - return debouncedFn - }, [tableType, setCountrySearchTerm, setSearchTerm]) - - const handleSearchChange = (searchValue: string) => { - const setLocalTerm = - tableType === 'countries' ? setLocalSearchTermCountry : setLocalSearchTerm - setLocalTerm(searchValue) - - if (!searchValue) { - debouncedSearchFn.cancel() - const setContextTerm = - tableType === 'countries' ? setCountrySearchTerm : setSearchTerm - setContextTerm('') - return - } - - debouncedSearchFn(searchValue) - } - - const handleReset = () => { - const currentSearchTerm = tableType === 'countries' ? searchTermCountry : searchTerm - - if (currentSearchTerm) { - const setLocalTerm = - tableType === 'countries' ? setLocalSearchTermCountry : setLocalSearchTerm - const setContextTerm = - tableType === 'countries' ? setCountrySearchTerm : setSearchTerm - - setLocalTerm('') - setContextTerm('') - debouncedSearchFn.cancel() - } - } - - const columns = tableType === 'countries' ? countryColumns : nodeColumns - const data = useMemo(() => { - return tableType === 'countries' ? countryStats : nodeData - }, [tableType, nodeData, countryStats]) - return (
@@ -949,7 +126,8 @@ export default function Table({ }} slotProps={{ toolbar: { - searchTerm: tableType === 'countries' ? searchTermCountry : searchTerm, + searchTerm: + tableType === TableTypeEnum.COUNTRIES ? searchTermCountry : searchTerm, onSearchChange: handleSearchChange, onReset: handleReset, tableType: tableType, @@ -960,7 +138,7 @@ export default function Table({ initialState={{ columns: { columnVisibilityModel: - tableType === 'nodes' + tableType === TableTypeEnum.NODES ? { network: false, publicKey: false, @@ -979,8 +157,12 @@ export default function Table({ }, pagination: { paginationModel: { - pageSize: tableType === 'countries' ? countryPageSize : pageSize, - page: (tableType === 'countries' ? countryCurrentPage : currentPage) - 1 + pageSize: + tableType === TableTypeEnum.COUNTRIES ? countryPageSize : pageSize, + page: + (tableType === TableTypeEnum.COUNTRIES + ? countryCurrentPage + : currentPage) - 1 } }, density: 'comfortable' @@ -989,8 +171,10 @@ export default function Table({ disableColumnMenu pageSizeOptions={[10, 25, 50, 100]} paginationModel={{ - page: (tableType === 'countries' ? countryCurrentPage : currentPage) - 1, - pageSize: tableType === 'countries' ? countryPageSize : pageSize + page: + (tableType === TableTypeEnum.COUNTRIES ? countryCurrentPage : currentPage) - + 1, + pageSize: tableType === TableTypeEnum.COUNTRIES ? countryPageSize : pageSize }} onPaginationModelChange={handlePaginationChange} loading={loading} @@ -1000,7 +184,7 @@ export default function Table({ sortingMode="server" filterMode="server" onSortModelChange={handleSortModelChange} - onFilterModelChange={debouncedHandleFilterChange} + onFilterModelChange={handleFilterChange} rowCount={totalItems} autoHeight={false} hideFooter={true} @@ -1031,14 +215,18 @@ export default function Table({ />
- tableType === 'countries' ? setCountryCurrentPage(page) : setCurrentPage(page) + tableType === TableTypeEnum.COUNTRIES + ? setCountryCurrentPage(page) + : setCurrentPage(page) } onPageSizeChange={(size: number) => - tableType === 'countries' ? setCountryPageSize(size) : setPageSize(size) + tableType === TableTypeEnum.COUNTRIES + ? setCountryPageSize(size) + : setPageSize(size) } /> {selectedNode && ( diff --git a/src/components/Table/tableConfig.ts b/src/components/Table/tableConfig.ts new file mode 100644 index 0000000..6a0a7c2 --- /dev/null +++ b/src/components/Table/tableConfig.ts @@ -0,0 +1,61 @@ +import { TableTypeEnum } from '../../shared/enums/TableTypeEnum' +import { GridFilterOperator } from '@mui/x-data-grid' + +export const SORT_FIELDS = { + [TableTypeEnum.COUNTRIES]: [ + 'totalNodes', + 'citiesWithNodes', + 'cityWithMostNodesCount' + ] as const, + [TableTypeEnum.NODES]: ['uptime', 'eligible', 'lastCheck'] as const +} as const + +type CountrySortFields = (typeof SORT_FIELDS)[TableTypeEnum.COUNTRIES][number] +type NodeSortFields = (typeof SORT_FIELDS)[TableTypeEnum.NODES][number] + +export type TableSortFields = { + [TableTypeEnum.COUNTRIES]: CountrySortFields + [TableTypeEnum.NODES]: NodeSortFields +} + +export const TABLE_CONFIG = { + DEBOUNCE_DELAY: 1000, + PAGE_SIZE_OPTIONS: [10, 25, 50, 100], + DEFAULT_DENSITY: 'comfortable' as const, + DEFAULT_PAGE_SIZE: 10, + + HIDDEN_COLUMNS: { + [TableTypeEnum.NODES]: { + network: false, + publicKey: false, + version: false, + http: false, + p2p: false, + supportedStorage: false, + platform: false, + codeHash: false, + allowedAdmins: false, + dnsFilter: false, + city: false, + country: false + }, + [TableTypeEnum.COUNTRIES]: {} + }, + + SORT_FIELDS, + + FILTER_OPERATORS: { + CONTAINS: 'contains', + EQUALS: 'eq', + GREATER_THAN: 'gt', + LESS_THAN: 'lt' + } as const, + + GRID_STYLE: { + HEIGHT: 'calc(100vh - 200px)', + WIDTH: '100%' + } +} as const + +export type FilterOperatorType = + (typeof TABLE_CONFIG.FILTER_OPERATORS)[keyof typeof TABLE_CONFIG.FILTER_OPERATORS] diff --git a/src/components/Table/utils.ts b/src/components/Table/utils.ts index 475a799..e1dc45d 100644 --- a/src/components/Table/utils.ts +++ b/src/components/Table/utils.ts @@ -1,4 +1,5 @@ import { GridApi } from '@mui/x-data-grid' +import { TableTypeEnum } from '../../shared/enums/TableTypeEnum' type NodeData = { id: string @@ -86,13 +87,13 @@ export const formatUptimePercentage = ( export const exportToCsv = ( apiRef: GridApi, - tableType: 'nodes' | 'countries', + tableType: TableTypeEnum, totalUptime: number | null ) => { if (!apiRef) return const columns = apiRef.getAllColumns().filter((col) => { - if (tableType === 'nodes') { + if (tableType === TableTypeEnum.NODES) { return col.field !== 'viewMore' && col.field !== 'location' } return true @@ -107,7 +108,7 @@ export const exportToCsv = ( const field = column.field const value = row[field] - if (tableType === 'countries') { + if (tableType === TableTypeEnum.COUNTRIES) { if (field === 'cityWithMostNodes') { const cityName = row.cityWithMostNodes || '' const nodeCount = row.cityWithMostNodesCount || 0 diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx index 05717c7..cb5e56f 100644 --- a/src/components/Toolbar/index.tsx +++ b/src/components/Toolbar/index.tsx @@ -14,7 +14,7 @@ import ClearIcon from '@mui/icons-material/Clear' import style from './style.module.css' import { exportToCsv } from '../Table/utils' import FileDownloadIcon from '@mui/icons-material/FileDownload' - +import { TableTypeEnum } from '../../shared/enums/TableTypeEnum' const StyledTextField = styled(TextField)({ '& .MuiOutlinedInput-root': { backgroundColor: '#CF1FB11A', @@ -43,7 +43,7 @@ interface CustomToolbarProps extends GridToolbarProps { onSearchChange: (value: string) => void onSearch: () => void onReset: () => void - tableType: 'nodes' | 'countries' + tableType: TableTypeEnum apiRef?: GridApi totalUptime: number | null } diff --git a/src/context/DataContext.tsx b/src/context/DataContext.tsx index 6e6f497..b431ead 100644 --- a/src/context/DataContext.tsx +++ b/src/context/DataContext.tsx @@ -11,7 +11,7 @@ import axios from 'axios' import { NodeData } from '@/shared/types/RowDataType' import { getApiRoute } from '@/config' import { CountryStatsType, SystemStats } from '../shared/types/dataTypes' -import { CountryStatsFilters, NodeFilters } from '../types/filters' +import { CountryStatsFilters, NodeFilters } from '../shared/types/filters' import { buildCountryStatsUrl } from '../shared/utils/urlBuilder' interface DataContextType { diff --git a/src/shared/enums/TableTypeEnum.ts b/src/shared/enums/TableTypeEnum.ts new file mode 100644 index 0000000..f61ec52 --- /dev/null +++ b/src/shared/enums/TableTypeEnum.ts @@ -0,0 +1,4 @@ +export enum TableTypeEnum { + NODES = 'nodes', + COUNTRIES = 'countries' +} diff --git a/src/types/filters.ts b/src/shared/types/filters.ts similarity index 100% rename from src/types/filters.ts rename to src/shared/types/filters.ts diff --git a/src/shared/types/tableTypes.ts b/src/shared/types/tableTypes.ts new file mode 100644 index 0000000..909776a --- /dev/null +++ b/src/shared/types/tableTypes.ts @@ -0,0 +1,76 @@ +import { TableTypeEnum } from '../enums/TableTypeEnum' +import { GridFilterModel, GridSortModel, GridValidRowModel } from '@mui/x-data-grid' +import { NodeData } from './RowDataType' +import { Dispatch, SetStateAction } from 'react' +import { CountryStatsType } from './dataTypes' + +// Sort Fields Configuration +export const SORT_FIELDS = { + [TableTypeEnum.COUNTRIES]: [ + 'totalNodes', + 'citiesWithNodes', + 'cityWithMostNodesCount' + ] as const, + [TableTypeEnum.NODES]: ['uptime', 'eligible', 'lastCheck'] as const +} as const + +export type CountrySortFields = (typeof SORT_FIELDS)[TableTypeEnum.COUNTRIES][number] +export type NodeSortFields = (typeof SORT_FIELDS)[TableTypeEnum.NODES][number] + +export interface TableProps { + tableType?: TableTypeEnum +} + +export interface TableState { + selectedNode: NodeData | null + searchTerm: string + searchTermCountry: string +} + +export interface FilterConfig { + value: string + operator: FilterOperator +} + +export interface NodeFilters { + [key: string]: FilterConfig +} + +export type FilterOperator = 'contains' | 'eq' | 'gt' | 'lt' + +export type DebouncedFunction = { + (value: string): void + cancel: () => void +} + +export interface TableHandlers { + handlePaginationChange: (paginationModel: { page: number; pageSize: number }) => void + handleSortModelChange: (sortModel: GridSortModel) => void + handleFilterChange: (filterModel: GridFilterModel) => void + handleSearchChange: (searchValue: string) => void + handleReset: () => void +} + +export interface TableHookReturn { + data: CountryStatsType[] | NodeData[] + loading: boolean + selectedNode: NodeData | null + setSelectedNode: Dispatch> + searchTerm: string + searchTermCountry: string + currentPage: number + pageSize: number + countryCurrentPage: number + countryPageSize: number + setCurrentPage: (page: number) => void + setPageSize: (size: number) => void + setCountryCurrentPage: (page: number) => void + setCountryPageSize: (size: number) => void + totalItems: number + totalUptime: number | null + handlePaginationChange: TableHandlers['handlePaginationChange'] + handleSortModelChange: TableHandlers['handleSortModelChange'] + handleFilterChange: TableHandlers['handleFilterChange'] + handleSearchChange: TableHandlers['handleSearchChange'] + handleReset: TableHandlers['handleReset'] +} diff --git a/src/shared/utils/urlBuilder.ts b/src/shared/utils/urlBuilder.ts index f1e1347..b728cc1 100644 --- a/src/shared/utils/urlBuilder.ts +++ b/src/shared/utils/urlBuilder.ts @@ -1,4 +1,4 @@ -import { CountryStatsFilters, PaginationParams, SortModel } from '../../types/filters' +import { CountryStatsFilters, PaginationParams, SortModel } from '../types/filters' export function buildCountryStatsUrl( baseUrl: string, From c47f7280ee0d19094327b489ca9d451520299bc7 Mon Sep 17 00:00:00 2001 From: tom1145 Date: Tue, 17 Dec 2024 11:30:52 +0200 Subject: [PATCH 09/14] fix: build --- src/components/Pages/Countries/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Pages/Countries/index.tsx b/src/components/Pages/Countries/index.tsx index f9b9322..c1f79f2 100644 --- a/src/components/Pages/Countries/index.tsx +++ b/src/components/Pages/Countries/index.tsx @@ -3,12 +3,13 @@ import styles from './index.module.css' import Table from '../../Table' import HeroSection from '../../HeroSection/HeroSection' +import { TableTypeEnum } from '../../../shared/enums/TableTypeEnum' const CountriesPage: React.FC = () => { return (
- +
) } From 4c1074dc79e3399ffda248314ccbd07b728471f1 Mon Sep 17 00:00:00 2001 From: tom1145 Date: Tue, 17 Dec 2024 11:54:01 +0200 Subject: [PATCH 10/14] refactor: disalbe filter for network --- src/components/Table/columns.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Table/columns.tsx b/src/components/Table/columns.tsx index a8fd97e..00e8cc2 100644 --- a/src/components/Table/columns.tsx +++ b/src/components/Table/columns.tsx @@ -369,7 +369,7 @@ export const nodeColumns = ( flex: 1, minWidth: 200, sortable: false, - filterable: true, + filterable: false, renderCell: (params: GridRenderCellParams) => { const networks = params.row.provider?.map((p) => p.network).join(', ') || '' return {networks} From b7278d3e14f686253957f638827b0efc4fdc61fc Mon Sep 17 00:00:00 2001 From: tom1145 Date: Tue, 17 Dec 2024 17:43:05 +0200 Subject: [PATCH 11/14] feat: loading animations --- src/components/Card/Card.module.css | 104 +++++++++++++++++++- src/components/Card/Card.tsx | 129 +++++++++++++++---------- src/components/Dashboard/Dashboard.tsx | 29 +++--- src/components/Map/index.tsx | 13 ++- src/components/Map/style.module.css | 15 +++ 5 files changed, 222 insertions(+), 68 deletions(-) diff --git a/src/components/Card/Card.module.css b/src/components/Card/Card.module.css index 83fa14a..b084f83 100644 --- a/src/components/Card/Card.module.css +++ b/src/components/Card/Card.module.css @@ -5,8 +5,21 @@ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; - height: 100%; - min-height: 238px; + aspect-ratio: 1 / 1; + width: 100%; + position: relative; + animation: fadeIn 0.5s ease-in; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .cardContent { @@ -14,6 +27,8 @@ flex-direction: column; justify-content: space-between; height: 100%; + position: absolute; + inset: 20px; } .cardTitle { @@ -65,3 +80,88 @@ line-height: 24px; } } + +.cardLoading { + position: relative; + overflow: hidden; +} + +.cardLoading .cardTitle, +.cardLoading .bigNumber, +.cardLoading .subText { + position: relative; + background: #f6f7f8; + border-radius: 4px; + overflow: hidden; +} + +.cardLoading .cardTitle { + height: 24px; + width: 70%; +} + +.cardLoading .bigNumber { + height: 60px; + width: 50%; + margin: 100px auto; +} + +.cardLoading .subText { + height: 20px; + width: 40%; + margin-left: auto; + margin-top: auto; + margin-bottom: 0; +} + +.cardLoading .cardTitle::after, +.cardLoading .bigNumber::after, +.cardLoading .subText::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 200%; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(207, 31, 177, 0.2) 50%, + transparent 100% + ); + animation: shimmer 2s infinite linear; + transform: translateX(-100%); +} + +.cardLoading .bigNumber::after { + animation-delay: 0.1s; +} + +.cardLoading .subText::after { + animation-delay: 0.2s; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(50%); + } +} + +.skeletonText { + background: #f0f0f0; + border-radius: 4px; + height: 24px; + width: 80%; + margin-bottom: 15px; +} + +.skeletonNumber { + background: #f0f0f0; + border-radius: 4px; + height: 60px; + width: 60%; + margin: 20px auto; +} diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index eee8108..fa38b12 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -22,10 +22,7 @@ interface CardProps { bigNumber?: string | number subText?: string additionalInfo?: React.ReactNode -} - -const getStrokeColor = ({ channel }: { channel: string }) => { - return channel === 'foreground' ? 'url(#gradient)' : '#E0E0E0' + isLoading?: boolean } const CustomBar = (props: any) => { @@ -74,58 +71,86 @@ const Card: React.FC = ({ chartData, bigNumber, subText, - additionalInfo + additionalInfo, + isLoading = false }) => { + const formatNumber = (num: string | number) => { + if (typeof num === 'string') return num + + if (num >= 1000 && num < 1000000) { + return `${(num / 1000).toFixed(1)}K` + } + if (num >= 1000000) { + return `${(num / 1000000).toFixed(2)}M` + } + return new Intl.NumberFormat('en-US').format(num) + } + return ( -
-

{title}

+
- {chartType === 'bar' && chartData && ( - - - - - - - - - - ( - - )} - /> - - - - )} - {chartType === 'line' && chartData && ( - - - - - - - - - - - + {isLoading ? ( + <> +