@cccsaurora/howler-ui 3.1.0-dev.1424 → 3.1.0-dev.1437

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.
Files changed (30) hide show
  1. package/components/app/hooks/useMatchers.d.ts +2 -1
  2. package/components/app/hooks/useMatchers.js +4 -1
  3. package/components/app/hooks/useMatchers.test.js +10 -0
  4. package/components/elements/display/Markdown.js +9 -3
  5. package/components/elements/display/Markdown.test.d.ts +1 -0
  6. package/components/elements/display/Markdown.test.js +25 -0
  7. package/components/elements/display/json/JSONViewer.js +10 -3
  8. package/components/elements/display/json/JSONViewer.test.d.ts +1 -0
  9. package/components/elements/display/json/JSONViewer.test.js +35 -0
  10. package/components/elements/hit/HitBanner.js +15 -66
  11. package/components/elements/hit/HitBanner.test.d.ts +1 -0
  12. package/components/elements/hit/HitBanner.test.js +209 -0
  13. package/components/elements/hit/HitCard.js +1 -1
  14. package/components/elements/hit/HitLabels.js +1 -1
  15. package/components/elements/hit/HitOutline.d.ts +2 -0
  16. package/components/elements/hit/HitOutline.js +73 -28
  17. package/components/elements/hit/HitOutline.test.d.ts +1 -0
  18. package/components/elements/hit/HitOutline.test.js +63 -0
  19. package/components/elements/hit/elements/AnalyticLink.js +4 -3
  20. package/components/elements/hit/elements/AnalyticLink.test.d.ts +1 -0
  21. package/components/elements/hit/elements/AnalyticLink.test.js +26 -0
  22. package/components/routes/help/TemplateDocumentation.js +14 -3
  23. package/components/routes/help/markdown/en/templates.md.js +1 -1
  24. package/locales/en/translation.json +2 -0
  25. package/locales/fr/translation.json +2 -0
  26. package/package.json +109 -111
  27. package/components/elements/hit/outlines/DefaultOutline.d.ts +0 -12
  28. package/components/elements/hit/outlines/DefaultOutline.js +0 -45
  29. package/components/elements/hit/outlines/al/AssemblyLineRules.d.ts +0 -5
  30. package/components/elements/hit/outlines/al/AssemblyLineRules.js +0 -46
@@ -1,42 +1,87 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Divider, Typography } from '@mui/material';
2
+ import { ContentPaste, FilterList, Info, Language, Lock, Person } from '@mui/icons-material';
3
+ import { IconButton, Stack, Tooltip, Typography, useTheme } from '@mui/material';
3
4
  import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
5
+ import { ApiConfigContext } from '@cccsaurora/howler-ui/components/app/providers/ApiConfigProvider';
6
+ import { ParameterContext } from '@cccsaurora/howler-ui/components/app/providers/ParameterProvider';
4
7
  import { useMyLocalStorageItem } from '@cccsaurora/howler-ui/components/hooks/useMyLocalStorage';
5
- import { isNil } from 'lodash-es';
6
- import { createElement, memo, useEffect, useMemo, useState } from 'react';
8
+ import get from 'lodash-es/get';
9
+ import isNil from 'lodash-es/isNil';
10
+ import isObject from 'lodash-es/isObject';
11
+ import { memo, useContext, useEffect, useMemo, useState } from 'react';
7
12
  import { useTranslation } from 'react-i18next';
8
- import { StorageKey } from '@cccsaurora/howler-ui/utils/constants';
13
+ import { Link } from 'react-router-dom';
14
+ import { useContextSelector } from 'use-context-selector';
15
+ import { PROVIDER_COLORS, StorageKey } from '@cccsaurora/howler-ui/utils/constants';
16
+ import { stringToColor } from '@cccsaurora/howler-ui/utils/utils';
17
+ import PluginTypography from '../PluginTypography';
9
18
  import { HitLayout } from './HitLayout';
