From b3972ffd92e0777836d67f6b88ef76d0f5682043 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Tue, 8 Jul 2025 17:54:41 -0300 Subject: [PATCH 01/15] feat: App Logs Export --- .../AppDetailsPage/tabs/AppLogs/AppLogs.tsx | 9 +- .../tabs/AppLogs/AppLogsItem.tsx | 6 +- .../tabs/AppLogs/Filters/AppLogsFilter.tsx | 31 +++- .../Filters/AppLogsFilterCompact.spec.tsx | 1 - .../AppLogsFilterContextualBar.spec.tsx | 1 - .../Filters/AppLogsFilterExpanded.spec.tsx | 1 - .../Filters/AppsLogsFilterOptionsCompact.tsx | 26 +++ .../AppLogs/Filters/DateTimeModal.spec.tsx | 1 - .../tabs/AppLogs/Filters/ExportLogsModal.tsx | 155 ++++++++++++++++++ yarn.lock | 25 +++ 10 files changed, 242 insertions(+), 14 deletions(-) create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx index baee5a9cc3d7a..66edf40d57774 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx @@ -25,7 +25,7 @@ const AppLogs = ({ id }: { id: string }): ReactElement => { const debouncedEvent = useDebouncedValue(event, 500); - const { data, isSuccess, isError, isFetching, error } = useLogs({ + const { data, isSuccess, isError, error, isFetching } = useLogs({ appId: id, current, itemsPerPage, @@ -50,13 +50,12 @@ const AppLogs = ({ id }: { id: string }): ReactElement => { return ( <> - + {isFetching && } {isError && } - {isSuccess && data?.logs?.length === 0 ? ( - - ) : ( + {!isFetching && isSuccess && data?.logs?.length === 0 && } + {!isFetching && isSuccess && data?.logs?.length > 0 && ( {data?.logs?.map((log, index) => )} diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.tsx index 9a13b40b81a26..7bb6f9b65a04c 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogsItem.tsx @@ -32,13 +32,17 @@ const AppLogsItem = ({ regionId, ...props }: AppLogsItemProps) => { ); + const handleClick = () => { + setExpanded(!expanded); + }; + const anchorRef = useRef(null); const formatDateAndTime = useFormatDateAndTime(); return ( <> - setExpanded(!expanded)}> + {title} diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx index 2f61a71071a19..e2d5b35a7b32b 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx @@ -1,18 +1,26 @@ -import { Box, Button, Icon, Label, Palette, TextInput } from '@rocket.chat/fuselage'; -import { useRouter } from '@rocket.chat/ui-contexts'; +import { Box, Button, Icon, IconButton, Label, Palette, TextInput } from '@rocket.chat/fuselage'; +import { useRouter, useSetModal } from '@rocket.chat/ui-contexts'; import { Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import CompactFilterOptions from './AppsLogsFilterOptionsCompact'; import { InstanceFilterSelect } from './InstanceFilterSelect'; import { SeverityFilterSelect } from './SeverityFilterSelect'; import { TimeFilterSelect } from './TimeFilterSelect'; import { useCompactMode } from '../../../useCompactMode'; import { useAppLogsFilterFormContext } from '../useAppLogsFilterForm'; +import { ExportLogsModal } from './ExportLogsModal'; -export const AppLogsFilter = () => { +type AppsLogsFilterProps = { + isLoading: boolean; +}; + +export const AppLogsFilter = ({ isLoading }: AppsLogsFilterProps) => { const { t } = useTranslation(); - const { control } = useAppLogsFilterFormContext(); + const { control, getValues } = useAppLogsFilterFormContext(); + + const setModal = useSetModal(); const router = useRouter(); @@ -26,6 +34,10 @@ export const AppLogsFilter = () => { ); }; + const openExportModal = () => { + setModal( setModal(null)} filterValues={getValues()} />); + }; + const compactMode = useCompactMode(); return ( @@ -70,11 +82,22 @@ export const AppLogsFilter = () => { } /> )} + {!compactMode && ( + openExportModal()} + /> + )} {compactMode && ( )} + {compactMode && } ); }; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx index 6bf622b6b19f1..9ca525a51febd 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterCompact.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { composeStories } from '@storybook/react'; import { render, screen } from '@testing-library/react'; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx index 5d1c1f9f3f9e5..67572297984cb 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterContextualBar.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { composeStories } from '@storybook/react'; import { render, screen } from '@testing-library/react'; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx index 842a4e9f00c10..073504af83d87 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilterExpanded.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { composeStories } from '@storybook/react'; import { render, screen } from '@testing-library/react'; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx new file mode 100644 index 0000000000000..ce58dbd8c08e7 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx @@ -0,0 +1,26 @@ +import { Box, Icon, Menu } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; + +type CompactFilterOptionsProps = { + handleExportLogs: () => void; + isLoading: boolean; +}; + +const CompactFilterOptions = ({ handleExportLogs, ...props }: CompactFilterOptionsProps) => { + const t = useTranslation(); + + const menuOptions = { + exportLogs: { + label: ( + + + {t('Export')} + + ), + action: handleExportLogs, + }, + }; + return ; +}; + +export default CompactFilterOptions; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx index e925f0f3191ea..75497569ea2ae 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/DateTimeModal.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import { mockAppRoot } from '@rocket.chat/mock-providers'; import { composeStories } from '@storybook/react'; import { render, screen } from '@testing-library/react'; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx new file mode 100644 index 0000000000000..1509ff73c6a07 --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx @@ -0,0 +1,155 @@ +import { Box, Button, Field, FieldLabel, FieldRow, Label, Modal, NumberInput, RadioButton } from '@rocket.chat/fuselage'; +import { useEndpoint, useRouteParameter } from '@rocket.chat/ui-contexts'; +import type { ReactNode } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { AppLogsFilterFormData } from '../useAppLogsFilterForm'; + +type ExportLogsModalProps = { + onClose: () => void; + filterValues: AppLogsFilterFormData; +}; + +type FormDataType = { + type: 'json' | 'csv'; + count: 'max' | 'custom'; + customExportAmount: number; +}; + +export const ExportLogsModal = ({ onClose, filterValues }: ExportLogsModalProps): ReactNode => { + const { t } = useTranslation(); + + const appId = useRouteParameter('id'); + + const { control, watch } = useForm({ + defaultValues: { + type: 'json', + count: 'max', + customExportAmount: 100, + }, + }); + + const formData = watch(); + + console.log(formData); + + const getExportedFile = useEndpoint('GET', `/apps/:id/export-logs`, { id: appId } as { id: string }); + + const handleConfirm = (): void => { + const { severity, event: method, startDate, endDate } = filterValues; + const logLevel = severity === 'all' ? undefined : severity; + getExportedFile({ + logLevel, + method, + startDate, + endDate, + count: formData.count === 'max' ? 2000 : formData.customExportAmount, + type: formData.type, + }); + }; + + return ( + + + {t('Export')} + + + + + + + + {t('JSON')} + ( + onChange('json')} + aria-describedby='JSONField' + checked={value === 'json'} + /> + )} + /> + + + + + {t('CSV')} + ( + onChange('csv')} + aria-describedby='plainTextField' + checked={value === 'csv'} + /> + )} + /> + + + + + + + + {t('Max_logs_export')} + ( + onChange('max')} + aria-describedby='maxLogsExport' + checked={value === 'max'} + /> + )} + /> + + + + + {t('Limit_number_of_logs')} + ( + onChange('custom')} + aria-describedby='customMaxLogs' + checked={value === 'custom'} + /> + )} + /> + + ( + + )} + /> + + + + + + + + + + + ); +}; diff --git a/yarn.lock b/yarn.lock index c1361a8e7c6ca..dd42797ad4af8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8169,6 +8169,7 @@ __metadata: jschardet: "npm:^3.1.4" jsdom: "npm:^26.1.0" jsdom-global: "npm:^3.0.2" + json-2-csv: "npm:^5.5.9" jsrsasign: "npm:^11.1.0" juice: "npm:^8.1.0" katex: "npm:~0.16.22" @@ -17754,6 +17755,13 @@ __metadata: languageName: node linkType: hard +"deeks@npm:3.1.0": + version: 3.1.0 + resolution: "deeks@npm:3.1.0" + checksum: 10/4297893cb792bc207b1fdac6090094c8c666669227b385bc69d98ea751294614948288a39a38faa0a44d7045d1bf05eacd188eea7516a1a7d0fee9a7faa34e7d + languageName: node + linkType: hard + "deep-eql@npm:^4.1.3": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" @@ -18147,6 +18155,13 @@ __metadata: languageName: node linkType: hard +"doc-path@npm:4.1.1": + version: 4.1.1 + resolution: "doc-path@npm:4.1.1" + checksum: 10/455dd7458d4fa9ec0662fc307c4c3a7112bd0d554b8a6e0e86a6c6554f125727b4f80af430c1038f5d471ed5586c95354260928a31b80e632519480d745af713 + languageName: node + linkType: hard + "docker-compose@npm:^0.24.8": version: 0.24.8 resolution: "docker-compose@npm:0.24.8" @@ -24746,6 +24761,16 @@ __metadata: languageName: node linkType: hard +"json-2-csv@npm:^5.5.9": + version: 5.5.9 + resolution: "json-2-csv@npm:5.5.9" + dependencies: + deeks: "npm:3.1.0" + doc-path: "npm:4.1.1" + checksum: 10/fa80a1a99e9c7e6844e6e13a48e3d0f11e6e98e90dc46e47fdc46b0fde2ecef324f8e13c68e11dbd0645f714c5089dc46e605a22f25c659294347a3426506947 + languageName: node + linkType: hard + "json-bigint@npm:^1.0.0": version: 1.0.0 resolution: "json-bigint@npm:1.0.0" From a16de3f28eea37ec67c7b32934ee293c96722d27 Mon Sep 17 00:00:00 2001 From: Douglas Gubert Date: Mon, 9 Jun 2025 15:53:16 -0300 Subject: [PATCH 02/15] feat: export logs endpoint --- .changeset/serious-apricots-compare.md | 7 + .../endpoints/appGeneralLogsHandler.ts | 2 +- .../endpoints/appLogsExportHandler.ts | 126 ++++++ .../communication/endpoints/appLogsHandler.ts | 2 +- .../ee/server/apps/communication/rest.ts | 2 + .../server/apps/storage/AppRealLogStorage.ts | 9 + apps/meteor/package.json | 3 +- .../tests/end-to-end/apps/app-logs-export.ts | 380 ++++++++++++++++++ .../src/server/storage/AppLogStorage.ts | 2 +- .../tests/test-data/storage/logStorage.ts | 2 +- .../src/apps/appLogsExportProps.ts | 23 ++ packages/rest-typings/src/apps/index.ts | 6 + yarn.lock | 17 + 13 files changed, 576 insertions(+), 5 deletions(-) create mode 100644 .changeset/serious-apricots-compare.md create mode 100644 apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts create mode 100644 apps/meteor/tests/end-to-end/apps/app-logs-export.ts create mode 100644 packages/rest-typings/src/apps/appLogsExportProps.ts diff --git a/.changeset/serious-apricots-compare.md b/.changeset/serious-apricots-compare.md new file mode 100644 index 0000000000000..103fa6644a6a2 --- /dev/null +++ b/.changeset/serious-apricots-compare.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/apps-engine': minor +'@rocket.chat/meteor': minor +--- + +Adds an endpoint to export apps logs as files diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts index 58dcd0abe9226..e6db29256b6ce 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appGeneralLogsHandler.ts @@ -27,7 +27,7 @@ export const registerAppGeneralLogsHandler = ({ api, _orch }: AppsRestApi) => return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); } - const result = await _orch.getLogStorage().find(query, options); + const result = await _orch.getLogStorage().findPaginated(query, options); return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total }); }, diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts new file mode 100644 index 0000000000000..f9c1e9bcacbda --- /dev/null +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts @@ -0,0 +1,126 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { isAppLogsExportProps } from '@rocket.chat/rest-typings'; +import { ajv } from '@rocket.chat/rest-typings/src/v1/Ajv'; +import { parse } from 'cookie'; +import { json2csv } from 'json-2-csv'; + +import { getPaginationItems } from '../../../../../app/api/server/helpers/getPaginationItems'; +import type { AppsRestApi } from '../rest'; +import { makeAppLogsQuery } from './lib/makeAppLogsQuery'; +import { APIClass } from '../../../../../app/api/server/ApiClass'; + +const isErrorResponse = ajv.compile({ + type: 'object', + properties: { + success: { + type: 'boolean', + enum: [false], + }, + error: { + type: 'string', + }, + }, +}); + +class ExportHandlerAPI extends APIClass { + protected async authenticatedRoute(req: Request): Promise { + const { rc_uid, rc_token } = parse(req.headers.get('cookie') || ''); + + if (rc_uid) { + req.headers.set('x-user-id', rc_uid); + } + + if (rc_token) { + req.headers.set('x-auth-token', rc_token); + } + + return super.authenticatedRoute(req); + } +} + +const adhocApi = new ExportHandlerAPI({ + useDefaultAuth: false, + prettyJson: process.env.NODE_ENV !== 'development', +}); + +export const registerAppLogsExportHandler = ({ api, _manager, _orch }: AppsRestApi) => { + adhocApi.get( + ':id/export-logs', + { + authRequired: true, + permissionsRequired: ['manage-apps'], + query: isAppLogsExportProps, + response: { + 200: ajv.compile({ + type: 'object', + properties: { + body: { + type: 'string', + format: 'binary', + description: 'The content of the exported logs file, either in JSON or CSV format.', + }, + }, + }), + 400: isErrorResponse, + 401: isErrorResponse, + 404: isErrorResponse, + }, + }, + + async function () { + const proxiedApp = _manager.getOneById(this.urlParams.id); + + if (!proxiedApp) { + return api.notFound(`No App found by the id of: ${this.urlParams.id}`); + } + + const { count } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const options = { + sort: sort || { _updatedAt: -1 }, + skip: 0, + limit: Math.min(count || 100, 2000), + }; + + let query: ReturnType; + + try { + query = makeAppLogsQuery({ appId: this.urlParams.id, ...this.queryParams }); + } catch (error) { + return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); + } + + const result = await _orch.getLogStorage().find(query, options); + + if (!result.length) { + return api.failure({ error: 'No logs found for the specified criteria' }); + } + + let fileContent: Buffer; + let filename: string; + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + + if (this.queryParams.type === 'json') { + fileContent = Buffer.from(JSON.stringify(result, null, 2), 'utf8'); + filename = `app-logs-${this.urlParams.id}-${timestamp}.json`; + } else { + fileContent = Buffer.from(json2csv(result, { expandArrayObjects: true }), 'utf8'); + filename = `app-logs-${this.urlParams.id}-${timestamp}.csv`; + } + + return { + body: fileContent, + statusCode: 200, + headers: { + // 'application/json' here creates problems down the line with the router + 'Content-Type': 'text/plain', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Content-Length': fileContent.length.toString(), + }, + }; + }, + ); + + api.router.use(adhocApi.router); +}; diff --git a/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts b/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts index df30b4a1b28c7..b1e0103c13fa6 100644 --- a/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts +++ b/apps/meteor/ee/server/apps/communication/endpoints/appLogsHandler.ts @@ -37,7 +37,7 @@ export const registerAppLogsHandler = ({ api, _manager, _orch }: AppsRestApi) => return api.failure({ error: error instanceof Error ? error.message : 'Unknown error' }); } - const result = await _orch.getLogStorage().find(query, options); + const result = await _orch.getLogStorage().findPaginated(query, options); return api.success({ offset, logs: result.logs, count: result.logs.length, total: result.total }); }, diff --git a/apps/meteor/ee/server/apps/communication/rest.ts b/apps/meteor/ee/server/apps/communication/rest.ts index 76ef4bc3b7d8e..428b132c7abbd 100644 --- a/apps/meteor/ee/server/apps/communication/rest.ts +++ b/apps/meteor/ee/server/apps/communication/rest.ts @@ -13,6 +13,7 @@ import { ZodError } from 'zod'; import { registerActionButtonsHandler } from './endpoints/actionButtonsHandler'; import { registerAppGeneralLogsHandler } from './endpoints/appGeneralLogsHandler'; +import { registerAppLogsExportHandler } from './endpoints/appLogsExportHandler'; import { registerAppLogsHandler } from './endpoints/appLogsHandler'; import { registerAppsCountHandler } from './endpoints/appsCountHandler'; import { API } from '../../../../app/api/server'; @@ -108,6 +109,7 @@ export class AppsRestApi { registerAppsCountHandler(this); registerAppLogsHandler(this); + registerAppLogsExportHandler(this); registerAppGeneralLogsHandler(this); this.api.addRoute( diff --git a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts index 87a009608e175..82fbf49a5ebc6 100644 --- a/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts +++ b/apps/meteor/ee/server/apps/storage/AppRealLogStorage.ts @@ -14,6 +14,15 @@ export class AppRealLogStorage extends AppLogStorage { [field: string]: any; }, options: IAppLogStorageFindOptions, + ) { + return this.db.find(query, options).toArray(); + } + + async findPaginated( + query: { + [field: string]: any; + }, + options: IAppLogStorageFindOptions, ) { const { cursor, totalCount } = this.db.findPaginated(query, options); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 1eaa640632191..84e647a6215a6 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -370,7 +370,8 @@ "is-svg": "^5.1.0", "isolated-vm": "5.0.4", "jschardet": "^3.1.4", - "jsdom": "^26.1.0", + "jsdom": "^26.0.0", + "json-2-csv": "^5.5.9", "jsrsasign": "^11.1.0", "juice": "^8.1.0", "katex": "~0.16.22", diff --git a/apps/meteor/tests/end-to-end/apps/app-logs-export.ts b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts new file mode 100644 index 0000000000000..59ed69ff591b4 --- /dev/null +++ b/apps/meteor/tests/end-to-end/apps/app-logs-export.ts @@ -0,0 +1,380 @@ +import type { ILoggerStorageEntry } from '@rocket.chat/apps-engine/server/logging'; +import type { App } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { after, before, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { apps } from '../../data/apps/apps-data'; +import { installTestApp, cleanupApps } from '../../data/apps/helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Apps - Logs Export', () => { + let app: App; + + before((done) => getCredentials(done)); + + before(async () => { + await cleanupApps(); + app = await installTestApp(); + }); + + after(() => cleanupApps()); + + it('should throw an error when trying to export logs for an invalid app', (done) => { + void request + .get(apps('/invalid-id/export-logs')) + .query({ type: 'json' }) + .set(credentials) + .expect(404) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No App found by the id of: invalid-id'); + }) + .end(done); + }); + + it('should export app logs as JSON successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.json'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid JSON + expect(() => JSON.parse(res.text)).to.not.throw(); + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs[0]).to.have.a.property('_id'); + expect(logs[0]).to.have.a.property('appId', app.id); + }) + .end(done); + }); + + it('should export app logs as CSV successfully', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'csv' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-disposition'); + expect(res.headers['content-disposition']).to.include('attachment'); + expect(res.headers['content-disposition']).to.include(`app-logs-${app.id}`); + expect(res.headers['content-disposition']).to.include('.csv'); + expect(res.headers).to.have.a.property('content-length'); + + // Verify the content is valid CSV (should have headers and at least one row) + const lines = res.text.split('\n'); + expect(lines.length).to.be.at.least(1); // At least header row + if (lines.length > 1 && lines[0]) { + // Check that headers contain expected fields + expect(lines[0]).to.include('_id'); + expect(lines[0]).to.include('appId'); + } + }) + .end(done); + }); + + it('should throw error when type is not specified', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .set(credentials) + .expect(400) + .end(done); + }); + + it('should export app logs with pagination parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(5); + }) + .end(done); + }); + + it('should export app logs with sorting parameters', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + sort: JSON.stringify({ _updatedAt: 1 }), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + // Verify sorting by checking if timestamps are in ascending order + if (logs.length > 1) { + for (let i = 1; i < logs.length; i++) { + const prevTime = new Date(logs[i - 1]._updatedAt).getTime(); + const currTime = new Date(logs[i]._updatedAt).getTime(); + expect(currTime).to.be.at.least(prevTime); + } + } + }) + .end(done); + }); + + it('should export app logs filtered by logLevel', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: '2', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should export app logs filtered by method', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ method: 'app:construct', type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + }); + }) + .end(done); + }); + + it('should export app logs filtered by date range', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + + // Verify that all returned logs are within the date range + logs.forEach((log: ILoggerStorageEntry) => { + const logDate = new Date(log._createdAt); + expect(logDate).to.be.above(startDate).and.below(endDate); + }); + }) + .end(done); + }); + + it('should export app logs with combined filters', (done) => { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - 1); // 1 day ago + const endDate = new Date(); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + logLevel: '2', + method: 'app:construct', + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + type: 'json', + count: 10, + }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(10); + + // Verify that all returned logs match all filter criteria + logs.forEach((log: ILoggerStorageEntry) => { + expect(log.method).to.equal('app:construct'); + + const logDate = new Date(log._createdAt); + expect(logDate >= startDate && logDate <= endDate).to.be.true; + + const entry = log.entries.find((entry) => ['error', 'warn', 'info', 'log', 'debug', 'success'].includes(entry.severity)); + expect(entry).to.exist; + }); + }) + .end(done); + }); + + it('should return an error when no logs are found for the specified criteria', (done) => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: futureDate.toISOString(), + type: 'json', + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body.error).to.be.equal('No logs found for the specified criteria'); + }) + .end(done); + }); + + it('should respect the maximum limit of 2000 logs', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ count: 5000, type: 'json' }) + .set(credentials) + .expect('Content-Type', 'text/plain') + .expect(200) + .expect((res) => { + const logs = JSON.parse(res.text); + expect(logs).to.be.an('array'); + expect(logs.length).to.be.at.most(2000); + }) + .end(done); + }); + + it('should reject invalid logLevel value', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ logLevel: 'debug' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date format', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ startDate: 'invalid-date' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid date range', (done) => { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() - 1); // endDate before startDate + + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should reject invalid additional properties', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ invalidProperty: 'value' }) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.a.property('success', false); + expect(res.body).to.have.a.property('error').that.is.not.empty; + }) + .end(done); + }); + + it('should handle authentication requirement', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .expect(401) + .end(done); + }); + + it('should handle authentication via cookie', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set({ cookie: `rc_uid=${credentials['X-User-Id']}; rc_token=${credentials['X-Auth-Token']}` }) + .expect(200) + .end(done); + }); + + it('should include proper filename with timestamp in Content-Disposition header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + const contentDisposition = res.headers['content-disposition']; + expect(contentDisposition).to.include('attachment'); + expect(contentDisposition).to.include(`app-logs-${app.id}`); + expect(contentDisposition).to.include('.json'); + + // Check that timestamp is included (format: YYYY-MM-DDTHH-MM-SS) + const timestampRegex = /\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/; + expect(contentDisposition).to.match(timestampRegex); + }) + .end(done); + }); + + it('should include Content-Length header', (done) => { + void request + .get(apps(`/${app.id}/export-logs`)) + .query({ type: 'json' }) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.headers).to.have.a.property('content-length'); + const contentLength = parseInt(res.headers['content-length'], 10); + expect(contentLength).to.be.a('number'); + expect(contentLength).to.be.greaterThan(0); + expect(contentLength).to.equal(Buffer.byteLength(res.text, 'utf8')); + }) + .end(done); + }); +}); diff --git a/packages/apps-engine/src/server/storage/AppLogStorage.ts b/packages/apps-engine/src/server/storage/AppLogStorage.ts index b7a8f355cbcd0..8a3487a5587be 100644 --- a/packages/apps-engine/src/server/storage/AppLogStorage.ts +++ b/packages/apps-engine/src/server/storage/AppLogStorage.ts @@ -14,7 +14,7 @@ export abstract class AppLogStorage { return this.engine; } - public abstract find( + public abstract findPaginated( query: { [field: string]: any }, options?: IAppLogStorageFindOptions, ): Promise<{ logs: ILoggerStorageEntry[]; total: number }>; diff --git a/packages/apps-engine/tests/test-data/storage/logStorage.ts b/packages/apps-engine/tests/test-data/storage/logStorage.ts index 898a8f6f2f0d2..5841d02385c44 100644 --- a/packages/apps-engine/tests/test-data/storage/logStorage.ts +++ b/packages/apps-engine/tests/test-data/storage/logStorage.ts @@ -7,7 +7,7 @@ export class TestsAppLogStorage extends AppLogStorage { super('nothing'); } - public find( + public findPaginated( query: { [field: string]: any }, options?: IAppLogStorageFindOptions, ): Promise<{ logs: ILoggerStorageEntry[]; total: number }> { diff --git a/packages/rest-typings/src/apps/appLogsExportProps.ts b/packages/rest-typings/src/apps/appLogsExportProps.ts new file mode 100644 index 0000000000000..9c1ecd6c9a33e --- /dev/null +++ b/packages/rest-typings/src/apps/appLogsExportProps.ts @@ -0,0 +1,23 @@ +import type { AppLogsProps } from './appLogsProps'; +import { ajv } from '../v1/Ajv'; + +export type AppLogsExportProps = Omit & { + type: 'json' | 'csv'; +}; + +const AppLogsExportPropsSchema = { + type: 'object', + properties: { + logLevel: { type: 'string', enum: ['0', '1', '2'], nullable: true }, + method: { type: 'string', nullable: true }, + startDate: { type: 'string', format: 'date-time', nullable: true }, + endDate: { type: 'string', format: 'date-time', nullable: true }, + type: { type: 'string', enum: ['json', 'csv'] }, + count: { type: 'number', minimum: 1, nullable: true }, + sort: { type: 'string', nullable: true }, + }, + required: ['type'], + additionalProperties: false, +}; + +export const isAppLogsExportProps = ajv.compile(AppLogsExportPropsSchema); diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 5bcfec5ecddcc..d6000eae61e91 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -16,9 +16,11 @@ import type { } from '@rocket.chat/core-typings'; import type * as UiKit from '@rocket.chat/ui-kit'; +import type { AppLogsExportProps } from './appLogsExportProps'; import type { AppLogsProps } from './appLogsProps'; import type { PaginatedResult } from '../helpers/PaginatedResult'; +export * from './appLogsExportProps'; export * from './appLogsProps'; export type AppsEndpoints = { @@ -106,6 +108,10 @@ export type AppsEndpoints = { }>; }; + '/apps/:id/export-logs': { + GET: (params: AppLogsExportProps) => Buffer; + }; + '/apps/:id/apis': { GET: () => { apis: IApiEndpointMetadata[]; diff --git a/yarn.lock b/yarn.lock index dd42797ad4af8..e5cbb812991b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17762,6 +17762,13 @@ __metadata: languageName: node linkType: hard +"deeks@npm:3.1.0": + version: 3.1.0 + resolution: "deeks@npm:3.1.0" + checksum: 10/4297893cb792bc207b1fdac6090094c8c666669227b385bc69d98ea751294614948288a39a38faa0a44d7045d1bf05eacd188eea7516a1a7d0fee9a7faa34e7d + languageName: node + linkType: hard + "deep-eql@npm:^4.1.3": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" @@ -24771,6 +24778,16 @@ __metadata: languageName: node linkType: hard +"json-2-csv@npm:^5.5.9": + version: 5.5.9 + resolution: "json-2-csv@npm:5.5.9" + dependencies: + deeks: "npm:3.1.0" + doc-path: "npm:4.1.1" + checksum: 10/fa80a1a99e9c7e6844e6e13a48e3d0f11e6e98e90dc46e47fdc46b0fde2ecef324f8e13c68e11dbd0645f714c5089dc46e605a22f25c659294347a3426506947 + languageName: node + linkType: hard + "json-bigint@npm:^1.0.0": version: 1.0.0 resolution: "json-bigint@npm:1.0.0" From 2697dda79ae19f611564b4463625236157bf340b Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Tue, 15 Jul 2025 14:24:00 -0300 Subject: [PATCH 03/15] feat: Implement exporting to files --- .../AppDetailsPage/tabs/AppLogs/AppLogs.tsx | 2 +- .../tabs/AppLogs/Filters/AppLogsFilter.tsx | 6 ++- .../tabs/AppLogs/Filters/ExportLogsModal.tsx | 46 +++++++++++++------ packages/i18n/src/locales/en.i18n.json | 4 ++ 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx index 66edf40d57774..ddc18e540216f 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/AppLogs.tsx @@ -50,7 +50,7 @@ const AppLogs = ({ id }: { id: string }): ReactElement => { return ( <> - + {isFetching && } {isError && } diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx index e2d5b35a7b32b..8771f6ca8ebf7 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx @@ -13,9 +13,10 @@ import { ExportLogsModal } from './ExportLogsModal'; type AppsLogsFilterProps = { isLoading: boolean; + noResults?: boolean; }; -export const AppLogsFilter = ({ isLoading }: AppsLogsFilterProps) => { +export const AppLogsFilter = ({ isLoading, noResults = false }: AppsLogsFilterProps) => { const { t } = useTranslation(); const { control, getValues } = useAppLogsFilterFormContext(); @@ -84,9 +85,10 @@ export const AppLogsFilter = ({ isLoading }: AppsLogsFilterProps) => { )} {!compactMode && ( openExportModal()} diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx index 1509ff73c6a07..1db806d315e06 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.tsx @@ -1,5 +1,5 @@ import { Box, Button, Field, FieldLabel, FieldRow, Label, Modal, NumberInput, RadioButton } from '@rocket.chat/fuselage'; -import { useEndpoint, useRouteParameter } from '@rocket.chat/ui-contexts'; +import { useRouteParameter } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -32,21 +32,39 @@ export const ExportLogsModal = ({ onClose, filterValues }: ExportLogsModalProps) const formData = watch(); - console.log(formData); - - const getExportedFile = useEndpoint('GET', `/apps/:id/export-logs`, { id: appId } as { id: string }); + const getFileUrl = ({ + severity, + event, + startDate, + endDate, + count, + type, + startTime, + endTime, + }: AppLogsFilterFormData & { type: 'json' | 'csv'; count: number }): string => { + let baseUrl = `/api/apps/${appId}/export-logs?`; + if (severity && severity !== 'all') { + baseUrl += `logLevel=${severity}&`; + } + if (event) { + baseUrl += `method=${event}&`; + } + if (startDate) { + baseUrl += `startDate=${new Date(`${startDate}T${startTime}`).toISOString()}&`; + } + if (endDate) { + baseUrl += `endDate=${new Date(`${endDate}T${endTime}`).toISOString()}&`; + } + if (count) { + baseUrl += `count=${count}&`; + } + baseUrl += `type=${type}`; + return baseUrl; + }; const handleConfirm = (): void => { - const { severity, event: method, startDate, endDate } = filterValues; - const logLevel = severity === 'all' ? undefined : severity; - getExportedFile({ - logLevel, - method, - startDate, - endDate, - count: formData.count === 'max' ? 2000 : formData.customExportAmount, - type: formData.type, - }); + const url = getFileUrl({ ...filterValues, type: formData.type, count: formData.count === 'max' ? 2000 : formData.customExportAmount }); + window.open(url, '_blank', 'noopener noreferrer'); }; return ( diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 8c196d14444e6..fa8d413aa3a45 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2009,6 +2009,7 @@ "Export_Messages": "Export messages", "Export_My_Data": "Export My Data (JSON)", "export-messages-as-pdf": "Export messages as PDF", + "Export_most_recent_logs": "Export most recent logs", "Export_as_PDF": "Export as PDF", "Export_as_file": "Export as file", "Export_conversation_transcript_as_PDF": "Export conversation transcript as PDF", @@ -3202,6 +3203,7 @@ "Master_volume_hint": "Controls the volume for all sounds coming from your workspace", "Max_Retry": "Maximum attemps to reconnect to the server", "Max_length_is": "Max length is %s", + "Max_logs_export": "Max (2000)", "Max_number_incoming_livechats_displayed": "Max number of items displayed in the queue", "Max_number_incoming_livechats_displayed_description": "(Optional) Max number of items displayed in the incoming Omnichannel queue.", "Max_number_of_chats_per_agent": "Max. number of simultaneous chats", @@ -3585,6 +3587,7 @@ "No_custom_fields_yet_description": "Add custom fields into contact or ticket details or display them on the live chat registration form for new visitors.", "No_data_available_for_the_selected_period": "No data available for the selected period", "No_data_found": "No data found", + "No_data_to_export": "No data to export", "No_departments_yet": "No departments yet", "No_departments_yet_description": "Organize agents into departments, set how tickets get forwarded and monitor their performance.", "No_direct_messages_yet": "No Direct Messages.", @@ -6228,6 +6231,7 @@ "leave-c_description": "Permission to leave channels", "leave-p": "Leave Private Groups", "leave-p_description": "Permission to leave private groups", + "Limit_number_of_logs": "Limit number", "line": "line", "link": "link", "logout-device-management": "Logout Device Management", From 62ff581ce7afabab5f09fba7cf3a81df886419bf Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Tue, 15 Jul 2025 14:31:21 -0300 Subject: [PATCH 04/15] test: update snapshots --- .../__snapshots__/AppLogsFilterCompact.spec.tsx.snap | 12 ++++++++++++ .../AppLogsFilterExpanded.spec.tsx.snap | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap index fef2400775f2e..168ae0770030e 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap @@ -57,6 +57,18 @@ exports[`renders AppLogsItem without crashing 1`] = ` Filters + diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap index e7d50dd0c75a4..325c428def22b 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterExpanded.spec.tsx.snap @@ -216,6 +216,18 @@ exports[`renders AppLogsItem without crashing 1`] = ` + From 76c3536159c0c85bdfc52e3244ec20606cb2ab01 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Tue, 15 Jul 2025 14:45:59 -0300 Subject: [PATCH 05/15] fix: Accessibility errors --- .../AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx | 2 ++ .../tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx index 8771f6ca8ebf7..6a3067f09a20f 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppLogsFilter.tsx @@ -92,6 +92,8 @@ export const AppLogsFilter = ({ isLoading, noResults = false }: AppsLogsFilterPr secondary mie={10} onClick={() => openExportModal()} + aria-label={noResults ? t('No_data_to_export') : t('Export')} + aria-disabled={noResults} /> )} {compactMode && ( diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx index ce58dbd8c08e7..219933fb302b0 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx @@ -12,7 +12,7 @@ const CompactFilterOptions = ({ handleExportLogs, ...props }: CompactFilterOptio const menuOptions = { exportLogs: { label: ( - + {t('Export')} From 1184ae88f6c1761d3dc768281de58fff4fae1479 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Tue, 15 Jul 2025 15:12:34 -0300 Subject: [PATCH 06/15] test: modal snapshot test --- .../Filters/AppsLogsFilterOptionsCompact.tsx | 2 +- .../AppLogs/Filters/ExportLogsModal.spec.tsx | 23 ++ .../Filters/ExportLogsModal.stories.tsx | 39 +++ .../AppLogsFilterCompact.spec.tsx.snap | 1 + .../ExportLogsModal.spec.tsx.snap | 232 ++++++++++++++++++ 5 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.stories.tsx create mode 100644 apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/ExportLogsModal.spec.tsx.snap diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx index 219933fb302b0..e69d6ad20dbd8 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/AppsLogsFilterOptionsCompact.tsx @@ -20,7 +20,7 @@ const CompactFilterOptions = ({ handleExportLogs, ...props }: CompactFilterOptio action: handleExportLogs, }, }; - return ; + return ; }; export default CompactFilterOptions; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx new file mode 100644 index 0000000000000..8aabba5fe68be --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.spec.tsx @@ -0,0 +1,23 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './ExportLogsModal.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders without crashing`, async (_storyname, Story) => { + const view = render(, { + wrapper: mockAppRoot().build(), + }); + expect(view.baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('Should have no a11y violations', async (_storyname, Story) => { + const { container } = render(, { wrapper: mockAppRoot().build() }); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); +}); diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.stories.tsx b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.stories.tsx new file mode 100644 index 0000000000000..6dc1afdfebe0c --- /dev/null +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/ExportLogsModal.stories.tsx @@ -0,0 +1,39 @@ +import { Box } from '@rocket.chat/fuselage'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { action } from '@storybook/addon-actions'; +import type { Meta, StoryFn } from '@storybook/react'; +import type { ComponentProps } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { ExportLogsModal } from './ExportLogsModal'; + +export default { + title: 'Marketplace/AppDetailsPage/AppLogs/Filters/ExportLogsModal', + component: ExportLogsModal, + args: { + onClose: action('onClose'), + filterValues: { + severity: 'all', + event: '', + startDate: '', + endDate: '', + }, + }, + decorators: [ + mockAppRoot().buildStoryDecorator(), + (fn) => { + const methods = useForm({}); + + return ( + + {fn()} + + ); + }, + ], + parameters: { + layout: 'fullscreen', + }, +} satisfies Meta; + +export const Default: StoryFn> = (args) => ; diff --git a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap index 168ae0770030e..d961a55584c29 100644 --- a/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap +++ b/apps/meteor/client/views/marketplace/AppDetailsPage/tabs/AppLogs/Filters/__snapshots__/AppLogsFilterCompact.spec.tsx.snap @@ -60,6 +60,7 @@ exports[`renders AppLogsItem without crashing 1`] = ` + + +
+
+
+ +
+ + +