diff --git a/package-lock.json b/package-lock.json index 84dd39116a..d045cc265a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8636,6 +8636,172 @@ "react": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", + "integrity": "sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-tooltip/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" + } + }, + "node_modules/@radix-ui/react-tooltip/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 + } + } + }, + "node_modules/@radix-ui/react-tooltip/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 + } + } + }, + "node_modules/@radix-ui/react-tooltip/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 + } + } + }, + "node_modules/@radix-ui/react-tooltip/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 + } + } + }, + "node_modules/@radix-ui/react-tooltip/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 + } + } + }, + "node_modules/@radix-ui/react-tooltip/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 + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", @@ -8792,6 +8958,91 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/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 + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/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 + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/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 + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", @@ -10415,6 +10666,12 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/js-cookie": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz", + "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==", + "dev": true + }, "node_modules/@types/js-yaml": { "version": "4.0.5", "dev": true, @@ -11636,6 +11893,12 @@ "pluralize": "8.0.0" } }, + "node_modules/@xobotyi/scrollbar-width": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", + "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", + "dev": true + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "license": "BSD-3-Clause" @@ -14072,6 +14335,15 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dev": true, + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/copyfiles": { "version": "2.4.1", "license": "MIT", @@ -14331,6 +14603,15 @@ "postcss": "^8.4" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "dev": true, + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/css-loader": { "version": "6.10.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.10.0.tgz", @@ -17218,6 +17499,18 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-loops": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.3.tgz", + "integrity": "sha512-8EZzEP0eKkEEVX+drtd9mtuQ+/QrlfW/5MlwcwK5Nds6EkZ/tRzEexkzUY2mIssnAyVLT+TKHuRXmFNNXYUd6g==", + "dev": true + }, + "node_modules/fast-shallow-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz", + "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==", + "dev": true + }, "node_modules/fast-xml-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.4.tgz", @@ -17247,6 +17540,12 @@ "node": ">= 4.9.1" } }, + "node_modules/fastest-stable-stringify": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", + "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==", + "dev": true + }, "node_modules/fastq": { "version": "1.13.0", "dev": true, @@ -18961,6 +19260,12 @@ "node": ">=10.18" } }, + "node_modules/hyphenate-style-name": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.5.tgz", + "integrity": "sha512-fedL7PRwmeVkgyhu9hLeTBaI6wcGk7JGJswdaRsa5aUbkXI1kr1xZwTPBtaYPpwf56878iDek6VbVnuWMebJmw==", + "dev": true + }, "node_modules/iconv-lite": { "version": "0.4.24", "license": "MIT", @@ -19112,6 +19417,16 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true }, + "node_modules/inline-style-prefixer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.0.tgz", + "integrity": "sha512-I7GEdScunP1dQ6IM2mQWh6v0mOYdYmH3Bp31UecKdrcUgcURTcctSe1IECdUznSHKSmsHtjrT3CwCPI1pyxfUQ==", + "dev": true, + "dependencies": { + "css-in-js-utils": "^3.1.0", + "fast-loops": "^1.1.3" + } + }, "node_modules/int64-buffer": { "version": "0.1.10", "license": "MIT" @@ -21166,6 +21481,12 @@ "node": ">=10" } }, + "node_modules/js-cookie": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", + "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==", + "dev": true + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -22708,6 +23029,66 @@ "resolved": "packages/cli", "link": true }, + "node_modules/nano-css": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.6.1.tgz", + "integrity": "sha512-T2Mhc//CepkTa3X4pUhKgbEheJHYAxD0VptuqFhDbGMUWVV2m+lkNiW/Ieuj35wrfC8Zm0l7HvssQh7zcEttSw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "css-tree": "^1.1.2", + "csstype": "^3.1.2", + "fastest-stable-stringify": "^2.0.2", + "inline-style-prefixer": "^7.0.0", + "rtl-css-js": "^1.16.1", + "stacktrace-js": "^2.0.2", + "stylis": "^4.3.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/nano-css/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/nano-css/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nano-css/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + }, + "node_modules/nano-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nano-css/node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==", + "dev": true + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -25833,6 +26214,48 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-universal-interface": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz", + "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==", + "dev": true, + "peerDependencies": { + "react": "*", + "tslib": "*" + } + }, + "node_modules/react-use": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.5.0.tgz", + "integrity": "sha512-PbfwSPMwp/hoL847rLnm/qkjg3sTRCvn6YhUZiHaUa3FA6/aNoFX79ul5Xt70O1rK+9GxSVqkY0eTwMdsR/bWg==", + "dev": true, + "dependencies": { + "@types/js-cookie": "^2.2.6", + "@xobotyi/scrollbar-width": "^1.9.5", + "copy-to-clipboard": "^3.3.1", + "fast-deep-equal": "^3.1.3", + "fast-shallow-equal": "^1.0.0", + "js-cookie": "^2.2.1", + "nano-css": "^5.6.1", + "react-universal-interface": "^0.6.2", + "resize-observer-polyfill": "^1.5.1", + "screenfull": "^5.1.0", + "set-harmonic-interval": "^1.0.1", + "throttle-debounce": "^3.0.1", + "ts-easing": "^0.2.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/react-use/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, "node_modules/read": { "version": "1.0.7", "license": "ISC", @@ -26105,6 +26528,12 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -26304,6 +26733,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, "node_modules/run-async": { "version": "3.0.0", "license": "MIT", @@ -26537,6 +26975,18 @@ "dev": true, "license": "MIT" }, + "node_modules/screenfull": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz", + "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -26773,6 +27223,15 @@ "node": ">= 0.4" } }, + "node_modules/set-harmonic-interval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz", + "integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==", + "dev": true, + "engines": { + "node": ">=6.9" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "license": "ISC" @@ -27123,6 +27582,15 @@ "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", "dev": true }, + "node_modules/stack-generator": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", + "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", + "dev": true, + "dependencies": { + "stackframe": "^1.3.4" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "license": "MIT", @@ -27162,6 +27630,36 @@ "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", "dev": true }, + "node_modules/stacktrace-gps": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", + "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", + "dev": true, + "dependencies": { + "source-map": "0.5.6", + "stackframe": "^1.3.4" + } + }, + "node_modules/stacktrace-gps/node_modules/source-map": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", + "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stacktrace-js": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", + "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", + "dev": true, + "dependencies": { + "error-stack-parser": "^2.0.6", + "stack-generator": "^2.0.5", + "stacktrace-gps": "^3.0.4" + } + }, "node_modules/static-eval": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", @@ -28328,6 +28826,15 @@ "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==", "dev": true }, + "node_modules/throttle-debounce": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz", + "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/through2": { "version": "2.0.5", "license": "MIT", @@ -28446,6 +28953,12 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "dev": true + }, "node_modules/toidentifier": { "version": "1.0.1", "license": "MIT", @@ -28522,6 +29035,12 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-easing": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz", + "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==", + "dev": true + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "dev": true, @@ -32796,6 +33315,7 @@ "@types/simple-oauth2": "^4.1.1", "@types/uuid": "^8.3.4", "@types/ws": "^8.5.4", + "get-port": "7.1.0", "nodemon": "^3.0.1", "typescript": "^5.3.3", "vitest": "0.33.0" @@ -32805,6 +33325,18 @@ "npm": ">=6.14.11" } }, + "packages/server/node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "packages/shared": { "name": "@nangohq/shared", "version": "0.39.30", @@ -33985,6 +34517,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-tooltip": "1.0.7", "@sentry/react": "7.83.0", "@tailwindcss/forms": "0.5.3", "@tanstack/react-table": "8.16.0", @@ -34012,6 +34545,7 @@ "react-router-dom": "6.8.2", "react-scripts": "5.0.1", "react-toastify": "9.1.1", + "react-use": "17.5.0", "swr": "2.2.5", "tailwind-merge": "2.2.1", "tailwindcss": "3.2.7", diff --git a/packages/logs/lib/models/helpers.ts b/packages/logs/lib/models/helpers.ts index 7f4b3b689b..16564045ab 100644 --- a/packages/logs/lib/models/helpers.ts +++ b/packages/logs/lib/models/helpers.ts @@ -1,5 +1,8 @@ import { nanoid } from '@nangohq/utils'; import type { MessageRow } from '@nangohq/types'; +import { z } from 'zod'; + +export const operationIdRegex = z.string().regex(/([0-9]|[a-zA-Z0-9]{20})/); export interface FormatMessageData { account?: { id: number; name?: string }; diff --git a/packages/logs/lib/models/messages.ts b/packages/logs/lib/models/messages.ts index 99ad7397bb..0f64cc035c 100644 --- a/packages/logs/lib/models/messages.ts +++ b/packages/logs/lib/models/messages.ts @@ -1,5 +1,5 @@ import { client } from '../es/client.js'; -import type { MessageRow, OperationRow, SearchLogsState } from '@nangohq/types'; +import type { MessageRow, OperationRow, SearchOperationsState } from '@nangohq/types'; import { indexMessages } from '../es/schema.js'; import type { opensearchtypes } from '@opensearch-project/opensearch'; import { errors } from '@opensearch-project/opensearch'; @@ -30,7 +30,12 @@ export async function createMessage(row: MessageRow): Promise { /** * List operations */ -export async function listOperations(opts: { accountId: number; environmentId?: number; limit: number; states: SearchLogsState[] }): Promise { +export async function listOperations(opts: { + accountId: number; + environmentId?: number; + limit: number; + states?: SearchOperationsState[] | undefined; +}): Promise { const query: opensearchtypes.QueryDslQueryContainer = { bool: { must: [{ term: { accountId: opts.accountId } }], @@ -41,7 +46,8 @@ export async function listOperations(opts: { accountId: number; environmentId?: if (opts.environmentId) { (query.bool!.must as opensearchtypes.QueryDslQueryContainer[]).push({ term: { environmentId: opts.environmentId } }); } - if (opts.states.length > 1 || opts.states[0] !== 'all') { + if (opts.states && (opts.states.length > 1 || opts.states[0] !== 'all')) { + // Where or (query.bool!.must as opensearchtypes.QueryDslQueryContainer[]).push({ bool: { should: opts.states.map((state) => { @@ -134,21 +140,42 @@ export async function setTimeouted(opts: Pick): Promise /** * List messages */ -export async function listMessages(opts: { parentId: MessageRow['parentId']; limit: number }): Promise { +export async function listMessages(opts: { + parentId: string; + limit: number; + states?: SearchOperationsState[] | undefined; + search?: string | undefined; +}): Promise { + const query: opensearchtypes.QueryDslQueryContainer = { + bool: { + must: [{ term: { parentId: opts.parentId } }], + should: [] + } + }; + + if (opts.states && (opts.states.length > 1 || opts.states[0] !== 'all')) { + // Where or + (query.bool!.must as opensearchtypes.QueryDslQueryContainer[]).push({ + bool: { + should: opts.states.map((state) => { + return { term: { state } }; + }) + } + }); + } + if (opts.search) { + (query.bool!.must as opensearchtypes.QueryDslQueryContainer[]).push({ + match_phrase_prefix: { message: { query: opts.search } } + }); + } + const res = await client.search<{ hits: { total: number; hits: { _source: MessageRow }[] } }>({ index: indexMessages.index, - size: 5000, + size: opts.limit, sort: ['createdAt:desc', '_score'], track_total_hits: true, - body: { - query: { - bool: { - must: [{ term: { parentId: opts.parentId } }] - } - } - } + body: { query } }); - const hits = res.body.hits; return { diff --git a/packages/server/lib/controllers/v1/logs/getOperation.ts b/packages/server/lib/controllers/v1/logs/getOperation.ts index eec838a833..7748ac3ea2 100644 --- a/packages/server/lib/controllers/v1/logs/getOperation.ts +++ b/packages/server/lib/controllers/v1/logs/getOperation.ts @@ -2,11 +2,11 @@ import { z } from 'zod'; import { asyncWrapper } from '../../../utils/asyncWrapper.js'; import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; import type { GetOperation } from '@nangohq/types'; -import { model, envs } from '@nangohq/logs'; +import { model, envs, operationIdRegex } from '@nangohq/logs'; const validation = z .object({ - operationId: z.string().regex(/^([0-9]+|[a-zA-Z0-9]{20})$/) + operationId: operationIdRegex }) .strict(); diff --git a/packages/server/lib/controllers/v1/logs/searchMessages.integration.test.ts b/packages/server/lib/controllers/v1/logs/searchMessages.integration.test.ts new file mode 100644 index 0000000000..e658c0c7d7 --- /dev/null +++ b/packages/server/lib/controllers/v1/logs/searchMessages.integration.test.ts @@ -0,0 +1,162 @@ +import { logContextGetter, migrateMapping } from '@nangohq/logs'; +import { multipleMigrations, seeders } from '@nangohq/shared'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { isError, isSuccess, runServer, shouldBeProtected, shouldRequireQueryEnv } from '../../../utils/tests.js'; + +let api: Awaited>; +describe('GET /logs', () => { + beforeAll(async () => { + await multipleMigrations(); + await migrateMapping(); + + api = await runServer(); + }); + afterAll(() => { + api.server.close(); + }); + + it('should be protected', async () => { + const res = await api.fetch('/api/v1/logs/messages', { method: 'POST', query: { env: 'dev' }, body: { operationId: '1' } }); + + shouldBeProtected(res); + }); + + it('should enforce env query params', async () => { + const { env } = await seeders.seedAccountEnvAndUser(); + // @ts-expect-error missing query on purpose + const res = await api.fetch('/api/v1/logs/messages', { method: 'POST', token: env.secret_key, body: { operationId: '1' } }); + + shouldRequireQueryEnv(res); + }); + + it('should validate body', async () => { + const { env } = await seeders.seedAccountEnvAndUser(); + const res = await api.fetch('/api/v1/logs/messages', { + method: 'POST', + query: { env: 'dev' }, + token: env.secret_key, + // @ts-expect-error on purpose + body: { limit: 'a', foo: 'bar' } + }); + + expect(res.json).toStrictEqual({ + error: { + code: 'invalid_body', + errors: [ + { + code: 'invalid_type', + message: 'Required', + path: ['operationId'] + }, + { + code: 'invalid_type', + message: 'Expected number, received string', + path: ['limit'] + }, + { + code: 'unrecognized_keys', + message: "Unrecognized key(s) in object: 'foo'", + path: [] + } + ] + } + }); + expect(res.res.status).toBe(400); + }); + + it('should search messages and get empty results', async () => { + const { account, env } = await seeders.seedAccountEnvAndUser(); + + const logCtx = await logContextGetter.create({ message: 'test 1', operation: { type: 'auth' } }, { account, environment: env }); + await logCtx.success(); + + const res = await api.fetch('/api/v1/logs/messages', { + method: 'POST', + query: { env: 'dev' }, + token: env.secret_key, + body: { operationId: logCtx.id, limit: 10 } + }); + + isSuccess(res.json); + expect(res.res.status).toBe(200); + expect(res.json).toStrictEqual({ + data: [], + pagination: { total: 0 } + }); + }); + + it('should search messages and get one result', async () => { + const { env, account } = await seeders.seedAccountEnvAndUser(); + + const logCtx = await logContextGetter.create({ message: 'test 1', operation: { type: 'auth' } }, { account, environment: env }); + await logCtx.info('test info'); + await logCtx.success(); + + const res = await api.fetch('/api/v1/logs/messages', { + method: 'POST', + query: { env: 'dev' }, + token: env.secret_key, + body: { operationId: logCtx.id, limit: 10 } + }); + + isSuccess(res.json); + expect(res.res.status).toBe(200); + expect(res.json).toStrictEqual({ + data: [ + { + accountId: null, + accountName: null, + code: null, + configId: null, + configName: null, + connectionId: null, + connectionName: null, + createdAt: expect.toBeIsoDate(), + endedAt: null, + environmentId: null, + environmentName: null, + error: null, + id: expect.any(String), + jobId: null, + level: 'info', + message: 'test info', + meta: null, + operation: null, + parentId: logCtx.id, + request: null, + response: null, + source: 'internal', + startedAt: null, + state: 'waiting', + syncId: null, + syncName: null, + title: null, + type: 'log', + updatedAt: expect.toBeIsoDate(), + userId: null + } + ], + pagination: { total: 1 } + }); + }); + + it('should search messages and not return results from an other account', async () => { + const { account, env } = await seeders.seedAccountEnvAndUser(); + const env2 = await seeders.seedAccountEnvAndUser(); + + const logCtx = await logContextGetter.create({ message: 'test 1', operation: { type: 'auth' } }, { account, environment: env }); + await logCtx.info('test info'); + await logCtx.success(); + + const res = await api.fetch('/api/v1/logs/messages', { + method: 'POST', + query: { env: 'dev' }, + token: env2.env.secret_key, + body: { operationId: logCtx.id, limit: 10 } + }); + + isError(res.json); + expect(res.res.status).toBe(404); + expect(res.json).toStrictEqual({ error: { code: 'not_found' } }); + }); +}); diff --git a/packages/server/lib/controllers/v1/logs/searchMessages.ts b/packages/server/lib/controllers/v1/logs/searchMessages.ts new file mode 100644 index 0000000000..3e5c457977 --- /dev/null +++ b/packages/server/lib/controllers/v1/logs/searchMessages.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { asyncWrapper } from '../../../utils/asyncWrapper.js'; +import type { SearchMessages } from '@nangohq/types'; +import { model, envs, operationIdRegex } from '@nangohq/logs'; +import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; + +const validation = z + .object({ + operationId: operationIdRegex, + limit: z.number().optional().default(100), + search: z.string().optional(), + states: z + .array(z.enum(['all', 'waiting', 'running', 'success', 'failed', 'timeout', 'cancelled'])) + .optional() + .default(['all']) + }) + .strict(); + +export const searchMessages = asyncWrapper(async (req, res) => { + if (!envs.NANGO_LOGS_ENABLED) { + res.status(404).send({ error: { code: 'feature_disabled' } }); + return; + } + + const emptyQuery = requireEmptyQuery(req, { withEnv: true }); + if (emptyQuery) { + res.status(400).send({ error: { code: 'invalid_query_params', errors: zodErrorToHTTP(emptyQuery.error) } }); + return; + } + + const val = validation.safeParse(req.body); + if (!val.success) { + res.status(400).send({ + error: { code: 'invalid_body', errors: zodErrorToHTTP(val.error) } + }); + return; + } + + const { environment, account } = res.locals; + + // Manually ensure that `operationId` belongs to the account for now + // Because not all the logs have accountId/environmentId + try { + const operation = await model.getOperation({ id: val.data.operationId }); + if (operation.accountId !== account.id || operation.environmentId !== environment.id) { + res.status(404).send({ error: { code: 'not_found' } }); + return; + } + } catch (err) { + if (err instanceof model.ResponseError && err.statusCode === 404) { + res.status(404).send({ error: { code: 'not_found' } }); + return; + } + throw err; + } + + const body: SearchMessages['Body'] = val.data; + const rawOps = await model.listMessages({ + parentId: body.operationId, + limit: body.limit!, + states: body.states, + search: body.search + }); + + res.status(200).send({ + data: rawOps.items, + pagination: { total: rawOps.count } + }); +}); diff --git a/packages/server/lib/controllers/v1/logs/searchLogs.integration.test.ts b/packages/server/lib/controllers/v1/logs/searchOperations.integration.test.ts similarity index 83% rename from packages/server/lib/controllers/v1/logs/searchLogs.integration.test.ts rename to packages/server/lib/controllers/v1/logs/searchOperations.integration.test.ts index a4e1ded973..b323d75844 100644 --- a/packages/server/lib/controllers/v1/logs/searchLogs.integration.test.ts +++ b/packages/server/lib/controllers/v1/logs/searchOperations.integration.test.ts @@ -1,7 +1,7 @@ import { logContextGetter, migrateMapping } from '@nangohq/logs'; import { multipleMigrations, seeders } from '@nangohq/shared'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { runServer, shouldBeProtected, shouldRequireQueryEnv } from '../../../utils/tests.js'; +import { isSuccess, runServer, shouldBeProtected, shouldRequireQueryEnv } from '../../../utils/tests.js'; let api: Awaited>; describe('GET /logs', () => { @@ -17,7 +17,7 @@ describe('GET /logs', () => { it('should be protected', async () => { // @ts-expect-error missing body on purpose - const res = await api.fetch('/api/v1/logs/search', { method: 'POST', query: { env: 'dev' } }); + const res = await api.fetch('/api/v1/logs/operations', { method: 'POST', query: { env: 'dev' } }); shouldBeProtected(res); }); @@ -25,14 +25,14 @@ describe('GET /logs', () => { it('should enforce env query params', async () => { const { env } = await seeders.seedAccountEnvAndUser(); // @ts-expect-error missing query on purpose - const res = await api.fetch('/api/v1/logs/search', { method: 'POST', token: env.secret_key }); + const res = await api.fetch('/api/v1/logs/operations', { method: 'POST', token: env.secret_key }); shouldRequireQueryEnv(res); }); it('should validate body', async () => { const { env } = await seeders.seedAccountEnvAndUser(); - const res = await api.fetch('/api/v1/logs/search', { + const res = await api.fetch('/api/v1/logs/operations', { method: 'POST', query: { env: 'dev' }, token: env.secret_key, @@ -62,13 +62,14 @@ describe('GET /logs', () => { it('should search logs and get empty results', async () => { const { env } = await seeders.seedAccountEnvAndUser(); - const res = await api.fetch('/api/v1/logs/search', { + const res = await api.fetch('/api/v1/logs/operations', { method: 'POST', query: { env: 'dev' }, token: env.secret_key, body: { limit: 10 } }); + isSuccess(res.json); expect(res.res.status).toBe(200); expect(res.json).toStrictEqual({ data: [], @@ -77,22 +78,20 @@ describe('GET /logs', () => { }); it('should search logs and get one result', async () => { - const { env } = await seeders.seedAccountEnvAndUser(); + const { env, account } = await seeders.seedAccountEnvAndUser(); - const logCtx = await logContextGetter.create( - { message: 'test 1', operation: { type: 'auth' } }, - { account: { id: env.account_id }, environment: { id: env.id } } - ); + const logCtx = await logContextGetter.create({ message: 'test 1', operation: { type: 'auth' } }, { account, environment: env }); await logCtx.info('test info'); await logCtx.success(); - const res = await api.fetch('/api/v1/logs/search', { + const res = await api.fetch('/api/v1/logs/operations', { method: 'POST', query: { env: 'dev' }, token: env.secret_key, body: { limit: 10 } }); + isSuccess(res.json); expect(res.res.status).toBe(200); expect(res.json).toStrictEqual({ data: [ @@ -107,7 +106,7 @@ describe('GET /logs', () => { createdAt: expect.toBeIsoDate(), endedAt: expect.toBeIsoDate(), environmentId: env.id, - environmentName: null, + environmentName: 'dev', error: null, id: logCtx.id, jobId: null, @@ -143,13 +142,14 @@ describe('GET /logs', () => { await logCtx.info('test info'); await logCtx.success(); - const res = await api.fetch('/api/v1/logs/search', { + const res = await api.fetch('/api/v1/logs/operations', { method: 'POST', query: { env: 'dev' }, token: env2.env.secret_key, body: { limit: 10 } }); + isSuccess(res.json); expect(res.res.status).toBe(200); expect(res.json).toStrictEqual({ data: [], diff --git a/packages/server/lib/controllers/v1/logs/searchLogs.ts b/packages/server/lib/controllers/v1/logs/searchOperations.ts similarity index 82% rename from packages/server/lib/controllers/v1/logs/searchLogs.ts rename to packages/server/lib/controllers/v1/logs/searchOperations.ts index 192cda5f2e..78c4998113 100644 --- a/packages/server/lib/controllers/v1/logs/searchLogs.ts +++ b/packages/server/lib/controllers/v1/logs/searchOperations.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { asyncWrapper } from '../../../utils/asyncWrapper.js'; import { requireEmptyQuery, zodErrorToHTTP } from '@nangohq/utils'; -import type { SearchLogs } from '@nangohq/types'; +import type { SearchOperations } from '@nangohq/types'; import { model, envs } from '@nangohq/logs'; const validation = z @@ -14,7 +14,7 @@ const validation = z }) .strict(); -export const searchLogs = asyncWrapper(async (req, res) => { +export const searchOperations = asyncWrapper(async (req, res) => { if (!envs.NANGO_LOGS_ENABLED) { res.status(404).send({ error: { code: 'feature_disabled' } }); return; @@ -35,8 +35,8 @@ export const searchLogs = asyncWrapper(async (req, res) => { } const env = res.locals['environment']; - const body: Required = val.data; - const rawOps = await model.listOperations({ accountId: env.account_id, environmentId: env.id, limit: body.limit, states: body.states }); + const body: SearchOperations['Body'] = val.data; + const rawOps = await model.listOperations({ accountId: env.account_id, environmentId: env.id, limit: body.limit!, states: body.states }); res.status(200).send({ data: rawOps.items, diff --git a/packages/server/lib/routes.ts b/packages/server/lib/routes.ts index d7db96548d..b1c183184e 100644 --- a/packages/server/lib/routes.ts +++ b/packages/server/lib/routes.ts @@ -31,8 +31,9 @@ import type { Response, Request } from 'express'; import { isCloud, isEnterprise, AUTH_ENABLED, MANAGED_AUTH_ENABLED, isBasicAuthEnabled, isTest } from '@nangohq/utils'; import { errorManager } from '@nangohq/shared'; import tracer from 'dd-trace'; -import { searchLogs } from './controllers/v1/logs/searchLogs.js'; +import { searchOperations } from './controllers/v1/logs/searchOperations.js'; import { getOperation } from './controllers/v1/logs/getOperation.js'; +import { searchMessages } from './controllers/v1/logs/searchMessages.js'; import { setMetadata } from './controllers/v1/connection/setMetadata.js'; import { updateMetadata } from './controllers/v1/connection/updateMetadata.js'; import type { ApiError } from '@nangohq/types'; @@ -207,7 +208,8 @@ web.route('/api/v1/onboarding/deploy').post(webAuth, onboardingController.deploy web.route('/api/v1/onboarding/sync-status').post(webAuth, onboardingController.checkSyncCompletion.bind(onboardingController)); web.route('/api/v1/onboarding/action').post(webAuth, onboardingController.writeGithubIssue.bind(onboardingController)); -web.route('/api/v1/logs/search').post(webAuth, searchLogs); +web.route('/api/v1/logs/operations').post(webAuth, searchOperations); +web.route('/api/v1/logs/messages').post(webAuth, searchMessages); web.route('/api/v1/logs/operations/:operationId').get(webAuth, getOperation); // Hosted signin diff --git a/packages/server/lib/utils/tests.ts b/packages/server/lib/utils/tests.ts index 81c7d3cafd..edee781a51 100644 --- a/packages/server/lib/utils/tests.ts +++ b/packages/server/lib/utils/tests.ts @@ -3,7 +3,7 @@ import type { Server } from 'node:http'; import { createServer } from 'node:http'; import { expect } from 'vitest'; import type { APIEndpoints, APIEndpointsPicker, APIEndpointsPickerWithPath, ApiError } from '@nangohq/types'; -import { getServerPort } from '@nangohq/shared'; +import getPort from 'get-port'; import { app } from '../routes.js'; @@ -105,8 +105,8 @@ export function shouldRequireQueryEnv({ res, json }: { res: Response; json: any */ export async function runServer(): Promise<{ server: Server; url: string; fetch: ReturnType }> { const server = createServer(app); + const port = await getPort(); return new Promise((resolve) => { - const port = getServerPort(); server.listen(port, () => { const url = `http://localhost:${port}`; resolve({ server, url, fetch: apiFetch(url) }); diff --git a/packages/server/package.json b/packages/server/package.json index cc3832f22d..6fa0ada307 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -81,6 +81,7 @@ "@types/simple-oauth2": "^4.1.1", "@types/uuid": "^8.3.4", "@types/ws": "^8.5.4", + "get-port": "7.1.0", "nodemon": "^3.0.1", "typescript": "^5.3.3", "vitest": "0.33.0" diff --git a/packages/types/lib/api.endpoints.ts b/packages/types/lib/api.endpoints.ts index d821294ae0..3758c27201 100644 --- a/packages/types/lib/api.endpoints.ts +++ b/packages/types/lib/api.endpoints.ts @@ -1,9 +1,9 @@ import type { EndpointMethod } from './api'; -import type { GetOperation, SearchLogs } from './logs/api'; +import type { GetOperation, SearchMessages, SearchOperations } from './logs/api'; import type { GetOnboardingStatus } from './onboarding/api'; import type { SetMetadata, UpdateMetadata } from './connection/api/metadata'; -export type APIEndpoints = SearchLogs | GetOperation | GetOnboardingStatus | SetMetadata | UpdateMetadata; +export type APIEndpoints = SearchOperations | GetOperation | SearchMessages | GetOnboardingStatus | SetMetadata | UpdateMetadata; /** * Automatically narrow endpoints type with Method + Path diff --git a/packages/types/lib/logs/api.ts b/packages/types/lib/logs/api.ts index 67eda44aa4..81c13fc062 100644 --- a/packages/types/lib/logs/api.ts +++ b/packages/types/lib/logs/api.ts @@ -1,20 +1,18 @@ import type { Endpoint } from '../api'; -import type { MessageState, OperationRow } from './messages'; +import type { MessageRow, MessageState, OperationRow } from './messages'; -export type SearchLogs = Endpoint<{ +export type SearchOperations = Endpoint<{ Method: 'POST'; - Path: '/api/v1/logs/search'; + Path: '/api/v1/logs/operations'; Querystring: { env: string }; - Body: { limit?: number; states?: SearchLogsState[] }; + Body: { limit?: number; states?: SearchOperationsState[] }; Success: { data: OperationRow[]; pagination: { total: number }; }; }>; - -export type SearchLogsState = 'all' | MessageState; - -export type SearchLogsData = SearchLogs['Success']['data'][0]; +export type SearchOperationsState = 'all' | MessageState; +export type SearchOperationsData = SearchOperations['Success']['data'][0]; export type GetOperation = Endpoint<{ Method: 'GET'; @@ -25,3 +23,15 @@ export type GetOperation = Endpoint<{ data: OperationRow; }; }>; + +export type SearchMessages = Endpoint<{ + Method: 'POST'; + Path: '/api/v1/logs/messages'; + Querystring: { env: string }; + Body: { operationId: string; limit?: number; states?: SearchOperationsState[]; search?: string | undefined }; + Success: { + data: MessageRow[]; + pagination: { total: number }; + }; +}>; +export type SearchMessagesData = SearchMessages['Success']['data'][0]; diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 3387386afe..618554974e 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -40,6 +40,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-tooltip": "1.0.7", "@sentry/react": "7.83.0", "@tailwindcss/forms": "0.5.3", "@tanstack/react-table": "8.16.0", @@ -67,6 +68,7 @@ "react-router-dom": "6.8.2", "react-scripts": "5.0.1", "react-toastify": "9.1.1", + "react-use": "17.5.0", "swr": "2.2.5", "tailwind-merge": "2.2.1", "tailwindcss": "3.2.7", diff --git a/packages/webapp/src/App.tsx b/packages/webapp/src/App.tsx index e0531fd8c9..8b3e4903a1 100644 --- a/packages/webapp/src/App.tsx +++ b/packages/webapp/src/App.tsx @@ -31,6 +31,7 @@ import UserSettings from './pages/UserSettings'; import { Homepage } from './pages/Homepage'; import { NotFound } from './pages/NotFound'; import { LogsSearch } from './pages/Logs/Search'; +import { TooltipProvider } from '@radix-ui/react-tooltip'; Sentry.init({ dsn: process.env.REACT_APP_PUBLIC_SENTRY_KEY, @@ -59,62 +60,64 @@ const App = () => { return ( - { - if (error.status === 401) { - return signout(); + + { + if (error.status === 401) { + return signout(); + } } - } - }} - > - - } /> - }> - {showInteractiveDemo && ( - }> - } /> - - )} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }} + > + + } /> + }> + {showInteractiveDemo && ( + }> + } /> + + )} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {AUTH_ENABLED && ( + <> + } /> + } /> + + )} + + } /> + {true && } />} {AUTH_ENABLED && ( <> - } /> - } /> + } /> + } /> + } /> + } /> )} - - } /> - {true && } />} - {AUTH_ENABLED && ( - <> - } /> - } /> - } /> - } /> - - )} - {(isCloud() || isLocal()) && } />} - } /> - - - + {(isCloud() || isLocal()) && } />} + } /> + + + + ); }; diff --git a/packages/webapp/src/components/ui/Skeleton.tsx b/packages/webapp/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000000..71a2f0cad1 --- /dev/null +++ b/packages/webapp/src/components/ui/Skeleton.tsx @@ -0,0 +1,7 @@ +import { cn } from '../../utils/utils'; + +function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
; +} + +export { Skeleton }; diff --git a/packages/webapp/src/components/ui/Table.tsx b/packages/webapp/src/components/ui/Table.tsx index e3572b3e5e..9ba34d2183 100644 --- a/packages/webapp/src/components/ui/Table.tsx +++ b/packages/webapp/src/components/ui/Table.tsx @@ -2,7 +2,7 @@ import { forwardRef } from 'react'; import { cn } from '../../utils/utils'; const Table = forwardRef>(({ className, ...props }, ref) => ( -
+
)); @@ -27,7 +27,7 @@ const Row = forwardRef, React.ComponentPropsWithoutRef>( + ({ className, sideOffset = 4, ...props }, ref) => ( + + ) +); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/packages/webapp/src/components/ui/button/Button.tsx b/packages/webapp/src/components/ui/button/Button.tsx index 12d0499756..29057c334f 100644 --- a/packages/webapp/src/components/ui/button/Button.tsx +++ b/packages/webapp/src/components/ui/button/Button.tsx @@ -1,9 +1,9 @@ import { Loader } from '@geist-ui/icons'; import type { VariantProps } from 'class-variance-authority'; import { cva } from 'class-variance-authority'; -import classNames from 'classnames'; import type React from 'react'; import { forwardRef } from 'react'; +import { cn } from '../../../utils/utils'; const buttonStyles = cva('disabled:pointer-events-none disabled:opacity-50 rounded text-sm', { variants: { @@ -49,11 +49,9 @@ const Button = forwardRef(function Button({ size } return ( - + {options.map((option) => { diff --git a/packages/webapp/src/pages/Logs/components/OperationRow.tsx b/packages/webapp/src/pages/Logs/components/OperationRow.tsx index 6197b51ce3..bfd11018f6 100644 --- a/packages/webapp/src/pages/Logs/components/OperationRow.tsx +++ b/packages/webapp/src/pages/Logs/components/OperationRow.tsx @@ -1,14 +1,16 @@ import type { Row } from '@tanstack/react-table'; import { flexRender } from '@tanstack/react-table'; -import type { SearchLogsData } from '@nangohq/types'; +import type { SearchOperationsData } from '@nangohq/types'; -import { Drawer, DrawerContent, DrawerTrigger } from '../../../components/ui/Drawer'; +import { Drawer, DrawerContent, DrawerTrigger, DrawerClose } from '../../../components/ui/Drawer'; import * as Table from '../../../components/ui/Table'; -import { Show } from '../Show'; +import { ShowOperation } from '../ShowOperation'; +import { Cross1Icon } from '@radix-ui/react-icons'; -export const OperationRow: React.FC<{ row: Row }> = ({ row }) => { +const drawerWidth = '1034px'; +export const OperationRow: React.FC<{ row: Row }> = ({ row }) => { return ( - + {row.getVisibleCells().map((cell) => ( @@ -17,8 +19,13 @@ export const OperationRow: React.FC<{ row: Row }> = ({ row }) => -
- +
+
+ + + +
+
diff --git a/packages/webapp/src/pages/Logs/components/OperationTag.tsx b/packages/webapp/src/pages/Logs/components/OperationTag.tsx index 0eb9dd7b63..55873b132e 100644 --- a/packages/webapp/src/pages/Logs/components/OperationTag.tsx +++ b/packages/webapp/src/pages/Logs/components/OperationTag.tsx @@ -1,18 +1,41 @@ // import { ChevronRight } from '@geist-ui/icons'; -import type { SearchLogsData } from '@nangohq/types'; +import type { SearchOperationsData } from '@nangohq/types'; +import { cn } from '../../../utils/utils'; +import { Tag } from './Tag'; +import { CrossCircledIcon, LoopIcon, PauseIcon, PlayIcon, ResumeIcon, UploadIcon } from '@radix-ui/react-icons'; +import * as Tooltip from '../../../components/ui/Tooltip'; + +export const OperationTag: React.FC<{ operation: Exclude; highlight?: boolean }> = ({ operation, highlight }) => { + if (operation.type === 'sync') { + return ( +
+ + {operation.type} + + + + + {operation.action === 'cancel' && } + {operation.action === 'init' && } + {operation.action === 'pause' && } + {operation.action === 'run' && } + {operation.action === 'run_full' && } + {operation.action === 'unpause' && } + + + +

+ {operation.action} {operation.type} +

+
+
+
+ ); + } -export const OperationTag: React.FC<{ operation: Exclude }> = ({ operation }) => { return ( -
+ {operation.type} - {/* {'action' in operation && ( - <> - - {operation.action} - - )} */} -
+ ); - - return null; }; diff --git a/packages/webapp/src/pages/Logs/components/SearchInOperation.tsx b/packages/webapp/src/pages/Logs/components/SearchInOperation.tsx new file mode 100644 index 0000000000..5ff653c276 --- /dev/null +++ b/packages/webapp/src/pages/Logs/components/SearchInOperation.tsx @@ -0,0 +1,141 @@ +import type { ColumnDef } from '@tanstack/react-table'; +import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { Input } from '../../../components/ui/input/Input'; +import { useSearchMessages } from '../../../hooks/useLogs'; +import type { SearchOperationsData } from '@nangohq/types'; +import { formatDateToLogFormat, formatQuantity } from '../../../utils/utils'; +import { useStore } from '../../../store'; +import * as Table from '../../../components/ui/Table'; +import Spinner from '../../../components/ui/Spinner'; +import Info from '../../../components/ui/Info'; +import { LevelTag } from './LevelTag'; +import { MessageRow } from './MessageRow'; +import { ChevronRightIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { useMemo, useState } from 'react'; +import { useDebounce } from 'react-use'; +import { Tag } from './Tag'; + +export const columns: ColumnDef[] = [ + { + accessorKey: 'createdAt', + header: 'Timestamp', + size: 170, + cell: ({ row }) => { + return
{formatDateToLogFormat(row.original.createdAt)}
; + } + }, + { + accessorKey: 'type', + header: 'Type', + size: 80, + cell: ({ row }) => { + return {row.original.type === 'log' ? 'Message' : 'HTTP'}; + } + }, + { + accessorKey: 'level', + header: 'Level', + size: 70, + cell: ({ row }) => { + return ; + } + }, + { + accessorKey: 'message', + header: 'Additional Info', + size: 'auto' as unknown as number, + cell: ({ row }) => { + return
{row.original.message}
; + } + }, + { + accessorKey: 'id', + header: '', + size: 40, + cell: () => { + return ( +
+ +
+ ); + } + } +]; + +export const SearchInOperation: React.FC<{ operationId: string }> = ({ operationId }) => { + const env = useStore((state) => state.env); + + const [search, setSearch] = useState(); + const [debouncedSearch, setDebouncedSearch] = useState(); + const { data, error, loading } = useSearchMessages(env, { limit: 20, operationId, search: debouncedSearch }); + + const table = useReactTable({ + data: data ? data.data : [], + columns, + getCoreRowModel: getCoreRowModel() + }); + useDebounce(() => setDebouncedSearch(search), 250, [search]); + + const total = useMemo(() => { + if (!data?.pagination) { + return 0; + } + return formatQuantity(data.pagination.total); + }, [data?.pagination]); + + return ( +
+
+

Logs {loading && }

+
{total} logs found
+
+
+ } + placeholder="Search logs..." + className="border-border-gray-400" + onChange={(e) => setSearch(e.target.value)} + /> +
+
+ {error && ( + + An error occurred + + )} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ) + ) : ( + + + No results. + + + )} + + +
+
+ ); +}; diff --git a/packages/webapp/src/pages/Logs/components/StatusTag.tsx b/packages/webapp/src/pages/Logs/components/StatusTag.tsx index 33f66692bf..9d55a30b05 100644 --- a/packages/webapp/src/pages/Logs/components/StatusTag.tsx +++ b/packages/webapp/src/pages/Logs/components/StatusTag.tsx @@ -1,41 +1,42 @@ -import type { SearchLogsData } from '@nangohq/types'; +import type { SearchOperationsData } from '@nangohq/types'; +import { Tag } from './Tag'; -export const StatusTag: React.FC<{ state: SearchLogsData['state'] }> = ({ state }) => { +export const StatusTag: React.FC<{ state: SearchOperationsData['state'] }> = ({ state }) => { if (state === 'success') { return ( -
-
Success
-
+ + Success + ); } else if (state === 'running') { return ( -
-
Running
-
+ + Running + ); } else if (state === 'cancelled') { return ( -
-
Cancelled
-
+ + Cancelled + ); } else if (state === 'failed') { return ( -
-
Failed
-
+ + Failed + ); } else if (state === 'timeout') { return ( -
-
Timeout
-
+ + Timeout + ); } else if (state === 'waiting') { return ( -
-
Waiting
-
+ + Waiting + ); } diff --git a/packages/webapp/src/pages/Logs/components/Tag.tsx b/packages/webapp/src/pages/Logs/components/Tag.tsx new file mode 100644 index 0000000000..3e1c1f1f8f --- /dev/null +++ b/packages/webapp/src/pages/Logs/components/Tag.tsx @@ -0,0 +1,14 @@ +import type { HTMLAttributes } from 'react'; +import { cn } from '../../../utils/utils'; + +export const Tag: React.FC<{ + children: React.ReactNode; + bgClassName?: HTMLAttributes['className']; + textClassName?: HTMLAttributes['className']; +}> = ({ children, bgClassName, textClassName }) => { + return ( +
+
{children}
+
+ ); +}; diff --git a/packages/webapp/src/pages/Logs/constants.tsx b/packages/webapp/src/pages/Logs/constants.tsx index b8b801a52c..12c802d856 100644 --- a/packages/webapp/src/pages/Logs/constants.tsx +++ b/packages/webapp/src/pages/Logs/constants.tsx @@ -1,24 +1,24 @@ import type { ColumnDef } from '@tanstack/react-table'; -import type { SearchLogsData, SearchLogsState } from '@nangohq/types'; -import { formatDateToIntFormat } from '../../utils/utils'; +import type { SearchOperationsData, SearchOperationsState } from '@nangohq/types'; +import { formatDateToLogFormat } from '../../utils/utils'; import { StatusTag } from './components/StatusTag'; import { OperationTag } from './components/OperationTag'; import type { MultiSelectArgs } from './components/MultiSelect'; -import { ChevronRight } from '@geist-ui/icons'; +import { ChevronRightIcon } from '@radix-ui/react-icons'; -export const columns: ColumnDef[] = [ +export const columns: ColumnDef[] = [ { accessorKey: 'createdAt', header: 'Timestamp', - size: 150, + size: 170, cell: ({ row }) => { - return formatDateToIntFormat(row.original.createdAt); + return
{formatDateToLogFormat(row.original.createdAt)}
; } }, { accessorKey: 'state', header: 'Status', - size: 100, + size: 90, cell: ({ row }) => { return ; } @@ -26,7 +26,7 @@ export const columns: ColumnDef[] = [ { accessorKey: 'operation', header: 'Type', - size: 200, + size: 100, cell: ({ row }) => { return ; } @@ -36,15 +36,15 @@ export const columns: ColumnDef[] = [ header: 'Integration', size: 200, cell: ({ row }) => { - return row.original.configName; + return
{row.original.configName}
; } }, { accessorKey: 'syncId', header: 'Script', - size: 200, + size: 180, cell: ({ row }) => { - return row.original.syncName; + return
{row.original.syncName}
; } }, { @@ -52,24 +52,24 @@ export const columns: ColumnDef[] = [ header: 'Connection', size: 200, cell: ({ row }) => { - return row.original.connectionName; + return
{row.original.connectionName}
; } }, { accessorKey: 'id', header: '', - size: 10, + size: 40, cell: () => { return ( -
- +
+
); } } ]; -export const statusDefaultOptions: SearchLogsState[] = ['all']; +export const statusDefaultOptions: SearchOperationsState[] = ['all']; export const statusOptions: MultiSelectArgs['options'] = [ { name: 'All', diff --git a/packages/webapp/src/utils/utils.tsx b/packages/webapp/src/utils/utils.tsx index a7b1fb4cb0..5e7cdc37ae 100644 --- a/packages/webapp/src/utils/utils.tsx +++ b/packages/webapp/src/utils/utils.tsx @@ -92,9 +92,9 @@ export function formatTimestampWithTZ(timestamp: number): string { return formattedDate; } -export function elapsedTime(start: number, end: number): string { - const startTime = new Date(start).getTime(); - const endTime = new Date(end).getTime(); +export function elapsedTime(start: Date | number, end: Date | number): string { + const startTime = start instanceof Date ? start.getTime() : new Date(start).getTime(); + const endTime = end instanceof Date ? end.getTime() : new Date(end).getTime(); if (isNaN(startTime) || isNaN(endTime)) { return ''; @@ -146,7 +146,7 @@ export function formatDateToUSFormat(dateString: string): string { return formattedDate; } -export function formatDateToIntFormat(dateString: string): string { +export function formatDateToLogFormat(dateString: string): string { const date = new Date(dateString); const options: Intl.DateTimeFormatOptions = { hour: '2-digit', @@ -154,6 +154,7 @@ export function formatDateToIntFormat(dateString: string): string { second: '2-digit', month: 'short', day: '2-digit', + fractionalSecondDigits: 2, hour12: false }; @@ -164,7 +165,7 @@ export function formatDateToIntFormat(dateString: string): string { } const parts = formattedDate.split(', '); - return `${parts[0]}, ${parts[1]}`; + return `${parts[0]} ${parts[1]}`; } export function parseCron(frequency: string): string { @@ -373,6 +374,11 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +const quantityFormatter = Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1, minimumFractionDigits: 0 }); +export function formatQuantity(quantity: number): string { + return quantityFormatter.format(quantity); +} + export function formatFrequency(frequency: string): string { const unitMap: Record = { minutes: 'm', diff --git a/packages/webapp/tailwind.config.js b/packages/webapp/tailwind.config.js index 7dadbafe4a..1acd8291ce 100644 --- a/packages/webapp/tailwind.config.js +++ b/packages/webapp/tailwind.config.js @@ -31,17 +31,22 @@ module.exports = { 'dark-700': '#18181B', 'dark-800': '#09090B', 'bg-dark-blue': '#182633', - 'state-green-900': '#84D65A', - 'state-green-400': '#84D65A', 'row-hover': '#0d0d14', - white: '#FFFFFF' + white: '#FFFFFF', + + // From Figma + 'green-base': '#84D65A' }, width: { largebox: '1200px', largecell: '480px' }, fontSize: { + s: '13px', '3xl': '28px' + }, + fontFamily: { + code: ['"Roboto Mono"', '"Source Code Pro"', 'system-ui', 'sans-serif'] } } },