diff --git a/.github/workflows/web-app-deployer.yml b/.github/workflows/web-app-deployer.yml index e211ea1c3..8ba37e017 100644 --- a/.github/workflows/web-app-deployer.yml +++ b/.github/workflows/web-app-deployer.yml @@ -38,6 +38,10 @@ on: description: 1Password GitHub token secret reference type: string required: false + OP_SENTRY_DSN: + description: Reference to the 1Password Sentry DSN secret + type: string + required: false ENABLE_QUALITY_CHECKS: description: When true the tests and lint checks are performed(default), false will skip. This is only valid in protopype and demo deployments type: boolean @@ -172,6 +176,7 @@ jobs: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} SLACK_WEBHOOK_URL: ${{inputs.OP_SLACK_WEBHOOK_URL}} ADD_FEED_FORM_GITHUB_TOKEN: ${{inputs.OP_ADD_FEED_FORM_GITHUB_TOKEN}} + SENTRY_DSN: ${{inputs.OP_SENTRY_DSN}} - name: Authenticate to Google Cloud DEV if: ${{ inputs.FIREBASE_PROJECT == 'dev' }} @@ -235,6 +240,10 @@ jobs: echo "REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI=3600000" >> $GITHUB_ENV echo "REACT_APP_FEED_API_BASE_URL=https://api.mobilitydatabase.org" >> $GITHUB_ENV echo "REACT_APP_GBFS_VALIDATOR_API_BASE_URL=https://dev.gbfs.api.mobilitydatabase.org" >> $GITHUB_ENV + echo "REACT_APP_SENTRY_DSN=${{ env.SENTRY_DSN }}" >> $GITHUB_ENV + echo "REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE=0.1" >> $GITHUB_ENV + echo "REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE=0.1" >> $GITHUB_ENV + echo "REACT_APP_SENTRY_TRACES_SAMPLE_RATE=0.05" >> $GITHUB_ENV else echo "Setting FIREBASE_PROJECT to 'dev'" echo "FIREBASE_PROJECT=dev" >> $GITHUB_ENV @@ -253,7 +262,7 @@ jobs: - name: Populate Variables working-directory: web-app run: | - ../scripts/replace-variables.sh -in_file src/.env.rename_me -out_file src/.env.${{ inputs.FIREBASE_PROJECT }} -variables REACT_APP_FIREBASE_API_KEY,REACT_APP_FIREBASE_AUTH_DOMAIN,REACT_APP_FIREBASE_PROJECT_ID,REACT_APP_FIREBASE_STORAGE_BUCKET,REACT_APP_FIREBASE_MESSAGING_SENDER_ID,REACT_APP_FIREBASE_APP_ID,REACT_APP_RECAPTCHA_SITE_KEY,REACT_APP_GOOGLE_ANALYTICS_ID,REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI,REACT_APP_FEED_API_BASE_URL,REACT_APP_GBFS_VALIDATOR_API_BASE_URL + ../scripts/replace-variables.sh -in_file src/.env.rename_me -out_file src/.env.${{ inputs.FIREBASE_PROJECT }} -variables REACT_APP_FIREBASE_API_KEY,REACT_APP_FIREBASE_AUTH_DOMAIN,REACT_APP_FIREBASE_PROJECT_ID,REACT_APP_FIREBASE_STORAGE_BUCKET,REACT_APP_FIREBASE_MESSAGING_SENDER_ID,REACT_APP_FIREBASE_APP_ID,REACT_APP_RECAPTCHA_SITE_KEY,REACT_APP_GOOGLE_ANALYTICS_ID,REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI,REACT_APP_FEED_API_BASE_URL,REACT_APP_GBFS_VALIDATOR_API_BASE_URL -optional_variables REACT_APP_SENTRY_DSN,REACT_APP_SENTRY_TRACES_SAMPLE_RATE,REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE,REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE - name: Run Install for Functions if: ${{ inputs.DEPLOY_FIREBASE_FUNCTIONS }} diff --git a/.github/workflows/web-prod.yml b/.github/workflows/web-prod.yml index d98d64346..284a202e2 100644 --- a/.github/workflows/web-prod.yml +++ b/.github/workflows/web-prod.yml @@ -13,4 +13,5 @@ jobs: FEED_SUBMIT_GOOGLE_SHEET_ID: "10eIUxWVtLmc2EATiwivgXBf4bOMErOnq7GFIoRedXHU" OP_SLACK_WEBHOOK_URL: "op://rbiv7rvkkrsdlpcrz3bmv7nmcu/Slack webhook URLs/rdpfgrmnbxqaelgi5oky3lryz4/internal-add-feeds" OP_ADD_FEED_FORM_GITHUB_TOKEN: "op://rbiv7rvkkrsdlpcrz3bmv7nmcu/cwzlqlspbw7goqjsdqu4b7matq/credential" + OP_SENTRY_DSN: "op://Employee/Sentry DSN - MobilityDatabase PROD/Sentry DSN PROD" secrets: inherit \ No newline at end of file diff --git a/scripts/replace-variables.sh b/scripts/replace-variables.sh index 83cfd34b6..bcfe6cdbd 100755 --- a/scripts/replace-variables.sh +++ b/scripts/replace-variables.sh @@ -23,17 +23,19 @@ # For an example of a valid input file, check `../infra/vars.tfvars.rename_me`. # All variables need to be set to the environment previous running the script. # Parameters: -# -variables Comma separated list of variables names. -# -in_file Full path and file name of the input file. -# -out_file Full path and file name of the output file. -# -no_quotes Option to disable enclosing variable in double quotes during substitution +# -variables Comma separated list of REQUIRED variable names. +# -optional_variables Comma separated list of OPTIONAL variable names (may be unset or empty). +# -in_file Full path and file name of the input file. +# -out_file Full path and file name of the output file. +# -no_quotes Option to disable enclosing variable in double quotes during substitution display_usage() { printf "\nThis script replaces variables from an input file creating/overriding the content of on an output file" printf "\nScript Usage:\n" echo "Usage: $0 [options]" echo "Options:" - echo " -variables Comma separated list of variables names." + echo " -variables Comma separated list of REQUIRED variable names." + echo " -optional_variables Comma separated list of OPTIONAL variable names." echo " -in_file Full path and file name of the input file." echo " -out_file Full path and file name of the output file." echo " -no_quotes Do not enclose variable values with quotes." @@ -42,6 +44,7 @@ display_usage() { } VARIABLES="" +OPTIONAL_VARIABLES="" INPUT_FILE="" OUT_FILE="" ADD_QUOTES="true" @@ -55,6 +58,11 @@ while [[ $# -gt 0 ]]; do shift # past argument shift # past value ;; + -optional_variables) + OPTIONAL_VARIABLES="$2" + shift # past argument + shift # past value + ;; -in_file) IN_FILE="$2" shift # past argument @@ -96,30 +104,46 @@ then fi list=$(echo "$VARIABLES" | tr "," "\n") +optional_list=$(echo "$OPTIONAL_VARIABLES" | tr "," "\n") -# Check if all variables are set -for varname in $list -do - if [[ -z "${!varname}" ]]; then - echo "Missing required variable value with name: $varname." - echo "Script will not execute variables replacement, bye for now." - exit 1 - fi +# Check required variables (optional ones may be unset or empty) +for varname in $list; do + if [[ -z "${!varname+x}" ]]; then + echo "Missing required variable (unset) with name: $varname." + echo "Script will not execute variables replacement, bye for now." + exit 1 + fi + if [[ -z "${!varname}" ]]; then + echo "Missing required variable (empty value) with name: $varname." + echo "Script will not execute variables replacement, bye for now." + exit 1 + fi done # Reads from input setting the first version of the output. output=$(<"$IN_FILE") # Replace variables and create output file -for varname in $list -do - # shellcheck disable=SC2001 - # shellcheck disable=SC2016 - if [[ "$ADD_QUOTES" == "true" ]]; then - output=$(echo "$output" | sed 's|{{'"$varname"'}}|'\""${!varname}"\"'|g') - else - output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"${!varname}"'|g') - fi +for varname in $list; do + # Required variables are guaranteed non-empty here + value="${!varname}" + # shellcheck disable=SC2001 + # shellcheck disable=SC2016 + if [[ "$ADD_QUOTES" == "true" ]]; then + output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"\"$value\""'|g') + else + output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"$value"'|g') + fi +done + +# Substitute optional variables +for varname in $optional_list; do + value="${!varname}" + if [[ "$ADD_QUOTES" == "true" ]]; then + output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"\"$value\""'|g') + else + output=$(echo "$output" | sed 's|{{'"$varname"'}}|'"$value"'|g') + fi done echo "$output" > "$OUT_FILE" diff --git a/web-app/package.json b/web-app/package.json index cd169f55c..b7eac3da1 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -10,6 +10,7 @@ "@mui/x-date-pickers": "^7.11.0", "@mui/x-tree-view": "^6.17.0", "@reduxjs/toolkit": "^1.9.6", + "@sentry/react": "^10.26.0", "@turf/center": "^6.5.0", "@types/i18next": "^13.0.0", "@types/leaflet": "^1.9.12", diff --git a/web-app/src/.env.rename_me b/web-app/src/.env.rename_me index de891ca74..c3ac64336 100644 --- a/web-app/src/.env.rename_me +++ b/web-app/src/.env.rename_me @@ -10,3 +10,7 @@ REACT_APP_GOOGLE_ANALYTICS_ID={{REACT_APP_GOOGLE_ANALYTICS_ID}} REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI={{REACT_APP_REMOTE_CONFIG_MINIMUM_FETCH_INTERVAL_MILLI}} REACT_APP_FEED_API_BASE_URL={{REACT_APP_FEED_API_BASE_URL}} REACT_APP_GBFS_VALIDATOR_API_BASE_URL={{REACT_APP_GBFS_VALIDATOR_API_BASE_URL}} +REACT_APP_SENTRY_DSN={{REACT_APP_SENTRY_DSN}} +REACT_APP_SENTRY_TRACES_SAMPLE_RATE={{REACT_APP_SENTRY_TRACES_SAMPLE_RATE}} +REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE={{REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE}} +REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE={{REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE}} \ No newline at end of file diff --git a/web-app/src/app/components/SentryErrorFallback.tsx b/web-app/src/app/components/SentryErrorFallback.tsx new file mode 100644 index 000000000..5d9e9b5ae --- /dev/null +++ b/web-app/src/app/components/SentryErrorFallback.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { + Box, + Button, + Collapse, + Typography, + Stack, + Card, + CardContent, + Alert, +} from '@mui/material'; + +export interface SentryErrorFallbackProps { + error: unknown; + eventId?: string; + resetError?: () => void; +} + +const formatError = (error: unknown): string => { + if (error instanceof Error) { + return `${error.message}\n${error.stack}`; + } + try { + return typeof error === 'string' ? error : JSON.stringify(error, null, 2); + } catch { + return String(error); + } +}; + +export const SentryErrorFallback: React.FC = ({ + error, + eventId, + resetError, +}) => { + const [showDetails, setShowDetails] = useState(false); + const details = formatError(error); + return ( + + + + + + + Something went wrong + + + Our team has been notified. You can try reloading or attempt to + recover. + + + {eventId != null && ( + + Event ID: {eventId} + + )} + + + {resetError != null && ( + + )} + + + + + {details} + + + + + + + ); +}; + +export default SentryErrorFallback; diff --git a/web-app/src/app/store/store.ts b/web-app/src/app/store/store.ts index 37165c1d6..697616e2b 100644 --- a/web-app/src/app/store/store.ts +++ b/web-app/src/app/store/store.ts @@ -17,6 +17,7 @@ import createSagaMiddleware from '@redux-saga/core'; import rootSaga from './saga/root-saga'; import rootReducer from './reducers'; +import { createReduxEnhancer } from '@sentry/react'; const persistConfig = { key: 'root', @@ -29,19 +30,50 @@ const persistedReducer = persistReducer(persistConfig, rootReducer); const sagaMiddleware = createSagaMiddleware(); -export const store = configureStore({ - reducer: persistedReducer, - devTools: process.env.NODE_ENV !== 'production', - middleware: (getDefaultMiddleware) => [ - ...getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], - }, - }), - sagaMiddleware, - ], +// Light-weight state sanitizer used by Sentry redux enhancer +const sanitizeState = (state: unknown): unknown => { + if (state == null || typeof state !== 'object') { + return state; + } + const copy: Record = {}; + for (const [k, v] of Object.entries(state)) { + if (v == null) { + copy[k] = v; + } else if (Array.isArray(v)) { + copy[k] = { __type: 'array', length: v.length }; + } else if (typeof v === 'object') { + copy[k] = { __type: 'object', keys: Object.keys(v).length }; + } else { + copy[k] = v; + } + } + return copy; +}; + +const sentryReduxEnhancer = createReduxEnhancer({ + attachReduxState: true, + stateTransformer: sanitizeState, }); +/* eslint-disable */ +const makeStore = () => + configureStore({ + reducer: persistedReducer, + devTools: process.env.NODE_ENV !== 'production', + middleware: (getDefaultMiddleware) => [ + ...getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }), + sagaMiddleware, + ], + enhancers: (existing) => [...existing, sentryReduxEnhancer], + }); +/* eslint-enable */ + +export const store = makeStore(); + // Expose store to Cypress e2e tests /* eslint-disable */ if (window.Cypress) { diff --git a/web-app/src/index.tsx b/web-app/src/index.tsx index c3fb54048..8581e9b84 100644 --- a/web-app/src/index.tsx +++ b/web-app/src/index.tsx @@ -1,3 +1,6 @@ +import './sentry'; +import { SentryErrorBoundary } from './sentry'; +import SentryErrorFallback from './app/components/SentryErrorFallback'; import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; @@ -22,7 +25,18 @@ root.render( - + ( + + )} + showDialog + > + + diff --git a/web-app/src/sentry.ts b/web-app/src/sentry.ts new file mode 100644 index 000000000..6450f1ed5 --- /dev/null +++ b/web-app/src/sentry.ts @@ -0,0 +1,84 @@ +import * as Sentry from '@sentry/react'; +import packageJson from '../package.json'; +import * as React from 'react'; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; + +// Helper to safely parse Sentry sample rates from environment variables +const parseSampleRate = ( + value: string | undefined, + defaultValue: number, +): number => { + const parsed = parseFloat(value ?? String(defaultValue)); + if (isNaN(parsed) || parsed < 0 || parsed > 1) { + return defaultValue; + } + return parsed; +}; + +const dsn = process.env.REACT_APP_SENTRY_DSN || ''; +const environment = + process.env.REACT_APP_FIREBASE_PROJECT_ID || + process.env.NODE_ENV || + 'mobility-feeds-dev'; +const release = packageJson.version; +const tracesSampleRate = parseSampleRate( + process.env.REACT_APP_SENTRY_TRACES_SAMPLE_RATE, + 0.05, +); +const replaysSessionSampleRate = parseSampleRate( + process.env.REACT_APP_SENTRY_REPLAY_SESSION_SAMPLE_RATE, + 0.0, +); +const replaysOnErrorSampleRate = parseSampleRate( + process.env.REACT_APP_SENTRY_REPLAY_ERROR_SAMPLE_RATE, + 1.0, +); + +if (dsn) { + const routerTracingIntegration = + Sentry.reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }); + + const integrations = []; + if (routerTracingIntegration) { + integrations.push(routerTracingIntegration); + } + const replayIntegration = Sentry.replayIntegration?.(); + if (replayIntegration) { + integrations.push(replayIntegration); + } + + Sentry.init({ + dsn, + environment, + release, + integrations: integrations, + tracesSampleRate, + replaysSessionSampleRate, + replaysOnErrorSampleRate, + ignoreErrors: [/ResizeObserver loop limit exceeded/i], + beforeSend(event) { + // remove user IP and geo context + if (event.user) { + delete event.user.ip_address; + } + if (event.contexts && event.contexts.geo) { + delete event.contexts.geo; + } + return event; + } + }); +} + +export const SentryErrorBoundary = Sentry.ErrorBoundary; +export const captureException = Sentry.captureException; diff --git a/web-app/yarn.lock b/web-app/yarn.lock index 7e905d0cf..e42184502 100644 --- a/web-app/yarn.lock +++ b/web-app/yarn.lock @@ -2962,6 +2962,61 @@ resolved "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.6.0.tgz" integrity sha512-2/U3GXA6YiPYQDLGwtGlnNgKYBSwCFIHf8Y9LUY5VATHdtbLlU0Y1R3QoBnT0aB4qv/BEiVVsj7LJXoQCgJ2vA== +"@sentry-internal/browser-utils@10.26.0": + version "10.26.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.26.0.tgz#ab8e12f009a9f3994c7438ccec7afc46c01ae00d" + integrity sha512-rPg1+JZlfp912pZONQAWZzbSaZ9L6R2VrMcCEa+2e2Gqk9um4b+LqF5RQWZsbt5Z0n0azSy/KQ6zAe/zTPXSOg== + dependencies: + "@sentry/core" "10.26.0" + +"@sentry-internal/feedback@10.26.0": + version "10.26.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.26.0.tgz#31f0cbf9fe9908432ceb409e5dce099b5028cefd" + integrity sha512-0vk9eQP0CXD7Y2WkcCIWHaAqnXOAi18/GupgWLnbB2kuQVYVtStWxtW+OWRe8W/XwSnZ5m6JBTVeokuk/O16DQ== + dependencies: + "@sentry/core" "10.26.0" + +"@sentry-internal/replay-canvas@10.26.0": + version "10.26.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.26.0.tgz#81a2cb606dcbb728aed646cb4ebffad1d7c64949" + integrity sha512-vs7d/P+8M1L1JVAhhJx2wo15QDhqAipnEQvuRZ6PV7LUcS1un9/Vx49FMxpIkx6JcKADJVwtXrS1sX2hoNT/kw== + dependencies: + "@sentry-internal/replay" "10.26.0" + "@sentry/core" "10.26.0" + +"@sentry-internal/replay@10.26.0": + version "10.26.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.26.0.tgz#294b9596f99905eca071782e48403cd4d82e38c3" + integrity sha512-FMySQnY2/p0dVtFUBgUO+aMdK2ovqnd7Q/AkvMQUsN/5ulyj6KZx3JX3CqOqRtAr1izoCe4Kh2pi5t//sQmvsg== + dependencies: + "@sentry-internal/browser-utils" "10.26.0" + "@sentry/core" "10.26.0" + +"@sentry/browser@10.26.0": + version "10.26.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.26.0.tgz#ad63a0e4ff5e5c1b6bcd8a678cee89ba9b5f60af" + integrity sha512-uvV4hnkt8bh8yP0disJ0fszy8FdnkyGtzyIVKdeQZbNUefwbDhd3H0KJrAHhJ5ocULMH3B+dipdPmw2QXbEflg== + dependencies: + "@sentry-internal/browser-utils" "10.26.0" + "@sentry-internal/feedback" "10.26.0" + "@sentry-internal/replay" "10.26.0" + "@sentry-internal/replay-canvas" "10.26.0" + "@sentry/core" "10.26.0" + +"@sentry/core@10.26.0": + version "10.26.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.26.0.tgz#381ce1987aca78b7dedae08f6b806406ab6e6ffe" + integrity sha512-TjDe5QI37SLuV0q3nMOH8JcPZhv2e85FALaQMIhRILH9Ce6G7xW5GSjmH91NUVq8yc3XtiqYlz/EenEZActc4Q== + +"@sentry/react@^10.26.0": + version "10.26.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.26.0.tgz#2c8d0f0eac07faece52fbede3a3e3a4d06c10e86" + integrity sha512-Qi0/FVXAalwQNr8zp0tocViH3+MRelW8ePqj3TdMzapkbXRuh07czdGgw8Zgobqcb7l4rRCRAUo2sl/H3KVkIw== + dependencies: + "@sentry/browser" "10.26.0" + "@sentry/core" "10.26.0" + hoist-non-react-statics "^3.3.2" + "@sideway/address@^4.1.3": version "4.1.4" resolved "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz" @@ -14006,7 +14061,16 @@ string-natural-compare@^3.0.1: resolved "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14089,7 +14153,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15569,7 +15640,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15587,6 +15658,15 @@ wrap-ansi@^6.0.1, wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"