mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-02 16:06:38 +00:00
Compare commits
5 Commits
revert-276
...
1.17.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fc1aa9191 | ||
|
|
ddf417f4ca | ||
|
|
d08be59055 | ||
|
|
322c136d1f | ||
|
|
e06f2f47b1 |
@@ -60,7 +60,7 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
|||||||
|
|
||||||
| <img width=500 /> | Description |
|
| <img width=500 /> | Description |
|
||||||
|-----------------|--------------|
|
|-----------------|--------------|
|
||||||
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing - no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. |
|
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. |
|
||||||
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
||||||
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
||||||
|
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Provisioning key updated",
|
"provisioningKeysUpdated": "Provisioning key updated",
|
||||||
"provisioningKeysUpdatedDescription": "Your changes have been saved.",
|
"provisioningKeysUpdatedDescription": "Your changes have been saved.",
|
||||||
"provisioningKeysBannerTitle": "Site Provisioning Keys",
|
"provisioningKeysBannerTitle": "Site Provisioning Keys",
|
||||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.",
|
||||||
"provisioningKeysBannerButtonText": "Learn More",
|
"provisioningKeysBannerButtonText": "Learn More",
|
||||||
"pendingSitesBannerTitle": "Pending Sites",
|
"pendingSitesBannerTitle": "Pending Sites",
|
||||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.",
|
||||||
"pendingSitesBannerButtonText": "Learn More",
|
"pendingSitesBannerButtonText": "Learn More",
|
||||||
"apiKeysSettings": "{apiKeyName} Settings",
|
"apiKeysSettings": "{apiKeyName} Settings",
|
||||||
"userTitle": "Manage All Users",
|
"userTitle": "Manage All Users",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Please enter a valid port number",
|
"targetErrorInvalidPortDescription": "Please enter a valid port number",
|
||||||
"targetErrorNoSite": "No site selected",
|
"targetErrorNoSite": "No site selected",
|
||||||
"targetErrorNoSiteDescription": "Please select a site for the target",
|
"targetErrorNoSiteDescription": "Please select a site for the target",
|
||||||
|
"targetTargetsCleared": "Targets cleared",
|
||||||
|
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||||
"targetCreated": "Target created",
|
"targetCreated": "Target created",
|
||||||
"targetCreatedDescription": "Target has been created successfully",
|
"targetCreatedDescription": "Target has been created successfully",
|
||||||
"targetErrorCreate": "Failed to create target",
|
"targetErrorCreate": "Failed to create target",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Enterprise features, 50 users, 50 sites, and priority support."
|
"description": "Enterprise features, 50 users, 50 sites, and priority support."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
"personalUseOnly": "Personal use only (free license — no checkout)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Continue to Checkout"
|
"continueToCheckout": "Continue to Checkout"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
|||||||
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
import { convertTargetsIfNessicary } from "../client/targets";
|
import { convertTargetsIfNessicary } from "../client/targets";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import config from "@server/lib/config";
|
|
||||||
|
|
||||||
export const handleGetConfigMessage: MessageHandler = async (context) => {
|
export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
@@ -56,7 +55,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
|
|
||||||
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
|
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
`handleGetConfigMessage: Site ${existingSite.siteId} last hole punch is too old, skipping`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, newts, sites, targetHealthCheck, targets } from "@server/db";
|
import { db, newts, sites } from "@server/db";
|
||||||
import {
|
import {
|
||||||
hasActiveConnections,
|
hasActiveConnections,
|
||||||
getClientConfigVersion
|
getClientConfigVersion
|
||||||
@@ -78,32 +78,6 @@ export const startNewtOfflineChecker = (): void => {
|
|||||||
.update(sites)
|
.update(sites)
|
||||||
.set({ online: false })
|
.set({ online: false })
|
||||||
.where(eq(sites.siteId, staleSite.siteId));
|
.where(eq(sites.siteId, staleSite.siteId));
|
||||||
|
|
||||||
const healthChecksOnSite = await db
|
|
||||||
.select()
|
|
||||||
.from(targetHealthCheck)
|
|
||||||
.innerJoin(
|
|
||||||
targets,
|
|
||||||
eq(targets.targetId, targetHealthCheck.targetId)
|
|
||||||
)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, targets.siteId))
|
|
||||||
.where(eq(sites.siteId, staleSite.siteId));
|
|
||||||
|
|
||||||
for (const healthCheck of healthChecksOnSite) {
|
|
||||||
logger.info(
|
|
||||||
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
|
|
||||||
);
|
|
||||||
await db
|
|
||||||
.update(targetHealthCheck)
|
|
||||||
.set({ hcHealth: "unknown" })
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
targetHealthCheck.targetHealthCheckId,
|
|
||||||
healthCheck.targetHealthCheck
|
|
||||||
.targetHealthCheckId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
|
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
|
||||||
@@ -128,8 +102,7 @@ export const startNewtOfflineChecker = (): void => {
|
|||||||
|
|
||||||
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
|
// loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline
|
||||||
for (const site of allWireguardSites) {
|
for (const site of allWireguardSites) {
|
||||||
const lastBandwidthUpdate =
|
const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate!).getTime() / 1000;
|
||||||
new Date(site.lastBandwidthUpdate!).getTime() / 1000;
|
|
||||||
if (
|
if (
|
||||||
lastBandwidthUpdate < wireguardOfflineThreshold &&
|
lastBandwidthUpdate < wireguardOfflineThreshold &&
|
||||||
site.online
|
site.online
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
|||||||
import { Alias } from "@server/lib/ip";
|
import { Alias } from "@server/lib/ip";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import config from "@server/lib/config";
|
|
||||||
|
|
||||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
logger.info("Handling register olm message!");
|
logger.info("Handling register olm message!");
|
||||||
@@ -275,7 +274,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||||
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
"Client last hole punch is too old and we have sites to send; skipping this register"
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
const [targetCheck] = await db
|
const [targetCheck] = await db
|
||||||
.select({
|
.select({
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
siteId: targets.siteId,
|
siteId: targets.siteId
|
||||||
hcStatus: targetHealthCheck.hcHealth
|
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
@@ -86,7 +85,6 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
eq(targets.resourceId, resources.resourceId)
|
eq(targets.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
.innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId))
|
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(targets.targetId, targetIdNum),
|
eq(targets.targetId, targetIdNum),
|
||||||
@@ -103,14 +101,6 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the status has changed
|
|
||||||
if (targetCheck.hcStatus === healthStatus.status) {
|
|
||||||
logger.debug(
|
|
||||||
`Health status for target ${targetId} is already ${healthStatus.status}, skipping update`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the target's health status in the database
|
// Update the target's health status in the database
|
||||||
await db
|
await db
|
||||||
.update(targetHealthCheck)
|
.update(targetHealthCheck)
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import DismissableBanner from "@app/components/DismissableBanner";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ArrowRight, Plug } from "lucide-react";
|
import { ArrowRight, Plug } from "lucide-react";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
|
||||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
type PendingSitesPageProps = {
|
type PendingSitesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -98,10 +96,6 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
<PaidFeaturesAlert
|
|
||||||
tiers={tierMatrix[TierFeature.SiteProvisioningKeys]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PendingSitesTable
|
<PendingSitesTable
|
||||||
sites={siteRows}
|
sites={siteRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
|
|||||||
@@ -774,8 +774,12 @@ function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("settingsUpdated"),
|
title: targets.length === 0
|
||||||
description: t("settingsUpdatedDescription")
|
? t("targetTargetsCleared")
|
||||||
|
: t("settingsUpdated"),
|
||||||
|
description: targets.length === 0
|
||||||
|
? t("targetTargetsClearedDescription")
|
||||||
|
: t("settingsUpdatedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
setTargetsToRemove([]);
|
setTargetsToRemove([]);
|
||||||
|
|||||||
@@ -614,7 +614,6 @@ export function InternalResourceForm({
|
|||||||
<SitesSelector
|
<SitesSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
selectedSite={selectedSite}
|
selectedSite={selectedSite}
|
||||||
filterTypes={["newt"]}
|
|
||||||
onSelectSite={(site) => {
|
onSelectSite={(site) => {
|
||||||
setSelectedSite(site);
|
setSelectedSite(site);
|
||||||
field.onChange(site.siteId);
|
field.onChange(site.siteId);
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { type PaginationState } from "@tanstack/react-table";
|
import { type PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
ArrowDown01Icon,
|
ArrowDown01Icon,
|
||||||
@@ -65,10 +63,6 @@ export default function PendingSitesTable({
|
|||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const canUseSiteProvisioning =
|
|
||||||
isPaidUser(tierMatrix[TierFeature.SiteProvisioningKeys]) &&
|
|
||||||
build !== "oss";
|
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
const booleanSearchFilterSchema = z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
@@ -456,7 +450,6 @@ export default function PendingSitesTable({
|
|||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing || isFiltering}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
refreshButtonDisabled={!canUseSiteProvisioning}
|
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
niceId: false,
|
niceId: false,
|
||||||
|
|||||||
@@ -311,7 +311,6 @@ export default function SiteProvisioningKeysTable({
|
|||||||
addButtonDisabled={!canUseSiteProvisioning}
|
addButtonDisabled={!canUseSiteProvisioning}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
refreshButtonDisabled={!canUseSiteProvisioning}
|
|
||||||
addButtonText={t("provisioningKeysAdd")}
|
addButtonText={t("provisioningKeysAdd")}
|
||||||
enableColumnVisibility={true}
|
enableColumnVisibility={true}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
|
|||||||
@@ -24,14 +24,12 @@ export type SitesSelectorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
selectedSite?: Selectedsite | null;
|
selectedSite?: Selectedsite | null;
|
||||||
onSelectSite: (selected: Selectedsite) => void;
|
onSelectSite: (selected: Selectedsite) => void;
|
||||||
filterTypes?: string[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SitesSelector({
|
export function SitesSelector({
|
||||||
orgId,
|
orgId,
|
||||||
selectedSite,
|
selectedSite,
|
||||||
onSelectSite,
|
onSelectSite
|
||||||
filterTypes
|
|
||||||
}: SitesSelectorProps) {
|
}: SitesSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||||
@@ -47,9 +45,7 @@ export function SitesSelector({
|
|||||||
|
|
||||||
// always include the selected site in the list of sites shown
|
// always include the selected site in the list of sites shown
|
||||||
const sitesShown = useMemo(() => {
|
const sitesShown = useMemo(() => {
|
||||||
const allSites: Array<Selectedsite> = filterTypes
|
const allSites: Array<Selectedsite> = [...sites];
|
||||||
? sites.filter((s) => filterTypes.includes(s.type))
|
|
||||||
: [...sites];
|
|
||||||
if (
|
if (
|
||||||
debouncedQuery.trim().length === 0 &&
|
debouncedQuery.trim().length === 0 &&
|
||||||
selectedSite &&
|
selectedSite &&
|
||||||
@@ -58,7 +54,7 @@ export function SitesSelector({
|
|||||||
allSites.unshift(selectedSite);
|
allSites.unshift(selectedSite);
|
||||||
}
|
}
|
||||||
return allSites;
|
return allSites;
|
||||||
}, [debouncedQuery, sites, selectedSite, filterTypes]);
|
}, [debouncedQuery, sites, selectedSite]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ type ControlledDataTableProps<TData, TValue> = {
|
|||||||
onAdd?: () => void;
|
onAdd?: () => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
refreshButtonDisabled?: boolean;
|
|
||||||
isNavigatingToAddPage?: boolean;
|
isNavigatingToAddPage?: boolean;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
filters?: DataTableFilter[];
|
filters?: DataTableFilter[];
|
||||||
@@ -92,7 +91,6 @@ export function ControlledDataTable<TData, TValue>({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
refreshButtonDisabled = false,
|
|
||||||
searchPlaceholder = "Search...",
|
searchPlaceholder = "Search...",
|
||||||
filters,
|
filters,
|
||||||
filterDisplayMode = "label",
|
filterDisplayMode = "label",
|
||||||
@@ -337,7 +335,7 @@ export function ControlledDataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isRefreshing || refreshButtonDisabled}
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||||
|
|||||||
@@ -174,7 +174,6 @@ type DataTableProps<TData, TValue> = {
|
|||||||
addButtonDisabled?: boolean;
|
addButtonDisabled?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
refreshButtonDisabled?: boolean;
|
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
searchColumn?: string;
|
searchColumn?: string;
|
||||||
defaultSort?: {
|
defaultSort?: {
|
||||||
@@ -208,7 +207,6 @@ export function DataTable<TData, TValue>({
|
|||||||
addButtonDisabled = false,
|
addButtonDisabled = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
refreshButtonDisabled = false,
|
|
||||||
searchPlaceholder = "Search...",
|
searchPlaceholder = "Search...",
|
||||||
searchColumn = "name",
|
searchColumn = "name",
|
||||||
defaultSort,
|
defaultSort,
|
||||||
@@ -626,7 +624,7 @@ export function DataTable<TData, TValue>({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isRefreshing || refreshButtonDisabled}
|
disabled={isRefreshing}
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user