@cccsaurora/howler-ui 2.13.0-dev.96 → 2.13.1-dev.184
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/hit/index.d.ts +1 -1
- package/api/hit/index.js +6 -2
- package/api/search/index.d.ts +1 -0
- package/api/view/index.d.ts +1 -1
- package/api/view/index.js +2 -2
- package/commons/components/notification/elements/item/NotificationItemDate.js +2 -2
- package/commons/components/utils/hooks/useEnv.d.ts +1 -1
- package/components/app/App.js +1 -3
- package/components/app/drawers/ApiKeyDrawer.js +4 -4
- package/components/app/hooks/useMatchers.d.ts +9 -0
- package/components/app/hooks/useMatchers.js +82 -0
- package/components/app/hooks/useMatchers.test.d.ts +1 -0
- package/components/app/hooks/useMatchers.test.js +237 -0
- package/components/app/hooks/useTitle.js +5 -4
- package/components/app/providers/AnalyticProvider.d.ts +0 -4
- package/components/app/providers/AnalyticProvider.js +1 -44
- package/components/app/providers/ApiConfigProvider.js +2 -1
- package/components/app/providers/HitProvider.d.ts +2 -1
- package/components/app/providers/HitProvider.js +8 -2
- package/components/app/providers/HitSearchProvider.d.ts +2 -1
- package/components/app/providers/HitSearchProvider.js +2 -1
- package/components/app/providers/SocketProvider.js +1 -1
- package/components/app/providers/ViewProvider.d.ts +1 -1
- package/components/app/providers/ViewProvider.js +3 -3
- package/components/app/providers/ViewProvider.test.d.ts +1 -0
- package/components/app/providers/ViewProvider.test.js +150 -0
- package/components/elements/display/ActionButton.d.ts +8 -0
- package/components/elements/display/ActionButton.js +18 -0
- package/components/elements/display/handlebars/helpers.js +17 -2
- package/components/elements/display/json/JSONViewer.d.ts +2 -0
- package/components/elements/display/json/JSONViewer.js +6 -13
- package/components/elements/hit/HitActions.js +4 -6
- package/components/elements/hit/HitBanner.js +13 -5
- package/components/elements/hit/HitCard.js +0 -5
- package/components/elements/hit/HitComments.js +5 -4
- package/components/elements/hit/HitOutline.d.ts +2 -2
- package/components/elements/hit/HitOutline.js +11 -21
- package/components/elements/hit/HitOverview.js +7 -4
- package/components/elements/hit/HitSummary.d.ts +2 -1
- package/components/elements/hit/HitSummary.js +8 -7
- package/components/elements/hit/aggregate/HitGraph.d.ts +1 -1
- package/components/elements/hit/aggregate/HitGraph.js +7 -7
- package/components/elements/hit/elements/HitTimestamp.js +8 -8
- package/components/elements/hit/related/PivotLink.js +11 -5
- package/components/elements/hit/related/RelatedIcon.d.ts +8 -0
- package/components/elements/hit/related/RelatedIcon.js +32 -0
- package/components/elements/hit/related/RelatedLink.js +4 -25
- package/components/hooks/useMyChart.d.ts +1 -1
- package/components/hooks/useMyChart.js +1 -1
- package/components/routes/advanced/QueryBuilder.js +47 -11
- package/components/routes/advanced/QueryEditor.js +8 -13
- package/components/routes/analytics/AnalyticOverview.d.ts +1 -1
- package/components/routes/analytics/AnalyticOverview.js +1 -1
- package/components/routes/analytics/AnalyticOverviews.d.ts +1 -1
- package/components/routes/analytics/AnalyticOverviews.js +1 -1
- package/components/routes/analytics/AnalyticSearch.js +14 -2
- package/components/routes/analytics/AnalyticTemplates.d.ts +1 -1
- package/components/routes/analytics/AnalyticTemplates.js +10 -7
- package/components/routes/analytics/RuleView.d.ts +1 -1
- package/components/routes/analytics/RuleView.js +1 -1
- package/components/routes/analytics/TriageSettings.d.ts +1 -1
- package/components/routes/analytics/TriageSettings.js +1 -1
- package/components/routes/analytics/widgets/Assessment.d.ts +1 -1
- package/components/routes/analytics/widgets/Assessment.js +1 -1
- package/components/routes/analytics/widgets/Created.d.ts +1 -1
- package/components/routes/analytics/widgets/Created.js +1 -1
- package/components/routes/analytics/widgets/Detection.d.ts +1 -1
- package/components/routes/analytics/widgets/Detection.js +1 -1
- package/components/routes/analytics/widgets/Escalation.d.ts +1 -1
- package/components/routes/analytics/widgets/Escalation.js +1 -1
- package/components/routes/analytics/widgets/Stacked.d.ts +1 -1
- package/components/routes/analytics/widgets/Stacked.js +1 -1
- package/components/routes/analytics/widgets/Status.d.ts +1 -1
- package/components/routes/analytics/widgets/Status.js +1 -1
- package/components/routes/help/SearchDocumentation.js +2 -1
- package/components/routes/help/TemplateDocumentation.js +5 -5
- package/components/routes/hits/search/HitBrowser.js +2 -2
- package/components/routes/hits/search/HitContextMenu.js +6 -7
- package/components/routes/hits/search/HitQuery.js +2 -1
- package/components/routes/hits/search/InformationPane.js +75 -78
- package/components/routes/hits/search/SearchPane.js +3 -9
- package/components/routes/hits/search/grid/AddColumnModal.js +10 -5
- package/components/routes/hits/search/grid/HitGrid.js +6 -5
- package/components/routes/hits/search/shared/CustomSpan.js +6 -6
- package/components/routes/hits/view/HitViewer.js +18 -26
- package/components/routes/home/index.js +4 -4
- package/components/routes/overviews/OverviewViewer.js +33 -31
- package/components/routes/settings/SecuritySection.js +2 -2
- package/components/routes/templates/TemplateViewer.js +27 -36
- package/components/routes/templates/Templates.js +4 -11
- package/components/routes/views/ViewComposer.js +8 -1
- package/components/routes/views/Views.js +25 -9
- package/index.js +7 -0
- package/locales/en/help/search.json +17 -0
- package/locales/en/translation.json +12 -3
- package/locales/fr/help/search.json +17 -0
- package/locales/fr/translation.json +12 -4
- package/models/WithMetadata.d.ts +10 -0
- package/models/WithMetadata.js +1 -0
- package/models/entities/generated/ApiType.d.ts +7 -0
- package/package.json +112 -111
- package/plugins/borealis/components/BorealisTypography.js +4 -2
- package/setupTests.d.ts +1 -0
- package/setupTests.js +12 -0
- package/tests/MockLocalStorage.d.ts +5 -0
- package/tests/MockLocalStorage.js +44 -0
- package/tests/server-handlers.d.ts +5 -0
- package/tests/server-handlers.js +97 -0
- package/tests/server.d.ts +3 -0
- package/tests/server.js +5 -0
- package/utils/constants.js +2 -2
- package/utils/stringUtils.d.ts +1 -0
- package/utils/stringUtils.js +9 -0
- package/utils/utils.js +3 -3
- package/components/app/providers/DossierProvider.d.ts +0 -16
- package/components/app/providers/DossierProvider.js +0 -82
- package/components/app/providers/TemplateProvider.d.ts +0 -14
- package/components/app/providers/TemplateProvider.js +0 -103
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HowlerSearchResponse } from '@cccsaurora/howler-ui/api/search';
|
|
2
2
|
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
3
3
|
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
4
|
+
import type { WithMetadata } from '@cccsaurora/howler-ui/models/WithMetadata';
|
|
4
5
|
import { type Dispatch, type FC, type PropsWithChildren, type SetStateAction } from 'react';
|
|
5
6
|
export interface QueryEntry {
|
|
6
7
|
[query: string]: string;
|
|
@@ -10,7 +11,7 @@ interface HitSearchProviderType {
|
|
|
10
11
|
displayType: 'list' | 'grid';
|
|
11
12
|
searching: boolean;
|
|
12
13
|
error: string | null;
|
|
13
|
-
response: HowlerSearchResponse<Hit
|
|
14
|
+
response: HowlerSearchResponse<WithMetadata<Hit>> | null;
|
|
14
15
|
viewId: string | null;
|
|
15
16
|
bundleId: string | null;
|
|
16
17
|
queryHistory: QueryEntry;
|
|
@@ -86,7 +86,8 @@ const HitSearchProvider = ({ children }) => {
|
|
|
86
86
|
query: fullQuery,
|
|
87
87
|
sort,
|
|
88
88
|
filters,
|
|
89
|
-
track_total_hits: trackTotalHits
|
|
89
|
+
track_total_hits: trackTotalHits,
|
|
90
|
+
metadata: ['template', 'overview', 'analytic']
|
|
90
91
|
}), { showError: false, throwError: true });
|
|
91
92
|
if (_response.total < offset) {
|
|
92
93
|
setOffset(0);
|
|
@@ -121,7 +121,7 @@ const SocketProvider = ({ children }) => {
|
|
|
121
121
|
setRetry(false);
|
|
122
122
|
// Here we go!
|
|
123
123
|
setStatus(Status.CONNECTING);
|
|
124
|
-
const host = window.location.host;
|
|
124
|
+
const host = window.location.host.includes('localhost') ? 'localhost:5000' : window.location.host;
|
|
125
125
|
const protocol = window.location.protocol.startsWith('http:') ? 'ws' : 'wss';
|
|
126
126
|
const ws = new WebSocket(`${protocol}://${host}/socket/v1/connect`);
|
|
127
127
|
// Add our listeners to the websocket
|
|
@@ -10,7 +10,7 @@ export interface ViewContextType {
|
|
|
10
10
|
removeFavourite: (id: string) => Promise<void>;
|
|
11
11
|
fetchViews: (ids?: string[]) => Promise<View[]>;
|
|
12
12
|
addView: (v: View) => Promise<View>;
|
|
13
|
-
editView: (id: string,
|
|
13
|
+
editView: (id: string, newView: Partial<Omit<View, 'view_id' | 'owner'>>) => Promise<View>;
|
|
14
14
|
removeView: (id: string) => Promise<void>;
|
|
15
15
|
getCurrentView: (lazy?: boolean) => Promise<View>;
|
|
16
16
|
}
|
|
@@ -69,11 +69,11 @@ const ViewProvider = ({ children }) => {
|
|
|
69
69
|
}
|
|
70
70
|
return views[id];
|
|
71
71
|
}, [defaultView, fetchViews, location.pathname, routeParams.id, views]);
|
|
72
|
-
const editView = useCallback(async (id,
|
|
73
|
-
const result = await dispatchApi(api.view.put(id,
|
|
72
|
+
const editView = useCallback(async (id, partialView) => {
|
|
73
|
+
const result = await dispatchApi(api.view.put(id, partialView));
|
|
74
74
|
setViews(_views => ({
|
|
75
75
|
..._views,
|
|
76
|
-
[id]: { ...(_views[id] ?? {}),
|
|
76
|
+
[id]: { ...(_views[id] ?? {}), ...partialView }
|
|
77
77
|
}));
|
|
78
78
|
return result;
|
|
79
79
|
}, [dispatchApi]);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import { hget, hpost, hput } from '@cccsaurora/howler-ui/api';
|
|
4
|
+
import MockLocalStorage from '@cccsaurora/howler-ui/tests/MockLocalStorage';
|
|
5
|
+
import { MOCK_RESPONSES } from '@cccsaurora/howler-ui/tests/server-handlers';
|
|
6
|
+
import { useContextSelector } from 'use-context-selector';
|
|
7
|
+
import { MY_LOCAL_STORAGE_PREFIX, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
8
|
+
import ViewProvider, { ViewContext } from './ViewProvider';
|
|
9
|
+
let mockUser = {
|
|
10
|
+
favourite_views: ['favourited_view_id']
|
|
11
|
+
};
|
|
12
|
+
vi.mock('@cccsaurora/howler-ui/api', { spy: true });
|
|
13
|
+
vi.mock('react-router-dom', () => ({
|
|
14
|
+
useLocation: vi.fn(() => ({ pathname: '/views/searched_view_id' })),
|
|
15
|
+
useParams: vi.fn(() => ({ id: 'searched_view_id' }))
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('@cccsaurora/howler-ui/commons/components/app/hooks', () => ({
|
|
18
|
+
useAppUser: () => ({
|
|
19
|
+
user: mockUser,
|
|
20
|
+
setUser: _user => (mockUser = _user)
|
|
21
|
+
})
|
|
22
|
+
}));
|
|
23
|
+
const mockLocalStorage = new MockLocalStorage();
|
|
24
|
+
// Replace localStorage in global scope
|
|
25
|
+
Object.defineProperty(window, 'localStorage', {
|
|
26
|
+
value: mockLocalStorage,
|
|
27
|
+
writable: true
|
|
28
|
+
});
|
|
29
|
+
const Wrapper = ({ children }) => {
|
|
30
|
+
return _jsx(ViewProvider, { children: children });
|
|
31
|
+
};
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
mockLocalStorage.clear();
|
|
34
|
+
});
|
|
35
|
+
describe('ViewContext', () => {
|
|
36
|
+
it('should fetch the defaultView on initialization', async () => {
|
|
37
|
+
mockLocalStorage.setItem(`${MY_LOCAL_STORAGE_PREFIX}.${StorageKey.DEFAULT_VIEW}`, JSON.stringify('searched_view_id'));
|
|
38
|
+
let hook = await act(async () => renderHook(() => useContextSelector(ViewContext, ctx => ctx.views), { wrapper: Wrapper }));
|
|
39
|
+
await waitFor(() => expect(hook.result.current.searched_view_id).not.toBeFalsy());
|
|
40
|
+
expect(hook.result.current.searched_view_id).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items[0]);
|
|
41
|
+
});
|
|
42
|
+
it('should allow the user to add and remove a favourite view', async () => {
|
|
43
|
+
const hook = await act(async () => {
|
|
44
|
+
return renderHook(() => useContextSelector(ViewContext, ctx => ({
|
|
45
|
+
addFavourite: ctx.addFavourite,
|
|
46
|
+
removeFavourite: ctx.removeFavourite
|
|
47
|
+
})), { wrapper: Wrapper });
|
|
48
|
+
});
|
|
49
|
+
await hook.result.current.addFavourite('example_view_id');
|
|
50
|
+
expect(mockUser.favourite_views).toEqual(['favourited_view_id', 'example_view_id']);
|
|
51
|
+
await hook.result.current.removeFavourite('example_view_id');
|
|
52
|
+
expect(mockUser.favourite_views).toEqual(['favourited_view_id']);
|
|
53
|
+
});
|
|
54
|
+
it('should allow the user to add and remove views', async () => {
|
|
55
|
+
const hook = await act(async () => {
|
|
56
|
+
return renderHook(() => useContextSelector(ViewContext, ctx => ({
|
|
57
|
+
addView: ctx.addView,
|
|
58
|
+
removeView: ctx.removeView,
|
|
59
|
+
views: ctx.views
|
|
60
|
+
})), { wrapper: Wrapper });
|
|
61
|
+
});
|
|
62
|
+
const result = await act(async () => hook.result.current.addView({
|
|
63
|
+
owner: 'user',
|
|
64
|
+
settings: {
|
|
65
|
+
advance_on_triage: false
|
|
66
|
+
},
|
|
67
|
+
view_id: 'example_created_view',
|
|
68
|
+
query: 'howler.id:*',
|
|
69
|
+
sort: 'event.created desc',
|
|
70
|
+
title: 'Example View',
|
|
71
|
+
type: 'personal',
|
|
72
|
+
span: 'date.range.1.month'
|
|
73
|
+
}));
|
|
74
|
+
hook.rerender();
|
|
75
|
+
expect(hook.result.current.views[result.view_id]).toEqual(result);
|
|
76
|
+
await act(async () => hook.result.current.removeView(result.view_id));
|
|
77
|
+
hook.rerender();
|
|
78
|
+
expect(hook.result.current.views[result.view_id]).toBeFalsy();
|
|
79
|
+
});
|
|
80
|
+
describe('fetchViews', () => {
|
|
81
|
+
let hook;
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
hook = await act(async () => {
|
|
84
|
+
return renderHook(() => useContextSelector(ViewContext, ctx => ctx.fetchViews), { wrapper: Wrapper });
|
|
85
|
+
});
|
|
86
|
+
vi.mocked(hpost).mockClear();
|
|
87
|
+
vi.mocked(hget).mockClear();
|
|
88
|
+
});
|
|
89
|
+
it('Should fetch all views when no ids are provided', async () => {
|
|
90
|
+
const result = await act(async () => hook.result.current());
|
|
91
|
+
expect(result.length).toBe(2);
|
|
92
|
+
expect(result[0].view_id).toBe('example_view_id');
|
|
93
|
+
expect(result[1].view_id).toBe('another_view_id');
|
|
94
|
+
});
|
|
95
|
+
it('Should search for specified views when ids are provided', async () => {
|
|
96
|
+
const result = await act(async () => hook.result.current(['searched_view_id']));
|
|
97
|
+
expect(hpost).toHaveBeenCalledOnce();
|
|
98
|
+
expect(hpost).toBeCalledWith('/api/v1/search/view', { query: 'view_id:(searched_view_id)', rows: 1 });
|
|
99
|
+
expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items);
|
|
100
|
+
});
|
|
101
|
+
it('Should search only for new views when ids are provided', async () => {
|
|
102
|
+
await act(async () => hook.result.current(['searched_view_id']));
|
|
103
|
+
expect(hpost).toHaveBeenCalledOnce();
|
|
104
|
+
expect(hpost).toBeCalledWith('/api/v1/search/view', { query: 'view_id:(searched_view_id)', rows: 1 });
|
|
105
|
+
vi.mocked(hpost).mockClear();
|
|
106
|
+
await act(async () => hook.result.current(['searched_view_id', 'searched_view_id_2']));
|
|
107
|
+
expect(hpost).toHaveBeenCalledOnce();
|
|
108
|
+
expect(hpost).toBeCalledWith('/api/v1/search/view', { query: 'view_id:(searched_view_id_2)', rows: 1 });
|
|
109
|
+
});
|
|
110
|
+
it('Should provide cached instances as a response when the same views are requested', async () => {
|
|
111
|
+
let result = await act(async () => hook.result.current(['searched_view_id']));
|
|
112
|
+
expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items);
|
|
113
|
+
result = await act(async () => hook.result.current(['searched_view_id']));
|
|
114
|
+
expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items);
|
|
115
|
+
expect(hpost).toHaveBeenCalledOnce();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
describe('getCurrentView', () => {
|
|
119
|
+
let hook;
|
|
120
|
+
beforeAll(async () => {
|
|
121
|
+
hook = await act(async () => {
|
|
122
|
+
return renderHook(() => useContextSelector(ViewContext, ctx => ctx.getCurrentView), { wrapper: Wrapper });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
it('should allow the user to fetch their current view based on the location', async () => {
|
|
126
|
+
// lazy load should return nothing
|
|
127
|
+
await expect(hook.result.current(true)).resolves.toBeFalsy();
|
|
128
|
+
const result = await act(async () => hook.result.current());
|
|
129
|
+
expect(result).toEqual(MOCK_RESPONSES['/api/v1/search/view'].items[0]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('editView', () => {
|
|
133
|
+
let hook;
|
|
134
|
+
beforeAll(async () => {
|
|
135
|
+
hook = await act(async () => {
|
|
136
|
+
return renderHook(() => useContextSelector(ViewContext, ctx => ctx.editView), { wrapper: Wrapper });
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
vi.mocked(hput).mockClear();
|
|
141
|
+
vi.mocked(hpost).mockClear();
|
|
142
|
+
});
|
|
143
|
+
it('should allow users to edit views', async () => {
|
|
144
|
+
const result = await act(async () => hook.result.current('example_view_id', { query: 'howler.id:*' }));
|
|
145
|
+
expect(hput).toHaveBeenCalledOnce();
|
|
146
|
+
expect(hput).toBeCalledWith('/api/v1/view/example_view_id', { query: 'howler.id:*' });
|
|
147
|
+
expect(result).toEqual(MOCK_RESPONSES['/api/v1/view/example_view_id']);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Button } from '@mui/material';
|
|
3
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
4
|
+
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
5
|
+
import useMyActionFunctions from '@cccsaurora/howler-ui/components/routes/action/useMyActionFunctions';
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { useTranslation } from 'react-i18next';
|
|
8
|
+
const ActionButton = ({ actionId, hitId, label, ...otherProps }) => {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const { dispatchApi } = useMyApi();
|
|
11
|
+
const { executeAction } = useMyActionFunctions();
|
|
12
|
+
const [action, setAction] = useState(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
dispatchApi(api.search.action.post({ query: `action_id:${actionId}`, rows: 1 })).then(result => setAction(result.items[0]));
|
|
15
|
+
}, [actionId, dispatchApi]);
|
|
16
|
+
return (_jsx(Button, { variant: otherProps.variant ?? 'outlined', disabled: !action, onClick: () => executeAction(actionId, `howler.id:${hitId}`), color: otherProps.color ?? 'primary', children: label ?? action?.name ?? t('loading') }));
|
|
17
|
+
};
|
|
18
|
+
export default ActionButton;
|
|
@@ -10,7 +10,9 @@ import { capitalize, get, groupBy, isObject } from 'lodash-es';
|
|
|
10
10
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
11
11
|
import { useMemo } from 'react';
|
|
12
12
|
import { usePluginStore } from 'react-pluggable';
|
|
13
|
+
import ActionButton from '../ActionButton';
|
|
13
14
|
import JSONViewer from '../json/JSONViewer';
|
|
15
|
+
const FETCH_RESULTS = {};
|
|
14
16
|
export const useHelpers = () => {
|
|
15
17
|
const pluginStore = usePluginStore();
|
|
16
18
|
const allHelpers = useMemo(() => [
|
|
@@ -55,8 +57,10 @@ export const useHelpers = () => {
|
|
|
55
57
|
documentation: 'Fetches the url provided and returns the given (flattened) key from the returned JSON object. Note that the result must be JSON!',
|
|
56
58
|
callback: async (url, key) => {
|
|
57
59
|
try {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
if (!FETCH_RESULTS[url]) {
|
|
61
|
+
FETCH_RESULTS[url] = fetch(url).then(res => res.json());
|
|
62
|
+
}
|
|
63
|
+
const json = await FETCH_RESULTS[url];
|
|
60
64
|
return flatten(json)[key];
|
|
61
65
|
}
|
|
62
66
|
catch (e) {
|
|
@@ -144,6 +148,17 @@ export const useHelpers = () => {
|
|
|
144
148
|
}) })] }) }));
|
|
145
149
|
}
|
|
146
150
|
},
|
|
151
|
+
{
|
|
152
|
+
keyword: 'action',
|
|
153
|
+
documentation: 'Execute a howler action given a specific action ID (from the URL when viewing the action, i.e. yaIKVqiKhWpyCsWdqsE4D)',
|
|
154
|
+
componentCallback: (actionId, hitId, context) => {
|
|
155
|
+
if (!actionId || !hitId) {
|
|
156
|
+
console.warn('Missing parameters for the action button.');
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return _jsx(ActionButton, { actionId: actionId, hitId: hitId, ...(context.hash ?? {}) });
|
|
160
|
+
}
|
|
161
|
+
},
|
|
147
162
|
...howlerPluginStore.plugins.flatMap(plugin => pluginStore.executeFunction(`${plugin}.helpers`))
|
|
148
163
|
], [pluginStore]);
|
|
149
164
|
return allHelpers;
|
|
@@ -8,10 +8,11 @@ import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/us
|
|
|
8
8
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
9
9
|
import { useTranslation } from 'react-i18next';
|
|
10
10
|
import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
|
|
11
|
+
import { validateRegex } from '@cccsaurora/howler-ui/utils/stringUtils';
|
|
11
12
|
import Throttler from '@cccsaurora/howler-ui/utils/Throttler';
|
|
12
13
|
import { removeEmpty, searchObject } from '@cccsaurora/howler-ui/utils/utils';
|
|
13
14
|
const THROTTLER = new Throttler(150);
|
|
14
|
-
const JSONViewer = ({ data, collapse = true }) => {
|
|
15
|
+
const JSONViewer = ({ data, collapse = true, hideSearch = false, filter }) => {
|
|
15
16
|
const { t } = useTranslation();
|
|
16
17
|
const { isDark } = useAppTheme();
|
|
17
18
|
const [compact] = useMyLocalStorageItem(StorageKey.COMPACT_JSON, true);
|
|
@@ -21,19 +22,11 @@ const JSONViewer = ({ data, collapse = true }) => {
|
|
|
21
22
|
useEffect(() => {
|
|
22
23
|
THROTTLER.debounce(() => {
|
|
23
24
|
const filteredData = removeEmpty(data, compact);
|
|
24
|
-
const searchedData = searchObject(filteredData, query, flat);
|
|
25
|
+
const searchedData = searchObject(filteredData, filter ?? query, flat);
|
|
25
26
|
setResult(searchedData);
|
|
26
27
|
});
|
|
27
|
-
}, [compact, data, flat, query]);
|
|
28
|
-
const hasError = useMemo(() =>
|
|
29
|
-
try {
|
|
30
|
-
new RegExp(query);
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
catch (e) {
|
|
34
|
-
return true;
|
|
35
|
-
}
|
|
36
|
-
}, [query]);
|
|
28
|
+
}, [compact, data, filter, flat, query]);
|
|
29
|
+
const hasError = useMemo(() => !validateRegex(filter ?? query), [query, filter]);
|
|
37
30
|
const shouldCollapse = useCallback((field) => {
|
|
38
31
|
return (field.name !== 'root' && field.type !== 'object') || field.namespace.length > 3;
|
|
39
32
|
}, []);
|
|
@@ -48,6 +41,6 @@ const JSONViewer = ({ data, collapse = true }) => {
|
|
|
48
41
|
// Type declaration is wrong - this is a valid prop
|
|
49
42
|
displayArrayKey: !compact
|
|
50
43
|
} })), [collapse, compact, isDark, result, shouldCollapse]);
|
|
51
|
-
return data ? (_jsxs(Stack, { direction: "column", spacing: 1, sx: { '& > div:first-of-type': { mt: 1, mr: 0.5 } }, children: [_jsx(Phrase, { value: query, onChange: setQuery, error: hasError, label: t('json.viewer.search.label'), placeholder: t('json.viewer.search.prompt'), disabled: !result, endAdornment: _jsx(IconButton, { onClick: () => setQuery(''), children: _jsx(Clear, {}) }) }), renderer] })) : (_jsx(Skeleton, { width: "100%", height: "95%", variant: "rounded" }));
|
|
44
|
+
return data ? (_jsxs(Stack, { direction: "column", spacing: 1, sx: { '& > div:first-of-type': { mt: 1, mr: 0.5 } }, children: [!hideSearch && (_jsx(Phrase, { value: query, onChange: setQuery, error: hasError, label: t('json.viewer.search.label'), placeholder: t('json.viewer.search.prompt'), disabled: !result, endAdornment: _jsx(IconButton, { onClick: () => setQuery(''), children: _jsx(Clear, {}) }) })), renderer] })) : (_jsx(Skeleton, { width: "100%", height: "95%", variant: "rounded" }));
|
|
52
45
|
};
|
|
53
46
|
export default JSONViewer;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { MoreHoriz } from '@mui/icons-material';
|
|
3
3
|
import { Box, CircularProgress, Divider, FormControl, FormControlLabel, FormLabel, IconButton, Menu, Radio, RadioGroup, Stack, Switch, useMediaQuery } from '@mui/material';
|
|
4
|
-
import
|
|
4
|
+
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
5
5
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
6
6
|
import { HitContext } from '@cccsaurora/howler-ui/components/app/providers/HitProvider';
|
|
7
7
|
import { HitSearchContext } from '@cccsaurora/howler-ui/components/app/providers/HitSearchProvider';
|
|
@@ -27,7 +27,7 @@ const HitActions = ({ hit, orientation = 'horizontal' }) => {
|
|
|
27
27
|
const { config } = useContext(ApiConfigContext);
|
|
28
28
|
const { values, set } = useMyLocalStorageProvider();
|
|
29
29
|
const pluginStore = usePluginStore();
|
|
30
|
-
const {
|
|
30
|
+
const { getMatchingAnalytic } = useMatchers();
|
|
31
31
|
const getCurrentView = useContextSelector(ViewContext, ctx => ctx.getCurrentView);
|
|
32
32
|
const selected = useContextSelector(ParameterContext, ctx => ctx?.selected);
|
|
33
33
|
const setSelected = useContextSelector(ParameterContext, ctx => ctx?.setSelected);
|
|
@@ -112,11 +112,9 @@ const HitActions = ({ hit, orientation = 'horizontal' }) => {
|
|
|
112
112
|
}
|
|
113
113
|
}, [keyboardDownHandler]);
|
|
114
114
|
useEffect(() => {
|
|
115
|
-
(
|
|
116
|
-
setAnalytic(await getAnalyticFromName(hit.howler.analytic));
|
|
117
|
-
})();
|
|
115
|
+
getMatchingAnalytic(hit).then(setAnalytic);
|
|
118
116
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
119
|
-
}, [hit
|
|
117
|
+
}, [hit?.howler.analytic]);
|
|
120
118
|
const handleOpenSetting = useCallback((e) => setOpenSetting(e.currentTarget), []);
|
|
121
119
|
const handleCloseSetting = useCallback(() => setOpenSetting(null), []);
|
|
122
120
|
const onShortcutChange = useCallback((__, s) => set(StorageKey.HIT_SHORTCUTS, s), [set]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Chip, Divider, Grid, Stack, Tooltip, Typography, avatarClasses, iconButtonClasses, useTheme } from '@mui/material';
|
|
3
|
-
import
|
|
3
|
+
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
4
4
|
import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
|
|
5
5
|
import { uniq } from 'lodash-es';
|
|
6
6
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
@@ -19,15 +19,19 @@ import { HitLayout } from './HitLayout';
|
|
|
19
19
|
const HitBanner = ({ hit, layout = HitLayout.NORMAL, showAssigned = true }) => {
|
|
20
20
|
const { t } = useTranslation();
|
|
21
21
|
const { config } = useContext(ApiConfigContext);
|
|
22
|
-
const { getIdFromName } = useContext(AnalyticContext);
|
|
23
22
|
const theme = useTheme();
|
|
24
23
|
const pluginStore = usePluginStore();
|
|
24
|
+
const { getMatchingAnalytic } = useMatchers();
|
|
25
25
|
const [analyticId, setAnalyticId] = useState();
|
|
26
26
|
const compressed = useMemo(() => layout === HitLayout.DENSE, [layout]);
|
|
27
27
|
const textVariant = useMemo(() => (layout === HitLayout.COMFY ? 'body1' : 'caption'), [layout]);
|
|
28
28
|
useEffect(() => {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
if (!hit?.howler.analytic) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
getMatchingAnalytic(hit).then(analytic => setAnalyticId(analytic.analytic_id));
|
|
33
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
|
+
}, [hit?.howler.analytic]);
|
|
31
35
|
const providerColor = useMemo(() => PROVIDER_COLORS[hit.event?.provider ?? 'unknown'] ?? stringToColor(hit.event.provider), [hit.event?.provider]);
|
|
32
36
|
const mitreId = useMemo(() => {
|
|
33
37
|
if (hit.threat?.framework?.toLowerCase().startsWith('mitre')) {
|
|
@@ -94,7 +98,11 @@ const HitBanner = ({ hit, layout = HitLayout.NORMAL, showAssigned = true }) => {
|
|
|
94
98
|
}, spacing: layout !== HitLayout.COMFY ? 1 : 2, divider: _jsx(Divider, { orientation: "horizontal", sx: [
|
|
95
99
|
layout !== HitLayout.COMFY && { marginTop: '4px !important' },
|
|
96
100
|
{ mr: `${theme.spacing(-1)} !important` }
|
|
97
|
-
] }), children: [_jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf: 'start', '& a': { color: 'text.primary' } }, children: [analyticId ? (_jsx(Link, { to: `/analytics/${analyticId}`,
|
|
101
|
+
] }), children: [_jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf: 'start', '& a': { color: 'text.primary' } }, children: [analyticId ? (_jsx(Link, { to: `/analytics/${analyticId}`, onAuxClick: e => {
|
|
102
|
+
e.stopPropagation();
|
|
103
|
+
}, onClick: e => {
|
|
104
|
+
e.stopPropagation();
|
|
105
|
+
}, children: hit.howler.analytic })) : (hit.howler.analytic), hit.howler.detection && ': ', hit.howler.detection] }), hit.howler?.rationale && (_jsxs(Typography, { flex: 1, variant: textVariant, color: ESCALATION_COLORS[hit.howler.escalation] + '.main', sx: { fontWeight: 'bold' }, children: [t('hit.header.rationale'), ": ", hit.howler.rationale] })), hit.howler?.outline && (_jsxs(_Fragment, { children: [_jsxs(Grid, { container: true, spacing: layout !== HitLayout.COMFY ? 1 : 2, sx: { ml: `${theme.spacing(-1)} !important` }, children: [hit.howler.outline.threat && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.threat", value: hit.howler.outline.threat }) })), hit.howler.outline.target && (_jsx(Grid, { item: true, children: _jsx(Wrapper, { i18nKey: "hit.header.target", value: hit.howler.outline.target }) }))] }), hit.howler.outline.indicators?.length > 0 && (_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Typography, { component: "span", variant: textVariant, children: [t('hit.header.indicators'), ":"] }), _jsx(Grid, { container: true, spacing: 0.5, sx: { mt: `${theme.spacing(-0.5)} !important`, ml: `${theme.spacing(0.25)} !important` }, children: uniq(hit.howler.outline.indicators).map((_indicator, index) => {
|
|
98
106
|
return (_jsx(Grid, { item: true, children: _jsxs(Stack, { direction: "row", children: [_jsx(PluginTypography, { context: "indicators", variant: textVariant, value: _indicator, children: _indicator }), index < hit.howler.outline.indicators.length - 1 && (_jsx(Typography, { variant: textVariant, children: ',' }))] }) }, _indicator));
|
|
99
107
|
}) })] })), hit.howler.outline.summary && (_jsx(Wrapper, { i18nKey: "hit.header.summary", value: hit.howler.outline.summary, paragraph: true, textOverflow: "wrap", sx: [compressed && { marginTop: `0 !important` }] }))] }))] }), _jsxs(Stack, { direction: "column", spacing: layout !== HitLayout.COMFY ? 0.5 : 1, alignSelf: "stretch", sx: [
|
|
100
108
|
{ minWidth: 0, alignItems: { sm: 'end', md: 'start' }, flex: 1, pl: 1 },
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { CardContent, Skeleton } from '@mui/material';
|
|
3
3
|
import { HitContext } from '@cccsaurora/howler-ui/components/app/providers/HitProvider';
|
|
4
|
-
import { TemplateContext } from '@cccsaurora/howler-ui/components/app/providers/TemplateProvider';
|
|
5
4
|
import { memo, useEffect } from 'react';
|
|
6
5
|
import { useContextSelector } from 'use-context-selector';
|
|
7
6
|
import HowlerCard from '../display/HowlerCard';
|
|
@@ -10,12 +9,8 @@ import HitLabels from './HitLabels';
|
|
|
10
9
|
import { HitLayout } from './HitLayout';
|
|
11
10
|
import HitOutline from './HitOutline';
|
|
12
11
|
const HitCard = ({ id, layout, readOnly = true }) => {
|
|
13
|
-
const refresh = useContextSelector(TemplateContext, ctx => ctx.refresh);
|
|
14
12
|
const getHit = useContextSelector(HitContext, ctx => ctx.getHit);
|
|
15
13
|
const hit = useContextSelector(HitContext, ctx => ctx.hits[id]);
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
refresh();
|
|
18
|
-
}, [refresh]);
|
|
19
14
|
useEffect(() => {
|
|
20
15
|
if (!hit) {
|
|
21
16
|
getHit(id);
|
|
@@ -3,7 +3,7 @@ import { Clear, KeyboardArrowDown, Send } from '@mui/icons-material';
|
|
|
3
3
|
import { Accordion, AccordionDetails, AccordionSummary, AvatarGroup, Chip, IconButton, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import { useAppUser } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
6
|
-
import
|
|
6
|
+
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
7
7
|
import { SocketContext } from '@cccsaurora/howler-ui/components/app/providers/SocketProvider';
|
|
8
8
|
import FlexOne from '@cccsaurora/howler-ui/components/elements/addons/layout/FlexOne';
|
|
9
9
|
import useMyApi from '@cccsaurora/howler-ui/components/hooks/useMyApi';
|
|
@@ -20,8 +20,8 @@ const HitComments = ({ hit, users }) => {
|
|
|
20
20
|
const { t } = useTranslation();
|
|
21
21
|
const navigate = useNavigate();
|
|
22
22
|
const { dispatchApi } = useMyApi();
|
|
23
|
-
const { getAnalyticFromName } = useContext(AnalyticContext);
|
|
24
23
|
const { addListener, removeListener, emit } = useContext(SocketContext);
|
|
24
|
+
const { getMatchingAnalytic } = useMatchers();
|
|
25
25
|
const [typers, setTypers] = useState([]);
|
|
26
26
|
const [loading, setLoading] = useState(false);
|
|
27
27
|
const [showClear, setShowClear] = useState(false);
|
|
@@ -51,12 +51,13 @@ const HitComments = ({ hit, users }) => {
|
|
|
51
51
|
}, [handler]);
|
|
52
52
|
useEffect(() => {
|
|
53
53
|
if (hit?.howler?.analytic) {
|
|
54
|
-
|
|
54
|
+
getMatchingAnalytic(hit).then(analytic => {
|
|
55
55
|
setAnalyticId(analytic?.analytic_id);
|
|
56
56
|
setAnalyticComments(sortByTimestamp(analytic?.comment ?? []));
|
|
57
57
|
});
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, [getMatchingAnalytic, hit?.howler?.analytic]);
|
|
60
61
|
const onSubmit = useCallback(async () => {
|
|
61
62
|
if (!input.current?.value || !hit || input.current.value.length > MAX_LENGTH)
|
|
62
63
|
return;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
2
|
+
import type { WithMetadata } from '@cccsaurora/howler-ui/models/WithMetadata';
|
|
2
3
|
import { HitLayout } from './HitLayout';
|
|
3
4
|
export declare const DEFAULT_FIELDS: string[];
|
|
4
5
|
declare const _default: import("react").NamedExoticComponent<{
|
|
5
|
-
hit: Hit
|
|
6
|
+
hit: WithMetadata<Hit>;
|
|
6
7
|
layout: HitLayout;
|
|
7
|
-
type?: "global" | "personal";
|
|
8
8
|
}>;
|
|
9
9
|
export default _default;
|
|
@@ -1,30 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Divider,
|
|
3
|
-
import
|
|
4
|
-
import { createElement, memo, useMemo } from 'react';
|
|
2
|
+
import { Box, Divider, Typography } from '@mui/material';
|
|
3
|
+
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
4
|
+
import { createElement, memo, useEffect, useMemo, useState } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
|
-
import { useContextSelector } from 'use-context-selector';
|
|
7
6
|
import { HitLayout } from './HitLayout';
|
|
8
7
|
import DefaultOutline from './outlines/DefaultOutline';
|
|
9
8
|
export const DEFAULT_FIELDS = ['howler.hash'];
|
|
10
|
-
const HitOutline = ({ hit, layout
|
|
9
|
+
const HitOutline = ({ hit, layout }) => {
|
|
11
10
|
const { t } = useTranslation();
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
11
|
+
const { getMatchingTemplate } = useMatchers();
|
|
12
|
+
const [template, setTemplate] = useState(null);
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
getMatchingTemplate(hit).then(setTemplate);
|
|
15
|
+
}, [getMatchingTemplate, hit]);
|
|
15
16
|
const outline = useMemo(() => {
|
|
16
|
-
if (template
|
|
17
|
-
return createElement(DefaultOutline, {
|
|
18
|
-
hit,
|
|
19
|
-
layout,
|
|
20
|
-
template,
|
|
21
|
-
fields: template.keys
|
|
22
|
-
});
|
|
23
|
-
}
|
|
24
|
-
else if (!loaded) {
|
|
25
|
-
return _jsx(Skeleton, { variant: "rounded", height: "50px" });
|
|
26
|
-
}
|
|
27
|
-
else if (template) {
|
|
17
|
+
if (template) {
|
|
28
18
|
return createElement(DefaultOutline, {
|
|
29
19
|
hit,
|
|
30
20
|
layout,
|
|
@@ -40,7 +30,7 @@ const HitOutline = ({ hit, layout, type }) => {
|
|
|
40
30
|
fields: DEFAULT_FIELDS
|
|
41
31
|
});
|
|
42
32
|
}
|
|
43
|
-
}, [hit, layout,
|
|
33
|
+
}, [hit, layout, template]);
|
|
44
34
|
return (_jsxs(Box, { sx: { py: 1, width: '100%', pr: 2 }, children: [layout === HitLayout.COMFY && (_jsx(Typography, { variant: "body1", fontWeight: "bold", sx: { mb: 1 }, children: t('hit.details.title') })), layout !== HitLayout.DENSE && _jsx(Divider, { orientation: "horizontal", sx: { mb: 1 } }), outline] }));
|
|
45
35
|
};
|
|
46
36
|
export default memo(HitOutline);
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { InsertLink } from '@mui/icons-material';
|
|
3
3
|
import { Box, IconButton, Skeleton } from '@mui/material';
|
|
4
|
-
import
|
|
4
|
+
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
5
5
|
import ErrorBoundary from '@cccsaurora/howler-ui/components/routes/ErrorBoundary';
|
|
6
|
-
import { memo,
|
|
6
|
+
import { memo, useEffect, useMemo, useState } from 'react';
|
|
7
7
|
import { Link } from 'react-router-dom';
|
|
8
8
|
import HandlebarsMarkdown from '../display/HandlebarsMarkdown';
|
|
9
9
|
const HitOverview = ({ content, hit }) => {
|
|
10
|
-
const { getMatchingOverview } =
|
|
11
|
-
const matchingOverview =
|
|
10
|
+
const { getMatchingOverview } = useMatchers();
|
|
11
|
+
const [matchingOverview, setMatchingOverview] = useState(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
getMatchingOverview(hit).then(setMatchingOverview);
|
|
14
|
+
}, [getMatchingOverview, hit]);
|
|
12
15
|
const link = useMemo(() => matchingOverview
|
|
13
16
|
? `/overviews/view?analytic=${encodeURIComponent(matchingOverview.analytic)}${matchingOverview.detection && '&detection=' + encodeURIComponent(matchingOverview.detection)}`
|
|
14
17
|
: hit
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { HowlerSearchResponse } from '@cccsaurora/howler-ui/api/search';
|
|
2
2
|
import type { Hit } from '@cccsaurora/howler-ui/models/entities/generated/Hit';
|
|
3
|
+
import type { WithMetadata } from '@cccsaurora/howler-ui/models/WithMetadata';
|
|
3
4
|
declare const _default: import("react").NamedExoticComponent<{
|
|
4
5
|
query: string;
|
|
5
|
-
response?: HowlerSearchResponse<Hit
|
|
6
|
+
response?: HowlerSearchResponse<WithMetadata<Hit>>;
|
|
6
7
|
execute?: boolean;
|
|
7
8
|
onStart?: () => void;
|
|
8
9
|
onComplete?: () => void;
|