mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
chore: add utils
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { FeatureModule } from '../../core/features';
|
||||
import { PermissionModule } from '../../core/permission';
|
||||
import { QuotaModule } from '../../core/quota';
|
||||
import { WorkspaceModule } from '../../core/workspaces';
|
||||
import { LicenseResolver } from './resolver';
|
||||
import { AdminLicenseResolver, LicenseResolver } from './resolver';
|
||||
import { LicenseService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [QuotaModule, PermissionModule, WorkspaceModule],
|
||||
providers: [LicenseService, LicenseResolver],
|
||||
imports: [FeatureModule, QuotaModule, PermissionModule, WorkspaceModule],
|
||||
providers: [LicenseService, LicenseResolver, AdminLicenseResolver],
|
||||
})
|
||||
export class LicenseModule {}
|
||||
|
||||
@@ -14,9 +14,14 @@ import GraphQLUpload, {
|
||||
|
||||
import { toBuffer, UseNamedGuard } from '../../base';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { Admin } from '../../core/common';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { WorkspaceType } from '../../core/workspaces';
|
||||
import { SubscriptionRecurring, SubscriptionVariant } from '../payment/types';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionVariant,
|
||||
} from '../payment/types';
|
||||
import { LicenseService } from './service';
|
||||
|
||||
@ObjectType()
|
||||
@@ -40,6 +45,42 @@ export class License {
|
||||
expiredAt!: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AdminLicensePreview {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
workspaceId!: string;
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: string;
|
||||
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
quantity!: number;
|
||||
|
||||
@Field(() => Date)
|
||||
issuedAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
expiresAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
endAt!: Date;
|
||||
|
||||
@Field()
|
||||
entity!: string;
|
||||
|
||||
@Field()
|
||||
issuer!: string;
|
||||
|
||||
@Field()
|
||||
valid!: boolean;
|
||||
}
|
||||
|
||||
@UseNamedGuard('selfhost')
|
||||
@Resolver(() => WorkspaceType)
|
||||
export class LicenseResolver {
|
||||
@@ -124,3 +165,18 @@ export class LicenseResolver {
|
||||
return license;
|
||||
}
|
||||
}
|
||||
|
||||
@Admin()
|
||||
@Resolver(() => AdminLicensePreview)
|
||||
export class AdminLicenseResolver {
|
||||
constructor(private readonly service: LicenseService) {}
|
||||
|
||||
@Mutation(() => AdminLicensePreview)
|
||||
async previewLicense(
|
||||
@Args('license', { type: () => GraphQLUpload }) licenseFile: FileUpload
|
||||
) {
|
||||
const buffer = await toBuffer(licenseFile.createReadStream());
|
||||
|
||||
return this.service.previewLicense(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,20 @@ interface License {
|
||||
endAt: number;
|
||||
}
|
||||
|
||||
export interface LicensePreview {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
plan: SubscriptionPlan.SelfHostedTeam;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
issuedAt: Date;
|
||||
expiresAt: Date;
|
||||
endAt: Date;
|
||||
entity: string;
|
||||
issuer: string;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
const BaseLicenseSchema = z.object({
|
||||
entity: z.string().nonempty(),
|
||||
issuer: z.string().nonempty(),
|
||||
@@ -167,6 +181,34 @@ export class LicenseService {
|
||||
return installed;
|
||||
}
|
||||
|
||||
previewLicense(license: Buffer): LicensePreview {
|
||||
const payload = this.decryptWorkspaceTeamLicensePayload(license);
|
||||
const data = payload.data;
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(payload.expiresAt);
|
||||
const endAt = new Date(data.endAt);
|
||||
|
||||
if (expiresAt < now || endAt < now) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Invalid license.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
workspaceId: data.workspaceId,
|
||||
plan: data.plan,
|
||||
recurring: data.recurring,
|
||||
quantity: data.quantity,
|
||||
issuedAt: new Date(payload.issuedAt),
|
||||
expiresAt,
|
||||
endAt,
|
||||
entity: payload.entity,
|
||||
issuer: payload.issuer,
|
||||
valid: true,
|
||||
};
|
||||
}
|
||||
|
||||
async activateTeamLicense(workspaceId: string, licenseKey: string) {
|
||||
const installedLicense = await this.getLicense(workspaceId);
|
||||
|
||||
@@ -480,6 +522,25 @@ export class LicenseService {
|
||||
}
|
||||
|
||||
private decryptWorkspaceTeamLicense(workspaceId: string, buf: Buffer) {
|
||||
const payload = this.decryptWorkspaceTeamLicensePayload(buf);
|
||||
|
||||
if (new Date(payload.expiresAt) < new Date()) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason:
|
||||
'License file has expired. Please contact with Affine support to fetch a latest one.',
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.data.workspaceId !== workspaceId) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Workspace mismatched with license.',
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private decryptWorkspaceTeamLicensePayload(buf: Buffer) {
|
||||
if (!this.crypto.AFFiNEProPublicKey) {
|
||||
throw new InternalServerError(
|
||||
'License public key is not loaded. Please contact with Affine support.'
|
||||
@@ -514,19 +575,6 @@ export class LicenseService {
|
||||
});
|
||||
}
|
||||
|
||||
if (new Date(parseResult.data.expiresAt) < new Date()) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason:
|
||||
'License file has expired. Please contact with Affine support to fetch a latest one.',
|
||||
});
|
||||
}
|
||||
|
||||
if (parseResult.data.data.workspaceId !== workspaceId) {
|
||||
throw new InvalidLicenseToActivate({
|
||||
reason: 'Workspace mismatched with license.',
|
||||
});
|
||||
}
|
||||
|
||||
return parseResult.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,20 @@ type AdminDashboardValueDayPoint {
|
||||
value: SafeInt!
|
||||
}
|
||||
|
||||
type AdminLicensePreview {
|
||||
endAt: DateTime!
|
||||
entity: String!
|
||||
expiresAt: DateTime!
|
||||
id: String!
|
||||
issuedAt: DateTime!
|
||||
issuer: String!
|
||||
plan: SubscriptionPlan!
|
||||
quantity: Int!
|
||||
recurring: SubscriptionRecurring!
|
||||
valid: Boolean!
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
type AdminSharedLinkTopItem {
|
||||
docId: String!
|
||||
guestViews: SafeInt!
|
||||
@@ -1632,6 +1646,7 @@ type Mutation {
|
||||
|
||||
"""mention user in a doc"""
|
||||
mentionUser(input: MentionInput!): ID!
|
||||
previewLicense(license: Upload!): AdminLicensePreview!
|
||||
publishDoc(docId: String!, mode: PublicDocMode = Page, workspaceId: String!): DocType!
|
||||
|
||||
"""queue workspace doc embedding"""
|
||||
|
||||
@@ -2582,6 +2582,27 @@ ${licenseBodyFragment}`,
|
||||
file: true,
|
||||
};
|
||||
|
||||
export const previewLicenseMutation = {
|
||||
id: 'previewLicenseMutation' as const,
|
||||
op: 'previewLicense',
|
||||
query: `mutation previewLicense($license: Upload!) {
|
||||
previewLicense(license: $license) {
|
||||
id
|
||||
workspaceId
|
||||
plan
|
||||
recurring
|
||||
quantity
|
||||
issuedAt
|
||||
expiresAt
|
||||
endAt
|
||||
entity
|
||||
issuer
|
||||
valid
|
||||
}
|
||||
}`,
|
||||
file: true,
|
||||
};
|
||||
|
||||
export const listNotificationsQuery = {
|
||||
id: 'listNotificationsQuery' as const,
|
||||
op: 'listNotifications',
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
mutation previewLicense($license: Upload!) {
|
||||
previewLicense(license: $license) {
|
||||
id
|
||||
workspaceId
|
||||
plan
|
||||
recurring
|
||||
quantity
|
||||
issuedAt
|
||||
expiresAt
|
||||
endAt
|
||||
entity
|
||||
issuer
|
||||
valid
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,21 @@ export interface AdminDashboardValueDayPoint {
|
||||
value: Scalars['SafeInt']['output'];
|
||||
}
|
||||
|
||||
export interface AdminLicensePreview {
|
||||
__typename?: 'AdminLicensePreview';
|
||||
endAt: Scalars['DateTime']['output'];
|
||||
entity: Scalars['String']['output'];
|
||||
expiresAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['String']['output'];
|
||||
issuedAt: Scalars['DateTime']['output'];
|
||||
issuer: Scalars['String']['output'];
|
||||
plan: SubscriptionPlan;
|
||||
quantity: Scalars['Int']['output'];
|
||||
recurring: SubscriptionRecurring;
|
||||
valid: Scalars['Boolean']['output'];
|
||||
workspaceId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface AdminSharedLinkTopItem {
|
||||
__typename?: 'AdminSharedLinkTopItem';
|
||||
docId: Scalars['String']['output'];
|
||||
@@ -1831,6 +1846,7 @@ export interface Mutation {
|
||||
linkCalendarAccount: Scalars['String']['output'];
|
||||
/** mention user in a doc */
|
||||
mentionUser: Scalars['ID']['output'];
|
||||
previewLicense: AdminLicensePreview;
|
||||
publishDoc: DocType;
|
||||
/** queue workspace doc embedding */
|
||||
queueWorkspaceEmbedding: Scalars['Boolean']['output'];
|
||||
@@ -2152,6 +2168,10 @@ export interface MutationMentionUserArgs {
|
||||
input: MentionInput;
|
||||
}
|
||||
|
||||
export interface MutationPreviewLicenseArgs {
|
||||
license: Scalars['Upload']['input'];
|
||||
}
|
||||
|
||||
export interface MutationPublishDocArgs {
|
||||
docId: Scalars['String']['input'];
|
||||
mode?: InputMaybe<PublicDocMode>;
|
||||
@@ -7172,6 +7192,28 @@ export type LicenseBodyFragment = {
|
||||
variant: SubscriptionVariant | null;
|
||||
};
|
||||
|
||||
export type PreviewLicenseMutationVariables = Exact<{
|
||||
license: Scalars['Upload']['input'];
|
||||
}>;
|
||||
|
||||
export type PreviewLicenseMutation = {
|
||||
__typename?: 'Mutation';
|
||||
previewLicense: {
|
||||
__typename?: 'AdminLicensePreview';
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
quantity: number;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
endAt: string;
|
||||
entity: string;
|
||||
issuer: string;
|
||||
valid: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ListNotificationsQueryVariables = Exact<{
|
||||
pagination: PaginationInput;
|
||||
}>;
|
||||
@@ -8742,6 +8784,11 @@ export type Mutations =
|
||||
variables: InstallLicenseMutationVariables;
|
||||
response: InstallLicenseMutation;
|
||||
}
|
||||
| {
|
||||
name: 'previewLicenseMutation';
|
||||
variables: PreviewLicenseMutationVariables;
|
||||
response: PreviewLicenseMutation;
|
||||
}
|
||||
| {
|
||||
name: 'mentionUserMutation';
|
||||
variables: MentionUserMutationVariables;
|
||||
|
||||
@@ -13,6 +13,9 @@ vi.mock('@affine/admin/use-query', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../use-mutation', () => ({
|
||||
useMutation: () => ({
|
||||
trigger: vi.fn(),
|
||||
}),
|
||||
useMutateQueryResource: () => () => {
|
||||
mutateQueryResourceMock();
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -12,6 +12,20 @@ import {
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@affine/admin/components/ui/chart';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@affine/admin/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@affine/admin/components/ui/dropdown-menu';
|
||||
import { Label } from '@affine/admin/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -30,18 +44,30 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@affine/admin/components/ui/table';
|
||||
import { useMutation } from '@affine/admin/use-mutation';
|
||||
import { useQuery } from '@affine/admin/use-query';
|
||||
import { adminDashboardQuery } from '@affine/graphql';
|
||||
import { adminDashboardQuery, previewLicenseMutation } from '@affine/graphql';
|
||||
import { ROUTES } from '@affine/routes';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
DatabaseIcon,
|
||||
FileSearchIcon,
|
||||
MessageSquareTextIcon,
|
||||
RefreshCwIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, Suspense, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useMutateQueryResource } from '../../use-mutation';
|
||||
import { Header } from '../header';
|
||||
@@ -154,6 +180,20 @@ type TrendPoint = {
|
||||
secondary?: number;
|
||||
};
|
||||
|
||||
type LicensePreview = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
plan: string;
|
||||
recurring: string;
|
||||
quantity: number;
|
||||
issuedAt: string;
|
||||
expiresAt: string;
|
||||
endAt: string;
|
||||
entity: string;
|
||||
issuer: string;
|
||||
valid: boolean;
|
||||
};
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return utcDateTimeFormatter.format(new Date(value));
|
||||
}
|
||||
@@ -418,6 +458,198 @@ function WindowSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function LicensePreviewDialog({
|
||||
license,
|
||||
onOpenChange,
|
||||
}: {
|
||||
license: LicensePreview | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const rows = license
|
||||
? [
|
||||
['Status', license.valid ? 'Valid' : 'Invalid'],
|
||||
['License ID', license.id],
|
||||
['Workspace ID', license.workspaceId],
|
||||
['Plan', license.plan],
|
||||
['Recurring', license.recurring],
|
||||
['Seats', intFormatter.format(license.quantity)],
|
||||
['Issued At', formatDateTime(license.issuedAt)],
|
||||
['File Expires At', formatDateTime(license.expiresAt)],
|
||||
['License Ends At', formatDateTime(license.endAt)],
|
||||
['Entity', license.entity],
|
||||
['Issuer', license.issuer],
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open={!!license} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>License Preview</DialogTitle>
|
||||
<DialogDescription>
|
||||
Signature and payload format are valid.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg border border-border/60 overflow-hidden">
|
||||
{rows.map(([label, value]) => (
|
||||
<div
|
||||
key={label}
|
||||
className="grid grid-cols-[140px_1fr] gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="min-w-0 break-words text-sm font-medium tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className="mt-2">
|
||||
<Button onClick={() => onOpenChange(false)}>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardActions({
|
||||
updatedAt,
|
||||
isValidating,
|
||||
onRefresh,
|
||||
}: {
|
||||
updatedAt: string;
|
||||
isValidating: boolean;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickerOpenRef = useRef(false);
|
||||
const [licensePreview, setLicensePreview] = useState<LicensePreview | null>(
|
||||
null
|
||||
);
|
||||
const { trigger: previewLicense } = useMutation({
|
||||
mutation: previewLicenseMutation,
|
||||
});
|
||||
|
||||
const notifyNoFileSelected = useCallback(() => {
|
||||
toast.error('No license file selected.');
|
||||
}, []);
|
||||
|
||||
const openLicensePicker = useCallback(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) {
|
||||
toast.error('Failed to open license file picker.');
|
||||
return;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
pickerOpenRef.current = true;
|
||||
|
||||
const handleFocus = () => {
|
||||
window.setTimeout(() => {
|
||||
if (pickerOpenRef.current) {
|
||||
pickerOpenRef.current = false;
|
||||
notifyNoFileSelected();
|
||||
}
|
||||
}, 200);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleFocus);
|
||||
input.click();
|
||||
}, [notifyNoFileSelected]);
|
||||
|
||||
const handleLicenseFileChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
pickerOpenRef.current = false;
|
||||
|
||||
if (!file) {
|
||||
notifyNoFileSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
previewLicense({ license: file })
|
||||
.then(data => {
|
||||
setLicensePreview(data.previewLicense);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
toast.error('Failed to preview license.');
|
||||
});
|
||||
},
|
||||
[notifyNoFileSelected, previewLicense]
|
||||
);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
environment.isSelfHosted
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'preview-license',
|
||||
label: 'Preview license',
|
||||
onSelect: openLicensePicker,
|
||||
},
|
||||
],
|
||||
[openLicensePicker]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Updated at {formatDateTime(updatedAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
{menuItems.length ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" aria-label="Dashboard menu">
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{menuItems.map(item => (
|
||||
<DropdownMenuItem key={item.key} onSelect={item.onSelect}>
|
||||
<FileSearchIcon className="mr-2 h-3.5 w-3.5" aria-hidden />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".lic,.license"
|
||||
className="hidden"
|
||||
onChange={handleLicenseFileChange}
|
||||
/>
|
||||
<LicensePreviewDialog
|
||||
license={licensePreview}
|
||||
onOpenChange={open => {
|
||||
if (!open) {
|
||||
setLicensePreview(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPageSkeleton() {
|
||||
return (
|
||||
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
|
||||
@@ -665,25 +897,13 @@ function DashboardPageContent() {
|
||||
<Header
|
||||
title="Dashboard"
|
||||
endFix={
|
||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
Updated at {formatDateTime(dashboard.generatedAt)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
revalidateQueryResource(adminDashboardQuery).catch(() => {});
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<DashboardActions
|
||||
updatedAt={dashboard.generatedAt}
|
||||
isValidating={isValidating}
|
||||
onRefresh={() => {
|
||||
revalidateQueryResource(adminDashboardQuery).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user