@cccsaurora/howler-ui 2.17.0-dev.525 → 2.17.0-dev.533
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/index.d.ts +0 -2
- package/api/index.js +2 -4
- package/api/search/index.d.ts +1 -2
- package/api/search/index.js +1 -2
- package/commons/components/leftnav/LeftNavDrawer.js +1 -1
- package/components/app/App.js +0 -14
- package/components/app/providers/FavouritesProvider.js +2 -2
- package/components/elements/PluginTypography.d.ts +1 -2
- package/components/elements/PluginTypography.js +2 -3
- package/components/elements/UserList.d.ts +2 -5
- package/components/elements/UserList.js +5 -14
- package/components/elements/addons/search/phrase/Phrase.js +1 -1
- package/components/elements/display/HowlerCard.js +1 -1
- package/components/elements/display/Modal.js +0 -1
- package/components/elements/display/icons/BundleButton.d.ts +6 -0
- package/components/elements/display/icons/BundleButton.js +32 -0
- package/components/elements/hit/HitBanner.js +48 -27
- package/components/elements/{ObjectDetails.d.ts → hit/HitDetails.d.ts} +1 -2
- package/components/elements/{ObjectDetails.js → hit/HitDetails.js} +17 -17
- package/components/elements/hit/outlines/DefaultOutline.js +1 -1
- package/components/elements/view/ViewTitle.js +1 -1
- package/components/hooks/useHitActions.d.ts +1 -1
- package/components/hooks/useHitActions.js +2 -2
- package/components/hooks/useHitSelection.js +35 -1
- package/components/hooks/useMyPreferences.js +1 -10
- package/components/hooks/useMySitemap.js +1 -4
- package/components/hooks/useMyTheme.js +2 -9
- package/components/routes/action/view/ActionSearch.js +1 -1
- package/components/routes/action/view/Integrations.js +9 -1
- package/components/routes/action/view/markdown/integrations.en.md.js +1 -0
- package/components/routes/action/view/markdown/integrations.fr.md.js +1 -0
- package/components/routes/advanced/QueryBuilder.js +1 -1
- package/components/routes/analytics/AnalyticDetails.js +2 -2
- package/components/routes/analytics/AnalyticSearch.js +1 -1
- package/components/routes/help/ApiDocumentation.js +1 -1
- package/components/routes/help/BundleDocumentation.d.ts +3 -0
- package/components/routes/help/BundleDocumentation.js +12 -0
- package/components/routes/help/HitDocumentation.js +3 -1
- package/components/routes/help/markdown/en/bundles.md.js +1 -0
- package/components/routes/help/markdown/fr/bundles.md.js +1 -0
- package/components/routes/hits/search/BundleParentMenu.d.ts +6 -0
- package/components/routes/hits/search/BundleParentMenu.js +32 -0
- package/components/routes/hits/search/HitContextMenu.js +27 -4
- package/components/routes/hits/search/HitContextMenu.test.js +140 -0
- package/components/routes/hits/search/InformationPane.d.ts +0 -1
- package/components/routes/hits/search/InformationPane.js +28 -6
- package/components/routes/hits/search/SearchPane.js +5 -3
- package/components/routes/hits/search/ViewLink.js +1 -1
- package/components/routes/hits/search/grid/EnhancedCell.js +1 -1
- package/components/routes/hits/view/HitViewer.js +4 -3
- package/components/routes/home/ViewCard.js +1 -1
- package/components/{elements/MarkdownEditor.js → routes/overviews/OverviewEditor.js} +3 -3
- package/components/routes/overviews/OverviewViewer.js +2 -2
- package/locales/en/translation.json +396 -423
- package/locales/fr/translation.json +421 -445
- package/models/entities/generated/{ThreatEnrichment.d.ts → Enrichment.d.ts} +1 -1
- package/models/entities/generated/Howler.d.ts +4 -0
- package/models/entities/generated/Rule.d.ts +10 -2
- package/models/entities/generated/Threat.d.ts +2 -2
- package/package.json +4 -16
- package/plugins/clue/components/ClueTypography.js +2 -2
- package/plugins/clue/utils.d.ts +1 -2
- package/utils/constants.d.ts +3 -3
- package/api/search/case.d.ts +0 -4
- package/api/search/case.js +0 -8
- package/api/v2/case/index.d.ts +0 -6
- package/api/v2/case/index.js +0 -18
- package/api/v2/index.d.ts +0 -4
- package/api/v2/index.js +0 -6
- package/api/v2/search/facet.d.ts +0 -3
- package/api/v2/search/facet.js +0 -12
- package/api/v2/search/index.d.ts +0 -6
- package/api/v2/search/index.js +0 -18
- package/components/elements/hit/elements/AnalyticLink.d.ts +0 -8
- package/components/elements/hit/elements/AnalyticLink.js +0 -22
- package/components/routes/cases/CaseCard.d.ts +0 -8
- package/components/routes/cases/CaseCard.js +0 -34
- package/components/routes/cases/CaseViewer.d.ts +0 -2
- package/components/routes/cases/CaseViewer.js +0 -24
- package/components/routes/cases/Cases.d.ts +0 -2
- package/components/routes/cases/Cases.js +0 -101
- package/components/routes/cases/constants.d.ts +0 -5
- package/components/routes/cases/constants.js +0 -5
- package/components/routes/cases/detail/AlertPanel.d.ts +0 -6
- package/components/routes/cases/detail/AlertPanel.js +0 -32
- package/components/routes/cases/detail/CaseDashboard.d.ts +0 -7
- package/components/routes/cases/detail/CaseDashboard.js +0 -49
- package/components/routes/cases/detail/CaseDetails.d.ts +0 -6
- package/components/routes/cases/detail/CaseDetails.js +0 -61
- package/components/routes/cases/detail/CaseOverview.d.ts +0 -7
- package/components/routes/cases/detail/CaseOverview.js +0 -43
- package/components/routes/cases/detail/CaseSidebar.d.ts +0 -6
- package/components/routes/cases/detail/CaseSidebar.js +0 -36
- package/components/routes/cases/detail/CaseTask.d.ts +0 -11
- package/components/routes/cases/detail/CaseTask.js +0 -57
- package/components/routes/cases/detail/ItemPage.d.ts +0 -6
- package/components/routes/cases/detail/ItemPage.js +0 -93
- package/components/routes/cases/detail/RelatedCasePanel.d.ts +0 -6
- package/components/routes/cases/detail/RelatedCasePanel.js +0 -31
- package/components/routes/cases/detail/TaskPanel.d.ts +0 -7
- package/components/routes/cases/detail/TaskPanel.js +0 -52
- package/components/routes/cases/detail/Untitled-1.md.js +0 -1
- package/components/routes/cases/detail/aggregates/CaseAggregate.d.ts +0 -12
- package/components/routes/cases/detail/aggregates/CaseAggregate.js +0 -19
- package/components/routes/cases/detail/aggregates/SourceAggregate.d.ts +0 -6
- package/components/routes/cases/detail/aggregates/SourceAggregate.js +0 -27
- package/components/routes/cases/detail/sidebar/CaseFolder.d.ts +0 -12
- package/components/routes/cases/detail/sidebar/CaseFolder.js +0 -179
- package/components/routes/cases/detail/sidebar/types.d.ts +0 -3
- package/components/routes/cases/hooks/useCase.d.ts +0 -13
- package/components/routes/cases/hooks/useCase.js +0 -38
- package/components/routes/cases/modals/ResolveModal.d.ts +0 -7
- package/components/routes/cases/modals/ResolveModal.js +0 -56
- package/components/routes/observables/ObservableViewer.d.ts +0 -7
- package/components/routes/observables/ObservableViewer.js +0 -27
- package/models/entities/generated/AttachmentsFile.d.ts +0 -12
- package/models/entities/generated/Case.d.ts +0 -28
- package/models/entities/generated/DestinationOriginal.d.ts +0 -19
- package/models/entities/generated/EmailAttachment.d.ts +0 -8
- package/models/entities/generated/EmailParent.d.ts +0 -19
- package/models/entities/generated/Enrichments.d.ts +0 -7
- package/models/entities/generated/EnrichmentsIndicator.d.ts +0 -21
- package/models/entities/generated/HttpResponse.d.ts +0 -11
- package/models/entities/generated/Item.d.ts +0 -9
- package/models/entities/generated/Observable.d.ts +0 -84
- package/models/entities/generated/ObservableCloud.d.ts +0 -20
- package/models/entities/generated/ObservableDestination.d.ts +0 -23
- package/models/entities/generated/ObservableEmail.d.ts +0 -30
- package/models/entities/generated/ObservableFile.d.ts +0 -36
- package/models/entities/generated/ObservableHowler.d.ts +0 -44
- package/models/entities/generated/ObservableHttp.d.ts +0 -11
- package/models/entities/generated/ObservableObserver.d.ts +0 -21
- package/models/entities/generated/ObservableOrganization.d.ts +0 -7
- package/models/entities/generated/ObservableProcess.d.ts +0 -34
- package/models/entities/generated/ObservableSource.d.ts +0 -23
- package/models/entities/generated/ObservableThreat.d.ts +0 -21
- package/models/entities/generated/ObservableTls.d.ts +0 -12
- package/models/entities/generated/ObserverIngress.d.ts +0 -9
- package/models/entities/generated/Task.d.ts +0 -10
- /package/components/{elements/MarkdownEditor.d.ts → routes/overviews/OverviewEditor.d.ts} +0 -0
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import { useAppBreadcrumbs } from '@cccsaurora/howler-ui/commons/components/app/hooks';
|
|
1
2
|
import { HitContext } from '@cccsaurora/howler-ui/components/app/providers/HitProvider';
|
|
2
3
|
import { HitSearchContext } from '@cccsaurora/howler-ui/components/app/providers/HitSearchProvider';
|
|
4
|
+
import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
|
|
5
|
+
import useMySitemap from '@cccsaurora/howler-ui/components/hooks/useMySitemap';
|
|
3
6
|
import { useCallback, useState } from 'react';
|
|
7
|
+
import { useNavigate } from 'react-router-dom';
|
|
4
8
|
import { useContextSelector } from 'use-context-selector';
|
|
5
9
|
const useHitSelection = () => {
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const { setItems } = useAppBreadcrumbs();
|
|
12
|
+
const { routes } = useMySitemap();
|
|
6
13
|
const response = useContextSelector(HitSearchContext, ctx => ctx.response);
|
|
7
14
|
const selectedHits = useContextSelector(HitContext, ctx => ctx.selectedHits);
|
|
8
15
|
const addHitToSelection = useContextSelector(HitContext, ctx => ctx.addHitToSelection);
|
|
9
16
|
const removeHitFromSelection = useContextSelector(HitContext, ctx => ctx.removeHitFromSelection);
|
|
17
|
+
const clearSelectedHits = useContextSelector(HitContext, ctx => ctx.clearSelectedHits);
|
|
18
|
+
const setSelected = useContextSelector(ParameterContext, ctx => ctx.setSelected);
|
|
10
19
|
const [lastSelected, setLastSelected] = useState(null);
|
|
11
20
|
const onClick = useCallback((e, hit) => {
|
|
12
21
|
setLastSelected(hit.howler.id);
|
|
@@ -38,7 +47,32 @@ const useHitSelection = () => {
|
|
|
38
47
|
e.stopPropagation();
|
|
39
48
|
return;
|
|
40
49
|
}
|
|
41
|
-
|
|
50
|
+
if (hit.howler.is_bundle) {
|
|
51
|
+
const searchRoute = routes.find(_route => _route.path.startsWith(location.pathname.replace(/^(\/.*)\/.+/, '$1')));
|
|
52
|
+
const newBreadcrumb = {
|
|
53
|
+
...searchRoute,
|
|
54
|
+
path: location.pathname + location.search
|
|
55
|
+
};
|
|
56
|
+
setItems([{ route: newBreadcrumb, matcher: null }]);
|
|
57
|
+
navigate(`/bundles/${hit.howler.id}?span=date.range.all&query=howler.id%3A*&offset=0`);
|
|
58
|
+
clearSelectedHits(hit.howler.id);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
clearSelectedHits(hit.howler.id);
|
|
62
|
+
setSelected(hit.howler.id);
|
|
63
|
+
}
|
|
64
|
+
}, [
|
|
65
|
+
addHitToSelection,
|
|
66
|
+
clearSelectedHits,
|
|
67
|
+
lastSelected,
|
|
68
|
+
navigate,
|
|
69
|
+
removeHitFromSelection,
|
|
70
|
+
response,
|
|
71
|
+
routes,
|
|
72
|
+
selectedHits,
|
|
73
|
+
setItems,
|
|
74
|
+
setSelected
|
|
75
|
+
]);
|
|
42
76
|
return { lastSelected, setLastSelected, onClick };
|
|
43
77
|
};
|
|
44
78
|
export default useHitSelection;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Api, Article, Book,
|
|
2
|
+
import { Api, Article, Book, Code, Dashboard, Description, Edit, ExitToApp, FormatListBulleted, Help, HelpCenter, Key, ManageSearch, QueryStats, SavedSearch, Search, Settings, SettingsSuggest, Shield, Storage, SupervisorAccount, Terminal, Topic } from '@mui/icons-material';
|
|
3
3
|
import { AppBrand } from '@cccsaurora/howler-ui/branding/AppBrand';
|
|
4
4
|
import Classification from '@cccsaurora/howler-ui/components/elements/display/Classification';
|
|
5
5
|
import DocumentationButton from '@cccsaurora/howler-ui/components/elements/display/DocumentationButton';
|
|
@@ -21,15 +21,6 @@ const useMyPreferences = () => {
|
|
|
21
21
|
icon: _jsx(Dashboard, {})
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
|
-
{
|
|
25
|
-
type: 'item',
|
|
26
|
-
element: {
|
|
27
|
-
id: 'cases',
|
|
28
|
-
i18nKey: 'route.cases',
|
|
29
|
-
route: '/cases',
|
|
30
|
-
icon: _jsx(BookRounded, {})
|
|
31
|
-
}
|
|
32
|
-
},
|
|
33
24
|
{
|
|
34
25
|
type: 'group',
|
|
35
26
|
element: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Article, Book,
|
|
2
|
+
import { Article, Book, Code, CreateNewFolder, Dashboard, Description, Edit, EditNote, FormatListBulleted, Help, Info, Key, Person, PersonSearch, QueryStats, SavedSearch, Search, Settings, SettingsSuggest, Shield, Storage, Terminal, Topic, Work } from '@mui/icons-material';
|
|
3
3
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
4
4
|
import { useMemo } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
@@ -24,9 +24,6 @@ const useMySitemap = () => {
|
|
|
24
24
|
return useMemo(() => ({
|
|
25
25
|
routes: [
|
|
26
26
|
{ path: '/', title: t('route.home'), isRoot: true, icon: _jsx(Dashboard, {}) },
|
|
27
|
-
{ path: '/cases', title: t('route.cases'), isRoot: true, icon: _jsx(BookRounded, {}) },
|
|
28
|
-
{ path: '/cases/:id', title: t('route.cases.view'), breadcrumbs: ['/cases'] },
|
|
29
|
-
{ path: '/cases/:id/*', title: t('route.cases.view'), breadcrumbs: ['/cases'] },
|
|
30
27
|
{ path: '/admin/users', title: t('route.admin.user.search'), isRoot: true, icon: _jsx(PersonSearch, {}) },
|
|
31
28
|
{
|
|
32
29
|
path: '/admin/users/:id',
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
const DEFAULT_THEME = {
|
|
2
|
-
components: {
|
|
3
|
-
MuiChip: {
|
|
4
|
-
defaultProps: {
|
|
5
|
-
size: 'small'
|
|
6
|
-
}
|
|
7
|
-
}
|
|
8
|
-
},
|
|
9
2
|
palette: {
|
|
10
3
|
dark: {
|
|
11
4
|
background: {
|
|
12
|
-
default: '#
|
|
13
|
-
paper: '#
|
|
5
|
+
default: '#202020',
|
|
6
|
+
paper: '#202020'
|
|
14
7
|
},
|
|
15
8
|
primary: {
|
|
16
9
|
main: '#7DA1DB'
|
|
@@ -109,7 +109,7 @@ const ActionSearch = () => {
|
|
|
109
109
|
e.stopPropagation();
|
|
110
110
|
await deleteAction(item.item.action_id);
|
|
111
111
|
onSearch();
|
|
112
|
-
}, children: _jsx(Delete, {}) })), _jsx(HowlerAvatar, { sx: { width: 24, height: 24, marginRight: '8px !important' }, userId: item.item.owner_id })] }), subheader: item.item.query }), _jsx(CardContent, { sx: { paddingTop: 0 }, children: _jsx(Grid, { container: true, spacing: 1, children: item.item.operations.map(d => (_jsx(Grid, { item: true, children: _jsx(Chip, { label: t(`operations.${d.operation_id}`) }) }, d.operation_id))) }) })] }, item.item.name));
|
|
112
|
+
}, children: _jsx(Delete, {}) })), _jsx(HowlerAvatar, { sx: { width: 24, height: 24, marginRight: '8px !important' }, userId: item.item.owner_id })] }), subheader: item.item.query }), _jsx(CardContent, { sx: { paddingTop: 0 }, children: _jsx(Grid, { container: true, spacing: 1, children: item.item.operations.map(d => (_jsx(Grid, { item: true, children: _jsx(Chip, { size: "small", label: t(`operations.${d.operation_id}`) }) }, d.operation_id))) }) })] }, item.item.name));
|
|
113
113
|
}, [deleteAction, navigate, onSearch, t, user.roles, user.username]);
|
|
114
114
|
return (_jsx(ItemManager, { onSearch: onSearch, onCreate: () => navigate('/action/execute'), onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: searching, aboveSearch: _jsx(Typography, { sx: theme => ({ fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }), variant: "body2", children: t('route.actions.search.prompt') }), searchFilters: _jsx(Autocomplete, { multiple: true, size: "small", value: searchModifiers, onChange: (__, values) => setSearchModifiers(values), getOptionLabel: trigger => t(`route.actions.trigger.${trigger}`), options: VALID_ACTION_TRIGGERS, renderInput: params => (_jsx(TextField, { ...params, sx: { maxWidth: '500px' }, label: t('route.actions.trigger') })) }), renderer: renderer, response: response, createPrompt: "route.actions.create", searchPrompt: "route.actions.search", createIcon: _jsx(Terminal, { sx: { mr: 1 } }) }));
|
|
115
115
|
};
|
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Stack, Tab, Tabs } from '@mui/material';
|
|
3
3
|
import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
|
|
4
|
+
import Markdown from '@cccsaurora/howler-ui/components/elements/display/Markdown';
|
|
5
|
+
import { isEmpty } from 'lodash-es';
|
|
4
6
|
import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
|
|
5
7
|
import { useEffect, useMemo, useState } from 'react';
|
|
6
8
|
import { useTranslation } from 'react-i18next';
|
|
7
9
|
import { usePluginStore } from 'react-pluggable';
|
|
8
10
|
import { useSearchParams } from 'react-router-dom';
|
|
11
|
+
import { default as INTEGRATIONS_EN, default as INTEGRATIONS_FR } from './markdown/integrations.en.md';
|
|
9
12
|
const Integrations = () => {
|
|
10
13
|
const { t } = useTranslation();
|
|
14
|
+
const { i18n } = useTranslation();
|
|
11
15
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
12
16
|
const pluginStore = usePluginStore();
|
|
13
17
|
const pluginIntegrations = useMemo(() => Object.fromEntries(howlerPluginStore.plugins.flatMap(plugin => pluginStore.executeFunction(`${plugin}.integrations`))), [pluginStore]);
|
|
14
18
|
const [tab, setTab] = useState(Object.keys(pluginIntegrations)[0] ?? '');
|
|
19
|
+
const md = useMemo(() => (i18n.language === 'en' ? INTEGRATIONS_EN : INTEGRATIONS_FR), [i18n.language]);
|
|
15
20
|
useEffect(() => {
|
|
16
21
|
searchParams.set('tab', tab);
|
|
17
22
|
setSearchParams(searchParams);
|
|
18
23
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
19
24
|
}, [tab]);
|
|
20
25
|
const tabData = useMemo(() => {
|
|
26
|
+
if (!tab) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
21
29
|
return pluginIntegrations[tab]();
|
|
22
30
|
}, [pluginIntegrations, tab]);
|
|
23
|
-
return (_jsx(PageCenter, { maxWidth: "1500px", textAlign: "left", height: "100%", children: _jsxs(Stack, { spacing: 1, children: [_jsx(Tabs, { value: tab, onChange: (_, _tab) => setTab(_tab), children: Object.keys(pluginIntegrations).map(integration => (_jsx(Tab, { label: t(`route.integrations.${integration}`), value: integration }, integration))) }), tabData] }) }));
|
|
31
|
+
return (_jsx(PageCenter, { maxWidth: "1500px", textAlign: "left", height: "100%", children: _jsxs(Stack, { spacing: 1, children: [!isEmpty(pluginIntegrations) && (_jsx(Tabs, { value: tab, onChange: (_, _tab) => setTab(_tab), children: Object.keys(pluginIntegrations).map(integration => (_jsx(Tab, { label: t(`route.integrations.${integration}`), value: integration }, integration))) })), tabData ?? _jsx(Markdown, { md: md })] }) }));
|
|
24
32
|
};
|
|
25
33
|
export default Integrations;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default "# Integrations and Plugins\n\n> **Note:** This page is fallback documentation. In `Integrations.tsx`, when plugins provide integration views, those plugin tabs/content are rendered and this markdown is replaced.\n\nHowler plugins let you extend both UI behavior and rendering paths without modifying core screens directly. Plugins are installed through the plugin store and then invoked across the app through `executeFunction(...)` hooks.\n\n## How the plugin system works\n\n- `HowlerPlugin` is the base class that defines extension points.\n- `howlerPluginStore` keeps global plugin state (installed plugins, lead formats, pivot formats, operations, routes, menus, sitemap entries).\n- On activation, each plugin can register named functions in the runtime plugin store.\n- The app calls those functions via `pluginStore.executeFunction(...)` in specific locations.\n\nIn practice, this means plugins can contribute features incrementally rather than replacing full pages.\n\n## What plugins can add\n\nFrom `HowlerPlugin.ts` and store usage, plugins can provide:\n\n- **Lead formats** (`addLead`) with:\n\t- a lead editor form (`lead.<format>.form`)\n\t- a lead renderer (`lead.<format>`)\n- **Pivot formats** (`addPivot`) with:\n\t- a pivot form (`pivot.<format>.form`)\n\t- a pivot link renderer (`pivot.<format>`)\n- **Custom action operations** (`addOperation`) with:\n\t- operation editor UI (`operation.<id>`)\n\t- operation help docs (`operation.<id>.documentation`)\n- **Menu entries**:\n\t- user menu items\n\t- admin menu items\n\t- main menu insertions/dividers\n- **Routing/navigation**:\n\t- routes\n\t- sitemap entries and breadcrumbs behavior\n- **Global extension hooks**:\n\t- `provider()` wrapper for app-wide context\n\t- `setup()` startup logic\n\t- `localization(...)` translation bundles\n\t- `helpers()` custom handlebars helpers\n\t- `typography(...)` and `chip(...)` custom UI rendering\n\t- `actions(...)` hit actions\n\t- `status(...)` hit banner/status widgets\n\t- `support()`, `help()`, and section-specific `settings(...)`\n\t- `documentation(md)` markdown post-processing\n\t- `on(event, hit)` event callback\n\n## Where hooks are executed\n\n`executeFunction(...)` is used throughout the app to render plugin output at runtime, for example:\n\n- lead rendering and lead form editors\n- pivot rendering and pivot form editors\n- custom operation editors and docs\n- plugin actions in hit views/context menus\n- hit status/banner components\n- typography/chip wrappers\n- plugin providers and startup setup\n- settings sections (`admin`, `local`, `profile`, `security`)\n- help/support panels\n- markdown documentation transforms\n\n## Clue plugin example\n\nThe Clue plugin (`ui/src/plugins/clue/index.tsx`) demonstrates a typical plugin:\n\n- registers localization bundles in English/French\n- provides a plugin provider + setup hook\n- adds a custom lead format (`clue`) with:\n\t- a lead form component\n\t- a renderer that parses lead metadata and renders a `Fetcher`\n- adds a custom pivot format (`clue`) with form + renderer\n- provides custom handlebars helpers\n- overrides plugin typography/chip renderers\n\nThis is the main pattern to follow when adding a new integration.\n"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default "# Int\u00e9grations et plugins\n\n> **Remarque :** cette page est une documentation de secours. Dans `Integrations.tsx`, quand des plugins fournissent des vues d\u2019int\u00e9gration, ces onglets/contenus plugins sont affich\u00e9s et ce markdown est remplac\u00e9.\n\nLes plugins Howler permettent d\u2019\u00e9tendre le comportement de l\u2019interface et le rendu sans modifier directement les \u00e9crans principaux. Les plugins sont install\u00e9s via le magasin de plugins, puis appel\u00e9s dans l\u2019application \u00e0 l\u2019aide des points d\u2019extension `executeFunction(...)`.\n\n## Fonctionnement du syst\u00e8me de plugins\n\n- `HowlerPlugin` est la classe de base qui d\u00e9finit les points d\u2019extension.\n- `howlerPluginStore` conserve l\u2019\u00e9tat global (plugins install\u00e9s, formats de lead, formats de pivot, op\u00e9rations, routes, menus, sitemap).\n- \u00c0 l\u2019activation, un plugin enregistre des fonctions nomm\u00e9es dans le magasin de plugins.\n- L\u2019application ex\u00e9cute ensuite ces fonctions via `pluginStore.executeFunction(...)` \u00e0 des endroits pr\u00e9cis.\n\nCe m\u00e9canisme permet d\u2019ajouter des capacit\u00e9s de fa\u00e7on incr\u00e9mentale, sans remplacer des pages compl\u00e8tes.\n\n## Ce qu\u2019un plugin peut ajouter\n\nD\u2019apr\u00e8s `HowlerPlugin.ts` et les usages du store, un plugin peut fournir :\n\n- **Formats de lead** (`addLead`) avec :\n\t- un formulaire d\u2019\u00e9dition (`lead.<format>.form`)\n\t- un rendu (`lead.<format>`)\n- **Formats de pivot** (`addPivot`) avec :\n\t- un formulaire (`pivot.<format>.form`)\n\t- un rendu de lien pivot (`pivot.<format>`)\n- **Op\u00e9rations d\u2019action personnalis\u00e9es** (`addOperation`) avec :\n\t- l\u2019UI d\u2019\u00e9dition de l\u2019op\u00e9ration (`operation.<id>`)\n\t- la documentation de l\u2019op\u00e9ration (`operation.<id>.documentation`)\n- **Entr\u00e9es de menu** :\n\t- menu utilisateur\n\t- menu administrateur\n\t- insertions/s\u00e9parateurs dans le menu principal\n- **Routage/navigation** :\n\t- routes\n\t- entr\u00e9es de sitemap et logique de fil d\u2019Ariane\n- **Points d\u2019extension globaux** :\n\t- `provider()` pour injecter un contexte global\n\t- `setup()` au d\u00e9marrage\n\t- `localization(...)` pour les traductions\n\t- `helpers()` pour les helpers handlebars\n\t- `typography(...)` et `chip(...)` pour le rendu UI\n\t- `actions(...)` pour les actions sur les hits\n\t- `status(...)` pour la banni\u00e8re/statut d\u2019un hit\n\t- `support()`, `help()` et `settings(...)` par section\n\t- `documentation(md)` pour post-traiter du markdown\n\t- `on(event, hit)` pour les \u00e9v\u00e9nements\n\n## O\u00f9 les hooks sont ex\u00e9cut\u00e9s\n\n`executeFunction(...)` est utilis\u00e9 dans plusieurs parties de l\u2019UI, notamment pour :\n\n- le rendu des leads et leurs formulaires\n- le rendu des pivots et leurs formulaires\n- les \u00e9diteurs d\u2019op\u00e9rations et leur documentation\n- les actions plugin dans les vues/context menus de hit\n- les composants de statut/banni\u00e8re des hits\n- les composants typographie/chip\n- les providers plugins et la logique `setup`\n- les sections de param\u00e8tres (`admin`, `local`, `profile`, `security`)\n- les vues d\u2019aide/support\n- la transformation de markdown de documentation\n\n## Exemple : plugin Clue\n\nLe plugin Clue (`ui/src/plugins/clue/index.tsx`) montre un exemple concret :\n\n- enregistre des bundles de traduction EN/FR\n- expose un provider et un hook de setup\n- ajoute un format de lead `clue` avec :\n\t- un composant de formulaire\n\t- un renderer qui lit les m\u00e9tadonn\u00e9es du lead et affiche un `Fetcher`\n- ajoute un format de pivot `clue` (formulaire + rendu)\n- fournit des helpers handlebars personnalis\u00e9s\n- fournit des rendus personnalis\u00e9s pour `typography` et `chip`\n\nC\u2019est le mod\u00e8le recommand\u00e9 pour d\u00e9velopper de nouvelles int\u00e9grations.\n"
|
|
@@ -233,7 +233,7 @@ const QueryBuilder = () => {
|
|
|
233
233
|
height: '100%',
|
|
234
234
|
'& .MuiFormControl-root': { height: '100%', '& > div': { height: '100%' } }
|
|
235
235
|
}
|
|
236
|
-
], slotProps: { paper: { sx: { minWidth: '600px' } } } }), _jsx(Card, { variant: "outlined", sx: { flex: 1, maxWidth: '350px', minWidth: '210px' }, children: _jsxs(Stack, { spacing: 0.5, sx: { px: 1, alignItems: 'start' }, children: [_jsxs(Typography, { variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: [t('route.advanced.rows'), ": ", STEPS[rows]] }), _jsx(Slider, { size: "small", valueLabelDisplay: "off", value: rows, onChange: (_, value) => setRows(value), min: 0, max: 9, step: 1, marks: true, track: false, sx: { py: 0.5 } })] }) })] }), type === 'lucene' && (_jsx(Autocomplete, { size: "small", getOptionLabel: opt => t(`route.advanced.query.type.${opt}`), options: LUCENE_QUERY_OPTIONS, value: queryType, onChange: (_event, value) => setQueryType(value), renderInput: params => (_jsx(TextField, { ...params, label: t('route.advanced.query.lucene.type'), sx: { minWidth: '230px' } })), renderOption: (props, option) => (_jsx(ListItemText, { ...props, sx: { flexDirection: 'column', alignItems: 'start !important' }, primary: t(`route.advanced.query.type.${option}`), secondary: t(`route.advanced.query.type.${option}.description`) })) })), queryType === 'groupby' && (_jsx(Autocomplete, { size: "small", options: fieldOptions, value: groupByField, onChange: (__, value) => setGroupByField(value), renderInput: params => _jsx(TextField, { ...params, label: t('route.advanced.pivot.field') }), sx: { minWidth: '200px', '& label': { zIndex: 1200 } }, onKeyDown: onKeyDown, PopperComponent: CustomPopper })), allFields && queryType !== 'facet' ? (_jsx(FormControlLabel, { control: _jsx(Checkbox, { size: "small", checked: allFields, onChange: (__, checked) => setAllFields(checked) }), label: t('route.advanced.fields.all'), sx: { '& > span': { color: 'text.secondary' }, alignSelf: 'start' } })) : (_jsx(Autocomplete, { fullWidth: true, renderTags: values => values.length <= 3 ? (_jsx(Stack, { direction: "row", spacing: 0.5, children: values.map(_value => (_jsx(Chip, { label: _value }, _value))) })) : (_jsx(Tooltip, { title: _jsx(Stack, { spacing: 1, children: values.map(_value => (_jsx("span", { children: _value }, _value))) }), children: _jsx(Chip, { label: values.length }) })), multiple: true, size: "small", options: fieldOptions, value: fields, onChange: (__, values) => (values.length > 0 ? setFields(values) : setAllFields(true)), renderInput: params => _jsx(TextField, { ...params, label: t('route.advanced.fields') }), sx: { maxWidth: '500px', width: '20vw', minWidth: '200px', '& label': { zIndex: 1200 } }, onKeyDown: onKeyDown, PopperComponent: CustomPopper })), _jsx(FlexOne, {}), type === 'lucene' &&
|
|
236
|
+
], slotProps: { paper: { sx: { minWidth: '600px' } } } }), _jsx(Card, { variant: "outlined", sx: { flex: 1, maxWidth: '350px', minWidth: '210px' }, children: _jsxs(Stack, { spacing: 0.5, sx: { px: 1, alignItems: 'start' }, children: [_jsxs(Typography, { variant: "caption", color: "text.secondary", sx: { whiteSpace: 'nowrap' }, children: [t('route.advanced.rows'), ": ", STEPS[rows]] }), _jsx(Slider, { size: "small", valueLabelDisplay: "off", value: rows, onChange: (_, value) => setRows(value), min: 0, max: 9, step: 1, marks: true, track: false, sx: { py: 0.5 } })] }) })] }), type === 'lucene' && (_jsx(Autocomplete, { size: "small", getOptionLabel: opt => t(`route.advanced.query.type.${opt}`), options: LUCENE_QUERY_OPTIONS, value: queryType, onChange: (_event, value) => setQueryType(value), renderInput: params => (_jsx(TextField, { ...params, label: t('route.advanced.query.lucene.type'), sx: { minWidth: '230px' } })), renderOption: (props, option) => (_jsx(ListItemText, { ...props, sx: { flexDirection: 'column', alignItems: 'start !important' }, primary: t(`route.advanced.query.type.${option}`), secondary: t(`route.advanced.query.type.${option}.description`) })) })), queryType === 'groupby' && (_jsx(Autocomplete, { size: "small", options: fieldOptions, value: groupByField, onChange: (__, value) => setGroupByField(value), renderInput: params => _jsx(TextField, { ...params, label: t('route.advanced.pivot.field') }), sx: { minWidth: '200px', '& label': { zIndex: 1200 } }, onKeyDown: onKeyDown, PopperComponent: CustomPopper })), allFields && queryType !== 'facet' ? (_jsx(FormControlLabel, { control: _jsx(Checkbox, { size: "small", checked: allFields, onChange: (__, checked) => setAllFields(checked) }), label: t('route.advanced.fields.all'), sx: { '& > span': { color: 'text.secondary' }, alignSelf: 'start' } })) : (_jsx(Autocomplete, { fullWidth: true, renderTags: values => values.length <= 3 ? (_jsx(Stack, { direction: "row", spacing: 0.5, children: values.map(_value => (_jsx(Chip, { size: "small", label: _value }, _value))) })) : (_jsx(Tooltip, { title: _jsx(Stack, { spacing: 1, children: values.map(_value => (_jsx("span", { children: _value }, _value))) }), children: _jsx(Chip, { size: "small", label: values.length }) })), multiple: true, size: "small", options: fieldOptions, value: fields, onChange: (__, values) => (values.length > 0 ? setFields(values) : setAllFields(true)), renderInput: params => _jsx(TextField, { ...params, label: t('route.advanced.fields') }), sx: { maxWidth: '500px', width: '20vw', minWidth: '200px', '& label': { zIndex: 1200 } }, onKeyDown: onKeyDown, PopperComponent: CustomPopper })), _jsx(FlexOne, {}), type === 'lucene' &&
|
|
237
237
|
(smallButtons ? (_jsx(Tooltip, { title: t('route.advanced.open'), children: _jsx(IconButton, { color: "primary", sx: { alignSelf: 'center' }, component: Link, disabled: !response, to: `/hits?query=${sanitizeMultilineLucene(query).replaceAll('\n', ' ').trim()}`, children: _jsx(OpenInNew, { fontSize: "small" }) }) })) : (_jsx(CustomButton, { size: "small", variant: "outlined", startIcon: _jsx(OpenInNew, {}), component: Link, disabled: !response, ...{ to: `/hits?query=${sanitizeMultilineLucene(query).replaceAll('\n', ' ').trim()}` }, children: t('route.advanced.open') }))), smallButtons ? (_jsx(Tooltip, { title: response ? t('route.advanced.create.rule') : t('route.advanced.create.rule.disabled'), children: _jsx(IconButton, { size: "small", sx: { alignSelf: 'center' }, color: "info", onClick: onCreateRule, disabled: !response, children: _jsx(SsidChart, {}) }) })) : (_jsx(CustomButton, { size: "small", variant: "outlined", color: "info", startIcon: _jsx(SsidChart, {}), onClick: onCreateRule, disabled: !response, ...{ to: `/hits?query=${sanitizeMultilineLucene(query).replaceAll('\n', ' ').trim()}` }, tooltip: !response && t('route.advanced.create.rule.disabled'), children: t('route.advanced.create.rule') }))] }), _jsxs(Box, { width: "100%", height: "calc(100vh - 112px)", sx: { position: 'relative', overflow: 'hidden', borderTop: `thin solid ${theme.palette.divider}` }, children: [_jsx(Box, { sx: {
|
|
238
238
|
position: 'absolute',
|
|
239
239
|
top: 0,
|
|
@@ -51,7 +51,7 @@ const AnalyticDetails = () => {
|
|
|
51
51
|
_setFilter(detection);
|
|
52
52
|
}
|
|
53
53
|
}, [filter]);
|
|
54
|
-
const onOwnerChange = useCallback(async (
|
|
54
|
+
const onOwnerChange = useCallback(async (ownerId) => {
|
|
55
55
|
const result = await dispatchApi(api.analytic.owner.post(analytic.analytic_id, { username: ownerId }), {
|
|
56
56
|
throwError: true,
|
|
57
57
|
showError: true
|
|
@@ -108,7 +108,7 @@ const AnalyticDetails = () => {
|
|
|
108
108
|
marginTop: '0 !important',
|
|
109
109
|
marginLeft: `${theme.spacing(-1)} !important`,
|
|
110
110
|
marginRight: `${theme.spacing(-1)} !important`
|
|
111
|
-
},
|
|
111
|
+
}, userId: analytic?.owner, onChange: onOwnerChange, i18nLabel: "route.analytics.set.owner" })) : (_jsx(HowlerAvatar, { userId: analytic?.owner })), _jsx(Stack, { children: users[analytic?.owner] ? (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "body1", children: users[analytic?.owner].name }), _jsx(Typography, { component: "a", href: `mailto:${users[analytic?.owner].email}`, variant: "caption", color: "text.secondary", children: users[analytic?.owner].email })] })) : (_jsxs(_Fragment, { children: [_jsx(Skeleton, { variant: "text", width: "70px" }), _jsx(Skeleton, { variant: "text", width: "60px" })] })) })] })] }), filteredContributors.length > 0 && (_jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { variant: "body1", color: "text.secondary", children: t('route.analytics.contributors') }), _jsx(Stack, { direction: "row", alignItems: "center", spacing: 1, children: filteredContributors.map(_user => (_jsx(HowlerAvatar, { userId: _user }, _user))) })] })), analytic?.rule_crontab && (_jsxs(Stack, { direction: "row", spacing: 1, children: [_jsxs(Stack, { spacing: 1, justifyContent: "space-between", children: [_jsx(Typography, { variant: "body1", color: "text.secondary", children: t('rule.interval') }), editingInterval ? (_jsxs(FormControl, { sx: { minWidth: '200px' }, children: [_jsx(InputLabel, { children: t('rule.interval') }), _jsx(Select, { size: "small", label: t('rule.interval'), onChange: event => setCrontab(event.target.value), value: crontab, children: RULE_INTERVALS.map(interval => (_jsx(MenuItem, { value: interval.crontab, children: t(interval.key) }, interval.key))) })] })) : (_jsx("code", { style: {
|
|
112
112
|
backgroundColor: theme.palette.background.paper,
|
|
113
113
|
padding: theme.spacing(0.5),
|
|
114
114
|
alignSelf: 'start',
|
|
@@ -128,7 +128,7 @@ const AnalyticSearchBase = () => {
|
|
|
128
128
|
padding: theme.spacing(0.5),
|
|
129
129
|
borderRadius: theme.shape.borderRadius,
|
|
130
130
|
border: `thin solid ${theme.palette.divider}`
|
|
131
|
-
}, children: item.item.rule_type })] })), _jsx(FlexOne, {}), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 1 }, children: [item.item.owner && _jsx(HowlerAvatar, { sx: { width: 24, height: 24 }, userId: item.item.owner }), filteredContributors.length > 0 && _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(AvatarGroup, { children: filteredContributors.map(contributor => (_jsx(HowlerAvatar, { sx: { width: 24, height: 24 }, userId: contributor }, contributor))) })] }), _jsx(Tooltip, { title: t('button.pin'), children: _jsx(IconButton, { size: "small", onClick: e => onFavourite(e, item.item), children: appUser.user?.favourite_analytics?.includes(item.item.analytic_id) ? _jsx(Star, {}) : _jsx(StarBorder, {}) }) })] }) }), item.item.detections?.length > 0 && (_jsx(CardContent, { sx: { paddingTop: 0 }, children: _jsxs(Grid, { container: true, spacing: 0.5, sx: { marginTop: `${theme.spacing(-0.5)} !important` }, children: [item.item.detections.slice(0, 5).map(d => (_jsx(Grid, { item: true, children: _jsx(Chip, { variant: "outlined", label: d }) }, d))), item.item.detections.length > 5 && (_jsx(Grid, { item: true, children: _jsx(Tooltip, { title: _jsx(Stack, { children: item.item.detections.slice(5).map(d => (_jsx("span", { children: d }, d))) }), children: _jsx(Chip, { variant: "outlined", label: `+ ${item.item.detections.length - 5}` }) }) }))] }) }))] }, item.item.name));
|
|
131
|
+
}, children: item.item.rule_type })] })), _jsx(FlexOne, {}), _jsxs(Stack, { direction: "row", spacing: 1, sx: { mt: 1 }, children: [item.item.owner && _jsx(HowlerAvatar, { sx: { width: 24, height: 24 }, userId: item.item.owner }), filteredContributors.length > 0 && _jsx(Divider, { orientation: "vertical", flexItem: true }), _jsx(AvatarGroup, { children: filteredContributors.map(contributor => (_jsx(HowlerAvatar, { sx: { width: 24, height: 24 }, userId: contributor }, contributor))) })] }), _jsx(Tooltip, { title: t('button.pin'), children: _jsx(IconButton, { size: "small", onClick: e => onFavourite(e, item.item), children: appUser.user?.favourite_analytics?.includes(item.item.analytic_id) ? _jsx(Star, {}) : _jsx(StarBorder, {}) }) })] }) }), item.item.detections?.length > 0 && (_jsx(CardContent, { sx: { paddingTop: 0 }, children: _jsxs(Grid, { container: true, spacing: 0.5, sx: { marginTop: `${theme.spacing(-0.5)} !important` }, children: [item.item.detections.slice(0, 5).map(d => (_jsx(Grid, { item: true, children: _jsx(Chip, { size: "small", variant: "outlined", label: d }) }, d))), item.item.detections.length > 5 && (_jsx(Grid, { item: true, children: _jsx(Tooltip, { title: _jsx(Stack, { children: item.item.detections.slice(5).map(d => (_jsx("span", { children: d }, d))) }), children: _jsx(Chip, { size: "small", variant: "outlined", label: `+ ${item.item.detections.length - 5}` }) }) }))] }) }))] }, item.item.name));
|
|
132
132
|
}, [appUser.user?.favourite_analytics, navigate, onFavourite, t, theme]);
|
|
133
133
|
return (_jsx(ItemManager, { onSearch: onSearch, onPageChange: onPageChange, phrase: phrase, setPhrase: setPhrase, hasError: hasError, searching: searching, searchAdornment: _jsx(InputAdornment, { position: "end", children: _jsx(Tooltip, { title: t(`route.analytics.search.filter.rules.${onlyRules < 0 ? 'hide' : onlyRules > 0 ? 'show' : 'toggle'}`), children: _jsx(IconButton, { onClick: () => setOnlyRules((((onlyRules + 2) % 3) - 1)), children: _jsx(SsidChart, { color: onlyRules < 0 ? 'error' : onlyRules > 0 ? 'info' : 'inherit', sx: { transition: theme.transitions.create(['color']) } }) }) }) }), aboveSearch: _jsx(Typography, { sx: { fontStyle: 'italic', color: theme.palette.text.disabled, mb: 0.5 }, variant: "body2", children: t('route.analytics.search.prompt') }), renderer: renderer, response: response, searchPrompt: "route.analytics.manager.search" }));
|
|
134
134
|
};
|
|
@@ -49,7 +49,7 @@ const ApiDocumentation = () => {
|
|
|
49
49
|
.replace(/(\S+)\s+=>\s+(.+)/g, '\n`$1`: $2\n')
|
|
50
50
|
.replace(/(Data Block:\n)([\s\S]+)(Result Example:)/, (__, p1, p2, p3) => `${p1}\`\`\`\n${p2.trim()}\n\`\`\`\n${p3}`)
|
|
51
51
|
.replace(/(Result Example:\n)([\s\S]+)$/, (__, p1, p2) => `${p1}\`\`\`\n${p2.trim()}\n\`\`\``) }));
|
|
52
|
-
return (_jsxs(Fragment, { children: [_jsxs(TableRow, { style: { marginBottom: '1rem' }, sx: [isLg && { '& > td': { borderBottom: 0 } }], children: [_jsx(TableCell, { children: _jsxs(Stack, { direction: "column", spacing: 1, alignItems: "start", children: [_jsx(Typography, { children: endpoint.name }), _jsx("code", { children: endpoint.path }), _jsxs(Stack, { direction: "row", spacing: 1, children: [endpoint.complete ? (_jsx(Chip, { label: "Stable", color: "success" })) : (_jsx(Chip, { label: "Unstable", color: "error" })), endpoint.protected ? (_jsx(Chip, { label: "Protected", color: "warning" })) : (_jsx(Chip, { label: "Unprotected" }))] }), _jsx(Stack, { spacing: 1, direction: "row", children: endpoint.methods.map(m => (_jsx(Chip, { size: "small", label: m }, m))) }), endpoint.ui_only && _jsx(Chip, { label: "UI Only" })] }) }), _jsx(TableCell, { children: _jsx(Stack, { spacing: 1, direction: "row", children: endpoint.required_type.map(type => (_jsx(Chip, { size: "small", label: type, color: user.roles?.includes(type) ? 'success' : 'default' }, type))) }) }), _jsx(TableCell, { children: _jsx(Stack, { spacing: 1, direction: "row", children: endpoint.required_priv.map((p) => (_jsx(Chip, { size: "small", label: t(APIKEY_LABELS[p]) }, p))) }) }), !isLg && _jsx(TableCell, { children: documentationCell })] }), isLg && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 3, sx: { '& pre': { whiteSpace: 'pre-wrap' } }, children: documentationCell }) }))] }, endpoint.id));
|
|
52
|
+
return (_jsxs(Fragment, { children: [_jsxs(TableRow, { style: { marginBottom: '1rem' }, sx: [isLg && { '& > td': { borderBottom: 0 } }], children: [_jsx(TableCell, { children: _jsxs(Stack, { direction: "column", spacing: 1, alignItems: "start", children: [_jsx(Typography, { children: endpoint.name }), _jsx("code", { children: endpoint.path }), _jsxs(Stack, { direction: "row", spacing: 1, children: [endpoint.complete ? (_jsx(Chip, { size: "small", label: "Stable", color: "success" })) : (_jsx(Chip, { size: "small", label: "Unstable", color: "error" })), endpoint.protected ? (_jsx(Chip, { size: "small", label: "Protected", color: "warning" })) : (_jsx(Chip, { size: "small", label: "Unprotected" }))] }), _jsx(Stack, { spacing: 1, direction: "row", children: endpoint.methods.map(m => (_jsx(Chip, { size: "small", label: m }, m))) }), endpoint.ui_only && _jsx(Chip, { size: "small", label: "UI Only" })] }) }), _jsx(TableCell, { children: _jsx(Stack, { spacing: 1, direction: "row", children: endpoint.required_type.map(type => (_jsx(Chip, { size: "small", label: type, color: user.roles?.includes(type) ? 'success' : 'default' }, type))) }) }), _jsx(TableCell, { children: _jsx(Stack, { spacing: 1, direction: "row", children: endpoint.required_priv.map((p) => (_jsx(Chip, { size: "small", label: t(APIKEY_LABELS[p]) }, p))) }) }), !isLg && _jsx(TableCell, { children: documentationCell })] }), isLg && (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 3, sx: { '& pre': { whiteSpace: 'pre-wrap' } }, children: documentationCell }) }))] }, endpoint.id));
|
|
53
53
|
}) })] }) })] }) }));
|
|
54
54
|
};
|
|
55
55
|
export default ApiDocumentation;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import Markdown from '@cccsaurora/howler-ui/components/elements/display/Markdown';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import BUNDLES_EN from './markdown/en/bundles.md';
|
|
6
|
+
import BUNDLES_FR from './markdown/fr/bundles.md';
|
|
7
|
+
const BundleDocumentation = () => {
|
|
8
|
+
const { i18n } = useTranslation();
|
|
9
|
+
const md = useMemo(() => (i18n.language === 'en' ? BUNDLES_EN : BUNDLES_FR), [i18n.language]);
|
|
10
|
+
return _jsx(Markdown, { md: md });
|
|
11
|
+
};
|
|
12
|
+
export default BundleDocumentation;
|
|
@@ -5,6 +5,7 @@ import { useScrollRestoration } from '@cccsaurora/howler-ui/components/hooks/use
|
|
|
5
5
|
import { useCallback, useState } from 'react';
|
|
6
6
|
import { useTranslation } from 'react-i18next';
|
|
7
7
|
import { useSearchParams } from 'react-router-dom';
|
|
8
|
+
import BundleDocumentation from './BundleDocumentation';
|
|
8
9
|
import HelpTabs from './components/HelpTabs';
|
|
9
10
|
import HitBannerDocumentation from './HitBannerDocumentation';
|
|
10
11
|
import HitLabelsDocumentation from './HitLabelsDocumentation';
|
|
@@ -22,7 +23,8 @@ const HitDocumentation = () => {
|
|
|
22
23
|
searchParams.set('tab', _tab);
|
|
23
24
|
setSearchParams(new URLSearchParams(searchParams));
|
|
24
25
|
}, [searchParams, setSearchParams]);
|
|
25
|
-
return (_jsx(PageCenter, { margin: 4, width: "100%", maxWidth: "1750px", textAlign: "left", children: _jsxs(Stack, { sx: { flexDirection: useHorizontal ? 'column' : 'row', '& h1': { mt: 0 } }, children: [_jsxs(HelpTabs, { value: tab, children: [_jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.schema.title') }), value: "schema", onClick: () => onChange('schema') }), _jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.banner.title') }), value: "header", onClick: () => onChange('header') }), _jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.links.title') }), value: "links", onClick: () => onChange('links') }), _jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.labels.title') }), value: "labels", onClick: () => onChange('labels') })] }), _jsx(Box, { children: {
|
|
26
|
+
return (_jsx(PageCenter, { margin: 4, width: "100%", maxWidth: "1750px", textAlign: "left", children: _jsxs(Stack, { sx: { flexDirection: useHorizontal ? 'column' : 'row', '& h1': { mt: 0 } }, children: [_jsxs(HelpTabs, { value: tab, children: [_jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.schema.title') }), value: "schema", onClick: () => onChange('schema') }), _jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.banner.title') }), value: "header", onClick: () => onChange('header') }), _jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.bundle.title') }), value: "bundle", onClick: () => onChange('bundle') }), _jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.links.title') }), value: "links", onClick: () => onChange('links') }), _jsx(Tab, { label: _jsx(Typography, { variant: "caption", children: t('help.hit.labels.title') }), value: "labels", onClick: () => onChange('labels') })] }), _jsx(Box, { children: {
|
|
27
|
+
bundle: () => _jsx(BundleDocumentation, {}),
|
|
26
28
|
header: () => _jsx(HitBannerDocumentation, {}),
|
|
27
29
|
links: () => _jsx(HitLinksDocumentation, {}),
|
|
28
30
|
labels: () => _jsx(HitLabelsDocumentation, {}),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default "<!-- docs/ingestion/bundles.md -->\n\n# Howler Hit Bundles\n\nHit bundles can be used to easily package together a large number of similar alerts, allowing analysts to easily triage them as a single incident. For example, consider a single computer that repeatedly makes a network call to `baddomain.ru` - while an alert may be generated for every instance of this computer hitting that domain, it makes sense for analysts to treat all these alerts as a single case.\n\n## Creating bundles through the Howler Client\n\nThere are a couple of ways to create a bundle through the howler client:\n\n```python\nfrom howler_client import get_client\n\nhowler = get_client(\"https://howler.dev.analysis.cyber.gc.ca\")\n\n\"\"\"Creating a howler bundle and the hits at the same time\"\"\"\nhowler.bundle.create(\n # First argument is the bundle hit\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0\n },\n # Second argument is a hit or list of hits to include in the bundle\n [\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0\n },\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0\n }\n ]\n)\n\n\"\"\"Creating a howler bundle from existing hits\"\"\"\nhowler.bundle.create(\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0,\n \"howler.hits\": [\"YcUsL8QsjmwwIdstieROk\", \"6s7MztwuSvz6tM0PgGJhvz\"]\n },\n # Note: In future releases, you won't need to include this argument\n []\n)\n\n\n\"\"\"Creating from a map\"\"\"\nbundle_hit = {\n \"score\": 0,\n \"bundle\": True\n}\n\nmap = {\n \"score\": [\"howler.score\"],\n \"bundle\": [\"howler.is_bundle\"]\n}\n\nhowler.bundle.create_from_map(\"example-test\", bundle_hit, map, [{\"score\": 0}])\n```\n\n## Viewing bundles on the Howler UI\n\nIn order to view created bundles on the Howler UI, you can use the query `howler.is_bundle:true`. This will provide a list of created bundles you can look through.\n\nClicking on a bundle will open up a slightly different search UI to normal. In this case, we automatically filter the search results to include only hits that are included in the bundle. To make this obvious, the header representing the bundle will appear above the search bar.\n\nYou can continue to filter through hits using the same queries as usual, and view them as usual. When triaging a bundle, assessing it will apply this assessment to all hits in the bundle, **except those that have already been triaged**. That is, if the bundle is open, all open hits will be assessed when you assess it.\n\nBundles also have a **Summary** tab not available for regular hits. This summary tab will aid you in aggregating data about all the hits in the bundle. Simply open the tab and click \"Create Summary\". Note that this may take some time, as a large number of queries are being run to aggregate the data.\n"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default "<!-- docs/ingestion/bundles.fr.md -->\n\n# Les groupes des hits Howler\n\nLes groupes des hits peuvent \u00eatre utilis\u00e9s pour regrouper facilement un grand nombre d'alertes similaires, ce qui permet aux analystes de les traiter comme un seul incident. Prenons l'exemple d'un ordinateur qui effectue \u00e0 plusieurs reprises un appel r\u00e9seau vers `baddomain.ru` - bien qu'une alerte puisse \u00eatre g\u00e9n\u00e9r\u00e9e pour chaque cas o\u00f9 cet ordinateur acc\u00e8de \u00e0 ce domaine, il est logique que les analystes traitent toutes ces alertes comme un seul et m\u00eame cas.\n\n## Cr\u00e9ation de groupes via le client Howler\n\nIl y a plusieurs fa\u00e7ons de cr\u00e9er un groupe via le client Howler:\n\n```python\nfrom howler_client import get_client\n\nhowler = get_client(\"https://howler.dev.analysis.cyber.gc.ca\")\n\n\"\"\"Cr\u00e9ation simultan\u00e9e d'un groupe howler et de hits\"\"\"\nhowler.bundle.create(\n # Le premier argument est le hit de l'offre group\u00e9e\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0\n },\n # Le deuxi\u00e8me argument est un hit ou une liste de hits \u00e0 inclure dans l'offre group\u00e9e.\n [\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0\n },\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0\n }\n ]\n)\n\n\"\"\"Cr\u00e9ation d'un groupe howler \u00e0 partir de hits existants\"\"\"\nhowler.bundle.create(\n {\n \"howler.analytic\": \"example-test\",\n \"howler.score\": 0,\n \"howler.hits\": [\"YcUsL8QsjmwwIdstieROk\", \"6s7MztwuSvz6tM0PgGJhvz\"]\n },\n # Noter: Dans les prochaines versions, vous n'aurez plus besoin d'inclure cet argument.\n []\n)\n\n\n\"\"\"Cr\u00e9ation \u00e0 partir d'une carte\"\"\"\nbundle_hit = {\n \"score\": 0,\n \"bundle\": True\n}\n\nmap = {\n \"score\": [\"howler.score\"],\n \"bundle\": [\"howler.is_bundle\"]\n}\n\nhowler.bundle.create_from_map(\"example-test\", bundle_hit, map, [{\"score\": 0}])\n```\n\n## Visualiser les groupes sur l'interface utilisateur de Howler\n\nAfin de visualiser les groupes cr\u00e9\u00e9s sur l'interface utilisateur de Howler, vous pouvez utiliser la requ\u00eate `howler.is_bundle:true`. Cela fournira une liste de groupes cr\u00e9\u00e9s que vous pourrez consulter.\n\nEn cliquant sur un groupe, vous ouvrirez une interface de recherche l\u00e9g\u00e8rement diff\u00e9rente de l'interface normale. Dans ce cas, nous filtrons automatiquement les r\u00e9sultats de la recherche pour n'inclure que les r\u00e9sultats inclus dans le groupe. Pour que cela soit \u00e9vident, l'en-t\u00eate repr\u00e9sentant le groupe appara\u00eet au-dessus de la barre de recherche.\n\nVous pouvez continuer \u00e0 filtrer les r\u00e9sultats en utilisant les m\u00eames requ\u00eates que d'habitude et \u00e0 les visualiser comme d'habitude. Lors du triage d'un groupe, son \u00e9valuation s'appliquera \u00e0 tous les hits du groupe, **sauf ceux qui ont d\u00e9j\u00e0 \u00e9t\u00e9 tri\u00e9s**. En d'autres termes, si le groupe est ouvert, tous les hits ouverts seront \u00e9valu\u00e9s lorsque vous l'\u00e9valuerez.\n\nLes groupes disposent \u00e9galement d'un onglet **R\u00e9sum\u00e9** qui n'est pas disponible pour les hits ordinaires. Cet onglet vous aidera \u00e0 regrouper les donn\u00e9es relatives \u00e0 tous les r\u00e9sultats du groupe. Il suffit d'ouvrir l'onglet et de cliquer sur \"Cr\u00e9er un sommaire\". Notez que cette op\u00e9ration peut prendre un certain temps, car un grand nombre de requ\u00eates sont ex\u00e9cut\u00e9es pour agr\u00e9ger les donn\u00e9es.\n"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AccountTree } from '@mui/icons-material';
|
|
3
|
+
import { IconButton, Paper, Popover, Skeleton, Stack, Tooltip } from '@mui/material';
|
|
4
|
+
import api from '@cccsaurora/howler-ui/api';
|
|
5
|
+
import HowlerCard from '@cccsaurora/howler-ui/components/elements/display/HowlerCard';
|
|
6
|
+
import HitBanner from '@cccsaurora/howler-ui/components/elements/hit/HitBanner';
|
|
7
|
+
import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
|
|
8
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
9
|
+
import { useTranslation } from 'react-i18next';
|
|
10
|
+
import { useNavigate } from 'react-router-dom';
|
|
11
|
+
const BundleParentMenu = ({ bundle }) => {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const navigate = useNavigate();
|
|
14
|
+
const [parentAnchor, setParentAnchor] = useState(null);
|
|
15
|
+
const [parentHits, setParentHits] = useState([]);
|
|
16
|
+
const onSelect = useCallback((bundleId) => {
|
|
17
|
+
navigate(`/bundles/${bundleId}?span=date.range.all&query=howler.id%3A*`);
|
|
18
|
+
setParentAnchor(null);
|
|
19
|
+
}, [navigate]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!parentAnchor) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
api.search.hit
|
|
25
|
+
.post({ query: `howler.id:(${bundle.howler.bundles.join(' OR ')})` })
|
|
26
|
+
.then(response => setParentHits(response.items));
|
|
27
|
+
}, [bundle.howler.bundles, parentAnchor]);
|
|
28
|
+
return (_jsxs(_Fragment, { children: [_jsx(Tooltip, { title: t('hit.bundle.parents.show'), children: _jsx(IconButton, { size: "small", onClick: event => setParentAnchor(event.currentTarget), children: _jsx(AccountTree, { fontSize: "small" }) }) }), _jsx(Popover, { open: !!parentAnchor, anchorEl: parentAnchor, anchorOrigin: { vertical: 'top', horizontal: 'left' }, transformOrigin: { horizontal: 'right', vertical: 'top' }, onClose: () => setParentAnchor(null), children: _jsx(Paper, { sx: { p: 1, minWidth: '750px' }, children: _jsx(Stack, { spacing: 1, children: parentHits.length < 1
|
|
29
|
+
? bundle.howler.bundles.map(id => _jsx(Skeleton, { variant: "rounded", height: "100px" }, id))
|
|
30
|
+
: parentHits.map(parent => (_jsx(HowlerCard, { sx: { p: 1, cursor: 'pointer' }, onClick: () => onSelect(parent.howler.id), children: _jsx(HitBanner, { hit: parent, layout: HitLayout.DENSE }) }, parent.howler.id))) }) }) })] }));
|
|
31
|
+
};
|
|
32
|
+
export default BundleParentMenu;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Assignment, Edit, HowToVote, KeyboardArrowRight, OpenInNew, QueryStats, RemoveCircleOutline, SettingsSuggest, Terminal } from '@mui/icons-material';
|
|
2
|
+
import { AddCircleOutline, Assignment, Edit, HowToVote, KeyboardArrowRight, OpenInNew, QueryStats, RemoveCircleOutline, SettingsSuggest, Terminal } from '@mui/icons-material';
|
|
3
3
|
import { Box, Divider, Fade, ListItemIcon, ListItemText, Menu, MenuItem, MenuList, Paper } from '@mui/material';
|
|
4
4
|
import api from '@cccsaurora/howler-ui/api';
|
|
5
5
|
import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
|
|
@@ -170,10 +170,9 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
|
|
|
170
170
|
sx: {
|
|
171
171
|
...transformProps,
|
|
172
172
|
overflow: 'visible !important'
|
|
173
|
-
}
|
|
174
|
-
elevation: 2
|
|
173
|
+
}
|
|
175
174
|
}
|
|
176
|
-
}, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { id: `${type}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [type]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: null })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: !!show[type], unmountOnExit: true, children: _jsx(Paper, { id: `${type}-submenu`, sx: calculateSubMenuStyles(show[type]), elevation:
|
|
175
|
+
}, MenuListProps: { dense: true, sx: { minWidth: '250px' } }, anchorOrigin: { vertical: 'top', horizontal: 'left' }, onClick: () => setAnchorEl(null), children: [_jsxs(MenuItem, { component: Link, to: `/hits/${hit?.howler.id}`, disabled: !hit, children: [_jsx(ListItemIcon, { children: _jsx(OpenInNew, {}) }), _jsx(ListItemText, { children: t('hit.panel.open') })] }), _jsxs(MenuItem, { component: Link, to: `/analytics/${analytic?.analytic_id}`, disabled: !analytic, children: [_jsx(ListItemIcon, { children: _jsx(QueryStats, {}) }), _jsx(ListItemText, { children: t('hit.panel.analytic.open') })] }), _jsx(Divider, {}), entries.map(([type, items]) => (_jsxs(MenuItem, { id: `${type}-menu-item`, sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, [type]: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, [type]: null })), disabled: rowStatus[type] === false, children: [_jsx(ListItemIcon, { children: ICON_MAP[type] ?? _jsx(Terminal, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t(`hit.details.actions.${type}`) }), rowStatus[type] !== false && (_jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } })), _jsx(Fade, { in: !!show[type], unmountOnExit: true, children: _jsx(Paper, { id: `${type}-submenu`, sx: calculateSubMenuStyles(show[type]), elevation: 8, children: _jsx(MenuList, { sx: { p: 0, borderTopLeftRadius: 0 }, dense: true, role: "group", children: items.map(a => (_jsx(MenuItem, { value: a.name, onClick: a.actionFunction, children: a.i18nKey ? t(a.i18nKey) : capitalize(a.name) }, a.name))) }) }) })] }, type))), _jsxs(MenuItem, { id: "actions-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, actions: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, actions: null })), disabled: actions.length < 1, children: [_jsx(ListItemIcon, { children: _jsx(SettingsSuggest, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('route.actions.change') }), actions.length > 0 && _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.actions, unmountOnExit: true, children: _jsx(Paper, { id: "actions-submenu", sx: calculateSubMenuStyles(show.actions), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: actions.map(action => (_jsx(MenuItem, { onClick: () => executeAction(action.action_id, `howler.id:${hit?.howler.id}`), children: _jsx(ListItemText, { children: action.name }) }, action.action_id))) }) }) })] }), !isEmpty(template?.keys ?? []) && (_jsxs(_Fragment, { children: [_jsx(Divider, {}), _jsxs(MenuItem, { id: "excludes-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, excludes: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, excludes: null })), children: [_jsx(ListItemIcon, { children: _jsx(RemoveCircleOutline, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('hit.panel.exclude') }), _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.excludes, unmountOnExit: true, children: _jsx(Paper, { id: "excludes-submenu", sx: calculateSubMenuStyles(show.excludes), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: template?.keys.map(key => {
|
|
177
176
|
// Build exclusion query based on current query and field value
|
|
178
177
|
let newQuery = '';
|
|
179
178
|
if (query !== DEFAULT_QUERY) {
|
|
@@ -199,6 +198,30 @@ const HitContextMenu = ({ children, getSelectedId, Component = Box }) => {
|
|
|
199
198
|
newQuery += `-${key}:"${sanitizeLuceneQuery(value.toString())}"`;
|
|
200
199
|
}
|
|
201
200
|
return (_jsx(MenuItem, { onClick: () => setQuery(newQuery), children: _jsx(ListItemText, { children: key }) }, key));
|
|
201
|
+
}) }) }) })] }), _jsxs(MenuItem, { id: "includes-menu-item", sx: { position: 'relative' }, onMouseEnter: ev => setShow(_show => ({ ..._show, includes: ev.target })), onMouseLeave: () => setShow(_show => ({ ..._show, includes: null })), children: [_jsx(ListItemIcon, { children: _jsx(AddCircleOutline, {}) }), _jsx(ListItemText, { sx: { flex: 1 }, children: t('hit.panel.include') }), _jsx(KeyboardArrowRight, { fontSize: "small", sx: { color: 'text.secondary', mr: -1 } }), _jsx(Fade, { in: !!show.includes, unmountOnExit: true, children: _jsx(Paper, { id: "includes-submenu", sx: calculateSubMenuStyles(show.includes), elevation: 8, children: _jsx(MenuList, { sx: { p: 0 }, dense: true, role: "group", children: template?.keys.map(key => {
|
|
202
|
+
// Build inclusion query based on current query and field
|
|
203
|
+
// If default, we include default query
|
|
204
|
+
let newQuery = `(${query}) AND `;
|
|
205
|
+
const value = get(hit, key);
|
|
206
|
+
if (!value) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
else if (Array.isArray(value)) {
|
|
210
|
+
// Handle array values by including all items
|
|
211
|
+
const sanitizedValues = value
|
|
212
|
+
.map(toString)
|
|
213
|
+
.filter(val => !!val)
|
|
214
|
+
.map(val => `"${sanitizeLuceneQuery(val)}"`);
|
|
215
|
+
if (sanitizedValues.length < 1) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
newQuery += `${key}:(${sanitizedValues.join(' OR ')})`;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
// Handle single value
|
|
222
|
+
newQuery += `${key}:"${sanitizeLuceneQuery(value.toString())}"`;
|
|
223
|
+
}
|
|
224
|
+
return (_jsx(MenuItem, { onClick: () => setQuery(newQuery), children: _jsx(ListItemText, { children: key }) }, key));
|
|
202
225
|
}) }) }) })] })] }))] })] }));
|
|
203
226
|
};
|
|
204
227
|
export default HitContextMenu;
|
|
@@ -623,6 +623,132 @@ describe('HitContextMenu', () => {
|
|
|
623
623
|
});
|
|
624
624
|
});
|
|
625
625
|
});
|
|
626
|
+
describe('Inclusion Filter Functionality', () => {
|
|
627
|
+
beforeEach(() => {
|
|
628
|
+
mockGetMatchingTemplate.mockResolvedValue(createMockTemplate({
|
|
629
|
+
keys: ['howler.detection', 'event.id']
|
|
630
|
+
}));
|
|
631
|
+
rerender(_jsx(Wrapper, { children: _jsx(HitContextMenu, { getSelectedId: mockGetSelectedId, children: _jsx("div", { children: "Test Content" }) }) }));
|
|
632
|
+
});
|
|
633
|
+
it('should render inclusion submenu with template keys', async () => {
|
|
634
|
+
act(() => {
|
|
635
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
636
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
637
|
+
});
|
|
638
|
+
await waitFor(() => {
|
|
639
|
+
expect(screen.getByRole('menu')).toBeInTheDocument();
|
|
640
|
+
});
|
|
641
|
+
act(() => {
|
|
642
|
+
const includesMenuItem = screen.getByText('Include By');
|
|
643
|
+
fireEvent.mouseEnter(includesMenuItem);
|
|
644
|
+
});
|
|
645
|
+
await waitFor(() => {
|
|
646
|
+
const submenu = screen.getByTestId('includes-submenu');
|
|
647
|
+
expect(submenu).toBeInTheDocument();
|
|
648
|
+
expect(submenu.textContent).toContain('howler.detection');
|
|
649
|
+
expect(submenu.textContent).toContain('event.id');
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
it('should generate inclusion query for single value', async () => {
|
|
653
|
+
act(() => {
|
|
654
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
655
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
656
|
+
});
|
|
657
|
+
await waitFor(() => {
|
|
658
|
+
expect(screen.getByRole('menu')).toBeInTheDocument();
|
|
659
|
+
});
|
|
660
|
+
act(() => {
|
|
661
|
+
const includesMenuItem = screen.getByText('Include By');
|
|
662
|
+
fireEvent.mouseEnter(includesMenuItem);
|
|
663
|
+
});
|
|
664
|
+
await waitFor(() => {
|
|
665
|
+
expect(screen.getByTestId('includes-submenu')).toBeInTheDocument();
|
|
666
|
+
});
|
|
667
|
+
await act(async () => {
|
|
668
|
+
const detectionKey = screen.getByText('howler.detection');
|
|
669
|
+
await user.click(detectionKey);
|
|
670
|
+
});
|
|
671
|
+
await waitFor(() => {
|
|
672
|
+
expect(mockParameterContext.setQuery).toHaveBeenCalledWith('(howler.status:open) AND howler.detection:"Test Detection"');
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
it('should generate inclusion query for array values', async () => {
|
|
676
|
+
mockGetMatchingTemplate.mockResolvedValue(createMockTemplate({
|
|
677
|
+
keys: ['howler.outline.indicators']
|
|
678
|
+
}));
|
|
679
|
+
rerender(_jsx(Wrapper, { children: _jsx(HitContextMenu, { getSelectedId: mockGetSelectedId, children: _jsx("div", { children: "Test Content" }) }) }));
|
|
680
|
+
act(() => {
|
|
681
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
682
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
683
|
+
});
|
|
684
|
+
await waitFor(() => {
|
|
685
|
+
const includesMenuItem = screen.getByText('Include By');
|
|
686
|
+
fireEvent.mouseEnter(includesMenuItem);
|
|
687
|
+
});
|
|
688
|
+
await waitFor(() => {
|
|
689
|
+
expect(screen.getByTestId('includes-submenu')).toBeInTheDocument();
|
|
690
|
+
});
|
|
691
|
+
await act(async () => {
|
|
692
|
+
const tagsKey = screen.getByText('howler.outline.indicators');
|
|
693
|
+
await user.click(tagsKey);
|
|
694
|
+
});
|
|
695
|
+
await waitFor(() => {
|
|
696
|
+
expect(mockParameterContext.setQuery).toHaveBeenCalledWith('(howler.status:open) AND howler.outline.indicators:("a" OR "b" OR "c")');
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
it('should preserve existing query when adding inclusion', async () => {
|
|
700
|
+
mockParameterContext.query = 'howler.status:open';
|
|
701
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
702
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
703
|
+
await waitFor(() => {
|
|
704
|
+
const includesMenuItem = screen.getByText('Include By');
|
|
705
|
+
fireEvent.mouseEnter(includesMenuItem);
|
|
706
|
+
});
|
|
707
|
+
await waitFor(() => {
|
|
708
|
+
expect(screen.getByTestId('includes-submenu')).toBeInTheDocument();
|
|
709
|
+
});
|
|
710
|
+
await act(async () => {
|
|
711
|
+
const detectionKey = screen.getByText('howler.detection');
|
|
712
|
+
await user.click(detectionKey);
|
|
713
|
+
});
|
|
714
|
+
await waitFor(() => {
|
|
715
|
+
expect(mockParameterContext.setQuery).toHaveBeenCalledWith('(howler.status:open) AND howler.detection:"Test Detection"');
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
it('should not render inclusion menu when template has no keys', async () => {
|
|
719
|
+
mockGetMatchingTemplate.mockResolvedValue(createMockTemplate({
|
|
720
|
+
keys: []
|
|
721
|
+
}));
|
|
722
|
+
rerender(_jsx(Wrapper, { children: _jsx(HitContextMenu, { getSelectedId: mockGetSelectedId, children: _jsx("div", { children: "Test Content" }) }) }));
|
|
723
|
+
act(() => {
|
|
724
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
725
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
726
|
+
});
|
|
727
|
+
await waitFor(() => {
|
|
728
|
+
expect(screen.getByRole('menu')).toBeInTheDocument();
|
|
729
|
+
});
|
|
730
|
+
expect(screen.queryByText('Include By')).toBeNull();
|
|
731
|
+
});
|
|
732
|
+
it('should skip null field values in inclusion menu', async () => {
|
|
733
|
+
act(() => {
|
|
734
|
+
mockHitContext.hits['test-hit-1'].event = {};
|
|
735
|
+
});
|
|
736
|
+
act(() => {
|
|
737
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
738
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
739
|
+
});
|
|
740
|
+
await waitFor(() => {
|
|
741
|
+
const includesMenuItem = screen.getByText('Include By');
|
|
742
|
+
fireEvent.mouseEnter(includesMenuItem);
|
|
743
|
+
});
|
|
744
|
+
await waitFor(() => {
|
|
745
|
+
const submenu = screen.getByTestId('includes-submenu');
|
|
746
|
+
expect(submenu).toBeInTheDocument();
|
|
747
|
+
expect(submenu.textContent).toContain('howler.detection');
|
|
748
|
+
expect(submenu.textContent).not.toContain('event.id');
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
});
|
|
626
752
|
describe('Multiple Hit Selection', () => {
|
|
627
753
|
it('should use selectedHits when current hit is included', async () => {
|
|
628
754
|
act(() => {
|
|
@@ -721,6 +847,20 @@ describe('HitContextMenu', () => {
|
|
|
721
847
|
expect(screen.queryByText('Exclude By')).toBeNull();
|
|
722
848
|
});
|
|
723
849
|
});
|
|
850
|
+
it('should not render inclusion menu when template is null', async () => {
|
|
851
|
+
mockGetMatchingTemplate.mockResolvedValue(null);
|
|
852
|
+
rerender(_jsx(Wrapper, { children: _jsx(HitContextMenu, { getSelectedId: mockGetSelectedId, children: _jsx("div", { children: "Test Content" }) }) }));
|
|
853
|
+
act(() => {
|
|
854
|
+
const contextMenuWrapper = screen.getByText('Test Content').parentElement;
|
|
855
|
+
fireEvent.contextMenu(contextMenuWrapper);
|
|
856
|
+
});
|
|
857
|
+
await waitFor(() => {
|
|
858
|
+
expect(screen.getByRole('menu')).toBeInTheDocument();
|
|
859
|
+
});
|
|
860
|
+
await waitFor(() => {
|
|
861
|
+
expect(screen.queryByText('Include By')).toBeNull();
|
|
862
|
+
});
|
|
863
|
+
});
|
|
724
864
|
it('should handle API failure gracefully', async () => {
|
|
725
865
|
mockDispatchApi.mockResolvedValue(null);
|
|
726
866
|
rerender(_jsx(Wrapper, { children: _jsx(HitContextMenu, { getSelectedId: mockGetSelectedId, children: _jsx("div", { children: "Test Content" }) }) }));
|