10
- import DefaultOutline from './outlines/DefaultOutline';
11
19
  export const DEFAULT_FIELDS = ['event.created', 'howler.id', 'howler.hash'];
12
- const HitOutline = ({ hit, layout, lazy = false, forceAllFields = false }) => {
20
+ const EditIcon = ({ label, icon: Icon, link }) => (_jsx(Tooltip, { title: label, children: _jsx(IconButton, { size: "small", component: Link, to: link, "aria-label": label, children: _jsx(Icon, { sx: { height: '16px !important', width: '16px !important' } }) }) }));
21
+ const HitOutline = ({ hit, layout, lazy = false, forceAllFields = false, template: providedTemplate = null }) => {
22
+ const theme = useTheme();
13
23
  const { t } = useTranslation();
24
+ const { config } = useContext(ApiConfigContext);
25
+ const addFilter = useContextSelector(ParameterContext, ctx => ctx?.addFilter);
14
26
  const { getMatchingTemplate } = useMatchers(lazy);
15
27
  const [templateFieldCount] = useMyLocalStorageItem(StorageKey.TEMPLATE_FIELD_COUNT, null);
16
28
  const [template, setTemplate] = useState(null);
17
- useEffect(() => {
18
- void getMatchingTemplate(hit).then(setTemplate);
19
- }, [getMatchingTemplate, hit]);
20
- const outline = useMemo(() => {
21
- if (template) {
22
- return createElement(DefaultOutline, {
23
- hit,
24
- layout,
25
- template,
26
- fields: !isNil(templateFieldCount) && !forceAllFields
27
- ? [...template.keys].slice(0, templateFieldCount)
28
- : template.keys,
29
- readonly: template.type === 'readonly'
30
- });
29
+ const providerColor = useMemo(() => {
30
+ if (!hit?.event.provider) {
31
+ return PROVIDER_COLORS.unknown;
32
+ }
33
+ return PROVIDER_COLORS[hit?.event.provider] ?? stringToColor(hit?.event.provider);
34
+ }, [hit?.event.provider]);
35
+ const fields = useMemo(() => {
36
+ const keys = template?.keys;
37
+ if (!keys?.length) {
38
+ return DEFAULT_FIELDS;
39
+ }
40
+ if (!isNil(templateFieldCount) && !forceAllFields) {
41
+ return keys.slice(0, templateFieldCount);
31
42
  }
32
- else {
33
- return createElement(DefaultOutline, {
34
- hit,
35
- layout,
36
- fields: DEFAULT_FIELDS
37
- });
43
+ return keys;
44
+ }, [template, templateFieldCount, forceAllFields]);
45
+ const editUrl = useMemo(() => {
46
+ const params = {
47
+ analytic: hit.howler.analytic,
48
+ type: template?.type ?? 'personal'
49
+ };
50
+ if (template?.detection) {
51
+ params.detection = template.detection;
38
52
  }
39
- }, [forceAllFields, hit, layout, template, templateFieldCount]);
40
- 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] }));
53
+ else if (!template && hit.howler.detection) {
54
+ params.detection = hit.howler.detection;
55
+ }
56
+ return '/templates/view?' + new URLSearchParams(params).toString();
57
+ }, [template, hit]);
58
+ useEffect(() => {
59
+ void getMatchingTemplate(hit, providedTemplate).then(setTemplate);
60
+ }, [getMatchingTemplate, hit, providedTemplate]);
61
+ if (fields.length < 1) {
62
+ return null;
63
+ }
64
+ return (_jsxs(Stack, { sx: { my: 1, borderLeft: `5px solid ${providerColor}`, pl: 1, alignItems: 'stretch' }, children: [_jsxs(Stack, { direction: "row", spacing: 0.5, alignItems: "center", children: [_jsx(Typography, { variant: "body2", fontWeight: "bold", children: t('hit.details.title') }), template?.type === 'readonly' ? (_jsx(EditIcon, { label: t('route.templates.builtin'), icon: Lock, link: editUrl })) : !template ? (_jsx(EditIcon, { label: t('route.templates.default'), icon: Info, link: editUrl })) : template.type === 'global' ? (_jsx(EditIcon, { label: t('route.templates.global'), icon: Language, link: editUrl })) : (_jsx(EditIcon, { label: t('route.templates.personal'), icon: Person, link: editUrl }))] }), (fields ?? [])
65
+ .map(field => [field, get(hit, field)])
66
+ .map(([field, data]) => {
67
+ const displayedData = (Array.isArray(data) ? data.join(', ') : isObject(data) ? JSON.stringify(data) : data)?.toString();
68
+ if (!displayedData) {
69
+ return null;
70
+ }
71
+ return (_jsxs(Stack, { direction: "row", spacing: 1, sx: {
72
+ '& .copy': { opacity: 0, cursor: 'pointer', transition: theme.transitions.create('opacity') },
73
+ '&:hover .copy': { opacity: 1 },
74
+ position: 'relative',
75
+ pr: '75px'
76
+ }, children: [_jsx(Tooltip, { title: (config.indexes.hit[field]?.description ?? t('none')).split('\n')[0], children: _jsxs(Typography, { variant: layout !== HitLayout.COMFY ? 'caption' : 'body1', fontWeight: "bold", children: [field, ":"] }) }), _jsx(PluginTypography, { context: "outline", variant: layout !== HitLayout.COMFY ? 'caption' : 'body1', whiteSpace: "normal", sx: { wordBreak: 'break-all' }, value: displayedData, field: field, obj: hit, children: displayedData }), _jsxs(Stack, { spacing: 0.25, direction: "row", sx: { position: 'absolute', right: 0, top: '50%', transform: 'translateY(-50%)' }, children: [_jsx(Tooltip, { title: t('hit.outline.copy'), children: _jsx(IconButton, { className: "copy", size: "small", onClick: e => {
77
+ e.preventDefault();
78
+ e.stopPropagation();
79
+ void navigator.clipboard.writeText(displayedData);
80
+ }, children: _jsx(ContentPaste, { fontSize: "small" }) }) }), addFilter && (_jsx(Tooltip, { title: t('hit.outline.add_filter'), children: _jsx(IconButton, { className: "copy", size: "small", onClick: e => {
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ addFilter(`${field}:"${displayedData}"`);
84
+ }, children: _jsx(FilterList, { fontSize: "small" }) }) }))] })] }, field));
85
+ })] }));
41
86
  };
