diff --git a/.eslintrc b/.eslintrc index 5068e0603..3208aaf48 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,7 +5,8 @@ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", - "plugin:react/recommended" + "plugin:react/recommended", + "plugin:@tanstack/query/recommended" ], "root": true, "ignorePatterns": ["vite.config.mts"], @@ -50,6 +51,7 @@ } }, "rules": { + "@tanstack/query/exhaustive-deps": "warn", "@typescript-eslint/ban-types": "warn", "@typescript-eslint/no-base-to-string": "warn", "@typescript-eslint/consistent-type-imports": "warn", diff --git a/package.json b/package.json index 526870636..c243b0d08 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "@mui/styles": "^5.11.9", "@mui/x-date-pickers": "^5.0.18", "@mui/x-tree-view": "^7.6.2", + "@tanstack/query-sync-storage-persister": "^5.59.0", + "@tanstack/react-query": "^5.56.2", + "@tanstack/react-query-persist-client": "^5.59.0", "@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react-swc": "^3.5.0", "autosuggest-highlight": "^3.3.4", @@ -41,6 +44,7 @@ "i18next": "^22.4.9", "i18next-browser-languagedetector": "^7.0.1", "lodash": "^4.17.21", + "lz-string": "^1.5.0", "md5": "^2.3.0", "moment": "^2.30.1", "moment-timezone": "^0.5.40", @@ -101,6 +105,8 @@ "@iconify/icons-simple-icons": "^1.2.56", "@iconify/react": "^4.1.1", "@jest/globals": "^29.6.4", + "@tanstack/eslint-plugin-query": "^5.58.1", + "@tanstack/react-query-devtools": "^5.56.2", "@testing-library/dom": "^7.31.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^14.1.2", diff --git a/pipelines/azure-test.yaml b/pipelines/azure-test.yaml index b9f74e7af..757a47560 100644 --- a/pipelines/azure-test.yaml +++ b/pipelines/azure-test.yaml @@ -33,6 +33,11 @@ jobs: yarn install displayName: Install assemblyline-ui-frontend + - script: | + set -xv # Echo commands before they are run + npm run tsc + displayName: TypeScript + - script: | set -xv # Echo commands before they are run npm run ci-test @@ -43,11 +48,6 @@ jobs: npm run ci-lint displayName: ESLint - - script: | - set -xv # Echo commands before they are run - npm run tsc - displayName: TypeScript - - task: PublishCodeCoverageResults@2 inputs: codeCoverageTool: Cobertura diff --git a/src/commons/components/pages/PageFullScreen.tsx b/src/commons/components/pages/PageFullScreen.tsx index 498d5a3e9..b5517981c 100644 --- a/src/commons/components/pages/PageFullScreen.tsx +++ b/src/commons/components/pages/PageFullScreen.tsx @@ -1,11 +1,12 @@ import FullscreenIcon from '@mui/icons-material/Fullscreen'; import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; import { IconButton, Tooltip, useTheme } from '@mui/material'; +import browser from 'browser-detect'; import useAppBar from 'commons/components/app/hooks/useAppBar'; import useAppBarHeight from 'commons/components/app/hooks/useAppBarHeight'; import useAppLayout from 'commons/components/app/hooks/useAppLayout'; import useFullscreenStatus from 'commons/components/utils/hooks/useFullscreenStatus'; -import { memo, useCallback, useRef } from 'react'; +import { memo, useCallback, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import PageContent from './PageContent'; @@ -31,6 +32,8 @@ const PageFullscreen = ({ children, margin = null, mb = 2, ml = 2, mr = 2, mt = const barWillHide = layout.current !== 'top' && appbar.autoHide; + const isFirefox = useMemo(() => browser().name === 'firefox', []); + try { [isFullscreen, setIsFullscreen] = useFullscreenStatus(maximizableElement); } catch (e) { @@ -63,7 +66,20 @@ const PageFullscreen = ({ children, margin = null, mb = 2, ml = 2, mr = 2, mt = paddingTop: theme.spacing(2), right: theme.spacing(2), zIndex: theme.zIndex.appBar + 1, - top: barWillHide || isFullscreen ? 0 : appBarHeight + top: barWillHide || isFullscreen ? 0 : appBarHeight, + ...(!isFirefox + ? null + : !isFullscreen + ? { + position: 'fixed', + top: '96px', + right: '32px' + } + : { + position: 'fixed', + top: '32px', + right: '32px' + }) }} > {fullscreenSupported ? null : ( diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index 0dca7954d..da68ae9ef 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -24,6 +24,7 @@ import Routes from 'components/routes/routes'; import Tos from 'components/routes/tos'; import setMomentFRLocale from 'helpers/moment-fr-locale'; import { getProvider } from 'helpers/utils'; +import { APIProvider } from 'lib/api/APIProvider'; import React, { useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router-dom'; @@ -102,13 +103,15 @@ export const MyApp: React.FC = () => { const myUser: CustomAppUserService = useMyUser(); return ( - - - - - - - + + + + + + + + + ); }; diff --git a/src/components/hooks/useMyAPI.tsx b/src/components/hooks/useMyAPI.tsx index 2ad0affa7..392792ff7 100644 --- a/src/components/hooks/useMyAPI.tsx +++ b/src/components/hooks/useMyAPI.tsx @@ -11,14 +11,14 @@ const DEFAULT_RETRY_MS = 32; export type APIResponseProps = { api_error_message: string; - api_response: APIResponse | any; + api_response: APIResponse; api_server_version: string; api_status_code: number; }; export type DownloadResponseProps = { api_error_message: string; - api_response: APIResponse | any; + api_response: APIResponse; api_server_version: string; api_status_code: number; filename?: string; @@ -67,9 +67,9 @@ type DownloadBlobProps = { }; type UseMyAPIReturn = { - apiCall: (props: APICallProps) => void; + apiCall: (props: APICallProps) => void; bootstrap: (props: BootstrapProps) => void; - downloadBlob: ( + downloadBlob: ( props: DownloadBlobProps ) => void; }; @@ -217,7 +217,7 @@ const useMyAPI = (): UseMyAPIReturn => { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, - body: (!body ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit + body: (body !== null ? (contentType === 'application/json' ? JSON.stringify(body) : body) : null) as BodyInit }; // Run enter callback diff --git a/src/components/hooks/useMySitemap.tsx b/src/components/hooks/useMySitemap.tsx index 073dc1162..58b185ade 100644 --- a/src/components/hooks/useMySitemap.tsx +++ b/src/components/hooks/useMySitemap.tsx @@ -10,6 +10,7 @@ import BusinessOutlinedIcon from '@mui/icons-material/BusinessOutlined'; import ChromeReaderModeOutlinedIcon from '@mui/icons-material/ChromeReaderModeOutlined'; import CodeOutlinedIcon from '@mui/icons-material/CodeOutlined'; import CompareArrowsOutlinedIcon from '@mui/icons-material/CompareArrowsOutlined'; +import CreateOutlinedIcon from '@mui/icons-material/CreateOutlined'; import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined'; import DataObjectOutlinedIcon from '@mui/icons-material/DataObjectOutlined'; import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; @@ -171,7 +172,13 @@ export default function useMySitemap() { breadcrumbs: ['/manage'] }, { - path: '/manage/workflow/:id', + path: '/manage/workflow/create/:id', + title: t('breadcrumb.workflow.create'), + icon: , + breadcrumbs: ['/manage', '/manage/workflows'] + }, + { + path: '/manage/workflow/detail/:id', title: t('breadcrumb.workflow.detail'), icon: , breadcrumbs: ['/manage', '/manage/workflows'] diff --git a/src/components/models/base/workflow.ts b/src/components/models/base/workflow.ts index 839e05ae9..084b49fac 100644 --- a/src/components/models/base/workflow.ts +++ b/src/components/models/base/workflow.ts @@ -92,3 +92,20 @@ export type WorkflowIndexed = Pick< | 'status' | 'workflow_id' >; + +export const DEFAULT_WORKFLOW: Workflow = { + classification: '', + creation_date: undefined, + creator: '', + description: '', + edited_by: '', + enabled: false, + hit_count: 0, + id: '', + labels: [], + last_edit: undefined, + name: '', + priority: '', + query: '', + status: '' +}; diff --git a/src/components/routes/admin/service_detail/general.tsx b/src/components/routes/admin/service_detail/general.tsx index d0058c42b..4fdbb041a 100644 --- a/src/components/routes/admin/service_detail/general.tsx +++ b/src/components/routes/admin/service_detail/general.tsx @@ -373,7 +373,7 @@ const ServiceGeneral = ({ isOptionEqualToValue={(option, value) => option.toUpperCase() === value.toUpperCase()} onChange={(_e, values) => { setModified(true); - setService(s => ({ ...s, recursion_prevention: values.toSorted() })); + setService(s => ({ ...s, recursion_prevention: [...values].sort() })); }} renderInput={params => } renderOption={(props, option, state) => ( diff --git a/src/components/routes/admin/services.tsx b/src/components/routes/admin/services.tsx index c3bfca550..ace31f208 100644 --- a/src/components/routes/admin/services.tsx +++ b/src/components/routes/admin/services.tsx @@ -74,7 +74,7 @@ export default function Services() { () => (serviceFeeds || []) .reduce((prev: string[], item) => (item?.summary ? [...prev, item.summary] : prev), []) - .toSorted(), + .sort(), [serviceFeeds] ); diff --git a/src/components/routes/alerts.tsx b/src/components/routes/alerts.tsx index 4488ee294..9b8ef1c46 100644 --- a/src/components/routes/alerts.tsx +++ b/src/components/routes/alerts.tsx @@ -1,4 +1,5 @@ -import { AlertTitle, Grid, Typography, useMediaQuery, useTheme } from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import { AlertTitle, Grid, IconButton, Tooltip, Typography, useMediaQuery, useTheme } from '@mui/material'; import SimpleList from 'commons/addons/lists/simplelist/SimpleList'; import useAppUser from 'commons/components/app/hooks/useAppUser'; import PageFullWidth from 'commons/components/pages/PageFullWidth'; @@ -6,11 +7,13 @@ import useALContext from 'components/hooks/useALContext'; import useDrawer from 'components/hooks/useDrawer'; import useMyAPI from 'components/hooks/useMyAPI'; import type { Alert, AlertIndexed, AlertItem } from 'components/models/base/alert'; +import type { Workflow } from 'components/models/base/workflow'; import type { CustomUser } from 'components/models/ui/user'; import InformativeAlert from 'components/visual/InformativeAlert'; import { DEFAULT_SUGGESTION } from 'components/visual/SearchBar/search-textfield'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { BiNetworkChart } from 'react-icons/bi'; import { useNavigate } from 'react-router'; import { useLocation } from 'react-router-dom'; import ForbiddenPage from './403'; @@ -27,6 +30,7 @@ import { SearchParamsProvider, useSearchParams } from './alerts/contexts/SearchP import AlertDetail from './alerts/detail'; import type { SearchParams } from './alerts/utils/SearchParams'; import type { SearchResult } from './alerts/utils/SearchParser'; +import { WorkflowCreate } from './manage/workflows/create'; type ListResponse = { items: AlertIndexed[]; @@ -113,10 +117,10 @@ const WrappedAlertsContent = () => { setScrollReset(true); } - apiCall({ + apiCall({ url: `${pathname}?${query2.toString()}`, method: 'GET', - onSuccess: ({ api_response }: { api_response: ListResponse | GroupedResponse }) => { + onSuccess: ({ api_response }) => { if ('tc_start' in api_response) { setSearchObject(o => ({ ...o, tc_start: api_response.tc_start })); } @@ -150,29 +154,46 @@ const WrappedAlertsContent = () => { (item: Alert) => { if (!item) return; if (isLGDown) document.getElementById(ALERT_SIMPLELIST_ID).blur(); - navigate(`${location.pathname}${location.search}#${item.alert_id}`); + navigate(`${location.pathname}${location.search}#/alert/${item.alert_id}`); }, [isLGDown, location.pathname, location.search, navigate] ); + const handleCreateWorkflow = useCallback(() => { + if (!currentUser.roles.includes('workflow_manage')) return; + const q = search.get('q'); + const fq = search.get('fq'); + + const values = (!q && !fq.length ? [''] : q ? [q] : []).concat(fq); + const query = values + .map(v => ([' or ', ' and '].some(a => v.toLowerCase().includes(a)) ? `(${v})` : v)) + .join(' AND '); + + const state: Partial = { query }; + navigate(`${location.pathname}${location.search}#/workflow/`, { state }); + }, [currentUser.roles, location.pathname, location.search, navigate, search]); + useEffect(() => { handleFetch(search); }, [handleFetch, search]); useEffect(() => { - if (!globalDrawerOpened && location.hash && location.hash !== '') { + if (alerts.length && !globalDrawerOpened && location.hash) { navigate(`${location.pathname}${location.search}`); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [globalDrawerOpened]); useEffect(() => { - if (!location.hash) { - closeGlobalDrawer(); - } else { - const id = location.hash.substr(1); + const url = new URL(`${window.location.origin}${location.hash.slice(1)}`); + if (url.pathname.startsWith('/alert/')) { + const id = url.pathname.slice('/alert/'.length); const alert = alerts.find(item => item.alert_id === id); setGlobalDrawer(, { hasMaximize: true }); + } else if (url.pathname.startsWith('/workflow/')) { + setGlobalDrawer(); + } else { + closeGlobalDrawer(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [alerts, closeGlobalDrawer, location.hash, setGlobalDrawer]); @@ -206,7 +227,7 @@ const WrappedAlertsContent = () => { useEffect(() => { const refresh = ({ detail = null }: CustomEvent) => { - setSearchObject(o => ({ ...o, ...detail, offset: 0, refresh: !o.refresh, fq: [...detail.fq, ...o.fq] })); + setSearchObject(o => ({ ...o, ...detail, offset: 0, refresh: !o.refresh, fq: [...(detail?.fq || []), ...o.fq] })); }; window.addEventListener('alertRefresh', refresh); @@ -224,8 +245,18 @@ const WrappedAlertsContent = () => { {t('alerts')} - + + {currentUser.roles.includes('workflow_manage') && ( + +
+ + + + +
+
+ )}
diff --git a/src/components/routes/alerts/components/Filters.tsx b/src/components/routes/alerts/components/Filters.tsx index 3d3f71abb..126e06986 100644 --- a/src/components/routes/alerts/components/Filters.tsx +++ b/src/components/routes/alerts/components/Filters.tsx @@ -91,7 +91,8 @@ export const SORT_OPTIONS: Option[] = [ { value: 'reporting_ts', label: 'alerted_ts' }, { value: 'owner', label: 'owner' }, { value: 'priority', label: 'priority' }, - { value: 'status', label: 'status' } + { value: 'status', label: 'status' }, + { value: 'al.score', label: 'al.score' } ]; export const TC_OPTIONS: Option[] = [ diff --git a/src/components/routes/alerts/components/Workflows.tsx b/src/components/routes/alerts/components/Workflows.tsx index ee0085a3e..5fc5eafdb 100644 --- a/src/components/routes/alerts/components/Workflows.tsx +++ b/src/components/routes/alerts/components/Workflows.tsx @@ -1,5 +1,6 @@ import AddIcon from '@mui/icons-material/Add'; import CloseOutlinedIcon from '@mui/icons-material/CloseOutlined'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import RemoveIcon from '@mui/icons-material/Remove'; import { Alert, @@ -381,6 +382,7 @@ const WrappedAlertWorkflows = ({ alerts = [] }: Props) => { margin: theme.spacing(0.25) }} /> + diff --git a/src/components/routes/alerts/utils/SearchParams.tsx b/src/components/routes/alerts/utils/SearchParams.tsx index aff35d8de..583bf5433 100644 --- a/src/components/routes/alerts/utils/SearchParams.tsx +++ b/src/components/routes/alerts/utils/SearchParams.tsx @@ -230,7 +230,7 @@ export class ArrayParam extends BaseParam { private append(prev: URLSearchParams, values: string[][]): void { values - .toSorted((a, b) => a.at(-1).localeCompare(b.at(-1))) + .sort((a, b) => a.at(-1).localeCompare(b.at(-1))) .map(value => this.fromPrefix(value)) .forEach(v => { prev.append(this.key, v); diff --git a/src/components/routes/dashboard.tsx b/src/components/routes/dashboard.tsx index 8e4745bf9..244d8ec37 100644 --- a/src/components/routes/dashboard.tsx +++ b/src/components/routes/dashboard.tsx @@ -400,9 +400,9 @@ const WrappedDispatcherCard = ({ dispatcher, up, down, handleStatusChange, statu {dispatcher.initialized ? (
{up.length === 0 && down.length === 0 && {t('no_services')}} - {up.length !== 0 && {up.join(' | ')}} + {up.length !== 0 && {up.sort().join(' | ')}} {up.length !== 0 && down.length !== 0 && :: } - {down.length !== 0 && {down.join(' | ')}} + {down.length !== 0 && {down.sort().join(' | ')}}
) : (
diff --git a/src/components/routes/manage/workflow_detail.tsx b/src/components/routes/manage/workflow_detail.tsx deleted file mode 100644 index 8b5b5f7e4..000000000 --- a/src/components/routes/manage/workflow_detail.tsx +++ /dev/null @@ -1,782 +0,0 @@ -import ControlPointDuplicateOutlinedIcon from '@mui/icons-material/ControlPointDuplicateOutlined'; -import EditOffOutlinedIcon from '@mui/icons-material/EditOffOutlined'; -import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; -import RemoveCircleOutlineOutlinedIcon from '@mui/icons-material/RemoveCircleOutlineOutlined'; -import ToggleOffOutlinedIcon from '@mui/icons-material/ToggleOffOutlined'; -import ToggleOnIcon from '@mui/icons-material/ToggleOn'; -import YoutubeSearchedForIcon from '@mui/icons-material/YoutubeSearchedFor'; -import type { Theme } from '@mui/material'; -import { - Autocomplete, - Button, - Checkbox, - Chip, - CircularProgress, - FormControlLabel, - Grid, - IconButton, - MenuItem, - Select, - Skeleton, - TextField, - Tooltip, - Typography, - useTheme -} from '@mui/material'; -import FormControl from '@mui/material/FormControl'; -import createStyles from '@mui/styles/createStyles'; -import makeStyles from '@mui/styles/makeStyles'; -import withStyles from '@mui/styles/withStyles'; -import Throttler from 'commons/addons/utils/throttler'; -import useAppUser from 'commons/components/app/hooks/useAppUser'; -import PageCenter from 'commons/components/pages/PageCenter'; -import useALContext from 'components/hooks/useALContext'; -import useMyAPI from 'components/hooks/useMyAPI'; -import useMySnackbar from 'components/hooks/useMySnackbar'; -import type { Workflow } from 'components/models/base/workflow'; -import { LABELS } from 'components/models/base/workflow'; -import type { CustomUser } from 'components/models/ui/user'; -import ForbiddenPage from 'components/routes/403'; -import Classification from 'components/visual/Classification'; -import ConfirmationDialog from 'components/visual/ConfirmationDialog'; -import Histogram from 'components/visual/Histogram'; -import Moment from 'components/visual/Moment'; -import { RouterPrompt } from 'components/visual/RouterPrompt'; -import AlertsTable from 'components/visual/SearchResult/alerts'; -import React, { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { Link, useParams } from 'react-router-dom'; - -const useStyles = makeStyles(theme => ({ - buttonProgress: { - position: 'absolute', - top: '50%', - left: '50%', - marginTop: -12, - marginLeft: -12 - } -})); - -type ParamProps = { - id: string; -}; - -type WorkflowDetailProps = { - workflow_id?: string; - close?: () => void; - mode?: 'read' | 'write'; -}; - -const MyMenuItem = withStyles((theme: Theme) => - createStyles({ - root: { - minHeight: theme.spacing(4) - } - }) -)(MenuItem); - -const THROTTLER = new Throttler(250); - -const WrappedWorkflowDetail = ({ workflow_id = null, close = () => null, mode = 'read' }: WorkflowDetailProps) => { - const { t, i18n } = useTranslation(['manageWorkflowDetail']); - const { id } = useParams(); - const theme = useTheme(); - const [workflow, setWorkflow] = useState(null); - const [originalWorkflow, setOriginalWorkflow] = useState(null); - const [histogram, setHistogram] = useState(null); - const [results, setResults] = useState(null); - const [hits, setHits] = useState(0); - const [runWorkflow, setRunWorkflow] = useState(false); - const [modified, setModified] = useState(false); - const [badQuery, setBadQuery] = useState(false); - const [buttonLoading, setButtonLoading] = useState(false); - const [deleteDialog, setDeleteDialog] = useState(false); - const [disableDialog, setDisableDialog] = useState(false); - const [enableDialog, setEnableDialog] = useState(false); - const [viewMode, setViewMode] = useState(mode); - const [workflowID, setWorkflowID] = useState(null); - const { c12nDef, configuration } = useALContext(); - const { user: currentUser } = useAppUser(); - const { showSuccessMessage, showErrorMessage } = useMySnackbar(); - const { apiCall } = useMyAPI(); - const classes = useStyles(); - const navigate = useNavigate(); - const inputRef = React.useRef(null); - - const DEFAULT_WORKFLOW: Workflow = { - classification: c12nDef.UNRESTRICTED, - creation_date: undefined, - creator: '', - description: '', - edited_by: '', - enabled: true, - hit_count: 0, - id: '', - labels: [], - last_edit: undefined, - name: '', - origin: configuration.ui.fqdn, - priority: '', - query: '', - status: '' - }; - - useEffect(() => { - setWorkflowID(workflow_id || id); - }, [id, workflow_id]); - - useEffect(() => { - if (workflowID && currentUser.roles.includes('workflow_view')) { - apiCall({ - url: `/api/v4/workflow/${workflowID}/`, - onSuccess: api_data => { - setWorkflow({ - ...api_data.api_response, - status: api_data.api_response.status || '', - priority: api_data.api_response.priority || '', - enabled: api_data.api_response.enabled === undefined ? true : api_data.api_response.enabled - }); - setOriginalWorkflow({ - ...api_data.api_response, - status: api_data.api_response.status || '', - priority: api_data.api_response.priority || '', - labels: api_data.api_response.labels || [], - enabled: api_data.api_response.enabled === undefined ? true : api_data.api_response.enabled - }); - - apiCall({ - method: 'POST', - url: '/api/v4/search/histogram/alert/events.ts/', - body: { - query: `events.entity_id:${workflowID}`, - mincount: 0, - start: 'now-30d/d', - end: 'now+1d/d-1s', - gap: '+1d' - }, - onSuccess: hist_data => { - setHistogram(hist_data.api_response); - } - }); - apiCall({ - method: 'GET', - url: `/api/v4/search/alert/?query=events.entity_id:${workflowID}&rows=10`, - onSuccess: top_data => { - setResults(top_data.api_response); - } - }); - }, - onFailure: api_data => { - showErrorMessage(api_data.api_error_message); - close(); - } - }); - setViewMode('read'); - } else { - setViewMode('write'); - setWorkflow({ ...DEFAULT_WORKFLOW }); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workflowID, id]); - - const handleNameChange = event => { - setModified(true); - setWorkflow({ ...workflow, name: event.target.value }); - }; - - const handleQueryChange = event => { - setModified(true); - setWorkflow({ ...workflow, query: event.target.value }); - THROTTLER.delay(() => { - if (event.target.value !== '') { - apiCall({ - method: 'GET', - url: `/api/v4/search/alert/?query=${encodeURI(event.target.value)}&rows=0&track_total_hits=true`, - onSuccess: api_data => { - setHits(api_data.api_response.total || 0); - setBadQuery(false); - }, - onFailure: () => { - setResults(0); - setBadQuery(true); - } - }); - } else { - setResults(0); - setBadQuery(false); - } - }); - }; - - const handleCheckboxChange = () => { - setRunWorkflow(!runWorkflow); - }; - - const handleLabelsChange = labels => { - setModified(true); - setWorkflow({ ...workflow, labels: labels.map(label => label.toUpperCase()) }); - }; - - const handlePriorityChange = event => { - setModified(true); - setWorkflow({ ...workflow, priority: event.target.value }); - }; - - const handleStatusChange = event => { - setModified(true); - setWorkflow({ ...workflow, status: event.target.value }); - }; - - const setClassification = classification => { - setModified(true); - setWorkflow({ ...workflow, classification }); - }; - - const enableWorkflow = () => { - apiCall({ - body: true, - url: `/api/v4/workflow/enable/${workflowID}/`, - method: 'PUT', - onSuccess: () => { - setEnableDialog(false); - showSuccessMessage(t('enable.success')); - setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); - setWorkflow({ ...workflow, enabled: true }); - }, - onEnter: () => setButtonLoading(true), - onExit: () => setButtonLoading(false) - }); - }; - - const disableWorkflow = () => { - apiCall({ - body: false, - url: `/api/v4/workflow/enable/${workflowID}/`, - method: 'PUT', - onSuccess: () => { - setDisableDialog(false); - showSuccessMessage(t('disable.success')); - setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); - setWorkflow({ ...workflow, enabled: false }); - }, - onEnter: () => setButtonLoading(true), - onExit: () => setButtonLoading(false) - }); - }; - - const removeWorkflow = () => { - apiCall({ - url: `/api/v4/workflow/${workflowID}/`, - method: 'DELETE', - onSuccess: () => { - setDeleteDialog(false); - showSuccessMessage(t('delete.success')); - if (id) { - setTimeout(() => navigate('/manage/workflows'), 1000); - } - setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); - close(); - }, - onEnter: () => setButtonLoading(true), - onExit: () => setButtonLoading(false) - }); - }; - - const saveWorkflow = () => { - apiCall({ - url: workflowID - ? `/api/v4/workflow/${workflowID}/?run_workflow=${runWorkflow}` - : `/api/v4/workflow/?run_workflow=${runWorkflow}`, - method: workflowID ? 'POST' : 'PUT', - body: { - ...workflow, - priority: !workflow.priority ? null : workflow.priority, - status: !workflow.status ? null : workflow.status - }, - onSuccess: () => { - showSuccessMessage(t(workflowID ? 'save.success' : 'add.success')); - setModified(false); - setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); - setViewMode('read'); - if (!workflowID) close(); - }, - onEnter: () => setButtonLoading(true), - onExit: () => setButtonLoading(false) - }); - }; - - useEffect(() => { - setWorkflowID(workflow_id || id); - return () => { - setWorkflowID(null); - }; - }, [id, workflow_id]); - - return currentUser.roles.includes('workflow_view') ? ( - - setDeleteDialog(false)} - handleAccept={removeWorkflow} - title={t('delete.title')} - cancelText={t('delete.cancelText')} - acceptText={t('delete.acceptText')} - text={t('delete.text')} - waiting={buttonLoading} - /> - setDisableDialog(false)} - handleAccept={disableWorkflow} - title={t('disable.title')} - cancelText={t('disable.cancelText')} - acceptText={t('disable.acceptText')} - text={t('disable.text')} - waiting={buttonLoading} - /> - setEnableDialog(false)} - handleAccept={enableWorkflow} - title={t('enable.title')} - cancelText={t('enable.cancelText')} - acceptText={t('enable.acceptText')} - text={t('enable.text')} - waiting={buttonLoading} - /> - -
- -
- -
-
- - - {t(workflowID ? 'title' : 'add.title')} - - {workflow ? workflowID : } - - - - {workflowID && - currentUser.roles.includes('workflow_view') && - (workflow ? ( - -
- - - -
-
- ) : null)} - {workflowID && - currentUser.roles.includes('workflow_manage') && - (workflow ? ( - -
- { - // Switch to write mode - setViewMode('write'); - setTimeout(() => { - inputRef.current.focus(); - }, 250); - - // Keep properties of workflow that are important - const keptProperties = { - classification: workflow.classification, - enabled: workflow.enabled, - labels: workflow.labels, - priority: workflow.priority, - query: workflow.query, - status: workflow.status - }; - - // Apply important properties on top of default workflow template - setWorkflow({ ...DEFAULT_WORKFLOW, ...keptProperties }); - setWorkflowID(null); - setModified(true); - }} - size="large" - disabled={viewMode !== 'read'} - > - - -
-
- ) : ( - - ))} - {workflowID && - currentUser.roles.includes('workflow_manage') && - (workflow ? ( - - - { - if (viewMode === 'read') { - // Switch to write mode - setViewMode('write'); - setTimeout(() => { - inputRef.current.focus(); - }, 250); - } else { - // Reset the state of the workflow, cancel changes - setViewMode('read'); - setWorkflow(originalWorkflow); - setModified(false); - } - }} - size="large" - disabled={workflow.origin !== configuration.ui.fqdn} - > - {viewMode === 'read' ? : } - - - - ) : ( - - ))} - {workflowID && - currentUser.roles.includes('workflow_manage') && - (workflow ? ( - -
- setDisableDialog(true) : () => setEnableDialog(true)} - size="large" - disabled={viewMode !== 'read'} - > - {workflow.enabled ? : } - -
-
- ) : ( - - ))} - {workflowID && - currentUser.roles.includes('workflow_manage') && - (workflow ? ( - -
- setDeleteDialog(true)} - size="large" - disabled={viewMode !== 'read'} - > - - -
-
- ) : ( - - ))} -
-
-
- - - {t('name')} - {workflow ? ( - - ) : ( - - )} - - - {t('query')} - {workflow ? ( - - ) : ( - - )} - - - {t('labels')} - {workflow ? ( - } - renderTags={(value, getTagProps) => - value.map((option, index) => ) - } - onChange={(event, value) => handleLabelsChange(value.map(v => v.toUpperCase()))} - disabled={!currentUser.roles.includes('workflow_manage') || viewMode === 'read'} - isOptionEqualToValue={(option, value) => { - return option.toUpperCase() === value.toUpperCase(); - }} - /> - ) : ( - - )} - - - {t('priority')} - {workflow ? ( - - - - ) : ( - - )} - - - {t('status')} - {workflow ? ( - - - - ) : ( - - )} - - - - - - {workflow && (viewMode === 'write' || modified) && ( - <> -
- } - label={ - - {t('backport_workflow_prompt')} ({hits} {t('backport_workflow_matching')}) - - } - > -
-
- -
- - )} - {viewMode === 'read' && !modified ? ( - - - - {t('statistics')} - - - - {t('hits')} - - - - {t('hit.count')} - - - {workflow ? workflow.hit_count : 0} - - - {t('hit.first')} - - - {workflow && workflow.first_seen ? ( - {workflow.first_seen} - ) : ( - t('hit.none') - )} - - - {t('hit.last')} - - - {workflow && workflow.last_seen ? ( - {workflow.last_seen} - ) : ( - t('hit.none') - )} - - - - - - {t('details')} - - - - {t('created_by')}: - - - {workflow && workflow.creator ? ( - <> - {workflow.creator} [{workflow.creation_date}] - - ) : ( - - )} - - - {t('edited_by')}: - - - {workflow && workflow.edited_by ? ( - <> - {workflow.edited_by} [{workflow.last_edit}] - - ) : ( - - )} - - - {t('origin')}: - - - {workflow && workflow ? workflow.origin : } - - - - - - ) : null} - {currentUser.roles.includes('alert_view') && viewMode === 'read' && !modified ? ( - <> - - - - - {t('last10')} - - - - - - ) : null} -
-
- ) : ( - - ); -}; - -const WorkflowDetail = React.memo(WrappedWorkflowDetail); -export default WorkflowDetail; diff --git a/src/components/routes/manage/workflows/components/Actions.tsx b/src/components/routes/manage/workflows/components/Actions.tsx new file mode 100644 index 000000000..3f3300387 --- /dev/null +++ b/src/components/routes/manage/workflows/components/Actions.tsx @@ -0,0 +1,363 @@ +import ControlPointDuplicateOutlinedIcon from '@mui/icons-material/ControlPointDuplicateOutlined'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; +import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined'; +import PlayCircleFilledWhiteOutlinedIcon from '@mui/icons-material/PlayCircleFilledWhiteOutlined'; +import RemoveCircleOutlineOutlinedIcon from '@mui/icons-material/RemoveCircleOutlineOutlined'; +import ToggleOffOutlinedIcon from '@mui/icons-material/ToggleOffOutlined'; +import ToggleOnIcon from '@mui/icons-material/ToggleOn'; +import YoutubeSearchedForIcon from '@mui/icons-material/YoutubeSearchedFor'; +import { Grid, IconButton, Skeleton, Tooltip, Typography, useTheme } from '@mui/material'; +import useALContext from 'components/hooks/useALContext'; +import useMyAPI from 'components/hooks/useMyAPI'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import type { Workflow } from 'components/models/base/workflow'; +import ConfirmationDialog from 'components/visual/ConfirmationDialog'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; + +type RunWorkflowActionProps = { + id: string; + workflow: Workflow; +}; + +export const RunWorkflowAction: React.FC = React.memo(({ id = null, workflow = null }) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const theme = useTheme(); + const { apiCall } = useMyAPI(); + const { user: currentUser } = useALContext(); + const { showSuccessMessage } = useMySnackbar(); + + const [dialog, setDialog] = useState(false); + const [loading, setLoading] = useState(false); + + const handleWorkflowRun = useCallback(() => { + if (!currentUser.roles.includes('workflow_manage')) return; + apiCall({ + url: `/api/v4/workflow/${id}/run`, + method: 'GET', + onSuccess: () => { + setDialog(false); + showSuccessMessage(t('run.success')); + }, + onEnter: () => setLoading(true), + onExit: () => setLoading(false) + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id]); + + if (!id || !currentUser.roles.includes('workflow_manage')) return null; + else if (!workflow) + return ; + else + return ( + <> + +
+ setDialog(v => !v)} size="large"> + + +
+
+ setDialog(false)} + handleAccept={handleWorkflowRun} + title={t('run.title')} + cancelText={t('run.cancelText')} + acceptText={t('run.acceptText')} + waiting={loading} + children={ + +
+ + + {t('run.text1')} + +
+ + + {t('run.text2')} + + + + {t('run.text3')} + +
+ } + /> + + ); +}); + +type ShowRelatedAlertsActionProps = { + id: string; + workflow: Workflow; +}; + +export const ShowRelatedAlertsAction: React.FC = React.memo( + ({ id = null, workflow = null }) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const theme = useTheme(); + const { user: currentUser } = useALContext(); + + if (!id || !currentUser.roles.includes('alert_view')) return null; + else if (!workflow) + return ; + else + return ( + +
+ + + +
+
+ ); + } +); + +type DuplicateWorkflowActionProps = { + id: string; + workflow: Workflow; +}; + +export const DuplicateWorkflowAction: React.FC = React.memo( + ({ id = null, workflow = null }) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const theme = useTheme(); + const navigate = useNavigate(); + const { user: currentUser } = useALContext(); + + const state = useMemo>( + () => ({ + classification: workflow?.classification || '', + name: workflow?.name || '', + query: workflow?.query || '', + labels: workflow?.labels || [], + priority: workflow?.priority || '', + status: workflow?.status || '', + enabled: workflow?.enabled || true + }), + [workflow] + ); + + if (!id || !currentUser.roles.includes('workflow_manage')) return null; + else if (!workflow) + return ; + else + return ( + +
+ { + navigate(`${location.pathname}${location.search}#/create/`, { state }); + }} + > + + +
+
+ ); + } +); + +type EditWorkflowActionProps = { + id: string; + workflow: Workflow; +}; + +export const EditWorkflowAction: React.FC = React.memo(({ id = null, workflow = null }) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const theme = useTheme(); + const { user: currentUser } = useALContext(); + + if (!id || !currentUser.roles.includes('workflow_manage')) return null; + else if (!workflow) + return ; + else + return ( + +
+ + + +
+
+ ); +}); + +type EnableWorkflowActionProps = { + id: string; + workflow: Workflow; + onChange: (enabled: boolean) => void; +}; + +export const EnableWorkflowAction: React.FC = React.memo( + ({ id = null, workflow = null, onChange = () => null }) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const theme = useTheme(); + const { apiCall } = useMyAPI(); + const { user: currentUser } = useALContext(); + const { showSuccessMessage } = useMySnackbar(); + + const [enableDialog, setEnableDialog] = useState(false); + const [disableDialog, setDisableDialog] = useState(false); + const [loading, setLoading] = useState(false); + + const handleWorkflowEnable = useCallback(() => { + if (!currentUser.roles.includes('workflow_manage')) return; + apiCall({ + url: `/api/v4/workflow/enable/${id}/`, + method: 'PUT', + body: { enabled: true }, + onSuccess: () => { + setEnableDialog(false); + showSuccessMessage(t('enable.success')); + setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); + onChange(true); + }, + onEnter: () => setLoading(true), + onExit: () => setLoading(false) + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id, onChange]); + + const handleWorkflowDisable = useCallback(() => { + if (!currentUser.roles.includes('workflow_manage')) return; + apiCall({ + url: `/api/v4/workflow/enable/${id}/`, + method: 'PUT', + body: { enabled: false }, + onSuccess: () => { + setDisableDialog(false); + showSuccessMessage(t('disable.success')); + setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); + onChange(false); + }, + onEnter: () => setLoading(true), + onExit: () => setLoading(false) + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id, onChange]); + + if (!id || !currentUser.roles.includes('workflow_manage')) return null; + else if (!workflow) + return ; + else + return ( + <> + +
+ setDisableDialog(true) : () => setEnableDialog(true)} + size="large" + > + {workflow.enabled ? : } + +
+
+ setDisableDialog(false)} + handleAccept={handleWorkflowDisable} + title={t('disable.title')} + cancelText={t('disable.cancelText')} + acceptText={t('disable.acceptText')} + text={t('disable.text')} + waiting={loading} + /> + setEnableDialog(false)} + handleAccept={handleWorkflowEnable} + title={t('enable.title')} + cancelText={t('enable.cancelText')} + acceptText={t('enable.acceptText')} + text={t('enable.text')} + waiting={loading} + /> + + ); + } +); + +type DeleteWorkflowActionProps = { + id: string; + workflow: Workflow; +}; + +export const DeleteWorkflowAction: React.FC = React.memo( + ({ id = null, workflow = null }) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const theme = useTheme(); + const location = useLocation(); + const navigate = useNavigate(); + const { apiCall } = useMyAPI(); + const { user: currentUser } = useALContext(); + const { showSuccessMessage } = useMySnackbar(); + + const [deleteDialog, setDeleteDialog] = useState(false); + const [loading, setLoading] = useState(false); + + const handleRemoveWorkflow = useCallback(() => { + if (!currentUser.roles.includes('workflow_manage')) return; + apiCall({ + url: `/api/v4/workflow/${id}/`, + method: 'DELETE', + onSuccess: () => { + setDeleteDialog(false); + showSuccessMessage(t('delete.success')); + navigate(`/manage/workflows${location.search}`); + setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); + }, + onEnter: () => setLoading(true), + onExit: () => setLoading(false) + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id, location.search]); + + if (!id || !currentUser.roles.includes('workflow_manage')) return null; + else if (!workflow) + return ; + else + return ( + <> + +
+ setDeleteDialog(true)} + size="large" + > + + +
+
+ setDeleteDialog(false)} + handleAccept={handleRemoveWorkflow} + title={t('delete.title')} + cancelText={t('delete.cancelText')} + acceptText={t('delete.acceptText')} + text={t('delete.text')} + waiting={loading} + /> + + ); + } +); diff --git a/src/components/routes/manage/workflows/components/Data.tsx b/src/components/routes/manage/workflows/components/Data.tsx new file mode 100644 index 000000000..1550c4476 --- /dev/null +++ b/src/components/routes/manage/workflows/components/Data.tsx @@ -0,0 +1,73 @@ +import useAppUser from 'commons/components/app/hooks/useAppUser'; +import useMyAPI from 'components/hooks/useMyAPI'; +import type { Alert } from 'components/models/base/alert'; +import type { SearchResult } from 'components/models/ui/search'; +import type { CustomUser } from 'components/models/ui/user'; +import Histogram from 'components/visual/Histogram'; +import AlertsTable from 'components/visual/SearchResult/alerts'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type AlertHistogramProps = { + id: string; +}; + +export const AlertHistogram: React.FC = ({ id }) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const { apiCall } = useMyAPI(); + const { user: currentUser } = useAppUser(); + + const [histogram, setHistogram] = useState<{ [s: string]: number }>(null); + + const handleReload = useCallback(() => { + if (!id || !currentUser.roles.includes('alert_view')) return; + + apiCall<{ [s: string]: number }>({ + url: '/api/v4/search/histogram/alert/events.ts/', + method: 'POST', + body: { + query: `events.entity_id:${id}`, + mincount: 0, + start: 'now-30d/d', + end: 'now+1d/d-1s', + gap: '+1d' + }, + onSuccess: ({ api_response }) => setHistogram(api_response) + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id]); + + useEffect(() => { + handleReload(); + }, [handleReload]); + + return ; +}; + +type AlertResultsProps = { + id: string; +}; + +export const AlertResults: React.FC = ({ id }) => { + const { apiCall } = useMyAPI(); + const { user: currentUser } = useAppUser(); + + const [results, setResults] = useState>(null); + + const handleReload = useCallback(() => { + if (!id || !currentUser.roles.includes('alert_view')) return; + + apiCall>({ + method: 'GET', + url: `/api/v4/search/alert/?query=events.entity_id:${id}&rows=10`, + onSuccess: ({ api_response }) => setResults(api_response) + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id]); + + useEffect(() => { + handleReload(); + }, [handleReload]); + + return ; +}; diff --git a/src/components/routes/manage/workflows/create.tsx b/src/components/routes/manage/workflows/create.tsx new file mode 100644 index 000000000..4afd8d063 --- /dev/null +++ b/src/components/routes/manage/workflows/create.tsx @@ -0,0 +1,414 @@ +import type { Theme } from '@mui/material'; +import { + Autocomplete, + Button, + Checkbox, + Chip, + CircularProgress, + FormControlLabel, + Grid, + MenuItem, + Select, + Skeleton, + TextField, + Typography, + useTheme +} from '@mui/material'; +import FormControl from '@mui/material/FormControl'; +import createStyles from '@mui/styles/createStyles'; +import makeStyles from '@mui/styles/makeStyles'; +import withStyles from '@mui/styles/withStyles'; +import Throttler from 'commons/addons/utils/throttler'; +import PageCenter from 'commons/components/pages/PageCenter'; +import useALContext from 'components/hooks/useALContext'; +import useMyAPI from 'components/hooks/useMyAPI'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import type { Alert } from 'components/models/base/alert'; +import type { Priority, Status, Workflow } from 'components/models/base/workflow'; +import { LABELS, PRIORITIES, STATUSES } from 'components/models/base/workflow'; +import type { SearchResult } from 'components/models/ui/search'; +import ForbiddenPage from 'components/routes/403'; +import Classification from 'components/visual/Classification'; +import _ from 'lodash'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'react-router'; +import { useParams } from 'react-router-dom'; + +const useStyles = makeStyles(() => ({ + buttonProgress: { + position: 'absolute', + top: '50%', + left: '50%', + marginTop: -12, + marginLeft: -12 + } +})); + +const THROTTLER = new Throttler(1000); + +const MyMenuItem = withStyles((theme: Theme) => + createStyles({ + root: { + minHeight: theme.spacing(4) + } + }) +)(MenuItem); + +type Params = { + id: string; +}; + +type Props = { + id?: string; + onClose?: (id?: string) => void; +}; + +const WrappedWorkflowCreate = ({ id: propID = null, onClose = () => null }: Props) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const { id: paramID } = useParams(); + const theme = useTheme(); + const classes = useStyles(); + const location = useLocation(); + const { apiCall } = useMyAPI(); + const { c12nDef, configuration, user: currentUser } = useALContext(); + const { showSuccessMessage, showErrorMessage } = useMySnackbar(); + + const [workflow, setWorkflow] = useState(null); + const [originalWorkflow, setOriginalWorkflow] = useState(null); + const [badQuery, setBadQuery] = useState(false); + const [results, setResults] = useState>(null); + const [runWorkflow, setRunWorkflow] = useState(false); + const [loading, setLoading] = useState(false); + + const inputRef = useRef(null); + + const defaultWorkflow = useMemo( + () => ({ + classification: c12nDef.UNRESTRICTED, + creation_date: undefined, + creator: '', + description: '', + edited_by: '', + enabled: true, + hit_count: 0, + id: '', + labels: [], + last_edit: undefined, + name: '', + origin: configuration.ui.fqdn, + priority: '', + query: '', + status: '' + }), + [c12nDef.UNRESTRICTED, configuration.ui.fqdn] + ); + + const id = useMemo(() => propID || paramID, [paramID, propID]); + + const modified = useMemo(() => !_.isEqual(workflow, originalWorkflow), [originalWorkflow, workflow]); + + const disabled = useMemo( + () => loading || !modified || badQuery || workflow?.query === '' || workflow?.name === '', + [badQuery, loading, modified, workflow?.name, workflow?.query] + ); + + const handleAdd = useCallback( + (wf: Workflow, run: boolean) => { + if (!currentUser.roles.includes('workflow_manage')) return; + + apiCall<{ success: boolean; workflow_id: string }>({ + url: `/api/v4/workflow/?run_workflow=${run}`, + method: 'PUT', + body: { + ...wf, + priority: !wf.priority ? null : wf.priority, + status: !wf.status ? null : wf.status + }, + onSuccess: ({ api_response }) => { + showSuccessMessage(t('add.success')); + setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); + setTimeout(() => window.dispatchEvent(new CustomEvent('alertRefresh', null)), 1500); + onClose(api_response.workflow_id); + }, + onEnter: () => setLoading(true), + onExit: () => setLoading(false) + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentUser.roles, t] + ); + + const handleUpdate = useCallback( + (wf: Workflow, run: boolean) => { + if (!currentUser.roles.includes('workflow_manage')) return; + + apiCall<{ success: boolean; workflow_id: string }>({ + url: `/api/v4/workflow/${id}/?run_workflow=${run}`, + method: 'POST', + body: { + ...wf, + priority: !wf.priority ? null : wf.priority, + status: !wf.status ? null : wf.status + }, + onSuccess: () => { + showSuccessMessage(t('update.success')); + setTimeout(() => window.dispatchEvent(new CustomEvent('reloadWorkflows')), 1000); + onClose(id); + }, + onEnter: () => setLoading(true), + onExit: () => setLoading(false) + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [currentUser.roles, id, t] + ); + + useEffect(() => { + if (!id || !currentUser.roles.includes('workflow_manage')) return; + + apiCall({ + url: `/api/v4/workflow/${id}/`, + onSuccess: ({ api_response }) => { + const wf = { + ...api_response, + status: api_response.status || '', + priority: api_response.priority || '', + enabled: api_response.enabled === undefined ? true : api_response.enabled + } as Workflow; + setWorkflow(wf); + setOriginalWorkflow(wf); + }, + onFailure: api_data => { + showErrorMessage(api_data.api_error_message); + onClose(); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id, onClose]); + + useEffect(() => { + setOriginalWorkflow(defaultWorkflow); + setWorkflow(defaultWorkflow); + }, [defaultWorkflow]); + + useEffect(() => { + const state = location?.state as Workflow; + if (!state) return; + setWorkflow(wf => ({ + ...wf, + ...(state?.classification && { classification: state.classification }), + ...(state?.name && { name: state.name }), + ...(state?.query && { query: state.query }), + ...(Array.isArray(state?.labels) && { labels: state.labels }), + ...(PRIORITIES.includes(state?.priority) && { priority: state.priority }), + ...(STATUSES.includes(state?.status) && { status: state.status }), + ...(state?.enabled && { enabled: state.enabled }) + })); + }, [location?.state]); + + useEffect(() => { + THROTTLER.delay(() => { + if (!!workflow?.query && currentUser.roles.includes('alert_view')) { + apiCall>({ + method: 'GET', + url: `/api/v4/search/alert/?query=${encodeURIComponent(workflow?.query)}&rows=10&track_total_hits=true`, + onSuccess: ({ api_response }) => { + setResults(api_response); + setBadQuery(false); + }, + onFailure: () => { + setResults({ items: [], offset: 0, rows: 10, total: 0 }); + setBadQuery(true); + } + }); + } else { + setResults({ items: [], offset: 0, rows: 10, total: 0 }); + setBadQuery(false); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workflow?.query]); + + if (!currentUser.roles.includes('workflow_manage')) return ; + else + return ( + + {/* */} + + {c12nDef.enforce && ( +
+ +
+ )} + +
+
+ + + {t(id ? 'edit.title' : 'add.title')} + + {!id ? null : workflow ? id : } + + + + + {id ? ( + <> + + + + ) : ( + + )} + + +
+
+ + + + {`${t('name')} ${t('required')}`} + {!workflow ? ( + + ) : ( + setWorkflow(wf => ({ ...wf, name: event.target.value }))} + /> + )} + + + {`${t('query')} ${t('required')}`} + {!workflow ? ( + + ) : ( + setWorkflow(wf => ({ ...wf, query: event.target.value }))} + /> + )} + + + {t('labels')} + {!workflow ? ( + + ) : ( + } + renderTags={(value, getTagProps) => + value.map((option, index) => ) + } + onChange={(event, value) => setWorkflow(wf => ({ ...wf, labels: value.map(v => v.toUpperCase()) }))} + isOptionEqualToValue={(option, value) => option.toUpperCase() === value.toUpperCase()} + /> + )} + + + {t('priority')} + {workflow ? ( + + + + ) : ( + + )} + + + {t('status')} + {workflow ? ( + + + + ) : ( + + )} + + + {!id && ( + + {!workflow ? ( + + ) : ( + setRunWorkflow(o => !o)} checked={runWorkflow}>} + label={ + + {t('backport_workflow_prompt')} ({results?.total || 0} {t('backport_workflow_matching')}) + + } + /> + )} + + )} + +
+ ); +}; + +export const WorkflowCreate = React.memo(WrappedWorkflowCreate); +export default WorkflowCreate; diff --git a/src/components/routes/manage/workflows/detail.tsx b/src/components/routes/manage/workflows/detail.tsx new file mode 100644 index 000000000..35b5613f2 --- /dev/null +++ b/src/components/routes/manage/workflows/detail.tsx @@ -0,0 +1,298 @@ +import { Grid, Skeleton, Typography, useTheme } from '@mui/material'; +import PageCenter from 'commons/components/pages/PageCenter'; +import useALContext from 'components/hooks/useALContext'; +import useMyAPI from 'components/hooks/useMyAPI'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import type { Workflow } from 'components/models/base/workflow'; +import ForbiddenPage from 'components/routes/403'; +import Classification from 'components/visual/Classification'; +import CustomChip from 'components/visual/CustomChip'; +import Moment from 'components/visual/Moment'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { + DeleteWorkflowAction, + DuplicateWorkflowAction, + EditWorkflowAction, + EnableWorkflowAction, + RunWorkflowAction, + ShowRelatedAlertsAction +} from './components/Actions'; +import { AlertHistogram, AlertResults } from './components/Data'; + +const CUSTOMCHIP_STYLES = { + borderRadius: '4px', + height: 'auto', + justifyContent: 'flex-start', + marginBottom: '4px', + marginTop: '8px', + minHeight: '40px', + overflow: 'auto', + textOverflow: 'initial', + whiteSpace: 'wrap' +}; + +type Params = { + id: string; +}; + +type Props = { + id?: string; + onClose?: () => void; +}; + +const WrappedWorkflowDetail = ({ id: propID = null, onClose = () => null }: Props) => { + const { t } = useTranslation(['manageWorkflowDetail']); + const { id: paramID } = useParams(); + const theme = useTheme(); + const { apiCall } = useMyAPI(); + const { c12nDef, user: currentUser } = useALContext(); + const { showErrorMessage } = useMySnackbar(); + + const [workflow, setWorkflow] = useState(null); + + const id = useMemo(() => propID || paramID, [paramID, propID]); + + const handleReload = useCallback(() => { + if (!id || !currentUser.roles.includes('workflow_view')) return; + + apiCall({ + url: `/api/v4/workflow/${id}/`, + onSuccess: ({ api_response }) => { + setWorkflow({ + ...api_response, + status: api_response.status || '', + priority: api_response.priority || '', + enabled: api_response.enabled === undefined ? true : api_response.enabled + }); + }, + onFailure: api_data => { + showErrorMessage(api_data.api_error_message); + onClose(); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentUser.roles, id, onClose]); + + useEffect(() => { + handleReload(); + }, [handleReload]); + + if (!currentUser.roles.includes('workflow_view')) return ; + else + return ( + + {c12nDef.enforce && ( +
+ +
+ )} + +
+
+ + + {t('title')} + {workflow ? id : } + + + + + + + setWorkflow(wf => ({ ...wf, enabled: enabled }))} + /> + + + +
+ + + {t('name')} + {!workflow ? ( + + ) : ( + {workflow.name}} + fullWidth + size="medium" + type="rounded" + variant="outlined" + style={CUSTOMCHIP_STYLES} + /> + )} + + + {t('query')} + {!workflow ? ( + + ) : ( + {workflow.query}} + fullWidth + size="medium" + type="rounded" + variant="outlined" + style={CUSTOMCHIP_STYLES} + /> + )} + + + {t('labels')} + {!workflow ? ( + + ) : ( + + {workflow.labels.map((label, i) => ( + + ))} +
+ } + fullWidth + size="medium" + type="rounded" + variant="outlined" + style={CUSTOMCHIP_STYLES} + /> + )} + + + {t('priority')} + {!workflow ? ( + + ) : ( + {workflow.priority}} + fullWidth + size="medium" + type="rounded" + variant="outlined" + style={CUSTOMCHIP_STYLES} + /> + )} + + + {t('status')} + {!workflow ? ( + + ) : ( + {workflow.status}} + fullWidth + size="medium" + type="rounded" + variant="outlined" + style={CUSTOMCHIP_STYLES} + /> + )} + + + + + + + {t('statistics')} + + + + {t('hits')} + + + + {t('hit.count')} + + + {workflow ? workflow.hit_count : 0} + + + {t('hit.first')} + + + {workflow && workflow.first_seen ? ( + {workflow.first_seen} + ) : ( + t('hit.none') + )} + + + {t('hit.last')} + + + {workflow && workflow.last_seen ? ( + {workflow.last_seen} + ) : ( + t('hit.none') + )} + + + + + + {t('details')} + + + + {t('created_by')}: + + + {workflow && workflow.creator ? ( + <> + {workflow.creator} {'['} + {workflow.creation_date} + {']'} + + ) : ( + + )} + + + {t('edited_by')}: + + + {workflow && workflow.edited_by ? ( + <> + {workflow.edited_by} {'['} + {workflow.last_edit} + {']'} + + ) : ( + + )} + + + {t('origin')}: + + + {workflow && workflow ? workflow.origin : } + + + + + + + {!currentUser.roles.includes('alert_view') ? null : ( + <> + + + + + {t('last10')} + + + + + + )} +
+ + ); +}; + +export const WorkflowDetail = React.memo(WrappedWorkflowDetail); +export default WorkflowDetail; diff --git a/src/components/routes/manage/workflows.tsx b/src/components/routes/manage/workflows/index.tsx similarity index 90% rename from src/components/routes/manage/workflows.tsx rename to src/components/routes/manage/workflows/index.tsx index e0e047d2e..f1e013214 100644 --- a/src/components/routes/manage/workflows.tsx +++ b/src/components/routes/manage/workflows/index.tsx @@ -23,7 +23,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { useLocation } from 'react-router-dom'; -import WorkflowDetail from './workflow_detail'; +import WorkflowCreate from './create'; +import WorkflowDetail from './detail'; type SearchResults = { items: WorkflowIndexed[]; @@ -84,7 +85,7 @@ const WorkflowsSearch = () => { ); const setWorkflowID = useCallback( - (wf_id: string) => navigate(`${location.pathname}${location.search || ''}#${wf_id}`), + (wf_id: string) => navigate(`${location.pathname}${location.search}#/detail/${wf_id}`), [location.pathname, location.search, navigate] ); @@ -95,12 +96,24 @@ const WorkflowsSearch = () => { }, [globalDrawerOpened]); useEffect(() => { - if (location.hash) { + const url = new URL(`${window.location.origin}${location.hash.slice(1)}`); + + if (url.pathname.startsWith('/detail/')) { setGlobalDrawer( navigate(`${location.pathname}${location.search}`)} + /> + ); + } else if (url.pathname.startsWith('/create/')) { + setGlobalDrawer( + + id + ? navigate(`${location.pathname}${location.search}#/detail/${id}`) + : navigate(`${location.pathname}${location.search}`) + } /> ); } else { @@ -138,7 +151,7 @@ const WorkflowsSearch = () => { style={{ color: theme.palette.mode === 'dark' ? theme.palette.success.light : theme.palette.success.dark }} - onClick={() => navigate(`${location.pathname}${location.search ? location.search : ''}#new`)} + onClick={() => navigate(`${location.pathname}${location.search}#/create/`)} size="large" > diff --git a/src/components/routes/routes.tsx b/src/components/routes/routes.tsx index 5ec09de20..2259d1ccc 100644 --- a/src/components/routes/routes.tsx +++ b/src/components/routes/routes.tsx @@ -51,8 +51,9 @@ const BadlistDetail = lazy(() => import('components/routes/manage/badlist_detail const ManageSignatures = lazy(() => import('components/routes/manage/signatures')); const SignatureDetail = lazy(() => import('components/routes/manage/signature_detail')); const ManageSignatureSources = lazy(() => import('components/routes/manage/signature_sources')); -const ManageWorkflows = lazy(() => import('components/routes/manage/workflows')); -const WorkflowDetail = lazy(() => import('components/routes/manage/workflow_detail')); +const ManageWorkflows = lazy(() => import('components/routes/manage/workflows/index')); +const WorkflowCreate = lazy(() => import('components/routes/manage/workflows/create')); +const WorkflowDetail = lazy(() => import('components/routes/manage/workflows/detail')); const RetroHunt = lazy(() => import('components/routes/retrohunt')); const RetroHuntDetail = lazy(() => import('components/routes/retrohunt/detail')); const Search = lazy(() => import('components/routes/search')); @@ -165,7 +166,8 @@ const WrappedRoutes = () => { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/routes/submission/detail/file_tree.tsx b/src/components/routes/submission/detail/file_tree.tsx index e5d56d5a6..a5dab8811 100644 --- a/src/components/routes/submission/detail/file_tree.tsx +++ b/src/components/routes/submission/detail/file_tree.tsx @@ -2,17 +2,20 @@ import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ArrowRightIcon from '@mui/icons-material/ArrowRight'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; -import { Box, Collapse, Divider, IconButton, Skeleton, Tooltip, Typography, useTheme } from '@mui/material'; +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import { Box, Button, Collapse, Divider, IconButton, Skeleton, Tooltip, Typography, useTheme } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; import useHighlighter from 'components/hooks/useHighlighter'; import useSafeResults from 'components/hooks/useSafeResults'; -import { SubmissionTree, Tree } from 'components/models/ui/submission'; +import type { SubmissionTree, Tree } from 'components/models/ui/submission'; import Verdict from 'components/visual/Verdict'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { Link } from 'react-router-dom'; +const MAX_FILE_COUNT = 500; + const useStyles = makeStyles(theme => ({ file_item: { cursor: 'pointer', @@ -106,92 +109,110 @@ type FileTreeProps = { }; const WrappedFileTree: React.FC = ({ tree, sid, defaultForceShown, force = false }) => { - const { t } = useTranslation('submissionDetail'); + const { t } = useTranslation(['submissionDetail']); const theme = useTheme(); const classes = useStyles(); const navigate = useNavigate(); const { isHighlighted } = useHighlighter(); const { showSafeResults } = useSafeResults(); - const [forcedShown, setForcedShown] = React.useState>([...defaultForceShown]); + + const [forcedShown, setForcedShown] = useState([...defaultForceShown]); + const [maxChildCount, setMaxChildCount] = useState(MAX_FILE_COUNT); + + const files = useMemo<[string, Tree][]>( + () => + Object.entries(tree) + .sort((a: [string, Tree], b: [string, Tree]) => (a[1].name.join() > b[1].name.join() ? 1 : -1)) + .reduce( + (prev, [sha256, item]: [string, Tree]) => + !isVisible(tree[sha256], defaultForceShown, isHighlighted, showSafeResults) || + (item.score < 0 && !showSafeResults && !force) || + prev.length > maxChildCount + ? [...prev] + : [...prev, [sha256, item]], + [] as [string, Tree][] + ), + [defaultForceShown, force, isHighlighted, maxChildCount, showSafeResults, tree] + ); return ( <> - {Object.entries(tree) - .sort((a: [string, Tree], b: [string, Tree]) => { - return a[1].name.join() > b[1].name.join() ? 1 : -1; - }) - .map(([sha256, item], i) => { - return !isVisible(tree[sha256], defaultForceShown, isHighlighted, showSafeResults) || - (item.score < 0 && !showSafeResults && !force) ? null : ( -
-
- {item.children && - Object.values(item.children).some(c => !isVisible(c, forcedShown, isHighlighted, showSafeResults)) ? ( - - { - setForcedShown([...forcedShown, ...Object.keys(item.children)]); - }} - > - - - - ) : item.children && Object.keys(item.children).some(key => forcedShown.includes(key)) ? ( - - { - const excluded = Object.keys(item.children); - setForcedShown(forcedShown.filter(val => !excluded.includes(val))); - }} - > - - - - ) : ( - - )} - { - e.preventDefault(); - if (item.sha256) - navigate(`/submission/detail/${sid}/${item.sha256}?name=${encodeURI(item.name[0])}`); + {files.map(([sha256, item], i) => ( +
+
+ {item.children && + Object.values(item.children).some(c => !isVisible(c, forcedShown, isHighlighted, showSafeResults)) ? ( + + { + setForcedShown([...forcedShown, ...Object.keys(item.children)]); }} - style={{ - wordBreak: 'break-word', - backgroundColor: isHighlighted(sha256) - ? theme.palette.mode === 'dark' - ? '#343a44' - : '#d8e3ea' - : null + > + + + + ) : item.children && Object.keys(item.children).some(key => forcedShown.includes(key)) ? ( + + { + const excluded = Object.keys(item.children); + setForcedShown(forcedShown.filter(val => !excluded.includes(val))); }} > -
- - - :: - - - - {item.name.sort().join(' | ')} - - {`[${item.type}]`} - -
- + +
+
+ ) : ( + + )} + { + e.preventDefault(); + if (item.sha256) navigate(`/submission/detail/${sid}/${item.sha256}?name=${encodeURI(item.name[0])}`); + }} + style={{ + wordBreak: 'break-word', + backgroundColor: isHighlighted(sha256) ? (theme.palette.mode === 'dark' ? '#343a44' : '#d8e3ea') : null + }} + > +
+ + + :: + + + + {item.name.sort().join(' | ')} + + {`[${item.type}]`} +
-
- -
-
- ); - })} + +
+
+ +
+
+ ))} + {files.length <= maxChildCount ? null : ( + + + + )} ); }; diff --git a/src/components/visual/DivTable.tsx b/src/components/visual/DivTable.tsx index fd6e43535..50448a84d 100644 --- a/src/components/visual/DivTable.tsx +++ b/src/components/visual/DivTable.tsx @@ -1,21 +1,10 @@ -import { - Link as MaterialLink, - Table, - TableBody, - TableBodyProps, - TableCell, - TableCellProps, - TableHead, - TableHeadProps, - TableProps, - TableRow, - TableSortLabel, - Theme -} from '@mui/material'; +import type { TableBodyProps, TableCellProps, TableHeadProps, TableProps, TableRowProps, Theme } from '@mui/material'; +import { Link as MaterialLink, Table, TableBody, TableCell, TableHead, TableRow, TableSortLabel } from '@mui/material'; import createStyles from '@mui/styles/createStyles'; import withStyles from '@mui/styles/withStyles'; -import SimpleSearchQuery from 'components/visual/SearchBar/simple-search-query'; +import type SimpleSearchQuery from 'components/visual/SearchBar/simple-search-query'; import React from 'react'; +import type { To } from 'react-router'; import { useNavigate } from 'react-router'; import { Link, useLocation } from 'react-router-dom'; @@ -119,7 +108,11 @@ export const SortableHeaderCell: React.FC = ({ ); }; -export const LinkRow = ({ children, to, ...other }) => ( +interface LinkRowProps extends TableRowProps { + to: To; +} + +export const LinkRow = ({ children, to, ...other }: LinkRowProps) => ( {children} @@ -155,7 +148,7 @@ export const DivTableBody = ({ children, ...other }: TableBodyProps) => ( ); -export const DivTable = ({ children, size = 'small' as 'small', ...other }: TableProps) => ( +export const DivTable = ({ children, size = 'small' as const, ...other }: TableProps) => ( {children}
diff --git a/src/components/visual/SearchResult/workflow.tsx b/src/components/visual/SearchResult/workflow.tsx index 99d82cbd7..d718958a9 100644 --- a/src/components/visual/SearchResult/workflow.tsx +++ b/src/components/visual/SearchResult/workflow.tsx @@ -4,13 +4,9 @@ import { AlertTitle, Skeleton } from '@mui/material'; import Paper from '@mui/material/Paper'; import TableContainer from '@mui/material/TableContainer'; import useALContext from 'components/hooks/useALContext'; -import { WorkflowIndexed } from 'components/models/base/workflow'; +import type { WorkflowIndexed } from 'components/models/base/workflow'; import type { SearchResult } from 'components/models/ui/search'; import Classification from 'components/visual/Classification'; -import Moment from 'components/visual/Moment'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; import { DivTable, DivTableBody, @@ -19,8 +15,12 @@ import { DivTableRow, LinkRow, SortableHeaderCell -} from '../DivTable'; -import InformativeAlert from '../InformativeAlert'; +} from 'components/visual/DivTable'; +import InformativeAlert from 'components/visual/InformativeAlert'; +import Moment from 'components/visual/Moment'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; type Props = { workflowResults: SearchResult; @@ -28,7 +28,7 @@ type Props = { }; const WrappedWorflowTable: React.FC = ({ workflowResults, setWorkflowID = null }) => { - const { t, i18n } = useTranslation(['search']); + const { t } = useTranslation(['search']); const { c12nDef } = useALContext(); return workflowResults ? ( @@ -54,7 +54,7 @@ const WrappedWorflowTable: React.FC = ({ workflowResults, setWorkflowID = { if (setWorkflowID) { event.preventDefault(); diff --git a/src/helpers/xsrf.tsx b/src/helpers/xsrf.tsx index 47878f7ef..845a906da 100644 --- a/src/helpers/xsrf.tsx +++ b/src/helpers/xsrf.tsx @@ -5,7 +5,7 @@ * @returns the CSRF token * */ -export default function getXSRFCookie() { +export default function getXSRFCookie(): string { let xsrfToken = null; if (document.cookie !== undefined) { try { @@ -18,5 +18,5 @@ export default function getXSRFCookie() { // Ignore... we will return null } } - return xsrfToken; + return xsrfToken as string; } diff --git a/src/lib/api/APIProvider.tsx b/src/lib/api/APIProvider.tsx new file mode 100644 index 000000000..9207b82de --- /dev/null +++ b/src/lib/api/APIProvider.tsx @@ -0,0 +1,46 @@ +import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; +import { keepPreviousData, QueryClient } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import { compress, decompress } from 'lz-string'; +import React from 'react'; +import { DEFAULT_GC_TIME, DEFAULT_STALE_TIME } from './constants'; +import type { APIQueryKey } from './models'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: DEFAULT_STALE_TIME, + gcTime: DEFAULT_GC_TIME, + placeholderData: keepPreviousData + } + } +}); + +type Props = { + children: React.ReactNode; +}; + +const persister = createSyncStoragePersister({ + storage: window.sessionStorage, + serialize: data => + compress( + JSON.stringify({ + ...data, + clientState: { + mutations: [], + queries: data.clientState.queries.filter(q => (q.queryKey[0] as APIQueryKey).allowCache) + } + }) + ), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + deserialize: data => JSON.parse(decompress(data)) +}); + +export const APIProvider = ({ children }: Props) => ( + + {children} + + +); diff --git a/src/lib/api/constants.ts b/src/lib/api/constants.ts new file mode 100644 index 000000000..fa30f5bf9 --- /dev/null +++ b/src/lib/api/constants.ts @@ -0,0 +1,9 @@ +export const DEFAULT_RETRY_MS = 10 * 1000; + +/** The time in milliseconds after data is considered stale. If set to Infinity, the data will never be considered stale. If set to a function, the function will be executed with the query to compute a staleTime. */ +export const DEFAULT_STALE_TIME = 1 * 60 * 1000; + +/** The time in milliseconds that unused/inactive cache data remains in memory. When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different garbage collection times are specified, the longest one will be used. Setting it to Infinity will disable garbage collection. */ +export const DEFAULT_GC_TIME = 5 * 60 * 1000; + +export const DEFAULT_INVALIDATE_DELAY = 1 * 1000; diff --git a/src/lib/api/invalidateApiQuery.ts b/src/lib/api/invalidateApiQuery.ts new file mode 100644 index 000000000..4fe3294d7 --- /dev/null +++ b/src/lib/api/invalidateApiQuery.ts @@ -0,0 +1,21 @@ +import type { Query } from '@tanstack/react-query'; +import { queryClient } from './APIProvider'; +import { DEFAULT_INVALIDATE_DELAY } from './constants'; +import type { ApiCallProps } from './utils'; + +export const invalidateApiQuery = ( + filter: (key: ApiCallProps) => boolean, + delay: number = DEFAULT_INVALIDATE_DELAY +) => { + setTimeout(async () => { + await queryClient.invalidateQueries({ + predicate: ({ queryKey }: Query) => { + try { + return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]); + } catch (err) { + return false; + } + } + }); + }, delay); +}; diff --git a/src/lib/api/models.ts b/src/lib/api/models.ts new file mode 100644 index 000000000..6588707cf --- /dev/null +++ b/src/lib/api/models.ts @@ -0,0 +1,33 @@ +export type APIResponse = { + api_error_message: string; + api_response: T; + api_server_version: string; + api_status_code: number; +}; + +export type BlobResponse = { + api_error_message: string; + api_response: unknown; + api_server_version: string; + api_status_code: number; + filename: string; + size: number; + type: string; +}; + +export type APIReturn = { + statusCode: number; + serverVersion: string; + data: Response; + error: string; +}; + +export type APIQueryKey = { + url: string; + contentType: string; + method: string; + body: Body; + reloadOnUnauthorize: boolean; + enabled: boolean; + [key: string]: unknown; +}; diff --git a/src/lib/api/updateApiQuery.ts b/src/lib/api/updateApiQuery.ts new file mode 100644 index 000000000..948dc462e --- /dev/null +++ b/src/lib/api/updateApiQuery.ts @@ -0,0 +1,19 @@ +import type { Query } from '@tanstack/react-query'; +import { queryClient } from './APIProvider'; +import type { APIResponse } from './models'; +import type { ApiCallProps } from './utils'; + +export const updateApiQuery = (filter: (key: ApiCallProps) => boolean, update: (prev: T) => T) => { + queryClient.setQueriesData>( + { + predicate: ({ queryKey }: Query) => { + try { + return typeof queryKey[0] === 'object' && queryKey[0] && filter(queryKey[0]); + } catch (err) { + return false; + } + } + }, + prev => ({ ...prev, api_response: update(prev?.api_response) }) + ); +}; diff --git a/src/lib/api/useApiMutation.tsx b/src/lib/api/useApiMutation.tsx new file mode 100644 index 000000000..11daaeca5 --- /dev/null +++ b/src/lib/api/useApiMutation.tsx @@ -0,0 +1,79 @@ +import type { UseMutationOptions } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Input = { + url: string; + contentType?: string; + method?: string; + body?: Body; +}; + +const DEFAULT_INPUT: Input = { url: null, contentType: 'application/json', method: 'GET', body: null }; + +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; + input?: TVariables; + context?: TContext; + previous?: TPrevious; +}; + +type Props = Omit< + UseMutationOptions, APIResponse, T['input'], T['context']>, + 'mutationKey' | 'mutationFn' | 'onSuccess' | 'onMutate' | 'onSettled' +> & { + input: Input | ((input: T['input']) => Input); + reloadOnUnauthorize?: boolean; + retryAfter?: number; + onSuccess?: (props?: { + data: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; + onFailure?: (props?: { + error: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; + onEnter?: (props?: { input: T['input'] }) => unknown; + onExit?: (props?: { + data: APIResponse; + error: APIResponse; + input: T['input']; + context: T['context']; + }) => Promise | unknown; +}; + +export const useApiMutation = ({ + input = null, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + onSuccess = () => null, + onFailure = () => null, + onEnter = () => null, + onExit = () => null, + ...options +}: Props) => { + const apiCallFn = useApiCallFn, T['body']>(); + + const mutation = useMutation, APIResponse, T['input'], unknown>({ + ...options, + mutationFn: async (variables: T['input']) => + apiCallFn({ + ...DEFAULT_INPUT, + ...(typeof input === 'function' ? input(variables) : input), + reloadOnUnauthorize, + retryAfter + }), + onSuccess: (data, variables, context) => onSuccess({ data, input: variables, context }), + onError: (error, variables, context) => onFailure({ error, input: variables, context }), + onMutate: variables => onEnter({ input: variables }), + onSettled: (data, error, variables, context) => onExit({ data, error, input: variables, context }) + }); + + return { ...mutation, ...getAPIResponse(mutation?.data, mutation?.error, mutation?.failureReason) }; +}; diff --git a/src/lib/api/useApiQuery.tsx b/src/lib/api/useApiQuery.tsx new file mode 100644 index 000000000..1eebdeeea --- /dev/null +++ b/src/lib/api/useApiQuery.tsx @@ -0,0 +1,48 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import type { ApiCallProps } from './utils'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataOptions, APIResponse, APIResponse, TQueryKey>, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useApiQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + ...options +}: Props) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const query = useQuery, APIResponse, APIResponse, QueryKey>( + { + ...options, + queryKey: [{ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter }], + enabled: Boolean(enabled), + queryFn: async ({ signal }) => + apiCallFn({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, signal }), + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; +}; diff --git a/src/lib/api/useBootstrap.tsx b/src/lib/api/useBootstrap.tsx new file mode 100644 index 000000000..97ddc056f --- /dev/null +++ b/src/lib/api/useBootstrap.tsx @@ -0,0 +1,166 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import useALContext from 'components/hooks/useALContext'; +import type { LoginParamsProps } from 'components/hooks/useMyAPI'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import type { Configuration } from 'components/models/base/config'; +import type { WhoAmIProps } from 'components/models/ui/user'; +import getXSRFCookie from 'helpers/xsrf'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { getAPIResponse, isAPIData } from './utils'; + +type Props = Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' +> & { + switchRenderedApp: (value: string) => void; + setConfiguration: (cfg: Configuration) => void; + setLoginParams: (params: LoginParamsProps) => void; + setUser: (user: WhoAmIProps) => void; + setReady: (layout: boolean, borealis: boolean) => void; + retryAfter?: number; +}; + +export const useBootstrap = ({ + switchRenderedApp, + setConfiguration, + setLoginParams, + setUser, + setReady, + retryAfter = DEFAULT_RETRY_MS, + ...options +}: Props< + APIResponse, + APIResponse, + APIResponse, + QueryKey +>) => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + const queryClient = useQueryClient(); + + const query = useQuery< + APIResponse, + APIResponse, + APIResponse + >( + { + ...options, + queryKey: [ + { + url: '/api/v4/user/whoami/', + contentType: 'application/json', + method: 'GET', + allowCache: false, + retryAfter + } + ], + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + queryFn: async ({ signal }) => { + // fetching the API's data + const res = await fetch('/api/v4/user/whoami/', { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid'), 30000); + switchRenderedApp('load'); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Forbiden response indicate that the user's account is locked. + if (res.status === 403) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + setConfiguration(json.api_response as Configuration); + switchRenderedApp('locked'); + return Promise.reject(json); + } + + // Unauthorized response indicate that the user is not logged in. + if (res.status === 401) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + localStorage.setItem('loginParams', JSON.stringify(json.api_response)); + sessionStorage.clear(); + setLoginParams(json.api_response as LoginParamsProps); + switchRenderedApp('login'); + return Promise.reject(json); + } + + // Daily quota error, stop everything! + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + switchRenderedApp('quota'); + return Promise.reject(json); + } + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + if (res.status === 200) { + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + const user = json.api_response as WhoAmIProps; + + // Set the current user + setUser(user); + + // Mark the interface ready + setReady(true, user.configuration.ui.api_proxies.includes('borealis')); + + // Render appropriate page + if (!user.agrees_with_tos && user.configuration.ui.tos) { + switchRenderedApp('tos'); + } else { + switchRenderedApp('routes'); + } + + return Promise.resolve(json); + } + } + }, + queryClient + ); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason) }; +}; diff --git a/src/lib/api/useDownloadBlob.tsx b/src/lib/api/useDownloadBlob.tsx new file mode 100644 index 000000000..6a46bad98 --- /dev/null +++ b/src/lib/api/useDownloadBlob.tsx @@ -0,0 +1,148 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import useALContext from 'components/hooks/useALContext'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import { getFileName } from 'helpers/utils'; +import getXSRFCookie from 'helpers/xsrf'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse, BlobResponse } from './models'; +import { getBlobResponse, isAPIData } from './utils'; + +type Props = Omit< + DefinedInitialDataOptions, + 'queryKey' | 'initialData' | 'enabled' +> & { + url: string; + allowCache?: boolean; + enabled?: boolean; + reloadOnUnauthorize?: boolean; + retryAfter?: number; +}; + +export const useMyQuery = ({ + url, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + allowCache = false, + enabled, + ...options +}: Props, BlobResponse, QueryKey>) => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + const queryClient = useQueryClient(); + + const query = useQuery, BlobResponse>( + { + ...options, + queryKey: [ + { + url, + method: 'GET', + allowCache, + enabled, + reloadOnUnauthorize, + retryAfter, + systemVersion: systemConfig.system.version + } + ], + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)), + queryFn: async ({ signal }) => { + // Reject if the query is not enabled + if (!enabled) return Promise.reject(null); + + // fetching the API's data + const res = await fetch(url, { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-XSRF-TOKEN': getXSRFCookie() }, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid')); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Reload when the user has exceeded their daily API call quota. + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if the user has exceeded their daily submissions quota. + if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + return Promise.reject(json); + } + + // Reload when the user is not logged in + if (res.status === 401 && reloadOnUnauthorize) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if API Server is unavailable and should attempt to retry + if (res.status === 502) { + showErrorMessage(json.api_error_message, 30000); + return Promise.reject(json); + } + + // Handle successful request + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + return Promise.resolve({ + api_error_message: '', + api_response: res.body, + api_server_version: systemConfig.system.version, + api_status_code: res.status, + filename: getFileName(res.headers.get('Content-Disposition')), + size: parseInt(res.headers.get('Content-Length')), + type: res.headers.get('Content-Type') + }); + } + }, + queryClient + ); + + return { ...query, ...getBlobResponse(query?.data, query?.error, query?.failureReason) }; +}; diff --git a/src/lib/api/useInfiniteApiQuery.tsx b/src/lib/api/useInfiniteApiQuery.tsx new file mode 100644 index 000000000..c4c386f53 --- /dev/null +++ b/src/lib/api/useInfiniteApiQuery.tsx @@ -0,0 +1,68 @@ +import type { DefinedInitialDataInfiniteOptions, InfiniteData, QueryKey } from '@tanstack/react-query'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; +import type { ApiCallProps } from './utils'; +import { useApiCallFn } from './utils'; + +type Types = { + body?: TBody & { offset: number }; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataInfiniteOptions< + APIResponse, + APIResponse, + InfiniteData, unknown>, + TQueryKey + >, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useApiInfiniteQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + throttleTime = null, + ...options +}: Props) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const queryKey = useMemo>( + () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] + ); + + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); + + const query = useInfiniteQuery< + APIResponse, + APIResponse, + InfiniteData, unknown> + >( + { + ...options, + queryKey: [throttledKey], + enabled: Boolean(enabled) && !!throttledKey && !isThrottling, + queryFn: async ({ pageParam, signal }) => + apiCallFn({ ...throttledKey, body: { ...throttledKey.body, offset: pageParam }, signal }), + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + return { ...query }; +}; diff --git a/src/lib/api/useThrottledApiQuery.tsx b/src/lib/api/useThrottledApiQuery.tsx new file mode 100644 index 000000000..f3de507ed --- /dev/null +++ b/src/lib/api/useThrottledApiQuery.tsx @@ -0,0 +1,58 @@ +import type { DefinedInitialDataOptions, QueryKey } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse } from './models'; +import { useThrottledState } from './useThrottledState'; +import type { ApiCallProps } from './utils'; +import { getAPIResponse, useApiCallFn } from './utils'; + +type Types = { + body?: TBody; + error?: TError; + response?: TResponse; +}; + +type Props = Omit< + DefinedInitialDataOptions, APIResponse, APIResponse, TQueryKey>, + 'queryKey' | 'initialData' | 'enabled' +> & + ApiCallProps; + +export const useThrottledApiQuery = ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + allowCache = false, + enabled = true, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + throttleTime = null, + ...options +}: Props) => { + const queryClient = useQueryClient(); + const apiCallFn = useApiCallFn(); + + const queryKey = useMemo>( + () => ({ url, contentType, method, body, allowCache, enabled, reloadOnUnauthorize, retryAfter, throttleTime }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowCache, JSON.stringify(body), contentType, enabled, method, reloadOnUnauthorize, retryAfter, throttleTime, url] + ); + + const [throttledKey, isThrottling] = useThrottledState(queryKey, throttleTime); + + const query = useQuery, APIResponse, APIResponse, QueryKey>( + { + ...options, + queryKey: [throttledKey], + enabled: Boolean(enabled) && !!throttledKey && !isThrottling, + queryFn: async ({ signal }) => apiCallFn({ signal, ...throttledKey }), + retry: (failureCount, error) => failureCount < 1 || error?.api_status_code === 502, + retryDelay: failureCount => (failureCount < 1 ? 1000 : Math.min(retryAfter, 10000)) + }, + queryClient + ); + + return { ...query, ...getAPIResponse(query?.data, query?.error, query?.failureReason), isThrottling }; +}; diff --git a/src/lib/api/useThrottledState.tsx b/src/lib/api/useThrottledState.tsx new file mode 100644 index 000000000..bc742aaa3 --- /dev/null +++ b/src/lib/api/useThrottledState.tsx @@ -0,0 +1,35 @@ +import Throttler from 'commons/addons/utils/throttler'; +import { useEffect, useMemo, useState } from 'react'; + +export const useThrottledState = ( + state: T, + time: number = null, + initialState: T = null +): [T, boolean] => { + const [value, setValue] = useState(initialState); + const [isThrottling, setIsThrottling] = useState(true); + + const throttler = useMemo(() => (!time ? null : new Throttler(time)), [time]); + + useEffect(() => { + let ignore = false; + + if (!time) { + if (ignore) return; + setValue(state); + setIsThrottling(false); + } else { + setIsThrottling(true); + throttler.delay(() => { + if (ignore) return; + setIsThrottling(false); + setValue(state); + }); + } + return () => { + ignore = true; + }; + }, [state, throttler, time]); + + return [value, isThrottling]; +}; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts new file mode 100644 index 000000000..547f8eb4f --- /dev/null +++ b/src/lib/api/utils.ts @@ -0,0 +1,150 @@ +import useALContext from 'components/hooks/useALContext'; +import useMySnackbar from 'components/hooks/useMySnackbar'; +import useQuota from 'components/hooks/useQuota'; +import getXSRFCookie from 'helpers/xsrf'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DEFAULT_RETRY_MS } from './constants'; +import type { APIResponse, BlobResponse } from './models'; + +export const isAPIData = (value: object): value is APIResponse => + value !== undefined && + value !== null && + 'api_response' in value && + 'api_error_message' in value && + 'api_server_version' in value && + 'api_status_code' in value; + +const getValue = (key, ...responses) => responses?.find(r => !!r?.[key])?.[key] || null; + +export const getAPIResponse = (data: APIResponse, error: APIResponse, failureReason: APIResponse) => ({ + statusCode: getValue('api_status_code', data, error, failureReason) as number, + serverVersion: getValue('api_server_version', data, error, failureReason) as string, + data: getValue('api_response', data, error, failureReason) as R, + error: getValue('api_error_message', data, error, failureReason) as E +}); + +export const getBlobResponse = (data: BlobResponse, error: APIResponse, failureReason: APIResponse) => ({ + statusCode: getValue('api_status_code', data, error, failureReason) as number, + serverVersion: getValue('api_server_version', data, error, failureReason) as string, + data: getValue('api_response', data, error, failureReason) as R, + error: getValue('api_error_message', data, error, failureReason) as E, + filename: getValue('filename', data, error, failureReason) as string, + size: getValue('size', data, error, failureReason) as number, + type: getValue('type', data, error, failureReason) as string +}); + +export type ApiCallProps = { + url: string; + contentType?: string; + method?: string; + body?: Body; + allowCache?: boolean; + enabled?: boolean; + reloadOnUnauthorize?: boolean; + retryAfter?: number; + signal?: AbortSignal; + throttleTime?: number; +}; + +export const useApiCallFn = () => { + const { t } = useTranslation(); + const { showErrorMessage, closeSnackbar } = useMySnackbar(); + const { configuration: systemConfig } = useALContext(); + const { setApiQuotaremaining, setSubmissionQuotaremaining } = useQuota(); + + return useCallback( + async ({ + url, + contentType = 'application/json', + method = 'GET', + body = null, + reloadOnUnauthorize = true, + retryAfter = DEFAULT_RETRY_MS, + enabled = true, + signal = null + }: ApiCallProps) => { + // Reject if the query is not enabled + if (!enabled) return Promise.reject(null); + + // fetching the API's data + const res = await fetch(url, { + method, + credentials: 'same-origin', + headers: { 'Content-Type': contentType, 'X-XSRF-TOKEN': getXSRFCookie() }, + body: (body === null ? null : contentType === 'application/json' ? JSON.stringify(body) : body) as BodyInit, + signal + }); + + // Setting the API quota + const apiQuota = res.headers.get('X-Remaining-Quota-Api'); + if (apiQuota) setApiQuotaremaining(parseInt(apiQuota)); + + // Setting the Submission quota + const submissionQuota = res.headers.get('X-Remaining-Quota-Submission'); + if (submissionQuota) setSubmissionQuotaremaining(parseInt(submissionQuota)); + + // Handle an unreachable API + if (res.status === 502) { + showErrorMessage(t('api.unreachable'), 10000); + return Promise.reject({ + api_error_message: t('api.unreachable'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 502 + }); + } + + const json = (await res.json()) as APIResponse; + + // Check for an invalid json format + if (!isAPIData(json)) { + showErrorMessage(t('api.invalid')); + return Promise.reject({ + api_error_message: t('api.invalid'), + api_response: '', + api_server_version: systemConfig.system.version, + api_status_code: 400 + }); + } + + const { api_error_message: error } = json; + + // Reload when the user has exceeded their daily API call quota. + if (res.status === 503 && ['API', 'quota', 'daily'].every(v => error.includes(v))) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if the user has exceeded their daily submissions quota. + if (res.status === 503 && ['quota', 'submission'].every(v => error.includes(v))) { + return Promise.reject(json); + } + + // Reload when the user is not logged in + if (res.status === 401 && reloadOnUnauthorize) { + window.location.reload(); + return Promise.reject(json); + } + + // Reject if API Server is unavailable and should attempt to retry + if (res.status === 502) { + showErrorMessage(json.api_error_message, 30000); + return Promise.reject(json); + } + + // Handle successful request + if (retryAfter !== DEFAULT_RETRY_MS) closeSnackbar(); + + // Handle all non-successful request + if (res.status !== 200) { + showErrorMessage(json.api_error_message); + return Promise.reject(json); + } + + return Promise.resolve(json); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [setApiQuotaremaining, setSubmissionQuotaremaining, systemConfig.system.version, t] + ); +}; diff --git a/src/locales/en/alerts.json b/src/locales/en/alerts.json index e44b6a8fa..376a60e4b 100644 --- a/src/locales/en/alerts.json +++ b/src/locales/en/alerts.json @@ -6,6 +6,7 @@ "actions.takeownershipdiag.content.single": "You are about to take ownership of this single alert with an ID of ", "actions.takeownershipdiag.header": "Take Ownership", "actions.takeownershipdiag.properties": "Search Properties", + "al.score": "Score", "al_results": "Submission Results", "alert_id": "Alert ID", "alert_info": "Alert info", @@ -165,6 +166,7 @@ "workflow.impact.low": "The workflow action will only affect the currently selected alert which matches the following filters:", "workflow.success": "Successfully performed the workflow action to the alerts.", "workflow.title": "Perform a workflow action", + "workflow.tooltip": "Create a new workflow", "workflow_action": "Perform a workflow action", "workflow_or_user": "Workflow/User", "workflows": "Workflow actions", diff --git a/src/locales/en/manage/badlist.json b/src/locales/en/manage/badlist.json index 9fcf62723..d05c0b569 100644 --- a/src/locales/en/manage/badlist.json +++ b/src/locales/en/manage/badlist.json @@ -1,13 +1,13 @@ { - "add_safelist": "Add an item to the badlist", + "add_badlist": "Add an item to the badlist", + "disabled": "Disabled hashes", "filter": "Filter badlisted hashes...", "filtered": "hash matching filter", "filtereds": "hashes matching filter", "searching": "Searching...", + "tag": "Tag based hashes", "title": "Badlist", "total": "hash in badlist", "totals": "hashes in badlist", - "user": "Hashes added by users", - "disabled": "Disabled hashes", - "tag": "Tag based hashes" + "user": "Hashes added by users" } diff --git a/src/locales/en/manage/workflow_detail.json b/src/locales/en/manage/workflow_detail.json index 0a177e339..061200c76 100644 --- a/src/locales/en/manage/workflow_detail.json +++ b/src/locales/en/manage/workflow_detail.json @@ -1,10 +1,11 @@ { - "add.button": "Add workflow", + "add.button": "Add", "add.success": "Successfully added the new workflow.", "add.title": "Adding a workflow", "backport_workflow_matching": "matching alerts", "backport_workflow_prompt": "Apply workflow to all existing alerts?", "cancel": "Cancel changes", + "cancel.button": "Cancel", "chart.title": "Number of hits in the last 30 days", "created_by": "Created by", "delete.acceptText": "Yes, remove it!", @@ -18,17 +19,18 @@ "disable.success": "Workflow was successfully disabled from the system", "disable.text": "Do you really want to disable the current workflow from the system?", "disable.title": "Disable workflow?", - "disabled": "Enable", - "duplicate": "Duplicate workflow", - "edit": "Edit workflow", + "disabled": "Enable this workflow", + "duplicate": "Create a duplicate of this workflow", + "edit": "Edit this workflow", "edit.disabled": "You cannot edit a workflow from another system...", + "edit.title": "Editing a workflow", "edited_by": "Edited by", "enable.acceptText": "Yes, enable it!", "enable.cancelText": "Cancel", "enable.success": "Workflow was successfully enabled from the system", "enable.text": "Do you really want to enable the current workflow from the system?", "enable.title": "Enable workflow?", - "enabled": "Disable", + "enabled": "Disable this workflow", "hit.count": "Hit Count:", "hit.first": "First Seen:", "hit.last": "Last Seen:", @@ -36,7 +38,7 @@ "hits": "Hits", "labels": "Labels", "last10": "Last 10 hits", - "name": "Name (Required)", + "name": "Name", "on": "on", "origin": "Origin", "priority": "Priority", @@ -45,10 +47,18 @@ "priority.LOW": "LOW", "priority.MEDIUM": "MEDIUM", "priority.null": "", - "query": "Query (Required)", + "query": "Query", "remove": "Remove workflow", + "required": "(Required)", + "run.acceptText": "Yes, run it!", + "run.cancelText": "Cancel", + "run.success": "Workflow was successfully executed", + "run.text1": "Only execute this action if you suspect that an error occurred where not all alerts were flagged to avoid cluttering up the alert's event record.", + "run.text2": "You are about to execute this workflow against all alerts matching the query including the ones that were already flagged by this workflow. This may add duplicate event item in those alerts' event record.", + "run.text3": "Do you really want to run this workflow?", + "run.title": "Execute the workflow?", + "run.tooltip": "Execute this workflow", "save": "Save Changes", - "save.success": "Workflow was successfully saved.", "statistics": "Statistics", "status": "Status", "status.ASSESS": "ASSESS", @@ -57,5 +67,7 @@ "status.TRIAGE": "TRIAGE", "status.null": "", "title": "Workflow Details", + "update.button": "Update", + "update.success": "Workflow was successfully updated.", "usage": "Show related alerts" } diff --git a/src/locales/en/submission/detail.json b/src/locales/en/submission/detail.json index ff6fb4aa2..8a8850db8 100644 --- a/src/locales/en/submission/detail.json +++ b/src/locales/en/submission/detail.json @@ -74,6 +74,7 @@ "resubmit.carbon_copy": "Use the same parameters", "resubmit.dynamic": "Resubmit for Dynamic analysis", "resubmit.modify": "Adjust parameters before submission", + "show_more": "Show more...", "submit.success": "Submission successfully resubmitted. You will be redirected to it...", "times.completed": "Completed Time", "times.submitted": "Start Time", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 2c027e781..c65886f12 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -39,6 +39,7 @@ "breadcrumb.submission.detail.file": "File", "breadcrumb.submission.report": "Report", "breadcrumb.tos": "Terms of Service", + "breadcrumb.workflow.create": "Create", "breadcrumb.workflow.detail": "Details", "classification.acceptText": "Proceed", "classification.cancelText": "Cancel", diff --git a/src/locales/fr/alerts.json b/src/locales/fr/alerts.json index 6d71c8765..7aa605a94 100644 --- a/src/locales/fr/alerts.json +++ b/src/locales/fr/alerts.json @@ -7,6 +7,7 @@ "actions.takeownershipdiag.content.single": "Vous êtes sur le point de de devenir le propriétaire de cette alerte unique avec l'identifiant ", "actions.takeownershipdiag.header": "Prendre possession", "actions.takeownershipdiag.properties": "Paramètres de recherche", + "al.score": "Score", "al_results": "Résultats de soumission", "alert_id": "ID de l'alerte", "alert_info": "Informations sur l'alerte", @@ -36,6 +37,7 @@ "extended_completed_desc": "L'analyse étendue s'est terminée avec succès.", "extended_incomplete": "Incomplète", "extended_incomplete_desc": "Échec de l'analyse étendue.", + "workflow.tooltip": "Créer un nouveau flux de travail", "extended_skipped": "Ignoré", "extended_skipped_desc": "L'analyse étendue a été ignorée.", "extended_submitted": "Soumise", diff --git a/src/locales/fr/manage/badlist.json b/src/locales/fr/manage/badlist.json index 0b2af7421..60323e650 100644 --- a/src/locales/fr/manage/badlist.json +++ b/src/locales/fr/manage/badlist.json @@ -1,13 +1,13 @@ { "add_badlist": "Ajouter un item à la liste mauvaise", + "disabled": "Hachages désactivés", "filter": "Filtrer les hachages de la liste mauvaise...", "filtered": "hachage correspondant au filtre", "filtereds": "hachages correspondant au filtre", "searching": "Recherche...", + "tag": "Hachages basés sur des balises", "title": "Liste Mauvaise", "total": "hachage dans la liste mauvaise", "totals": "hachages dans la liste mauvaise", - "user": "Hachages ajoutés par les utilisateurs", - "disabled": "Hachages désactivés", - "tag": "Hachages basés sur des balises" + "user": "Hachages ajoutés par les utilisateurs" } diff --git a/src/locales/fr/manage/workflow_detail.json b/src/locales/fr/manage/workflow_detail.json index aec8dde80..0c9d15711 100644 --- a/src/locales/fr/manage/workflow_detail.json +++ b/src/locales/fr/manage/workflow_detail.json @@ -5,6 +5,7 @@ "backport_workflow_matching": "alertes correspondantes", "backport_workflow_prompt": "Appliquer le flux de travail à toutes les alertes existantes ?", "cancel": "Annuler les modifications", + "cancel.button": "Annuler", "chart.title": "Nombre de fois utilisé dans les derniers 30 jours", "created_by": "Créé par", "delete.acceptText": "Oui, supprimez-le !", @@ -18,17 +19,18 @@ "disable.success": "Le flux de travail a été désactivé avec succès dans le système", "disable.text": "Voulez-vous vraiment désactiver le flux de travail actuel dans le système?", "disable.title": "Désactiver le flux de travail?", - "disabled": "Activer", - "duplicate": "Flux de travail en double", - "edit": "Modifier le flux de travail", + "disabled": "Activer ce flux de travail", + "duplicate": "Créer un double de ce flux de travail", + "edit": "Modifier ce flux de travail", "edit.disabled": "Vous ne pouvez pas modifier un flux de travail d'un autre système...", + "edit.title": "Modifier un flux de travail", "edited_by": "Édité par", "enable.acceptText": "Oui, activez-le !", "enable.cancelText": "Annuler", "enable.success": "Le flux de travail a été activé avec succès dans le système", "enable.text": "Voulez-vous vraiment activer le flux de travail actuel dans le système?", "enable.title": "Activer le flux de travail?", - "enabled": "Désactiver", + "enabled": "Désactiver ce flux de travail", "hit.count": "Nombre :", "hit.first": "Première vue :", "hit.last": "Dernière vue :", @@ -36,7 +38,7 @@ "hits": "Utilisation", "labels": "Étiquettes", "last10": "10 dernières utilisations", - "name": "Nom (Requis)", + "name": "Nom", "on": "le", "origin": "Origine", "priority": "Priorité", @@ -45,10 +47,18 @@ "priority.LOW": "BASSE", "priority.MEDIUM": "MOYENNE", "priority.null": "", - "query": "Requête (Requis)", - "remove": "Enlever le flux de travail", + "query": "Requête", + "remove": "Supprimer ce flux de travail", + "required": "(Requis)", + "run.acceptText": "Oui, exécutez-le !", + "run.cancelText": "Annuler", + "run.success": "Le flux de travail a été exécuté avec succès", + "run.text1": "N'exécutez cette action que si vous soupçonnez qu'une erreur s'est produite là où toutes les alertes n'ont pas été signalées, afin d'éviter d'encombrer le registre d'événements de ces alertes.", + "run.text2": "Vous êtes sur le point d'exécuter ce flux de travail sur toutes les alertes correspondant à la requête, y compris celles déjà signalées par ce flux de travail. Cela peut causer un dédoublement d'événements au registre de ces alertes.", + "run.text3": "Voulez-vous vraiment exécuter ce flux de travail ?", + "run.title": "Exécuter le flux de travail ?", + "run.tooltip": "Exécuter ce flux de travail", "save": "Sauvegarder les modifications", - "save.success": "Le flux de travail a été enregistré avec succès.", "statistics": "Statistiques", "status": "Statut", "status.ASSESS": "ÉVALUER", @@ -57,5 +67,7 @@ "status.TRIAGE": "TRIAGE", "status.null": "", "title": "Détails du flux de travail", + "update.button": "Modifier", + "update.success": "Le flux de travail a été enregistré avec succès.", "usage": "Afficher les alertes associées" } diff --git a/src/locales/fr/submission/detail.json b/src/locales/fr/submission/detail.json index a8b9817ba..a071227f2 100644 --- a/src/locales/fr/submission/detail.json +++ b/src/locales/fr/submission/detail.json @@ -74,6 +74,7 @@ "resubmit.carbon_copy": "Utiliser les mêmes paramètres", "resubmit.dynamic": "Resoumettre pour analyse dynamique", "resubmit.modify": "Ajustez les paramètres avant la soumission", + "show_more": "Afficher plus...", "submit.success": "Soumission soumis à nouveau avec succès. Vous y serez redirigé ...", "times.completed": "Temps terminé", "times.submitted": "Temps de début", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 246a90de1..4fac78811 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -39,6 +39,7 @@ "breadcrumb.submission.detail.file": "Fichiers", "breadcrumb.submission.report": "Rapport", "breadcrumb.tos": "Conditions d'utilisation", + "breadcrumb.workflow.create": "Créer", "breadcrumb.workflow.detail": "Détails", "classification.acceptText": "Procéder", "classification.cancelText": "Annuler", diff --git a/yarn.lock b/yarn.lock index a8b21b634..4fba0e01c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2289,6 +2289,64 @@ dependencies: "@swc/counter" "^0.1.3" +"@tanstack/eslint-plugin-query@^5.58.1": + version "5.58.1" + resolved "https://registry.yarnpkg.com/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.58.1.tgz#92893d6d0f895de1cafec5b21386154bcaf66d48" + integrity sha512-hJR3N5ilK60gCgDWr7pWHV/vDiDVczT95F8AGIcg1gf9117aLPK+LDu+xP2JuEWpWKpsQ6OpWdVMim9kKlMybw== + dependencies: + "@typescript-eslint/utils" "^8.3.0" + +"@tanstack/query-core@5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.56.2.tgz#2def2fb0290cd2836bbb08afb0c175595bb8109b" + integrity sha512-gor0RI3/R5rVV3gXfddh1MM+hgl0Z4G7tj6Xxpq6p2I03NGPaJ8dITY9Gz05zYYb/EJq9vPas/T4wn9EaDPd4Q== + +"@tanstack/query-core@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.59.0.tgz#d8323f1c6eb0e573ab0aa85a7b7690d0c263818a" + integrity sha512-WGD8uIhX6/deH/tkZqPNcRyAhDUqs729bWKoByYHSogcshXfFbppOdTER5+qY7mFvu8KEFJwT0nxr8RfPTVh0Q== + +"@tanstack/query-devtools@5.56.1": + version "5.56.1" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.56.1.tgz#319c362dd19c6cfe005e74a8777baefa4a4f72de" + integrity sha512-xnp9jq/9dHfSCDmmf+A5DjbIjYqbnnUL2ToqlaaviUQGRTapXQ8J+GxusYUu1IG0vZMaWdiVUA4HRGGZYAUU+A== + +"@tanstack/query-persist-client-core@5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.59.0.tgz#a896386edde3531fec8c0a29aeae9f4ac4681f4c" + integrity sha512-uGXnTgck1AX2xXDVj417vtQD4Sz3J1D5iPxVhfUc7f/fkY9Qad2X7Id9mZUtll1/m9z55DfHoXMXlx5H1JK6fQ== + dependencies: + "@tanstack/query-core" "5.59.0" + +"@tanstack/query-sync-storage-persister@^5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.59.0.tgz#e3d66a36c25de94b89e563b4b669796fd36b19ae" + integrity sha512-PBQ6Chg/rgRQuQcTaFV/GiCDZdzZvbODKrpti+0fOPjKipEtodGRqtvYsPlf1Y7yb4AbZTECAKUQFjCv/gZVaA== + dependencies: + "@tanstack/query-core" "5.59.0" + "@tanstack/query-persist-client-core" "5.59.0" + +"@tanstack/react-query-devtools@^5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.56.2.tgz#c129cdb811927085434ea27691e4b7f605eb4128" + integrity sha512-7nINJtRZZVwhTTyDdMIcSaXo+EHMLYJu1S2e6FskvvD5prx87LlAXXWZDfU24Qm4HjshEtM5lS3HIOszNGblcw== + dependencies: + "@tanstack/query-devtools" "5.56.1" + +"@tanstack/react-query-persist-client@^5.59.0": + version "5.59.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.59.0.tgz#a73bfaf49fdc8d2502c6dc7661e2eb6ad9beeb8f" + integrity sha512-pUZxMXyy8Atv0rKzbRuCjC2wkQ6uTnKqlKtZ7djwv8r89tF1O5+p4DRCpMzQ+8w/qPRo9yMu/nDgGPcEZ1Fr8Q== + dependencies: + "@tanstack/query-persist-client-core" "5.59.0" + +"@tanstack/react-query@^5.56.2": + version "5.56.2" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.56.2.tgz#3a0241b9d010910905382f5e99160997b8795f91" + integrity sha512-SR0GzHVo6yzhN72pnRhkEFRAHMsUo5ZPzAxfTMvUxFIDVS6W9LYUp6nXW3fcHVdg0ZJl8opSH85jqahvm6DSVg== + dependencies: + "@tanstack/query-core" "5.56.2" + "@testing-library/dom@^7.31.2": version "7.31.2" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.31.2.tgz#df361db38f5212b88555068ab8119f5d841a8c4a" @@ -2705,6 +2763,14 @@ "@typescript-eslint/types" "7.8.0" "@typescript-eslint/visitor-keys" "7.8.0" +"@typescript-eslint/scope-manager@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.8.0.tgz#30b23a6ae5708bd7882e40675ef2f1b2beac741f" + integrity sha512-EL8eaGC6gx3jDd8GwEFEV091210U97J0jeEHrAYvIYosmEGet4wJ+g0SYmLu+oRiAwbSA5AVrt6DxLHfdd+bUg== + dependencies: + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/visitor-keys" "8.8.0" + "@typescript-eslint/type-utils@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz#286f0389c41681376cdad96b309cedd17d70346a" @@ -2735,6 +2801,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.8.0.tgz#1fd2577b3ad883b769546e2d1ef379f929a7091d" integrity sha512-wf0peJ+ZGlcH+2ZS23aJbOv+ztjeeP8uQ9GgwMJGVLx/Nj9CJt17GWgWWoSmoRVKAX2X+7fzEnAjxdvK2gqCLw== +"@typescript-eslint/types@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.8.0.tgz#08ea5df6c01984d456056434641491fbf7a1bf43" + integrity sha512-QJwc50hRCgBd/k12sTykOJbESe1RrzmX6COk8Y525C9l7oweZ+1lw9JiU56im7Amm8swlz00DRIlxMYLizr2Vw== + "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -2762,6 +2833,20 @@ semver "^7.6.0" ts-api-utils "^1.3.0" +"@typescript-eslint/typescript-estree@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.0.tgz#072eaab97fdb63513fabfe1cf271812affe779e3" + integrity sha512-ZaMJwc/0ckLz5DaAZ+pNLmHv8AMVGtfWxZe/x2JVEkD5LnmhWiQMMcYT7IY7gkdJuzJ9P14fRy28lUrlDSWYdw== + dependencies: + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/visitor-keys" "8.8.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + "@typescript-eslint/utils@5.62.0", "@typescript-eslint/utils@^5.58.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -2789,6 +2874,16 @@ "@typescript-eslint/typescript-estree" "7.8.0" semver "^7.6.0" +"@typescript-eslint/utils@^8.3.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.8.0.tgz#bd8607e3a68c461b69169c7a5824637dc9e8b3f1" + integrity sha512-QE2MgfOTem00qrlPgyByaCHay9yb1+9BjnMFnSFkUKQfu7adBXDTnCAivURnuPPAG/qiB+kzKkZKmKfaMT0zVg== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@typescript-eslint/scope-manager" "8.8.0" + "@typescript-eslint/types" "8.8.0" + "@typescript-eslint/typescript-estree" "8.8.0" + "@typescript-eslint/visitor-keys@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" @@ -2805,6 +2900,14 @@ "@typescript-eslint/types" "7.8.0" eslint-visitor-keys "^3.4.3" +"@typescript-eslint/visitor-keys@8.8.0": + version "8.8.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.0.tgz#f93965abd38c82a1a1f5574290a50d02daf1cd2e" + integrity sha512-8mq51Lx6Hpmd7HnA2fcHQo3YgfX1qbccxQOgZcb4tvasu//zXRaA1j5ZRFeCw/VRAdFi4mRM9DnZw0Nu0Q2d1g== + dependencies: + "@typescript-eslint/types" "8.8.0" + eslint-visitor-keys "^3.4.3" + "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"