mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Pagination for Peerlist (#21)
Previously all peers used to be displayed on a single page. This change aims to improve the usability of the Peers page by paginating the peer list. By default 25 peers will be shown per page, and there will be 5 pages shown in the pagination bar per default. The current page shown will be centered in the bar shown below.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import Navbar from './components/Navbar';
|
||||
import {Redirect, Route, Switch} from 'react-router-dom';
|
||||
import Peers from './views/Peers';
|
||||
import {Peers} from './views/Peers';
|
||||
import Footer from './components/Footer';
|
||||
import {useAuth0} from "@auth0/auth0-react";
|
||||
import Loading from "./components/Loading";
|
||||
|
||||
219
src/components/PaginatedPeersList.js
Normal file
219
src/components/PaginatedPeersList.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/solid";
|
||||
import { withAuthenticationRequired } from "@auth0/auth0-react";
|
||||
import Loading from "../components/Loading";
|
||||
|
||||
// @data the data that will be paginated
|
||||
// @RenderComponent the component that needs to be rendered
|
||||
// @pageLimit number of Elements shown in Pagination bar
|
||||
// @dataLimit maximum Elements rendered per page
|
||||
const PaginatedPeersList = (props) => {
|
||||
const [pageCount] = useState(Math.ceil(props.data.length / props.dataLimit)); // actual pageCount we have
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// sliding window of size pageLimit for shown elements of bar
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ behavior: "smooth", top: "0px" });
|
||||
}, [currentPage]);
|
||||
|
||||
function goToNextPage() {
|
||||
if (currentPage === pageCount) return;
|
||||
setCurrentPage((page) => page + 1);
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
if (currentPage === 1) return;
|
||||
setCurrentPage((page) => page - 1);
|
||||
}
|
||||
|
||||
function changePage(event) {
|
||||
const pageNumber = Number(event.target.textContent);
|
||||
setCurrentPage(pageNumber);
|
||||
}
|
||||
|
||||
function goToFirst() {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
|
||||
function goToLast() {
|
||||
setCurrentPage(pageCount);
|
||||
}
|
||||
|
||||
const getPaginatedData = () => {
|
||||
const startIndex = currentPage * props.dataLimit - props.dataLimit;
|
||||
const endIndex = startIndex + props.dataLimit;
|
||||
return props.data.slice(startIndex, endIndex);
|
||||
};
|
||||
|
||||
const compressPagination = () => {
|
||||
// if the pageLimit is greater than the actual number of pages we have, just render all pages
|
||||
if (props.pageLimit > pageCount) {
|
||||
return [...Array(pageCount).keys()].map((index) => index + 1);
|
||||
}
|
||||
|
||||
// if the currentPage is already presented in the paginationBar we can just leave the bar alone
|
||||
// center the currentPage
|
||||
let bar = [];
|
||||
let offset = Math.floor(props.pageLimit / 2);
|
||||
|
||||
if (currentPage - offset <= 1) {
|
||||
return [...Array(props.pageLimit).keys()].map((index) => index + 1);
|
||||
}
|
||||
|
||||
if (currentPage + offset > pageCount) {
|
||||
for (let i = pageCount - props.pageLimit + 1; i <= pageCount; i++)
|
||||
bar.push(i);
|
||||
return bar;
|
||||
}
|
||||
|
||||
for (let i = offset; i > 0; i--) {
|
||||
bar.push(currentPage - i);
|
||||
}
|
||||
bar.push(currentPage);
|
||||
for (let i = 1; i <= offset; i++) {
|
||||
bar.push(currentPage + i);
|
||||
}
|
||||
|
||||
return bar;
|
||||
};
|
||||
|
||||
function PaginationBarElem(props) {
|
||||
let default_btn =
|
||||
"z-10 bg-white squared-md border-gray-300 text-gray-700 relative inline-flex items-center px-4 py-2 border text-sm font-medium hover:bg-gray-50";
|
||||
let clicked_btn =
|
||||
"z-10 bg-gray-50 border-gray-500 text-gray-600 relative inline-flex items-center px-4 py-2 border text-sm font-medium hover:bg-gray-50";
|
||||
|
||||
return (
|
||||
<a
|
||||
aria-current="page"
|
||||
className={
|
||||
props.pageNo === props.clicked ? clicked_btn : default_btn
|
||||
}
|
||||
onClick={changePage}
|
||||
>
|
||||
{props.pageNo}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 sm:-mx-6 lg:-mx-8">
|
||||
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div className="shadow border-b border-gray-200 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
{[
|
||||
"Name",
|
||||
"IP",
|
||||
"Status",
|
||||
"Last Seen",
|
||||
"OS",
|
||||
"Version",
|
||||
].map((col) => {
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
key={col}
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th
|
||||
scope="col"
|
||||
className="relative px-6 py-3"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Edit
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{getPaginatedData().map((elem) =>
|
||||
props.RenderComponent(elem)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{" "}
|
||||
<span className="font-medium">{currentPage}</span>{" "}
|
||||
to <span className="font-medium">{pageCount}</span>{" "}
|
||||
of <span className="font-medium">{pageCount}</span>
|
||||
</p>
|
||||
</div>
|
||||
{pageCount == 1 ? (
|
||||
<div />
|
||||
) : (
|
||||
<div>
|
||||
<nav
|
||||
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<a
|
||||
className="relative inline-flex items-center px-2 py-2 squared-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
onClick={goToFirst}
|
||||
>
|
||||
first
|
||||
</a>
|
||||
<a
|
||||
className="relative inline-flex items-center px-2 py-2 squared-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
onClick={goToPreviousPage}
|
||||
>
|
||||
<span className="sr-only">Previous</span>
|
||||
<ChevronLeftIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
<div>
|
||||
{compressPagination().map((elem) => {
|
||||
return (
|
||||
<PaginationBarElem
|
||||
clicked={currentPage}
|
||||
pageNo={elem}
|
||||
key={elem}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<a
|
||||
className="relative inline-flex items-center px-2 py-2 squared-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
onClick={goToNextPage}
|
||||
>
|
||||
<span className="sr-only">Next</span>
|
||||
<ChevronRightIcon
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
className="relative inline-flex items-center px-2 py-2 squared-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
onClick={goToLast}
|
||||
>
|
||||
last
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default withAuthenticationRequired(PaginatedPeersList, {
|
||||
onRedirecting: () => <Loading />,
|
||||
});
|
||||
@@ -1,206 +1,167 @@
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useAuth0, withAuthenticationRequired} from "@auth0/auth0-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAuth0, withAuthenticationRequired } from "@auth0/auth0-react";
|
||||
import Loading from "../components/Loading";
|
||||
import {deletePeer, getPeers} from "../api/ManagementAPI";
|
||||
import {timeAgo} from "../utils/common";
|
||||
import { deletePeer, getPeers } from "../api/ManagementAPI";
|
||||
import { timeAgo } from "../utils/common";
|
||||
import EditButton from "../components/EditButton";
|
||||
import CopyText from "../components/CopyText";
|
||||
import DeleteModal from "../components/DeleteDialog";
|
||||
import EmptyPeersPanel from "../components/EmptyPeers";
|
||||
|
||||
import PaginatedPeersList from "../components/PaginatedPeersList"
|
||||
|
||||
export const Peers = () => {
|
||||
const [peers, setPeers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [peerToDelete, setPeerToDelete] = useState(null);
|
||||
const [deleteDialogText, setDeleteDialogText] = useState("");
|
||||
const [deleteDialogTitle, setDeleteDialogTitle] = useState("");
|
||||
|
||||
const [peers, setPeers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [deleteDialogText, setDeleteDialogText] = useState("")
|
||||
const [deleteDialogTitle, setDeleteDialogTitle] = useState("")
|
||||
const [peerToDelete, setPeerToDelete] = useState(null)
|
||||
const { getAccessTokenSilently } = useAuth0();
|
||||
|
||||
const {
|
||||
getAccessTokenSilently,
|
||||
} = useAuth0();
|
||||
const handleError = (error) => {
|
||||
console.error("Error to fetch data:", error);
|
||||
setLoading(false);
|
||||
setError(error);
|
||||
};
|
||||
|
||||
const handleError = error => {
|
||||
console.error('Error to fetch data:', error);
|
||||
setLoading(false)
|
||||
setError(error);
|
||||
};
|
||||
//called when user clicks on table row menu item
|
||||
const handleRowMenuClick = (action, peer) => {
|
||||
if (action === "Delete") {
|
||||
setPeerToDelete(peer);
|
||||
setDeleteDialogText(
|
||||
"Are you sure you want to delete peer from your account?"
|
||||
);
|
||||
setDeleteDialogTitle('Delete peer "' + peer.Name + '"');
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
//called when user clicks on table row menu item
|
||||
const handleRowMenuClick = (action, peer) => {
|
||||
if (action === 'Delete') {
|
||||
setPeerToDelete(peer)
|
||||
setDeleteDialogText("Are you sure you want to delete peer from your account?")
|
||||
setDeleteDialogTitle("Delete peer \"" + peer.Name + "\"")
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
};
|
||||
const refresh = () => {
|
||||
getPeers(getAccessTokenSilently)
|
||||
.then((responseData) =>
|
||||
responseData.sort((a, b) => (a.Name > b.Name ? 1 : -1))
|
||||
)
|
||||
.then((sorted) => setPeers(sorted))
|
||||
.then(() => setLoading(false))
|
||||
.catch((error) => handleError(error));
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
getPeers(getAccessTokenSilently)
|
||||
.then(responseData => responseData.sort((a, b) => (a.Name > b.Name) ? 1 : -1))
|
||||
.then(sorted => setPeers(sorted))
|
||||
.then(() => setLoading(false))
|
||||
.catch(error => handleError(error))
|
||||
}
|
||||
// after user confirms (or not) deletion of the peer
|
||||
const handleDeleteConfirmation = (confirmed) => {
|
||||
setShowDeleteDialog(false);
|
||||
if (confirmed) {
|
||||
deletePeer(getAccessTokenSilently, peerToDelete.IP)
|
||||
.then(() => setPeerToDelete(null))
|
||||
.then(() => refresh())
|
||||
.catch((error) => {
|
||||
setPeerToDelete(null);
|
||||
console.log(error);
|
||||
});
|
||||
} else {
|
||||
setPeerToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// after user confirms (or not) deletion of the peer
|
||||
const handleDeleteConfirmation = (confirmed) => {
|
||||
setShowDeleteDialog(false)
|
||||
if (confirmed) {
|
||||
deletePeer(getAccessTokenSilently, peerToDelete.IP)
|
||||
.then(() => setPeerToDelete(null))
|
||||
.then(() => refresh())
|
||||
.catch(error => {
|
||||
setPeerToDelete(null)
|
||||
console.log(error)
|
||||
})
|
||||
} else {
|
||||
setPeerToDelete(null)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [getAccessTokenSilently]);
|
||||
|
||||
const PeerRow = (peer) => {
|
||||
return (
|
||||
<tr key={peer.IP}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium font-semibold font-mono text-gray-900">
|
||||
{peer.Name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium font-mono text-gray-900">
|
||||
<CopyText
|
||||
text={peer.IP.toUpperCase()}
|
||||
idPrefix={"peers-ip-" + peer.IP}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{peer.Connected && (
|
||||
<span className="px-2 inline-flex text-sm leading-5 font-mono squared-full bg-green-100 text-green-800">
|
||||
Online
|
||||
</span>
|
||||
)}
|
||||
{!peer.Connected && (
|
||||
<span className="px-2 inline-flex text-sm leading-5 font-mono squared-full bg-red-100 text-red-800">
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
|
||||
{peer.ConnectedP ? "just now" : timeAgo(peer.LastSeen)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
|
||||
{peer.OS}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
|
||||
{peer.Version}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-m font-medium">
|
||||
<EditButton
|
||||
items={[{ name: "Delete" }]}
|
||||
handler={(action) => handleRowMenuClick(action, peer)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [getAccessTokenSilently])
|
||||
return (
|
||||
<>
|
||||
<div className="py-10">
|
||||
<header>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-mono leading-tight text-gray-900 font-bold">
|
||||
Peers
|
||||
</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-8 sm:px-0">
|
||||
{loading && <Loading />}
|
||||
{error != null && <span>{error.toString()}</span>}
|
||||
<main>
|
||||
{loading && <Loading />}
|
||||
{error != null && (
|
||||
<span>{error.toString()}</span>
|
||||
)}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-10">
|
||||
<header>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-mono leading-tight text-gray-900 font-bold">Peers</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-8 sm:px-0">
|
||||
{loading && (<Loading/>)}
|
||||
{error != null && (
|
||||
<span>{error.toString()}</span>
|
||||
)}
|
||||
<main>
|
||||
{loading && (<Loading/>)}
|
||||
{error != null && (
|
||||
<span>{error.toString()}</span>
|
||||
)}
|
||||
|
||||
{peers.length === 0 ?
|
||||
(<EmptyPeersPanel/>) : (
|
||||
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-8 sm:px-0">
|
||||
<DeleteModal show={showDeleteDialog}
|
||||
confirmCallback={handleDeleteConfirmation}
|
||||
text={deleteDialogText} title={deleteDialogTitle}/>
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 sm:-mx-6 lg:-mx-8">
|
||||
<div
|
||||
className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
|
||||
<div
|
||||
className="shadow border-b border-gray-200 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-100">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
IP
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Last Seen
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
OS
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
Version
|
||||
</th>
|
||||
<th scope="col" className="relative px-6 py-3">
|
||||
<span className="sr-only">Edit</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{peers.map((peer, idx) => (
|
||||
<tr key={peer.IP}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium font-semibold font-mono text-gray-900">{peer.Name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium font-mono text-gray-900">
|
||||
<CopyText text={peer.IP.toUpperCase()}
|
||||
idPrefix={"peers-ip-" + peer.IP}/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{peer.Connected && (
|
||||
<span
|
||||
className="px-2 inline-flex text-sm leading-5 font-mono squared-full bg-green-100 text-green-800">
|
||||
Online
|
||||
</span>
|
||||
)}
|
||||
{!peer.Connected && (
|
||||
<span
|
||||
className="px-2 inline-flex text-sm leading-5 font-mono squared-full bg-red-100 text-red-800">
|
||||
Offline
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">
|
||||
{peer.Connected ? ("just now") : timeAgo(peer.LastSeen)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">{peer.OS}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-700">{peer.Version}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-m font-medium">
|
||||
<EditButton items={[{name: "Delete"}]}
|
||||
handler={action => handleRowMenuClick(action, peer)}/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
;
|
||||
|
||||
export default withAuthenticationRequired(Peers,
|
||||
{
|
||||
onRedirecting: () => <Loading/>,
|
||||
}
|
||||
);
|
||||
{peers.length === 0 ? (
|
||||
<EmptyPeersPanel />
|
||||
) : (
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-8 sm:px-0">
|
||||
<DeleteModal
|
||||
show={showDeleteDialog}
|
||||
confirmCallback={
|
||||
handleDeleteConfirmation
|
||||
}
|
||||
text={deleteDialogText}
|
||||
title={deleteDialogTitle}
|
||||
/>
|
||||
<PaginatedPeersList
|
||||
data={peers}
|
||||
RenderComponent={PeerRow}
|
||||
dataLimit={25}
|
||||
pageLimit={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default withAuthenticationRequired(Peers, {
|
||||
onRedirecting: () => <Loading />,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user