42
87
  export default memo(HitOutline);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { BrowserRouter } from 'react-router-dom';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { HitLayout } from './HitLayout';
7
+ import HitOutline from './HitOutline';
8
+ const addFilter = vi.fn();
9
+ const getMatchingTemplate = vi.fn((_hit, template) => Promise.resolve(template));
10
+ vi.mock('components/app/hooks/useMatchers', () => ({
11
+ default: () => ({ getMatchingTemplate })
12
+ }));
13
+ vi.mock('components/app/providers/ApiConfigProvider', async () => {
14
+ const { createContext } = await import('react');
15
+ return {
16
+ ApiConfigContext: createContext({ config: { indexes: { hit: {} } } })
17
+ };
18
+ });
19
+ vi.mock('components/app/providers/ParameterProvider', () => ({
20
+ ParameterContext: {}
21
+ }));
22
+ vi.mock('components/hooks/useMyLocalStorage', () => ({
23
+ useMyLocalStorageItem: () => [null]
24
+ }));
25
+ vi.mock('components/elements/PluginTypography', () => ({
26
+ default: ({ children }) => _jsx("span", { children: children })
27
+ }));
28
+ vi.mock('react-i18next', () => ({
29
+ useTranslation: () => ({ t: (key) => key })
30
+ }));
31
+ vi.mock('use-context-selector', () => ({
32
+ useContextSelector: (_context, selector) => selector({ addFilter })
33
+ }));
34
+ vi.mock('utils/constants', () => ({
35
+ PROVIDER_COLORS: { unknown: '#000000' },
36
+ StorageKey: { TEMPLATE_FIELD_COUNT: 'template-field-count' }
37
+ }));
38
+ vi.mock('utils/utils', () => ({
39
+ stringToColor: () => '#ffffff'
40
+ }));
41
+ describe('HitOutline', () => {
42
+ it('renders supplied template fields and adds a filter for the selected value', async () => {
43
+ const user = userEvent.setup();
44
+ const hit = {
45
+ event: { provider: 'endpoint' },
46
+ howler: { id: 'hit-1', analytic: 'analytic-1', detection: 'hit-detection' },
47
+ details: { values: ['first', 'second'] }
48
+ };
49
+ const template = {
50
+ keys: ['event.provider', 'details.values'],
51
+ type: 'global',
52
+ detection: 'template-detection'
53
+ };
54
+ render(_jsx(BrowserRouter, { children: _jsx(HitOutline, { hit: hit, layout: HitLayout.NORMAL, template: template }) }));
55
+ expect(await screen.findByText('event.provider:')).toBeInTheDocument();
56
+ expect(screen.getByText('endpoint')).toBeInTheDocument();
57
+ expect(screen.getByText('details.values:')).toBeInTheDocument();
58
+ expect(screen.getByText('first, second')).toBeInTheDocument();
59
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/templates/view?analytic=analytic-1&type=global&detection=template-detection');
60
+ await user.click(screen.getAllByLabelText('hit.outline.add_filter')[0]);
61
+ expect(addFilter).toHaveBeenCalledWith('event.provider:"endpoint"');
62
+ });
63
+ });
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Typography } from '@mui/material';
2
+ import { Link as LinkIcon } from '@mui/icons-material';
3
+ import { IconButton, Stack, Typography } from '@mui/material';
3
4
  import useMatchers from '@cccsaurora/howler-ui/components/app/hooks/useMatchers';
