From 2b061d6feb925f6d9427a178bc584923b63973ad Mon Sep 17 00:00:00 2001 From: Andres Aguilera Date: Thu, 29 Oct 2026 13:57:39 -0300 Subject: [PATCH] feat: Enhance integration setup guides and improve UI components - Added support for displaying guide settings in the IntegrationDrawer component. - Refactored ChannelGuide to utilize ForwarderGuide for better configuration management. - Updated CustomSetup to handle missing data types and improve user feedback. - Enhanced ForwarderGuide to support default ports and protocol selection. - Introduced GenericCollectorGuide for better handling of collector integrations. - Improved RemoteEnablePanel to utilize default ports more effectively. - Updated injectedModules to include forwarder data types and improved metadata handling. - Enhanced integration tests to ensure proper guide metadata assignment and deduplication. - Added new localization strings for setup guides in English and Spanish. --- .../components/IntegrationDrawer.tsx | 13 +- .../components/setup/ChannelGuide.tsx | 125 +++++++++--------- .../components/setup/CustomSetup.tsx | 99 ++++++++------ .../setup/collector/ForwarderGuide.tsx | 15 ++- .../setup/collector/GenericCollectorGuide.tsx | 83 +++++++++++- .../setup/collector/RemoteEnablePanel.tsx | 12 +- .../constants/injectedModules.test.ts | 78 ++++++++++- .../integrations/constants/injectedModules.ts | 104 +++++++++++++-- .../integrations/pages/IntegrationsPage.tsx | 54 +++----- .../src/features/integrations/types/index.ts | 2 + frontend/src/shared/i18n/locales/en.json | 4 + frontend/src/shared/i18n/locales/es.json | 4 + 12 files changed, 431 insertions(+), 162 deletions(-) diff --git a/frontend/src/features/integrations/components/IntegrationDrawer.tsx b/frontend/src/features/integrations/components/IntegrationDrawer.tsx index 9bf269c02..661d5f493 100644 --- a/frontend/src/features/integrations/components/IntegrationDrawer.tsx +++ b/frontend/src/features/integrations/components/IntegrationDrawer.tsx @@ -18,6 +18,7 @@ import { CrowdStrikeGuide } from '@/features/integrations/components/setup/cloud import { M365Guide, O365_GRAPH_FIELDS } from '@/features/integrations/components/setup/collector/collectors/M365Guide' import { SophosGuide } from '@/features/integrations/components/setup/collector/collectors/SophosGuide' import { CustomSetup } from '@/features/integrations/components/setup/CustomSetup' +import { Section } from '@/features/integrations/components/ui/Section' import { getIntegrationDashboardSearch } from '@/features/dashboard/utils/integration-dashboard-routing' import type { Integration } from '@/features/integrations/types' @@ -195,7 +196,17 @@ export function IntegrationDrawer({ integration: i, onClose }: IntegrationDrawer
<> {isCustom(i) && } - {!isCustom(i) && isAgent(i) && } + {!isCustom(i) && isAgent(i) && i.guideChannel && } + {!isCustom(i) && isAgent(i) && !i.guideChannel && ( + <> + + {i.guideSetting && ( +
+

{i.guideSetting}

+
+ )} + + )} {!isCustom(i) && i.kind === 'cloud' && isAWS(i.name) && } {!isCustom(i) && i.kind === 'cloud' && isAzure(i.name) && } {!isCustom(i) && i.kind === 'cloud' && isGCP(i) && } diff --git a/frontend/src/features/integrations/components/setup/ChannelGuide.tsx b/frontend/src/features/integrations/components/setup/ChannelGuide.tsx index 0f3ca4e01..70daac203 100644 --- a/frontend/src/features/integrations/components/setup/ChannelGuide.tsx +++ b/frontend/src/features/integrations/components/setup/ChannelGuide.tsx @@ -5,7 +5,9 @@ import type { ReactNode } from 'react' import { Section } from '@/features/integrations/components/ui/Section' import { CodeBlock } from '@/features/integrations/components/ui/CodeBlock' import { AgentSetup } from '@/features/integrations/components/setup/AgentSetup' -import { forwarderHost } from '@/features/integrations/components/setup/collector/ForwarderGuide' +import { ForwarderGuide } from '@/features/integrations/components/setup/collector/ForwarderGuide' +import type { RemoteEnableSelection } from '@/features/integrations/components/setup/collector/RemoteEnablePanel' +import { defaultPortFor } from '@/features/integrations/components/setup/collector/protoCatalog' import type { Integration, IntegrationGuideChannel } from '@/features/integrations/types' export function ChannelGuide({ integration }: { integration: Integration }) { @@ -41,74 +43,79 @@ function GuideLayout({ function SyslogDeviceGuide({ integration }: { integration: Integration }) { const { t } = useTranslation() - const [protocol, setProtocol] = useState<'tcp' | 'udp'>('tcp') - const host = forwarderHost() + const sourceType = integration.forwarderDataType || 'syslog' + const port = defaultPortFor(sourceType, 'tcp') || '7014' + const [selection, setSelection] = useState({ proto: 'tcp', port }) + const host = selection.collector?.ip || '' return ( - -

- Configure the device to send syslog to the UTMStack Collector. TCP is recommended for chatty - devices; the UDP receiver uses a 2,048-byte message buffer and truncates longer messages. -

- - +
+

+ Configure the device to send syslog to the selected UTMStack Collector. TCP is recommended + for chatty devices; the UDP receiver uses a 2,048-byte message buffer and truncates longer + messages. +

+ -

- For a device that only supports port 514, change the Collector's syslog UDP port with: -

- -

- Only do this on a Collector that is not running Cisco integrations; they may already use UDP - 514. The command refuses a port that is busy. Changing a built-in port is not done with - enable-integration. -

-

- {t('datasources.drawer.viewLogs')} will open Log Explorer for this integration's data type. -

- {integration.guideSetting && ( -

{integration.guideSetting}

- )} - + /> +

+ {t('datasources.drawer.viewLogs')} will open Log Explorer for this integration's data type. +

+ {integration.guideSetting && ( +

{integration.guideSetting}

+ )} +
+ ) } function NetflowExporterGuide({ integration }: { integration: Integration }) { - const host = forwarderHost() + const sourceType = integration.forwarderDataType || 'netflow' + const port = defaultPortFor(sourceType, 'udp') || '2055' + const [selection, setSelection] = useState({ proto: 'udp', port }) + const host = selection.collector?.ip || '' + return ( - -

- Configure the exporter to send NetFlow over UDP to the UTMStack Collector: -

- +
+

+ Configure the exporter to send NetFlow over UDP to the selected UTMStack Collector: +

+ -

- Select the export version supported by the device and configure its source interface. Verify - that flows arrive in Log Explorer using the View logs button. -

- {integration.guideSetting && ( -

{integration.guideSetting}

- )} - + /> +

+ Select the export version supported by the device and configure its source interface. Verify + that flows arrive in Log Explorer using the View logs button. +

+ {integration.guideSetting && ( +

{integration.guideSetting}

+ )} +
+ ) } diff --git a/frontend/src/features/integrations/components/setup/CustomSetup.tsx b/frontend/src/features/integrations/components/setup/CustomSetup.tsx index 962e5cc98..dd9b4a53a 100644 --- a/frontend/src/features/integrations/components/setup/CustomSetup.tsx +++ b/frontend/src/features/integrations/components/setup/CustomSetup.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useTranslation, Trans } from 'react-i18next' -import { Forward, Server, ShieldCheck } from 'lucide-react' +import { Forward, Server, ShieldCheck } from 'lucide-react' import { Section } from '@/features/integrations/components/ui/Section' import { CodeBlock } from '@/features/integrations/components/ui/CodeBlock' import { @@ -9,7 +9,7 @@ import { FlowEdge, } from '@/features/integrations/components/setup/collector/ForwarderGuide' import type { Integration } from '@/features/integrations/types' -import { type RemoteEnableSelection } from './collector/RemoteEnablePanel' +import type { RemoteEnableSelection } from './collector/RemoteEnablePanel' const ROOT = 'integrations.setup.custom' // A custom data type has no ProtoPorts/HTTPPorts catalog entry, so every @@ -18,7 +18,8 @@ const ROOT = 'integrations.setup.custom' export function CustomSetup({ integration }: { integration: Integration }) { const { t } = useTranslation() - const name = integration.dataType || integration.moduleName?.toLowerCase() || 'my-integration' + const dataType = integration.dataType + const name = dataType || integration.moduleName?.toLowerCase() || 'my-integration' const [selection, setSelection] = useState({ proto: 'udp', port: '7100' }) const { proto, port } = selection @@ -28,43 +29,55 @@ export function CustomSetup({ integration }: { integration: Integration }) { // to the Forwarder host. const endpoint = `${proto}://127.0.0.1:${port}/logs` - return ( -
- -

{t(`${ROOT}.intro.body`)}

-
- } - title={t(`${ROOT}.diagram.source`)} - sub={t(`${ROOT}.diagram.sourceSub`)} - tone="neutral" - /> - - } - title={t(`${ROOT}.diagram.forwarder`)} - sub={`:${selection.port}`} - tone="accent" - /> - - } - title="UTMStack" - sub={t(`${ROOT}.diagram.utmstackSub`)} - tone="brand" - /> -
-

