diff --git a/.env.example b/.env.example index 7bb7766df0..3034cb4eb2 100644 --- a/.env.example +++ b/.env.example @@ -95,3 +95,6 @@ GOOGLE_APPLICATION_CREDENTIALS= # Key to send email MAILGUN_API_KEY= + +# Redis (optional) +NANGO_REDIS_URL= diff --git a/package-lock.json b/package-lock.json index 976403e601..f9b19a0c17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34945,6 +34945,7 @@ "@radix-ui/react-dropdown-menu": "2.0.6", "@radix-ui/react-icons": "1.3.0", "@radix-ui/react-popover": "1.0.7", + "@radix-ui/react-scroll-area": "1.0.5", "@radix-ui/react-tooltip": "1.0.7", "@sentry/react": "8.4.0", "@tailwindcss/forms": "0.5.3", @@ -35108,6 +35109,214 @@ "react": "^18.2.0" } }, + "packages/webapp/node_modules/@radix-ui/number": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", + "integrity": "sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "packages/webapp/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz", + "integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "packages/webapp/node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "packages/webapp/node_modules/@remix-run/router": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.3.tgz", diff --git a/packages/logs/lib/client.integration.test.ts b/packages/logs/lib/client.integration.test.ts index d093f4fdbb..32738c74ec 100644 --- a/packages/logs/lib/client.integration.test.ts +++ b/packages/logs/lib/client.integration.test.ts @@ -27,6 +27,15 @@ describe('client', () => { vi.clearAllMocks(); }); + it('should list nothing', async () => { + const list = await listOperations({ accountId: account.id, limit: 1, states: ['all'] }); + expect(list).toStrictEqual({ + cursor: null, + count: 0, + items: [] + }); + }); + it('should insert an operation', async () => { const spy = vi.spyOn(model, 'createMessage'); const ctx = await logContextGetter.create(operationPayload, { start: false, account, environment }, { logToConsole: false }); @@ -35,6 +44,7 @@ describe('client', () => { const list = await listOperations({ accountId: account.id, limit: 1, states: ['all'] }); expect(list).toStrictEqual({ + cursor: null, count: 1, items: [ { diff --git a/packages/logs/lib/models/helpers.ts b/packages/logs/lib/models/helpers.ts index 66718bc9a6..af6df5a96f 100644 --- a/packages/logs/lib/models/helpers.ts +++ b/packages/logs/lib/models/helpers.ts @@ -1,6 +1,7 @@ import { nanoid } from '@nangohq/utils'; import type { MessageRow } from '@nangohq/types'; import { z } from 'zod'; +import type { estypes } from '@elastic/elasticsearch'; export const operationIdRegex = z.string().regex(/([0-9]|[a-zA-Z0-9]{20})/); @@ -72,3 +73,11 @@ export const oldLevelToNewLevel = { silly: 'debug', http: 'info' } as const; + +export function createCursor({ sort }: estypes.SearchHit): string { + return Buffer.from(JSON.stringify(sort)).toString('base64'); +} + +export function parseCursor(str: string): any[] { + return JSON.parse(Buffer.from(str, 'base64').toString('utf8')); +} diff --git a/packages/logs/lib/models/messages.integration.test.ts b/packages/logs/lib/models/messages.integration.test.ts new file mode 100644 index 0000000000..a9e616d495 --- /dev/null +++ b/packages/logs/lib/models/messages.integration.test.ts @@ -0,0 +1,57 @@ +import { describe, beforeAll, it, expect, vi } from 'vitest'; +import { deleteIndex, migrateMapping } from '../es/helpers.js'; +import type { ListOperations } from './messages.js'; +import { listOperations } from './messages.js'; +import { afterEach } from 'node:test'; +import { logContextGetter } from './logContextGetter.js'; +import type { OperationRowInsert } from '@nangohq/types'; + +const account = { id: 1234, name: 'test' }; +const environment = { id: 5678, name: 'dev' }; +const operationPayload: OperationRowInsert = { operation: { type: 'sync', action: 'run' }, message: '' }; + +describe('model', () => { + beforeAll(async () => { + await deleteIndex(); + await migrateMapping(); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('operations', () => { + it('should list nothing', async () => { + const list = await listOperations({ accountId: account.id, limit: 1, states: ['all'] }); + expect(list).toStrictEqual({ + cursor: null, + count: 0, + items: [] + }); + }); + + it('should paginate', async () => { + await logContextGetter.create(operationPayload, { start: false, account, environment }, { logToConsole: false }); + await logContextGetter.create(operationPayload, { start: false, account, environment }, { logToConsole: false }); + + // First operation = should list one + const list1 = await listOperations({ accountId: account.id, limit: 1, states: ['all'] }); + expect(list1.count).toBe(2); + expect(list1.items).toHaveLength(1); + expect(list1.cursor).toBeDefined(); + + // Second operation = should list the second one + const list2 = await listOperations({ accountId: account.id, limit: 1, states: ['all'], cursor: list1.cursor! }); + expect(list2.count).toBe(2); + expect(list2.items).toHaveLength(1); + expect(list2.cursor).toBeDefined(); + expect(list2.items[0]!.id).not.toEqual(list1.items[0]!.id); + + // Empty results + // When we get the second operation, it's not possible to know if there are more after so we still need to return a cursor + const list3 = await listOperations({ accountId: account.id, limit: 1, states: ['all'], cursor: list2.cursor! }); + expect(list3.count).toBe(2); + expect(list3.items).toHaveLength(0); + expect(list3.cursor).toBeNull(); + }); + }); +}); diff --git a/packages/logs/lib/models/messages.ts b/packages/logs/lib/models/messages.ts index de92fabf61..c76e2a0342 100644 --- a/packages/logs/lib/models/messages.ts +++ b/packages/logs/lib/models/messages.ts @@ -12,10 +12,13 @@ import type { import { indexMessages } from '../es/schema.js'; import type { estypes } from '@elastic/elasticsearch'; import { errors } from '@elastic/elasticsearch'; +import { createCursor, parseCursor } from './helpers.js'; +import { isTest } from '@nangohq/utils'; export interface ListOperations { count: number; items: OperationRow[]; + cursor: string | null; } export interface ListMessages { count: number; @@ -35,7 +38,7 @@ export async function createMessage(row: MessageRow): Promise { index: indexMessages.index, id: row.id, document: row, - refresh: true + refresh: isTest }); } @@ -52,6 +55,7 @@ export async function listOperations(opts: { connections?: SearchOperationsConnection[] | undefined; syncs?: SearchOperationsSync[] | undefined; period?: SearchOperationsPeriod | undefined; + cursor?: string | null | undefined; }): Promise { const query: estypes.QueryDslQueryContainer = { bool: { @@ -128,20 +132,25 @@ export async function listOperations(opts: { }); } + const cursor = opts.cursor ? parseCursor(opts.cursor) : undefined; const res = await client.search({ index: indexMessages.index, size: opts.limit, - sort: [{ createdAt: 'desc' }, '_score'], + sort: [{ createdAt: 'desc' }, 'id'], track_total_hits: true, + search_after: cursor, query }); const hits = res.hits; + const total = typeof hits.total === 'object' ? hits.total.value : hits.hits.length; + const totalPage = hits.hits.length; return { - count: typeof hits.total === 'object' ? hits.total.value : hits.hits.length, + count: total, items: hits.hits.map((hit) => { return hit._source!; - }) + }), + cursor: totalPage > 0 && total > totalPage && opts.limit <= totalPage ? createCursor(hits.hits[hits.hits.length - 1]!) : null }; } @@ -163,7 +172,7 @@ export async function update(opts: { id: MessageRow['id']; data: Partial>>({ index: indexMessages.index, id: opts.id, - refresh: true, + refresh: isTest, body: { doc: { ...opts.data, @@ -243,7 +252,7 @@ export async function listMessages(opts: { const res = await client.search({ index: indexMessages.index, size: opts.limit, - sort: [{ createdAt: 'desc' }, '_score'], + sort: [{ createdAt: 'desc' }, 'id'], track_total_hits: true, query }); diff --git a/packages/server/lib/controllers/v1/logs/searchOperations.integration.test.ts b/packages/server/lib/controllers/v1/logs/searchOperations.integration.test.ts index 4bc77d11cd..674a533672 100644 --- a/packages/server/lib/controllers/v1/logs/searchOperations.integration.test.ts +++ b/packages/server/lib/controllers/v1/logs/searchOperations.integration.test.ts @@ -71,9 +71,9 @@ describe('POST /logs/operations', () => { isSuccess(res.json); expect(res.res.status).toBe(200); - expect(res.json).toStrictEqual({ + expect(res.json).toStrictEqual({ data: [], - pagination: { total: 0 } + pagination: { total: 0, cursor: null } }); }); @@ -135,7 +135,7 @@ describe('POST /logs/operations', () => { userId: null } ], - pagination: { total: 1 } + pagination: { total: 1, cursor: null } }); }); @@ -161,7 +161,7 @@ describe('POST /logs/operations', () => { expect(res.res.status).toBe(200); expect(res.json).toStrictEqual({ data: [], - pagination: { total: 0 } + pagination: { total: 0, cursor: null } }); }); }); diff --git a/packages/server/lib/controllers/v1/logs/searchOperations.ts b/packages/server/lib/controllers/v1/logs/searchOperations.ts index eaba64f867..693ac4b796 100644 --- a/packages/server/lib/controllers/v1/logs/searchOperations.ts +++ b/packages/server/lib/controllers/v1/logs/searchOperations.ts @@ -6,9 +6,10 @@ import { model, envs } from '@nangohq/logs'; const validation = z .object({ - limit: z.number().optional().default(100), + limit: z.number().max(500).optional().default(100), states: z .array(z.enum(['all', 'waiting', 'running', 'success', 'failed', 'timeout', 'cancelled'])) + .max(10) .optional() .default(['all']), types: z @@ -31,12 +32,14 @@ const validation = z 'webhook' ]) ) + .max(20) .optional() .default(['all']), - integrations: z.array(z.string()).optional().default(['all']), - connections: z.array(z.string()).optional().default(['all']), - syncs: z.array(z.string()).optional().default(['all']), - period: z.object({ from: z.string().datetime(), to: z.string().datetime() }).optional() + integrations: z.array(z.string()).max(10).optional().default(['all']), + connections: z.array(z.string()).max(10).optional().default(['all']), + syncs: z.array(z.string()).max(10).optional().default(['all']), + period: z.object({ from: z.string().datetime(), to: z.string().datetime() }).optional(), + cursor: z.string().or(z.null()).optional() }) .strict(); @@ -71,11 +74,12 @@ export const searchOperations = asyncWrapper(async (req, res) integrations: body.integrations, connections: body.connections, syncs: body.syncs, - period: body.period + period: body.period, + cursor: body.cursor }); res.status(200).send({ data: rawOps.items, - pagination: { total: rawOps.count } + pagination: { total: rawOps.count, cursor: rawOps.cursor } }); }); diff --git a/packages/types/lib/logs/api.ts b/packages/types/lib/logs/api.ts index a73265290a..74957729ac 100644 --- a/packages/types/lib/logs/api.ts +++ b/packages/types/lib/logs/api.ts @@ -16,10 +16,11 @@ export type SearchOperations = Endpoint<{ connections?: SearchOperationsConnection[] | undefined; syncs?: SearchOperationsSync[] | undefined; period?: SearchOperationsPeriod | undefined; + cursor?: string | null | undefined; }; Success: { data: OperationRow[]; - pagination: { total: number }; + pagination: { total: number; cursor: string | null }; }; }>; export type SearchOperationsState = 'all' | MessageState; diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 46983b7f10..1a930c34a9 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-dropdown-menu": "2.0.6", "@radix-ui/react-icons": "1.3.0", "@radix-ui/react-popover": "1.0.7", + "@radix-ui/react-scroll-area": "1.0.5", "@radix-ui/react-tooltip": "1.0.7", "@sentry/react": "8.4.0", "@tailwindcss/forms": "0.5.3", diff --git a/packages/webapp/src/components/DebugMode.tsx b/packages/webapp/src/components/DebugMode.tsx index 5b62ab1e25..17baf0d14b 100644 --- a/packages/webapp/src/components/DebugMode.tsx +++ b/packages/webapp/src/components/DebugMode.tsx @@ -7,5 +7,5 @@ export const DebugMode: React.FC = () => { return null; } - return
Debug mode activated
; + return
Debug mode activated
; }; diff --git a/packages/webapp/src/components/LeftNavBar.tsx b/packages/webapp/src/components/LeftNavBar.tsx index c93499b4dd..8b6a88faf9 100644 --- a/packages/webapp/src/components/LeftNavBar.tsx +++ b/packages/webapp/src/components/LeftNavBar.tsx @@ -89,8 +89,8 @@ export default function LeftNavBar(props: LeftNavBarProps) { } return ( -
-
+
+
Nango diff --git a/packages/webapp/src/components/TopNavBar.tsx b/packages/webapp/src/components/TopNavBar.tsx index 61f5748061..7ccd67c15f 100644 --- a/packages/webapp/src/components/TopNavBar.tsx +++ b/packages/webapp/src/components/TopNavBar.tsx @@ -16,39 +16,37 @@ export default function NavBar() { }; return ( -
-
-
- {isHNDemo && ( - - This is a test account. Click{' '} - {' '} - to create a real account. - - )} -
- +
+
+ {isHNDemo && ( + + This is a test account. Click{' '} + {' '} + to create a real account. + + )} +
+
); diff --git a/packages/webapp/src/components/ui/ScrollArea.tsx b/packages/webapp/src/components/ui/ScrollArea.tsx new file mode 100644 index 0000000000..d0cb006d64 --- /dev/null +++ b/packages/webapp/src/components/ui/ScrollArea.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; +import { cn } from '../../utils/utils'; + +const ScrollArea = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, children, ...props }, ref) => ( + + {children} + + + + ) +); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/packages/webapp/src/hooks/useLogs.tsx b/packages/webapp/src/hooks/useLogs.tsx index b18683696a..115525650d 100644 --- a/packages/webapp/src/hooks/useLogs.tsx +++ b/packages/webapp/src/hooks/useLogs.tsx @@ -1,50 +1,72 @@ import type { GetOperation, SearchFilters, SearchMessages, SearchOperations } from '@nangohq/types'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import useSWR from 'swr'; import { swrFetcher } from '../utils/api'; -export function useSearchOperations(enabled: boolean, env: string, body: SearchOperations['Body']) { +export function useSearchOperations(env: string, body: SearchOperations['Body']) { const [loading, setLoading] = useState(false); const [data, setData] = useState(); const [error, setError] = useState(); + const signal = useRef(); + + async function manualFetch(cursor?: SearchOperations['Body']['cursor']) { + if (signal.current && !signal.current.signal.aborted) { + signal.current.abort(); + } - async function fetchData() { setLoading(true); + signal.current = new AbortController(); try { const res = await fetch(`/api/v1/logs/operations?env=${env}`, { method: 'POST', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' } + body: JSON.stringify({ ...body, cursor }), + headers: { 'Content-Type': 'application/json' }, + signal: signal.current.signal }); if (res.status !== 200) { - setData(undefined); - setError((await res.json()) as SearchOperations['Errors']); - return; + return { error: (await res.json()) as SearchOperations['Errors'] }; } - setError(undefined); - setData((await res.json()) as SearchOperations['Success']); + return { res: (await res.json()) as SearchOperations['Success'] }; } catch (err) { - setData(undefined); - setError(err as any); + if (err instanceof DOMException && err.ABORT_ERR) { + return; + } + return { error: err }; } finally { setLoading(false); } } - useEffect(() => { - if (enabled && !loading) { - void fetchData(); + async function fetchData(cursor?: SearchOperations['Body']['cursor']) { + const man = await manualFetch(cursor); + if (!man) { + return; + } + if (man.error) { + setData(undefined); + setError(man.error as any); + return; } - }, [enabled, env, body.limit, body.states, body.integrations, body.period, body.types, body.connections, body.syncs]); - function trigger() { - if (enabled && !loading) { - void fetchData(); + setError(undefined); + setData(man.res); + } + + // We trigger manually to control live refresh, infinite scroll + // useEffect(() => { + // if (enabled && !loading) { + // void fetchData(); + // } + // }, [enabled, env, body.limit, body.states, body.integrations, body.period, body.types, body.connections, body.syncs]); + + function trigger(cursor?: SearchOperations['Body']['cursor']) { + if (!loading) { + void fetchData(cursor); } } - return { data, error, loading, trigger }; + return { data, error, loading, trigger, manualFetch }; } export function useGetOperation(env: string, params: GetOperation['Params']) { diff --git a/packages/webapp/src/layout/DashboardLayout.tsx b/packages/webapp/src/layout/DashboardLayout.tsx index e7caf9147b..c26c8f1cb7 100644 --- a/packages/webapp/src/layout/DashboardLayout.tsx +++ b/packages/webapp/src/layout/DashboardLayout.tsx @@ -1,23 +1,32 @@ +import type { ClassValue } from 'clsx'; import { DebugMode } from '../components/DebugMode'; import type { LeftNavBarItems } from '../components/LeftNavBar'; import LeftNavBar from '../components/LeftNavBar'; import TopNavBar from '../components/TopNavBar'; +import { cn } from '../utils/utils'; interface DashboardLayoutI { children: React.ReactNode; selectedItem: LeftNavBarItems; - marginBottom?: number; + fullWidth?: boolean; + className?: ClassValue; } -export default function DashboardLayout({ children, selectedItem, marginBottom = 24 }: DashboardLayoutI) { +export default function DashboardLayout({ children, selectedItem, fullWidth = false, className }: DashboardLayoutI) { return ( -
- - -
+
+
+ +
+
-
-
{children}
+
+
+
+ +
+
+
{children}
diff --git a/packages/webapp/src/pages/Activity.tsx b/packages/webapp/src/pages/Activity.tsx index 9562fa2943..c378e0ccf0 100644 --- a/packages/webapp/src/pages/Activity.tsx +++ b/packages/webapp/src/pages/Activity.tsx @@ -363,7 +363,7 @@ export default function Activity() { if (error || logActivitiesError) { return ( - + ); @@ -371,14 +371,14 @@ export default function Activity() { if (!activities) { return ( - + ); } return ( - +
diff --git a/packages/webapp/src/pages/EnvironmentSettings.tsx b/packages/webapp/src/pages/EnvironmentSettings.tsx index 41ef230c39..41a1fac0a2 100644 --- a/packages/webapp/src/pages/EnvironmentSettings.tsx +++ b/packages/webapp/src/pages/EnvironmentSettings.tsx @@ -442,7 +442,7 @@ export const EnvironmentSettings: React.FC = () => { {secretKey && ( -
+

Environment Settings

diff --git a/packages/webapp/src/pages/Logs/Search.tsx b/packages/webapp/src/pages/Logs/Search.tsx index 35ef78845a..475de6ea73 100644 --- a/packages/webapp/src/pages/Logs/Search.tsx +++ b/packages/webapp/src/pages/Logs/Search.tsx @@ -2,53 +2,95 @@ import { LeftNavBarItems } from '../../components/LeftNavBar'; import DashboardLayout from '../../layout/DashboardLayout'; import { useStore } from '../../store'; import Info from '../../components/ui/Info'; -import { Loading } from '@geist-ui/core'; import { useSearchOperations } from '../../hooks/useLogs'; import * as Table from '../../components/ui/Table'; import { getCoreRowModel, useReactTable, flexRender } from '@tanstack/react-table'; import { MultiSelect } from './components/MultiSelect'; import { columns, integrationsDefaultOptions, statusDefaultOptions, statusOptions, syncsDefaultOptions, typesDefaultOptions } from './constants'; -import { useEffect, useMemo, useState } from 'react'; -import type { SearchOperationsIntegration, SearchOperationsPeriod, SearchOperationsState, SearchOperationsSync, SearchOperationsType } from '@nangohq/types'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { + SearchOperations, + SearchOperationsData, + SearchOperationsIntegration, + SearchOperationsPeriod, + SearchOperationsState, + SearchOperationsSync, + SearchOperationsType +} from '@nangohq/types'; import Spinner from '../../components/ui/Spinner'; -import { OperationRow } from './components/OperationRow'; // import { Input } from '../../components/ui/input/Input'; // import { MagnifyingGlassIcon } from '@radix-ui/react-icons'; import { formatQuantity } from '../../utils/utils'; import { Link, useSearchParams } from 'react-router-dom'; -import { useInterval } from 'react-use'; +import { useDebounce, useIntersection, useInterval } from 'react-use'; import { SearchableMultiSelect } from './components/SearchableMultiSelect'; import { TypesSelect } from './components/TypesSelect'; import { DatePicker } from './components/DatePicker'; +import Button from '../../components/ui/button/Button'; +import { OperationRow } from './components/OperationRow'; +import { Skeleton } from '../../components/ui/Skeleton'; + +const limit = 20; export const LogsSearch: React.FC = () => { const env = useStore((state) => state.env); const [searchParams, setSearchParams] = useSearchParams(); - // State - const [hasLogs, setHasLogs] = useState(false); + // --- Global state const [synced, setSynced] = useState(false); - // Data fetch + // --- Data fetch const [states, setStates] = useState(statusDefaultOptions); const [types, setTypes] = useState(typesDefaultOptions); const [integrations, setIntegrations] = useState(integrationsDefaultOptions); const [connections, setConnections] = useState(integrationsDefaultOptions); const [syncs, setSyncs] = useState(syncsDefaultOptions); const [period, setPeriod] = useState(); - const { data, error, loading, trigger } = useSearchOperations(synced, env, { limit: 20, states, types, integrations, connections, syncs, period }); + const cursor = useRef(); + const [hasLoadedMore, setHasLoadedMore] = useState(false); + const [readyToDisplay, setReadyToDisplay] = useState(false); + const { data, error, loading, trigger, manualFetch } = useSearchOperations(env, { limit, states, types, integrations, connections, syncs, period }); + const [operations, setOperations] = useState([]); + useEffect(() => { + // Data aggregation to enable infinite scroll + // Because states are changing we need to deduplicate and update rows + setOperations((prev) => { + if (prev.length <= 0 || !data?.data) { + return data?.data || []; + } - const table = useReactTable({ - data: data ? data.data : [], - columns, - getCoreRowModel: getCoreRowModel() - }); + const next = data.data; + for (const item of prev) { + if (next.find((n) => n.id === item.id)) { + continue; + } + next.push(item); + } - const isLive = useMemo(() => { - return !period; - }, [period]); + return next; + }); + setReadyToDisplay(true); + }, [data?.data]); + useEffect(() => { + if (data?.pagination.cursor && !hasLoadedMore) { + // We set the cursor only on first page (if we haven't hit a next page) + // Otherwise the live refresh will erase + cursor.current = data.pagination.cursor; + } + }, [hasLoadedMore, data]); + useDebounce( + () => { + // We clear the cursor because it's a brand new search + cursor.current = null; + // Debounce the trigger to avoid spamming the backend and avoid conflict with rapid filter change + trigger(); + }, + 200, + [limit, states, types, integrations, connections, syncs, period] + ); + // --- Query Params useEffect( function syncQueryParamsToState() { // Sync the query params to the react state, it allows to share the URL @@ -58,23 +100,35 @@ export const LogsSearch: React.FC = () => { } const tmpStates = searchParams.get('states'); - setStates(tmpStates ? (tmpStates.split(',') as any) : statusDefaultOptions); + if (tmpStates) { + setStates(tmpStates.split(',') as any); + } const tmpIntegrations = searchParams.get('integrations'); - setIntegrations(tmpIntegrations ? (tmpIntegrations.split(',') as any) : integrationsDefaultOptions); + if (tmpIntegrations) { + setIntegrations(tmpIntegrations.split(',') as any); + } const tmpConnections = searchParams.get('integrations'); - setIntegrations(tmpConnections ? (tmpConnections.split(',') as any) : integrationsDefaultOptions); + if (tmpConnections) { + setIntegrations(tmpConnections.split(',') as any); + } const tmpSyncs = searchParams.get('syncs'); - setSyncs(tmpSyncs ? (tmpSyncs.split(',') as any) : syncsDefaultOptions); + if (tmpSyncs) { + setSyncs(tmpSyncs.split(',') as any); + } const tmpTypes = searchParams.get('types'); - setTypes(tmpTypes ? (tmpTypes.split(',') as any) : typesDefaultOptions); + if (tmpTypes) { + setTypes(tmpTypes.split(',') as any); + } const tmpFrom = searchParams.get('from'); const tmpTo = searchParams.get('to'); - setPeriod(tmpFrom && tmpTo ? { from: tmpFrom, to: tmpTo } : undefined); + if (tmpFrom && tmpTo) { + setPeriod({ from: tmpFrom, to: tmpTo }); + } setSynced(true); }, @@ -83,6 +137,11 @@ export const LogsSearch: React.FC = () => { useEffect( function syncStateToQueryParams() { + // reset pagination and stored items + setOperations([]); + setHasLoadedMore(false); + setReadyToDisplay(false); + // Sync the state back to the URL for sharing const tmp = new URLSearchParams({ states: states as any, @@ -100,32 +159,70 @@ export const LogsSearch: React.FC = () => { [states, integrations, period, connections, syncs, types] ); - useEffect(() => { - if (!loading) { - // We set this so it does not flicker when we go from a state of "filtered no records" to "default with records"... - // ...to not redisplay the empty state - setHasLogs(true); + // --- Table Display + const table = useReactTable({ + data: operations, + columns, + getCoreRowModel: getCoreRowModel() + }); + const totalHumanReadable = useMemo(() => { + if (!data?.pagination) { + return 0; } - }, [loading]); + return formatQuantity(data.pagination.total); + }, [data?.pagination]); + // --- Live // auto refresh + const isLive = useMemo(() => { + return !period; + }, [period]); useInterval( - () => { - // Auto refresh + function onAutoRefresh() { trigger(); }, - synced && isLive ? 10000 : null + synced && isLive && !loading ? 7000 : null ); - const total = useMemo(() => { - if (!data?.pagination) { - return 0; + // --- Infinite scroll + // We use the cursor manually because we want to keep refreshing the head even we add stuff to the tail + const bottomScrollRef = useRef(null); + const bottomScroll = useIntersection(bottomScrollRef, { + root: null, + rootMargin: '0px', + threshold: 1 + }); + const appendItems = async () => { + if (!cursor.current) { + return; } - return formatQuantity(data.pagination.total); - }, [data?.pagination]); + const rows = await manualFetch(cursor.current); + if (!rows || 'error' in rows) { + return; + } + + setHasLoadedMore(true); + cursor.current = rows.res.pagination.cursor; + setOperations((prev) => [...prev, ...rows.res.data]); + }; + useEffect(() => { + // when the load more button is fully in view + if (!bottomScroll || !bottomScroll.isIntersecting) { + return; + } + if (cursor.current && !loading) { + void appendItems(); + } + }, [bottomScroll, loading, bottomScrollRef]); + + const loadMore = () => { + if (!loading) { + void appendItems(); + } + }; if (error) { return ( - +

Logs

{error.error.code === 'feature_disabled' ? (
@@ -147,32 +244,25 @@ export const LogsSearch: React.FC = () => { ); } - if ((loading && !data) || !data) { - return ( - - - - ); - } - - if (data.pagination.total <= 0 && !hasLogs) { + if (!synced) { return ( - +

Logs

-
-

You don't have logs yet.

-
Note that logs older than 15 days are automatically cleared.
+
+ + +
); } return ( - +

Logs {loading && }

-
{total} logs found
+
{totalHumanReadable} logs found
@@ -212,16 +302,39 @@ export const LogsSearch: React.FC = () => { {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ) - ) : ( + table.getRowModel().rows.map((row) => ) + ) : operations.length <= 0 && !loading && readyToDisplay ? ( No results. + ) : ( + + {table.getAllColumns().map((col, i) => { + return ( + + + + ); + })} + )} + {data && data.pagination.total > 0 && data.data.length > 0 && cursor.current && readyToDisplay && ( +
+ +
+ )} ); }; diff --git a/packages/webapp/src/pages/Logs/ShowMessage.tsx b/packages/webapp/src/pages/Logs/ShowMessage.tsx index 458ab771ba..1ef2e5fb73 100644 --- a/packages/webapp/src/pages/Logs/ShowMessage.tsx +++ b/packages/webapp/src/pages/Logs/ShowMessage.tsx @@ -47,6 +47,7 @@ export const ShowMessage: React.FC<{ message: MessageRow }> = ({ message }) => { styles={() => { return { code: { padding: '0', whiteSpace: 'pre-wrap' } }; }} + noCopy > {message.message} diff --git a/packages/webapp/src/pages/Logs/components/DatePicker.tsx b/packages/webapp/src/pages/Logs/components/DatePicker.tsx index 5b8d8d1527..ac8a3bb185 100644 --- a/packages/webapp/src/pages/Logs/components/DatePicker.tsx +++ b/packages/webapp/src/pages/Logs/components/DatePicker.tsx @@ -57,19 +57,23 @@ export const DatePicker: React.FC<{ const [selectedPreset, setSelectedPreset] = useState(undefined); const [date, setDate] = useState(); + const [tmpDate, setTmpDate] = useState(); const months = useMemo(() => { const today = new Date(); return today.getDate() < 24 ? 2 : 1; }, []); + const disabledBefore = useMemo(() => { return addDays(new Date(), -14); }, []); + const disabledAfter = useMemo(() => { return new Date(); }, []); + const display = useMemo(() => { - if (!date || !date.from) { + if (!date || !date.from || !date.to) { return 'Live - Last 14 days'; } if (date.from && date.to) { @@ -82,20 +86,29 @@ export const DatePicker: React.FC<{ const range = getPresetRange(preset); setSelectedPreset(preset); onChange(range); + setTmpDate(range); }; const onClickLive = () => { setSelectedPreset(undefined); onChange(undefined); + setTmpDate(undefined); }; useEffect(() => { setDate(period ? { from: new Date(period.from), to: new Date(period.to) } : undefined); }, [period]); + useEffect(() => { + // We use a tmp date because we only want to commit full range, not partial from/to + if (!tmpDate || (tmpDate.from && tmpDate.to)) { + onChange(tmpDate); + } + }, [tmpDate]); + return ( - +