4
5
  import { useEffect, useState } from 'react';
5
6
  import { Link } from 'react-router-dom';
@@ -13,10 +14,10 @@ const AnalyticLink = ({ hit, lazy = false, compressed, alignSelf = 'start' }) =>
13
14
  void getMatchingAnalytic(hit).then(analytic => setAnalyticId(analytic?.analytic_id));
14
15
  // eslint-disable-next-line react-hooks/exhaustive-deps
15
16
  }, [hit?.howler.analytic]);
16
- return (_jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf, '& a': { color: 'text.primary' } }, children: [analyticId ? (_jsx(Link, { to: `/analytics/${analyticId}`, target: "_blank", rel: "noopener noreferrer", onAuxClick: e => {
17
+ return (_jsxs(Stack, { direction: "row", alignItems: "center", spacing: 0.5, children: [_jsx(IconButton, { size: "small", component: Link, onAuxClick: e => {
17
18
  e.stopPropagation();
18
19
  }, onClick: e => {
19
20
  e.stopPropagation();
20
- }, children: hit.howler.analytic })) : (hit.howler.analytic), hit.howler.detection && ': ', hit.howler.detection] }));
21
+ }, disabled: !analyticId, to: `/analytics/${analyticId}`, target: "_blank", rel: "noopener noreferrer", children: _jsx(LinkIcon, { fontSize: "small" }) }), _jsxs(Typography, { variant: compressed ? 'body1' : 'h6', fontWeight: compressed && 'bold', sx: { alignSelf, '& a': { color: 'text.primary' } }, children: [hit.howler.analytic, hit.howler.detection && ' > ', hit.howler.detection] })] }));
21
22
  };
22
23
  export default AnalyticLink;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import { BrowserRouter } from 'react-router-dom';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import AnalyticLink from './AnalyticLink';