- {t(`${ROOT}.intro.note`)} + if (!dataType) { + return ( +

+

+ {t('integrations.setup.collector.guideUnavailableBody', { name: integration.name })}

- )} - > + ) + } + + return ( +
+ +

{t(`${ROOT}.intro.body`)}

+
+ } + title={t(`${ROOT}.diagram.source`)} + sub={t(`${ROOT}.diagram.sourceSub`)} + tone="neutral" + /> + + } + title={t(`${ROOT}.diagram.forwarder`)} + sub={`:${selection.port}`} + tone="accent" + /> + + } + title="UTMStack" + sub={t(`${ROOT}.diagram.utmstackSub`)} + tone="brand" + /> +
+

+ {t(`${ROOT}.intro.note`)} +

+ + )} + >
{isHttp ? ( @@ -90,7 +103,11 @@ export function CustomSetup({ integration }: { integration: Integration }) {

', + port: selection.port, + proto: selection.proto.toUpperCase(), + }} components={{ hl: }} />

@@ -102,9 +119,7 @@ export function CustomSetup({ integration }: { integration: Integration }) {

{t(`${ROOT}.parserBody`, { dataType: name })}

-
- +
) } - diff --git a/frontend/src/features/integrations/components/setup/collector/ForwarderGuide.tsx b/frontend/src/features/integrations/components/setup/collector/ForwarderGuide.tsx index a0beb172b..a7921c94b 100644 --- a/frontend/src/features/integrations/components/setup/collector/ForwarderGuide.tsx +++ b/frontend/src/features/integrations/components/setup/collector/ForwarderGuide.tsx @@ -157,10 +157,14 @@ interface ForwarderGuideProps { source: string /** Forwarder port for this data type, e.g. "7016" (illustrative default shown in the diagram). */ port: string + /** Stable initial port fallback when the data type has no catalog entry. */ + defaultPort?: string /** The dataType shown in the connection details, e.g. "ibm-aix". */ sourceType: string /** Initial protocol, when the device needs something other than the catalog's first entry (e.g. ESXi/SentinelOne are TCP-only in practice). */ defaultProto?: Proto + /** Explicit protocol choices when this integration has a narrower catalog than its data type. */ + availableProtos?: Proto[] /** Vendor-specific device-side config (how to point the device at the Forwarder). */ header?: ReactNode onSetSelection?:(selection:RemoteEnableSelection)=>void @@ -170,18 +174,24 @@ interface ForwarderGuideProps { export function ForwarderGuide({ source, port, + defaultPort, sourceType, defaultProto, + availableProtos: configuredProtos, header, onSetSelection, children }: ForwarderGuideProps) { const { t } = useTranslation() - const availableProtos = useMemo(() => availableProtosFor(sourceType), [sourceType]) + const availableProtos = useMemo( + () => configuredProtos ?? availableProtosFor(sourceType), + [sourceType, configuredProtos], + ) const initialProto = defaultProto ?? availableProtos[0] + const fallbackPort = defaultPortFor(sourceType, initialProto) || defaultPort || port const [selection, setSelection] = useState(() => ({ proto: initialProto, - port: defaultPortFor(sourceType, initialProto) || port, + port: fallbackPort, apiKey: null, })) @@ -215,6 +225,7 @@ export function ForwarderGuide({ dataType={sourceType} availableProtos={availableProtos} defaultProto={defaultProto} + defaultPort={fallbackPort} step={1} onSelectionChange={setSelection} onRequestAddCollector={handleAddCollector} diff --git a/frontend/src/features/integrations/components/setup/collector/GenericCollectorGuide.tsx b/frontend/src/features/integrations/components/setup/collector/GenericCollectorGuide.tsx index d8147fbdb..8e12850ab 100644 --- a/frontend/src/features/integrations/components/setup/collector/GenericCollectorGuide.tsx +++ b/frontend/src/features/integrations/components/setup/collector/GenericCollectorGuide.tsx @@ -1,10 +1,85 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Section } from '@/features/integrations/components/ui/Section' +import { CodeBlock } from '@/features/integrations/components/ui/CodeBlock' import { ChannelGuide } from '@/features/integrations/components/setup/ChannelGuide' +import { ForwarderGuide } from '@/features/integrations/components/setup/collector/ForwarderGuide' +import type { RemoteEnableSelection } from './RemoteEnablePanel' +import { availableProtosFor, defaultPortFor, type Proto } from './protoCatalog' import type { Integration } from '@/features/integrations/types' -interface GenericCollectorGuideProps { - integration: Integration +const ROOT = 'integrations.setup.collector' + +function getDefaultEndpoint(integration: Integration): { proto: Proto; port: string; protos: Proto[] } | null { + const dataType = integration.forwarderDataType || integration.dataType + if (!dataType) return null + + const availableProtos = availableProtosFor(dataType) + const configuredDefault = integration.defaultPort?.match(/^(\d+)(?:\/(udp|tcp|tls|http|https))?$/i) + const configuredProto = configuredDefault?.[2]?.toLowerCase() as Proto | undefined + const configuredPort = Number(configuredDefault?.[1]) + const validConfiguredPort = configuredPort > 0 && configuredPort <= 65535 + const catalogProtos = availableProtos.filter((proto) => !!defaultPortFor(dataType, proto)) + + if (catalogProtos.length > 0) { + const proto = configuredProto && catalogProtos.includes(configuredProto) + ? configuredProto + : catalogProtos[0] + const port = defaultPortFor(dataType, proto) || (validConfiguredPort ? String(configuredPort) : '') + return port ? { proto, port, protos: catalogProtos } : null + } + + if (!configuredProto || !validConfiguredPort) return null + return { proto: configuredProto, port: String(configuredPort), protos: [configuredProto] } } -export function GenericCollectorGuide({ integration: i }: GenericCollectorGuideProps) { - return +export function GenericCollectorGuide({ integration }: { integration: Integration }) { + const { t } = useTranslation() + const endpoint = getDefaultEndpoint(integration) + const [selection, setSelection] = useState(() => ({ + proto: endpoint?.proto ?? 'udp', + port: endpoint?.port ?? '', + })) + + if (integration.guideChannel) { + return + } + + const forwarderDataType = integration.forwarderDataType || integration.dataType + + if (integration.ingestType?.toLowerCase() === 'forwarder' && endpoint && forwarderDataType) { + const host = selection.collector?.ip || '' + + return ( + +
+

+ {t(`${ROOT}.genericSourceBody`, { name: integration.name })} +

+ +
+
+ ) + } + + return ( +
+

+ {t(`${ROOT}.guideUnavailableBody`, { name: integration.name })} +

+
+ ) } diff --git a/frontend/src/features/integrations/components/setup/collector/RemoteEnablePanel.tsx b/frontend/src/features/integrations/components/setup/collector/RemoteEnablePanel.tsx index 2a4893280..90c9643a1 100644 --- a/frontend/src/features/integrations/components/setup/collector/RemoteEnablePanel.tsx +++ b/frontend/src/features/integrations/components/setup/collector/RemoteEnablePanel.tsx @@ -24,6 +24,7 @@ interface RemoteEnablePanelProps { dataType: string availableProtos: Proto[] defaultProto?: Proto + defaultPort?: string step?: number onSelectionChange?: (selection: RemoteEnableSelection) => void onRequestAddCollector?: () => void @@ -133,6 +134,7 @@ export function RemoteEnablePanel({ dataType, availableProtos, defaultProto, + defaultPort = '', step, onSelectionChange, onRequestAddCollector, @@ -148,7 +150,7 @@ export function RemoteEnablePanel({ const initialProto = defaultProto ?? availableProtos[0] const [collectorId, setCollectorId] = useState(null) const [proto, setProto] = useState(initialProto) - const [port, setPort] = useState(() => defaultPortFor(dataType, initialProto)) + const [port, setPort] = useState(() => defaultPortFor(dataType, initialProto) || defaultPort) const httpDefaults = httpDefaultsFor(dataType) const [auth, setAuth] = useState(httpDefaults?.auth ?? '') const [path, setPath] = useState(httpDefaults?.path ?? '') @@ -215,7 +217,7 @@ export function RemoteEnablePanel({ if (data.configured) { const nextProto = (data.proto as Proto) || initialProto setProto(nextProto) - setPort(data.port || defaultPortFor(dataType, nextProto)) + setPort(data.port || defaultPortFor(dataType, nextProto) || defaultPort) setAuth((data.auth as HttpAuth) ?? '') setPath(data.path ?? '') setSignatureHeader(data.signatureHeader ?? '') @@ -223,11 +225,11 @@ export function RemoteEnablePanel({ } setProto(initialProto) - setPort(defaultPortFor(dataType, initialProto)) + setPort(defaultPortFor(dataType, initialProto) || defaultPort) setAuth(httpDefaults?.auth ?? '') setPath(httpDefaults?.path ?? '') setSignatureHeader(httpDefaults?.signatureHeader ?? '') - }, [collectorId, currentConfig.isSuccess, currentConfig.data]) + }, [collectorId, currentConfig.isSuccess, currentConfig.data, defaultPort]) if (!canManage) { return ( @@ -351,7 +353,7 @@ export function RemoteEnablePanel({ onChange={(e) => { const next = e.target.value as Proto setProto(next) - setPort(defaultPortFor(dataType, next)) + setPort(defaultPortFor(dataType, next) || defaultPort) setGeneratedSecret(null) }} disabled={availableProtos.length <= 1 || isConfigLocked} diff --git a/frontend/src/features/integrations/constants/injectedModules.test.ts b/frontend/src/features/integrations/constants/injectedModules.test.ts index c3514ebd2..1ad792f58 100644 --- a/frontend/src/features/integrations/constants/injectedModules.test.ts +++ b/frontend/src/features/integrations/constants/injectedModules.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { INJECTED_MODULES } from './injectedModules' +import { applyInjectedGuideMetadata, INJECTED_MODULES, mergeInjectedIntegrations } from './injectedModules' describe('frontend-injected integration guides', () => { it('keeps guide-only catalog entries available with unique stable ids', () => { @@ -9,15 +9,85 @@ describe('frontend-injected integration guides', () => { }) it('provides a demonstrative integration for every supported guide channel', () => { - const channels = new Set(INJECTED_MODULES.map((integration) => integration.guideChannel)) + const channels = new Set( + INJECTED_MODULES + .map((integration) => integration.guideChannel) + .filter((channel) => channel !== undefined), + ) expect(channels).toEqual(new Set(['syslog', 'netflow', 'windows-agent', 'linux-agent'])) }) + it('assigns every injected integration an explicit ingest type', () => { + expect(INJECTED_MODULES.every((integration) => integration.ingestType)).toBe(true) + expect(INJECTED_MODULES.find((integration) => integration.moduleName === 'NGINX')?.ingestType).toBe('agent') + expect(INJECTED_MODULES.find((integration) => integration.moduleName === 'IIS')?.ingestType).toBe('agent') + expect(INJECTED_MODULES.find((integration) => integration.moduleName === 'SYSLOG')?.ingestType).toBe('forwarder') + expect(INJECTED_MODULES.find((integration) => integration.moduleName === 'NGINX')?.kind).toBe('agents & syslog') + }) + it('uses the supported Syslog and NetFlow endpoints', () => { const syslog = INJECTED_MODULES.find((integration) => integration.moduleName === 'SYSLOG') const netflow = INJECTED_MODULES.find((integration) => integration.moduleName === 'NETFLOW') - expect(syslog).toMatchObject({ dataType: 'syslog', defaultPort: '7014/tcp', logFilterField: 'dataSource' }) - expect(netflow).toMatchObject({ dataType: 'netflow', defaultPort: '2055/udp', logFilterField: 'log.exporter' }) + expect(syslog).toMatchObject({ + dataType: 'syslog', + forwarderDataType: 'syslog', + defaultPort: '7014/tcp', + logFilterField: 'dataSource', + }) + expect(netflow).toMatchObject({ + dataType: 'netflow', + forwarderDataType: 'netflow', + defaultPort: '2055/udp', + logFilterField: 'log.exporter', + }) + }) + + it('adds guide metadata to backend rows without replacing backend-owned fields', () => { + const syslog = INJECTED_MODULES.find((integration) => integration.moduleName === 'SYSLOG')! + const backendIntegration = { + ...syslog, + id: 'backend-syslog-id', + dataType: 'backend-syslog-type', + ingestType: 'collector', + forwarderDataType: undefined, + defaultPort: '6514/tcp', + guideChannel: undefined, + } + + expect(applyInjectedGuideMetadata(backendIntegration)).toMatchObject({ + id: 'backend-syslog-id', + dataType: 'backend-syslog-type', + ingestType: 'collector', + forwarderDataType: 'syslog', + defaultPort: '6514/tcp', + guideChannel: 'syslog', + guideOnly: true, + }) + }) + + it('leaves integrations without matching guide metadata unchanged', () => { + const integration = { ...INJECTED_MODULES[0], moduleName: 'UNMAPPED_INTEGRATION' } + expect(applyInjectedGuideMetadata(integration)).toBe(integration) + }) + + it('deduplicates backend and injected rows case-insensitively while keeping backend identity', () => { + const injectedSyslog = INJECTED_MODULES.find((integration) => integration.moduleName === 'SYSLOG')! + const backendSyslog = { + ...injectedSyslog, + id: 'backend-syslog-id', + moduleName: 'syslog', + dataType: 'backend-syslog-type', + } + + const merged = mergeInjectedIntegrations([backendSyslog]) + const syslogEntries = merged.filter((integration) => integration.moduleName?.toUpperCase() === 'SYSLOG') + + expect(syslogEntries).toHaveLength(1) + expect(syslogEntries[0]).toMatchObject({ + id: 'backend-syslog-id', + dataType: 'backend-syslog-type', + forwarderDataType: 'syslog', + }) }) it('filters nested Windows and Linux product fields with is-one-of', () => { diff --git a/frontend/src/features/integrations/constants/injectedModules.ts b/frontend/src/features/integrations/constants/injectedModules.ts index 0ed2d7c01..a481d290a 100644 --- a/frontend/src/features/integrations/constants/injectedModules.ts +++ b/frontend/src/features/integrations/constants/injectedModules.ts @@ -1,6 +1,7 @@ import type { Integration, DeployKind, + IngestType, IntegrationGuideChannel, IntegrationLogFilter, } from '@/features/integrations/types' @@ -54,38 +55,75 @@ const SEEDS: InjectedSeed[] = [ ] interface GuideMetadata { - channel: IntegrationGuideChannel + channel?: IntegrationGuideChannel + ingestType: IngestType dataType?: string + forwarderDataType?: string defaultPort?: string filters?: IntegrationLogFilter[] filterField?: 'dataSource' | 'log.exporter' setting?: string } +const SYSLOG_FORWARDER: GuideMetadata = { + channel: 'syslog', + ingestType: 'forwarder', + dataType: 'syslog', + forwarderDataType: 'syslog', + defaultPort: '7014/tcp', + filterField: 'dataSource', +} + const GUIDE_METADATA: Record = { - DEFAULT: { - channel: 'syslog', - dataType: 'syslog', - defaultPort: '7014/tcp', - filterField: 'dataSource', - }, + APACHE: SYSLOG_FORWARDER, + APACHE2: SYSLOG_FORWARDER, + ELASTICSEARCH: SYSLOG_FORWARDER, + HAPROXY: SYSLOG_FORWARDER, + KAFKA: SYSLOG_FORWARDER, + KIBANA: SYSLOG_FORWARDER, + LOGSTASH: SYSLOG_FORWARDER, + MONGODB: SYSLOG_FORWARDER, + MYSQL: SYSLOG_FORWARDER, + NATS: SYSLOG_FORWARDER, NGINX: { channel: 'linux-agent', + ingestType: 'agent', dataType: 'linux', filters: [{ field: 'log.syslogIdentifier', operator: 'IS_ONE_OF_TERMS', value: ['nginx'] }], setting: 'Configure Nginx access and error logs to be forwarded to the Linux journal or syslog.', }, - SYSLOG: { channel: 'syslog', defaultPort: '7014/tcp', filterField: 'dataSource' }, - NETFLOW: { channel: 'netflow', defaultPort: '2055/udp', filterField: 'log.exporter' }, + OSQUERY: { + ingestType: 'agent', + setting: 'Install the UTMStack agent on the host and configure it to collect the osquery results.', + }, + POSTGRESQL: SYSLOG_FORWARDER, + REDIS: SYSLOG_FORWARDER, + SYSLOG: SYSLOG_FORWARDER, + TRAEFIK: SYSLOG_FORWARDER, + NETFLOW: { + channel: 'netflow', + ingestType: 'forwarder', + dataType: 'netflow', + forwarderDataType: 'netflow', + defaultPort: '2055/udp', + filterField: 'log.exporter', + }, JSON: { channel: 'syslog', + ingestType: 'forwarder', dataType: 'syslog', + forwarderDataType: 'syslog', defaultPort: '7014/tcp', filterField: 'dataSource', setting: 'The legacy JSON HTTP endpoint is not published in v12. This demo entry uses the supported syslog channel; send JSON text as a syslog message.', }, + IIS: { + ingestType: 'agent', + setting: 'Install the Windows UTMStack agent on the IIS host and configure it to collect IIS log files.', + }, WINDOWS_SQL_SERVER: { channel: 'windows-agent', + ingestType: 'agent', filters: [ { field: 'log.providerName', operator: 'IS_ONE_OF_TERMS', value: ['MSSQLSERVER'] }, { field: 'log.channel', operator: 'IS_ONE_OF_TERMS', value: ['Application'] }, @@ -94,6 +132,7 @@ const GUIDE_METADATA: Record = { }, WINDOWS_FILE_AUDITING: { channel: 'windows-agent', + ingestType: 'agent', filters: [ { field: 'log.providerName', operator: 'IS_ONE_OF_TERMS', value: ['Microsoft-Windows-Security-Auditing'] }, { field: 'log.channel', operator: 'IS_ONE_OF_TERMS', value: ['Security'] }, @@ -103,14 +142,18 @@ const GUIDE_METADATA: Record = { } export const INJECTED_MODULES: Integration[] = SEEDS.map((s) => { - const guide = GUIDE_METADATA[s.moduleName] ?? GUIDE_METADATA.DEFAULT + const guide = GUIDE_METADATA[s.moduleName] + if (!guide) { + throw new Error(`Missing guide metadata for injected integration "${s.moduleName}"`) + } + return { id: s.id, name: s.name, moduleName: s.moduleName, dataType: guide.dataType ?? s.dataType, - ingestType: 'forwarder', - kind: s.kind, + ingestType: guide.ingestType, + kind: guide.ingestType === 'agent' ? 'agents & syslog' : s.kind, status: 'available', description: guide.setting ?? '', category: s.category, @@ -119,11 +162,46 @@ export const INJECTED_MODULES: Integration[] = SEEDS.map((s) => { guideOnly: true, defaultPort: guide.defaultPort ?? s.defaultPort, guideChannel: guide.channel, + forwarderDataType: guide.forwarderDataType, logFilters: guide.filters, logFilterField: guide.filterField, - guideSetting: guide.setting ?? `Configure ${s.name} to send its logs through the supported syslog channel.`, + guideSetting: guide.setting, events24h: undefined, rate: undefined, darkInvert: false, } }) + +export function applyInjectedGuideMetadata(integration: Integration): Integration { + const moduleName = integration.moduleName?.toUpperCase() + const guide = moduleName ? GUIDE_METADATA[moduleName] : undefined + if (!guide) return integration + + return { + ...integration, + ...(integration.dataType || !guide.dataType ? {} : { dataType: guide.dataType }), + ...(integration.ingestType ? {} : { ingestType: guide.ingestType }), + ...(guide.channel ? { guideChannel: guide.channel } : {}), + ...(guide.forwarderDataType ? { forwarderDataType: guide.forwarderDataType } : {}), + ...(integration.defaultPort || !guide.defaultPort ? {} : { defaultPort: guide.defaultPort }), + ...(guide.filters ? { logFilters: guide.filters } : {}), + ...(guide.filterField ? { logFilterField: guide.filterField } : {}), + ...(guide.setting ? { guideSetting: guide.setting } : {}), + guideOnly: true, + } +} + +export function mergeInjectedIntegrations( + backendIntegrations: Integration[], + injectedIntegrations: Integration[] = INJECTED_MODULES, +): Integration[] { + const backendModuleNames = new Set( + backendIntegrations.map((integration) => integration.moduleName?.toUpperCase()).filter(Boolean), + ) + const normalizedBackend = backendIntegrations.map(applyInjectedGuideMetadata) + const missingInjected = injectedIntegrations.filter( + (integration) => !backendModuleNames.has(integration.moduleName?.toUpperCase()), + ) + + return [...normalizedBackend, ...missingInjected] +} diff --git a/frontend/src/features/integrations/pages/IntegrationsPage.tsx b/frontend/src/features/integrations/pages/IntegrationsPage.tsx index f0749326d..846e332e7 100644 --- a/frontend/src/features/integrations/pages/IntegrationsPage.tsx +++ b/frontend/src/features/integrations/pages/IntegrationsPage.tsx @@ -13,7 +13,7 @@ import { AddCustomIntegrationCard } from '@/features/integrations/components/Add import { CreateIntegrationDrawer } from '@/features/integrations/components/CreateIntegrationDrawer' import { KIND_META, categoryLabel } from '@/features/integrations/constants' import { SYSTEM_MODULES } from '@/features/integrations/constants/systemModules' -import { INJECTED_MODULES } from '@/features/integrations/constants/injectedModules' +import { INJECTED_MODULES, mergeInjectedIntegrations } from '@/features/integrations/constants/injectedModules' import type { Integration, DeployKind, @@ -32,7 +32,7 @@ const LOGO = (slug: string) => { } const INJECTED_BY_MODULE_NAME = new Map( - INJECTED_MODULES.map((integration) => [integration.moduleName ?? '', integration]), + INJECTED_MODULES.map((integration) => [(integration.moduleName ?? '').toUpperCase(), integration]), ) function mapModuleToIntegration(module: IntegrationResponse, t: TFunction): Integration { @@ -40,13 +40,15 @@ function mapModuleToIntegration(module: IntegrationResponse, t: TFunction): Inte // System integrations: icon, description and category are owned by the frontend // (per-theme icons + i18n). Custom ones keep using the catalog row's own icon // and description, and group under 'custom'. - const injectedMeta = INJECTED_BY_MODULE_NAME.get(module.name) + const injectedMeta = INJECTED_BY_MODULE_NAME.get(module.name.toUpperCase()) const meta = isSystem ? SYSTEM_MODULES[module.name] : undefined const category = meta?.category ?? injectedMeta?.category ?? 'custom' // Map the category to a known DeployKind; anything unknown falls back to // 'other' (Collector group) so a new integration never breaks the grid. - const kind: DeployKind = (category in KIND_META ? category : 'other') as DeployKind + const kind: DeployKind = module.ingestType?.toLowerCase() === 'agent' + ? 'agents & syslog' + : (category in KIND_META ? category : 'other') as DeployKind const description = isSystem ? t(`integrations.modules.${module.name}.description`, { defaultValue: module.description || '' }) @@ -86,8 +88,6 @@ function mapModuleToIntegration(module: IntegrationResponse, t: TFunction): Inte logo, logoDark, darkInvert: false, - ...(module.name.toUpperCase() === 'NETFLOW' ? { guideChannel: 'netflow' as const } : {}), - ...(module.name.toUpperCase() === 'NETFLOW' ? { logFilterField: 'log.exporter' as const } : {}), events24h: undefined, rate: undefined, } @@ -181,33 +181,23 @@ export function IntegrationsPage() { const modules = integrations.integrations.data || [] const displayList = useMemo(() => { - const injectedByName = new Map(INJECTED_MODULES.map((entry) => [entry.moduleName, entry])) - const catalog = modules.map((m) => { - const mapped = mapModuleToIntegration(m, t) - const guideMetadata = injectedByName.get(m.name) - return guideMetadata - ? { - ...mapped, - guideChannel: guideMetadata.guideChannel, - guideOnly: true, - logFilters: guideMetadata.logFilters, - logFilterField: guideMetadata.logFilterField, - guideSetting: guideMetadata.guideSetting, - } - : mapped + const backend = modules.map((module) => mapModuleToIntegration(module, t)) + const merged = mergeInjectedIntegrations(backend, INJECTED_MODULES) + const backendModuleNames = new Set(modules.map((module) => module.name.toUpperCase())) + + return merged.map((integration) => { + if (backendModuleNames.has((integration.moduleName ?? '').toUpperCase())) { + return integration + } + + return { + ...integration, + name: t(`integrations.modules.${integration.moduleName}.name`, { defaultValue: integration.name }), + description: t(`integrations.modules.${integration.moduleName}.description`, { + defaultValue: integration.description, + }), + } }) - // Injected catalog cards are frontend-owned; dedupe against the backend - // list by moduleName so a future backend row for the same module never - // shows up twice (backend row wins — it carries the real id + dataType). - const backendModuleNames = new Set(modules.map((m) => m.name)) - const injected = INJECTED_MODULES - .filter((i) => !backendModuleNames.has(i.moduleName ?? '')) - .map((i) => ({ - ...i, - name: t(`integrations.modules.${i.moduleName}.name`, { defaultValue: i.name }), - description: t(`integrations.modules.${i.moduleName}.description`, { defaultValue: i.description }), - })) - return [...catalog, ...injected] }, [modules, t]) // The raw catalog row for the module being edited (for its actual stored icon). diff --git a/frontend/src/features/integrations/types/index.ts b/frontend/src/features/integrations/types/index.ts index 1ae0d0af1..4e1f0ac6e 100644 --- a/frontend/src/features/integrations/types/index.ts +++ b/frontend/src/features/integrations/types/index.ts @@ -62,6 +62,8 @@ export interface Integration { defaultPort?: string; /** Frontend-only guide route for injected and channel-based integrations. */ guideChannel?: IntegrationGuideChannel; + /** Forwarder configuration data type when it differs from the integration's log data type. */ + forwarderDataType?: string; /** This entry is a presentation/demo fallback and has no catalog row of its own. */ guideOnly?: boolean; /** Technology-specific filters passed to Log Explorer. */ diff --git a/frontend/src/shared/i18n/locales/en.json b/frontend/src/shared/i18n/locales/en.json index bde3ffd8c..d369bcdbb 100644 --- a/frontend/src/shared/i18n/locales/en.json +++ b/frontend/src/shared/i18n/locales/en.json @@ -2165,6 +2165,10 @@ "macCompat": "Requires macOS 11 (Big Sur) or later on Apple Silicon (M1/M2/M3 or newer). Intel-based Macs are not supported." }, "collector": { + "guideUnavailableTitle": "Setup guide unavailable", + "guideUnavailableBody": "A setup guide for {{name}} has not been configured yet. Contact your administrator before enabling this integration.", + "genericSourceTitle": "Configure {{name}}", + "genericSourceBody": "Configure the source to send events to the selected UTMStack Collector using these connection details.", "howItWorksTitle": "How it works", "howItWorksBody": "{{name}} doesn't run a UTMStack agent. Instead, you install the UTMStack collector somewhere on the network that both your {{name}} device and UTMStack can reach. {{name}} sends syslog to the collector, the collector parses, normalizes, and forwards events into UTMStack.", "step1Title": "1 · Install the UTMStack collector", diff --git a/frontend/src/shared/i18n/locales/es.json b/frontend/src/shared/i18n/locales/es.json index 423d50e8e..da9931069 100644 --- a/frontend/src/shared/i18n/locales/es.json +++ b/frontend/src/shared/i18n/locales/es.json @@ -1922,6 +1922,10 @@ "macCompat": "Requiere macOS 11 (Big Sur) o posterior en Apple Silicon (M1/M2/M3 o más reciente). Los Mac basados en Intel no son compatibles." }, "collector": { + "guideUnavailableTitle": "Guía de configuración no disponible", + "guideUnavailableBody": "Todavía no se configuró una guía para {{name}}. Contacta al administrador antes de habilitar esta integración.", + "genericSourceTitle": "Configurar {{name}}", + "genericSourceBody": "Configura la fuente para enviar eventos al Collector de UTMStack seleccionado usando estos datos de conexión.", "howItWorksTitle": "Cómo funciona", "howItWorksBody": "{{name}} no ejecuta un agente de UTMStack. En su lugar, instalas el colector de UTMStack en algún lugar de la red que tanto tu dispositivo {{name}} como UTMStack puedan alcanzar. {{name}} envía syslog al colector, el colector analiza, normaliza y reenvía eventos a UTMStack.", "step1Title": "1 · Instalar el colector de UTMStack",