7
+ const getMatchingAnalytic = vi.hoisted(() => vi.fn());
8
+ vi.mock('components/app/hooks/useMatchers', () => ({
9
+ default: () => ({ getMatchingAnalytic })
10
+ }));
11
+ describe('AnalyticLink', () => {
12
+ beforeEach(() => {
13
+ getMatchingAnalytic.mockResolvedValue({ analytic_id: 'analytic-id' });
14
+ });
15
+ it('renders an isolated link button when the analytic is resolved', async () => {
16
+ const user = userEvent.setup();
17
+ const onClick = vi.fn();
18
+ const hit = { howler: { analytic: 'Analytic Name', detection: 'Detection Name' } };
19
+ render(_jsx(BrowserRouter, { children: _jsx("div", { onClick: onClick, children: _jsx(AnalyticLink, { hit: hit }) }) }));
20
+ const link = await screen.findByRole('link');
21
+ expect(link).toHaveAttribute('href', '/analytics/analytic-id');
22
+ expect(screen.getByRole('heading')).toHaveTextContent('Analytic Name > Detection Name');
23
+ await user.click(link);
24
+ expect(onClick).not.toHaveBeenCalled();
25
+ });
26
+ });
@@ -3,7 +3,7 @@ import { Card, CardContent, Stack } from '@mui/material';
3
3
  import PageCenter from '@cccsaurora/howler-ui/commons/components/pages/PageCenter';
4
4
  import Markdown from '@cccsaurora/howler-ui/components/elements/display/Markdown';
5
5
  import { HitLayout } from '@cccsaurora/howler-ui/components/elements/hit/HitLayout';
6
- import DefaultOutline from '@cccsaurora/howler-ui/components/elements/hit/outlines/DefaultOutline';
6
+ import HitOutline from '@cccsaurora/howler-ui/components/elements/hit/HitOutline';
7
7
  import { useScrollRestoration } from '@cccsaurora/howler-ui/components/hooks/useScrollRestoration';
8
8
  import dayjs from 'dayjs';
9
9
  import howlerPluginStore from '@cccsaurora/howler-ui/plugins/store';
@@ -13,6 +13,11 @@ import { usePluginStore } from 'react-pluggable';
13
13
  import { modifyDocumentation } from '@cccsaurora/howler-ui/utils/utils';
14
14
  import TEMPLATES_EN from './markdown/en/templates.md';
15
15
  import TEMPLATES_FR from './markdown/fr/templates.md';
16
+ const TEMPLATE = {
17
+ analytic: 'Cat Checker',
18
+ owner: 'cat',
19
+ type: 'personal'
20
+ };
16
21
  const ALERTS = [
17
22
  {
18
23
  howler: { id: 'hit1', analytic: 'Cat Checker', detection: 'Listening for Meows' },
@@ -43,8 +48,14 @@ const TemplateDocumentation = () => {
43
48
  ALERTS.forEach((alert, index) => {
44
49
  markdown = markdown.replace(`$ALERT_${index + 1}`, JSON.stringify(alert, null, 2));
45
50
  });
46
- return modifyDocumentation(markdown.split('\n===SPLIT===\n'), howlerPluginStore, pluginStore);
51
+ return markdown
52
+ .split('\n===SPLIT===\n')
53
+ .map(section => modifyDocumentation(section, howlerPluginStore, pluginStore));
47
54
  }, [i18n.language, pluginStore]);
48
- return (_jsxs(PageCenter, { margin: 4, width: "100%", textAlign: "left", children: [_jsx(Markdown, { md: md1 }), _jsx(Stack, { spacing: 1, children: ALERTS.map(alert => (_jsx(Card, { variant: "outlined", children: _jsx(CardContent, { children: _jsx(DefaultOutline, { hit: alert, fields: Object.keys(alert).flatMap(key => Object.keys(alert[key]).map(key2 => [key, key2].join('.'))), layout: HitLayout.NORMAL, readonly: true }) }) }, alert.howler.id))) }), _jsx(Markdown, { md: md2 })] }));
55
+ return (_jsxs(PageCenter, { margin: 4, width: "100%", textAlign: "left", children: [_jsx(Markdown, { md: md1 }), _jsx(Stack, { spacing: 1, children: ALERTS.map(alert => (_jsx(Card, { variant: "outlined", children: _jsx(CardContent, { children: _jsx(HitOutline, { hit: alert, template: {
56
+ ...TEMPLATE,
57
+ detection: alert.howler.detection,
58
+ keys: Object.keys(alert['event']).map(key => `event.${key}`)
59
+ }, layout: HitLayout.NORMAL, forceAllFields: true }) }) }, alert.howler.id))) }), _jsx(Markdown, { md: md2 })] }));
49
60
  };
50
61
  export default TemplateDocumentation;
@@ -1 +1 @@
1
- export default "# Howler Templates\n\nHowler is, fundamentally, an application that allows analysts to triage hits and alerts. In order to make sure analysts can do this as efficiently as possible, we want to have the ability to present relevant data for a given alert to analysts in an easy, understandable way.\n\nTo this end, Howler allows analysts and detection engineers to create **templates**, which allow various analytics and their detections to present fields and data relevant to triaging alerts generated by that analytic/detection. For example, let's consider two different alerts, by two different detections:\n\n```json\n$ALERT_1\n```\n\n```json\n$ALERT_2\n```\n\nNote that while both share some similar fields, they also differ. We want each of these alert cards to present different data - for that, we can use templates. This allows us to show both hits in the same list, but with differing fields displayed:\n\n===SPLIT===\n\nAs we can see, by specifying a template for each of the detections, different data will be presented to the analyst. To do so, you can use the template creator [here]($CURRENT_URL/templates/view?type=personal).\n\n```alert\nNote that you must have ingested some hits for the given analytic/detection pair for it to show as an option in the template creation UI!\n```\n"
1
+ export default "# Howler Templates\n\nHowler is, fundamentally, an application that allows analysts to triage hits and alerts. In order to make sure analysts can do this as efficiently as possible, we want to have the ability to present relevant data for a given alert to analysts in an easy, understandable way.\n\nTo this end, Howler allows analysts and detection engineers to create **templates**, which allow various analytics and their detections to present fields and data relevant to triaging alerts generated by that analytic/detection. For example, let's consider two different alerts, by two different detections:\n\n```json[hideSearch=true]\n$ALERT_1\n```\n\n---\n\n```json[hideSearch=true]\n$ALERT_2\n```\n\nNote that while both share some similar fields, they also differ. We want each of these alert cards to present different data - for that, we can use templates. This allows us to show both hits in the same list, but with differing fields displayed:\n\n===SPLIT===\n\nAs we can see, by specifying a template for each of the detections, different data will be presented to the analyst. To do so, you can use the template creator [here]($CURRENT_URL/templates/view?type=personal).\n\n```alert\nNote that you must have ingested some hits for the given analytic/detection pair for it to show as an option in the template creation UI!\n```\n"
@@ -212,6 +212,8 @@
212
212
  "hit.notebook.select": "Please Select a notebook",
213
213
  "hit.notebook.tooltip": "Open in Jupyterhub",
214
214
  "hit.open": "Open Hit",
215
+ "hit.outline.add_filter": "Add filter",
216
+ "hit.outline.copy": "Copy value",
215
217
  "hit.overview.missing": "No overview has been created for this hit. In order to create an overview, press the add button to the right.",
216
218
  "hit.panel.aggregation.run": "Create Summary",
217
219
  "hit.panel.bundles.open": "Parent Bundles",
@@ -212,6 +212,8 @@
212
212
  "hit.notebook.select": "Veuillez sélectionner un notebook",
213
213
  "hit.notebook.tooltip": "Ouvrir dans Jupyterhub",
214
214
  "hit.open": "Ouvrir hit",
215
+ "hit.outline.add_filter": "Ajouter un filtre",
216
+ "hit.outline.copy": "Copier la valeur",
215
217
  "hit.overview.missing": "Aucune vue d'ensemble n'a été créée pour ce hit. Pour créer une vue d'ensemble, cliquez sur le bouton pour ajouter à droite.",
216
218
  "hit.panel.aggregation.run": "Créer un sommaire",
217
219
  "hit.panel.bundles.open": "Groupes parentaux",