@cryptotaxi247 / CoPilot / commits / b87b9812

763 event searchs (#764)

* Remove unused MySQL dialect import from event sources table migration * Implement SIEM event sources functionality with CRUD operations and validation * Add SIEM alerts functionality with query endpoint and response models * Refactor SIEM alerts functionality: remove alerts routes and models, add events routes and models * Add Lucene query support and field mappings endpoint for event queries * Implement event sources functionality: add API endpoints, components, and types for managing event sources * Update index pattern placeholder to include customer code * Add event search functionality with UI components and API integration * Add event search navigation and route parameter handling * Refactor Navbar items: reorder and update labels for SIEM and Event Search * Add warning alert for no event sources configured in Event Search * Add documentation for Event Search and Event Sources - Created a new page for Event Search detailing how to query raw SIEM events, including prerequisites, steps for searching, and tips for effective usage. - Added a new page for Event Sources explaining how to configure event sources for customers, including definitions, steps for creation, and important considerations. * Bump version to 0.1.50 * precommit-fixes

taylor_socfortress committed Mar 14, 2026 at 18:01 UTC b87b98123ae2eb045213a801ec08d644780868af
34 files changed +1976 -26
backend/alembic/versions/85ea2970828c_add_event_sources_tables.py
+25 -20
@@ -5,40 +5,45 @@ Revises: fb51d610b306
5 Create Date: 2026-03-13 17:00:07.745510
6
7 """
8 -from typing import Sequence, Union
8 +from typing import Sequence
9 +from typing import Union
10
10 -from alembic import op
11 import sqlalchemy as sa
12 -from sqlalchemy.dialects import mysql
12 +
13 +from alembic import op
14
15 # revision identifiers, used by Alembic.
15 -revision: str = '85ea2970828c'
16 -down_revision: Union[str, None] = 'fb51d610b306'
16 +revision: str = "85ea2970828c"
17 +down_revision: Union[str, None] = "fb51d610b306"
18 branch_labels: Union[str, Sequence[str], None] = None
19 depends_on: Union[str, Sequence[str], None] = None
20
21
22 def upgrade() -> None:
23 # ### commands auto generated by Alembic - please adjust! ###
23 - op.create_table('event_sources',
24 - sa.Column('id', sa.Integer(), nullable=False),
25 - sa.Column('customer_code', sa.String(length=50), nullable=False),
26 - sa.Column('name', sa.String(length=255), nullable=False),
27 - sa.Column('index_pattern', sa.String(length=1024), nullable=False),
28 - sa.Column('event_type', sa.String(length=50), nullable=False),
29 - sa.Column('time_field', sa.String(length=255), nullable=False),
30 - sa.Column('enabled', sa.Boolean(), nullable=False),
31 - sa.Column('created_at', sa.DateTime(), nullable=False),
32 - sa.Column('updated_at', sa.DateTime(), nullable=False),
33 - sa.ForeignKeyConstraint(['customer_code'], ['customers.customer_code'], ),
34 - sa.PrimaryKeyConstraint('id')
24 + op.create_table(
25 + "event_sources",
26 + sa.Column("id", sa.Integer(), nullable=False),
27 + sa.Column("customer_code", sa.String(length=50), nullable=False),
28 + sa.Column("name", sa.String(length=255), nullable=False),
29 + sa.Column("index_pattern", sa.String(length=1024), nullable=False),
30 + sa.Column("event_type", sa.String(length=50), nullable=False),
31 + sa.Column("time_field", sa.String(length=255), nullable=False),
32 + sa.Column("enabled", sa.Boolean(), nullable=False),
33 + sa.Column("created_at", sa.DateTime(), nullable=False),
34 + sa.Column("updated_at", sa.DateTime(), nullable=False),
35 + sa.ForeignKeyConstraint(
36 + ["customer_code"],
37 + ["customers.customer_code"],
38 + ),
39 + sa.PrimaryKeyConstraint("id"),
40 )
36 - op.create_index(op.f('ix_event_sources_customer_code'), 'event_sources', ['customer_code'], unique=False)
41 + op.create_index(op.f("ix_event_sources_customer_code"), "event_sources", ["customer_code"], unique=False)
42 # ### end Alembic commands ###
43
44
45 def downgrade() -> None:
46 # ### commands auto generated by Alembic - please adjust! ###
42 - op.drop_index(op.f('ix_event_sources_customer_code'), table_name='event_sources')
43 - op.drop_table('event_sources')
47 + op.drop_index(op.f("ix_event_sources_customer_code"), table_name="event_sources")
48 + op.drop_table("event_sources")
49 # ### end Alembic commands ###
backend/app/db/universal_models.py
+1
@@ -501,6 +501,7 @@ class SCAReport(SQLModel, table=True):
501 # Relationship to Customers table
502 customer: Optional["Customers"] = Relationship()
503
504 +
505 class EventSources(SQLModel, table=True):
506 __tablename__ = "event_sources"
507
backend/app/routers/siem.py new
+10
@@ -0,0 +1,10 @@
1 +from fastapi import APIRouter
2 +
3 +from app.siem.routes.event_sources import event_sources_router
4 +from app.siem.routes.events import siem_events_router
5 +
6 +# Instantiate the APIRouter
7 +router = APIRouter()
8 +
9 +router.include_router(event_sources_router, prefix="/siem/event_sources", tags=["SIEM - Event Sources"])
10 +router.include_router(siem_events_router, prefix="/siem/events", tags=["SIEM - Events"])
backend/app/siem/routes/event_sources.py new
+112
@@ -0,0 +1,112 @@
1 +from fastapi import APIRouter
2 +from fastapi import Depends
3 +from fastapi import HTTPException
4 +from fastapi import Security
5 +from loguru import logger
6 +from sqlalchemy.ext.asyncio import AsyncSession
7 +from sqlalchemy.future import select
8 +
9 +from app.auth.utils import AuthHandler
10 +from app.db.db_session import get_db
11 +from app.db.universal_models import Customers
12 +from app.siem.schema.event_sources import EventSourceCreate
13 +from app.siem.schema.event_sources import EventSourceDeleteResponse
14 +from app.siem.schema.event_sources import EventSourceOperationResponse
15 +from app.siem.schema.event_sources import EventSourceResponse
16 +from app.siem.schema.event_sources import EventSourcesListResponse
17 +from app.siem.schema.event_sources import EventSourceUpdate
18 +from app.siem.services.event_sources import create_event_source
19 +from app.siem.services.event_sources import delete_event_source
20 +from app.siem.services.event_sources import get_event_sources_by_customer
21 +from app.siem.services.event_sources import update_event_source
22 +
23 +event_sources_router = APIRouter()
24 +
25 +
26 +async def verify_customer_exists(customer_code: str, db: AsyncSession) -> None:
27 + result = await db.execute(
28 + select(Customers).filter(Customers.customer_code == customer_code),
29 + )
30 + if not result.scalars().first():
31 + raise HTTPException(
32 + status_code=404,
33 + detail=f"Customer with customer_code {customer_code} not found",
34 + )
35 +
36 +
37 +@event_sources_router.get(
38 + "/{customer_code}",
39 + response_model=EventSourcesListResponse,
40 + description="Get all event sources for a customer",
41 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
42 +)
43 +async def get_event_sources_endpoint(
44 + customer_code: str,
45 + db: AsyncSession = Depends(get_db),
46 +) -> EventSourcesListResponse:
47 + logger.info(f"Getting event sources for customer {customer_code}")
48 + await verify_customer_exists(customer_code, db)
49 + event_sources = await get_event_sources_by_customer(customer_code, db)
50 + return EventSourcesListResponse(
51 + event_sources=[EventSourceResponse.from_orm(es) for es in event_sources],
52 + success=True,
53 + message="Event sources retrieved successfully",
54 + )
55 +
56 +
57 +@event_sources_router.post(
58 + "",
59 + response_model=EventSourceOperationResponse,
60 + description="Create a new event source for a customer",
61 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
62 +)
63 +async def create_event_source_endpoint(
64 + event_source: EventSourceCreate,
65 + db: AsyncSession = Depends(get_db),
66 +) -> EventSourceOperationResponse:
67 + logger.info(f"Creating event source for customer {event_source.customer_code}")
68 + await verify_customer_exists(event_source.customer_code, db)
69 + created = await create_event_source(event_source, db)
70 + return EventSourceOperationResponse(
71 + event_source=EventSourceResponse.from_orm(created),
72 + success=True,
73 + message="Event source created successfully",
74 + )
75 +
76 +
77 +@event_sources_router.put(
78 + "/{event_source_id}",
79 + response_model=EventSourceOperationResponse,
80 + description="Update an existing event source",
81 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
82 +)
83 +async def update_event_source_endpoint(
84 + event_source_id: int,
85 + update_data: EventSourceUpdate,
86 + db: AsyncSession = Depends(get_db),
87 +) -> EventSourceOperationResponse:
88 + logger.info(f"Updating event source {event_source_id}")
89 + updated = await update_event_source(event_source_id, update_data, db)
90 + return EventSourceOperationResponse(
91 + event_source=EventSourceResponse.from_orm(updated),
92 + success=True,
93 + message="Event source updated successfully",
94 + )
95 +
96 +
97 +@event_sources_router.delete(
98 + "/{event_source_id}",
99 + response_model=EventSourceDeleteResponse,
100 + description="Delete an event source",
101 + dependencies=[Security(AuthHandler().require_any_scope("admin"))],
102 +)
103 +async def delete_event_source_endpoint(
104 + event_source_id: int,
105 + db: AsyncSession = Depends(get_db),
106 +) -> EventSourceDeleteResponse:
107 + logger.info(f"Deleting event source {event_source_id}")
108 + await delete_event_source(event_source_id, db)
109 + return EventSourceDeleteResponse(
110 + success=True,
111 + message="Event source deleted successfully",
112 + )
backend/app/siem/routes/events.py new
+58
@@ -0,0 +1,58 @@
1 +from typing import Optional
2 +
3 +from fastapi import APIRouter
4 +from fastapi import Depends
5 +from fastapi import Query
6 +from fastapi import Security
7 +from loguru import logger
8 +from sqlalchemy.ext.asyncio import AsyncSession
9 +
10 +from app.auth.utils import AuthHandler
11 +from app.db.db_session import get_db
12 +from app.siem.schema.events import EventsQueryParams
13 +from app.siem.schema.events import EventsQueryResponse
14 +from app.siem.schema.events import FieldMappingsResponse
15 +from app.siem.services.events import get_field_mappings
16 +from app.siem.services.events import query_events
17 +
18 +siem_events_router = APIRouter()
19 +
20 +
21 +@siem_events_router.get(
22 + "/{customer_code}/{source_name}",
23 + response_model=EventsQueryResponse,
24 + description="Query events from a customer's event source with scroll-based pagination and optional Lucene query",
25 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
26 +)
27 +async def query_events_endpoint(
28 + customer_code: str,
29 + source_name: str,
30 + timerange: str = Query("24h", description="Time range (e.g. '1h', '24h', '7d', '1w')"),
31 + page_size: int = Query(50, ge=1, le=1000, description="Number of results per page"),
32 + scroll_id: Optional[str] = Query(None, description="Scroll ID for fetching the next page"),
33 + query: Optional[str] = Query(None, description="Lucene query string (e.g. 'agent_name:piHole AND agent_id:088')"),
34 + db: AsyncSession = Depends(get_db),
35 +) -> EventsQueryResponse:
36 + logger.info(f"Querying events for customer {customer_code}, source {source_name}")
37 + params = EventsQueryParams(
38 + timerange=timerange,
39 + page_size=page_size,
40 + scroll_id=scroll_id,
41 + query=query,
42 + )
43 + return await query_events(customer_code, source_name, params, db)
44 +
45 +
46 +@siem_events_router.get(
47 + "/{customer_code}/{source_name}/fields",
48 + response_model=FieldMappingsResponse,
49 + description="Get index field name mappings for a customer's event source to assist with building queries",
50 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
51 +)
52 +async def get_field_mappings_endpoint(
53 + customer_code: str,
54 + source_name: str,
55 + db: AsyncSession = Depends(get_db),
56 +) -> FieldMappingsResponse:
57 + logger.info(f"Getting field mappings for customer {customer_code}, source {source_name}")
58 + return await get_field_mappings(customer_code, source_name, db)
backend/app/siem/schema/event_sources.py new
+63
@@ -0,0 +1,63 @@
1 +from datetime import datetime
2 +from enum import Enum
3 +from typing import List
4 +from typing import Optional
5 +
6 +from pydantic import BaseModel
7 +from pydantic import Field
8 +
9 +
10 +class EventType(str, Enum):
11 + EDR = "EDR"
12 + EPP = "EPP"
13 + CLOUD_INTEGRATION = "Cloud Integration"
14 + NETWORK_SECURITY = "Network Security"
15 +
16 +
17 +class EventSourceCreate(BaseModel):
18 + customer_code: str = Field(..., max_length=50)
19 + name: str = Field(..., max_length=255)
20 + index_pattern: str = Field(..., max_length=1024)
21 + event_type: EventType
22 + time_field: str = Field("timestamp", max_length=255)
23 + enabled: bool = True
24 +
25 +
26 +class EventSourceUpdate(BaseModel):
27 + name: Optional[str] = Field(None, max_length=255)
28 + index_pattern: Optional[str] = Field(None, max_length=1024)
29 + event_type: Optional[EventType] = None
30 + time_field: Optional[str] = Field(None, max_length=255)
31 + enabled: Optional[bool] = None
32 +
33 +
34 +class EventSourceResponse(BaseModel):
35 + id: int
36 + customer_code: str
37 + name: str
38 + index_pattern: str
39 + event_type: str
40 + time_field: str
41 + enabled: bool
42 + created_at: datetime
43 + updated_at: datetime
44 +
45 + class Config:
46 + orm_mode = True
47 +
48 +
49 +class EventSourcesListResponse(BaseModel):
50 + event_sources: List[EventSourceResponse]
51 + success: bool
52 + message: str
53 +
54 +
55 +class EventSourceOperationResponse(BaseModel):
56 + event_source: Optional[EventSourceResponse] = None
57 + success: bool
58 + message: str
59 +
60 +
61 +class EventSourceDeleteResponse(BaseModel):
62 + success: bool
63 + message: str
backend/app/siem/schema/events.py new
+36
@@ -0,0 +1,36 @@
1 +from typing import Any
2 +from typing import Dict
3 +from typing import List
4 +from typing import Optional
5 +
6 +from pydantic import BaseModel
7 +from pydantic import Field
8 +
9 +
10 +class EventsQueryParams(BaseModel):
11 + timerange: str = Field("24h", description="Time range (e.g. '1h', '24h', '7d', '1w')")
12 + page_size: int = Field(50, ge=1, le=1000, description="Number of results per page")
13 + scroll_id: Optional[str] = Field(None, description="Scroll ID for fetching the next page")
14 + query: Optional[str] = Field(None, description="Lucene query string (e.g. 'agent_name:piHole AND agent_id:088')")
15 +
16 +
17 +class EventsQueryResponse(BaseModel):
18 + events: List[Dict[str, Any]]
19 + total: int
20 + scroll_id: Optional[str] = None
21 + page_size: int
22 + success: bool
23 + message: str
24 +
25 +
26 +class FieldMapping(BaseModel):
27 + field: str = Field(..., description="Field name (e.g. 'agent_name', 'agent_id')")
28 + type: str = Field(..., description="OpenSearch field type (e.g. 'keyword', 'text', 'long', 'date')")
29 +
30 +
31 +class FieldMappingsResponse(BaseModel):
32 + fields: List[FieldMapping]
33 + total: int
34 + index_pattern: str
35 + success: bool
36 + message: str
backend/app/siem/services/event_sources.py new
+82
@@ -0,0 +1,82 @@
1 +from typing import List
2 +
3 +from fastapi import HTTPException
4 +from loguru import logger
5 +from sqlalchemy import select
6 +from sqlalchemy.ext.asyncio import AsyncSession
7 +
8 +from app.db.universal_models import EventSources
9 +from app.siem.schema.event_sources import EventSourceCreate
10 +from app.siem.schema.event_sources import EventSourceUpdate
11 +
12 +
13 +async def get_event_sources_by_customer(
14 + customer_code: str,
15 + db: AsyncSession,
16 +) -> List[EventSources]:
17 + logger.info(f"Fetching event sources for customer {customer_code}")
18 + result = await db.execute(
19 + select(EventSources).where(EventSources.customer_code == customer_code),
20 + )
21 + return result.scalars().all()
22 +
23 +
24 +async def get_event_source_by_id(
25 + event_source_id: int,
26 + db: AsyncSession,
27 +) -> EventSources:
28 + result = await db.execute(
29 + select(EventSources).where(EventSources.id == event_source_id),
30 + )
31 + event_source = result.scalars().first()
32 + if not event_source:
33 + raise HTTPException(status_code=404, detail="Event source not found")
34 + return event_source
35 +
36 +
37 +async def create_event_source(
38 + event_source_data: EventSourceCreate,
39 + db: AsyncSession,
40 +) -> EventSources:
41 + logger.info(f"Creating event source '{event_source_data.name}' for customer {event_source_data.customer_code}")
42 + # Check for duplicate name within the same customer
43 + result = await db.execute(
44 + select(EventSources).where(
45 + EventSources.customer_code == event_source_data.customer_code,
46 + EventSources.name == event_source_data.name,
47 + ),
48 + )
49 + if result.scalars().first():
50 + raise HTTPException(
51 + status_code=400,
52 + detail=f"Event source '{event_source_data.name}' already exists for customer {event_source_data.customer_code}",
53 + )
54 +
55 + db_event_source = EventSources(**event_source_data.dict())
56 + db.add(db_event_source)
57 + await db.flush()
58 + await db.refresh(db_event_source)
59 + await db.commit()
60 + return db_event_source
61 +
62 +
63 +async def update_event_source(
64 + event_source_id: int,
65 + update_data: EventSourceUpdate,
66 + db: AsyncSession,
67 +) -> EventSources:
68 + event_source = await get_event_source_by_id(event_source_id, db)
69 + event_source.update_from_model(update_data)
70 + await db.commit()
71 + await db.refresh(event_source)
72 + return event_source
73 +
74 +
75 +async def delete_event_source(
76 + event_source_id: int,
77 + db: AsyncSession,
78 +) -> None:
79 + event_source = await get_event_source_by_id(event_source_id, db)
80 + await db.delete(event_source)
81 + await db.commit()
82 + logger.info(f"Deleted event source {event_source_id}")
backend/app/siem/services/events.py new
+212
@@ -0,0 +1,212 @@
1 +from fastapi import HTTPException
2 +from loguru import logger
3 +from sqlalchemy import select
4 +from sqlalchemy.ext.asyncio import AsyncSession
5 +
6 +from app.connectors.wazuh_indexer.utils.universal import AlertsQueryBuilder
7 +from app.connectors.wazuh_indexer.utils.universal import (
8 + create_wazuh_indexer_client_async,
9 +)
10 +from app.db.universal_models import EventSources
11 +from app.siem.schema.events import EventsQueryParams
12 +from app.siem.schema.events import EventsQueryResponse
13 +from app.siem.schema.events import FieldMapping
14 +from app.siem.schema.events import FieldMappingsResponse
15 +
16 +
17 +async def get_event_source_by_customer_and_name(
18 + customer_code: str,
19 + source_name: str,
20 + db: AsyncSession,
21 +) -> EventSources:
22 + result = await db.execute(
23 + select(EventSources).where(
24 + EventSources.customer_code == customer_code,
25 + EventSources.name == source_name,
26 + ),
27 + )
28 + event_source = result.scalars().first()
29 + if not event_source:
30 + raise HTTPException(
31 + status_code=404,
32 + detail=f"Event source '{source_name}' not found for customer {customer_code}",
33 + )
34 + if not event_source.enabled:
35 + raise HTTPException(
36 + status_code=400,
37 + detail=f"Event source '{source_name}' is disabled",
38 + )
39 + return event_source
40 +
41 +
42 +async def query_events(
43 + customer_code: str,
44 + source_name: str,
45 + params: EventsQueryParams,
46 + db: AsyncSession,
47 +) -> EventsQueryResponse:
48 + logger.info(f"Querying events for customer {customer_code}, source {source_name}")
49 +
50 + # If a scroll_id is provided, continue scrolling
51 + if params.scroll_id:
52 + return await _scroll_next_page(params.scroll_id)
53 +
54 + # Look up event source to get index_pattern and time_field
55 + event_source = await get_event_source_by_customer_and_name(customer_code, source_name, db)
56 +
57 + return await _initial_search(
58 + index_pattern=event_source.index_pattern,
59 + time_field=event_source.time_field,
60 + timerange=params.timerange,
61 + page_size=params.page_size,
62 + query=params.query,
63 + )
64 +
65 +
66 +async def _initial_search(
67 + index_pattern: str,
68 + time_field: str,
69 + timerange: str,
70 + page_size: int,
71 + query: str = None,
72 +) -> EventsQueryResponse:
73 + es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
74 + try:
75 + query_builder = AlertsQueryBuilder()
76 + query_builder.add_time_range(timerange=timerange, timestamp_field=time_field)
77 + query_builder.add_sort(time_field, order="desc")
78 +
79 + # Add Lucene query_string if provided
80 + if query:
81 + query_builder.query["query"]["bool"]["must"].append(
82 + {"query_string": {"query": query, "default_operator": "AND"}},
83 + )
84 +
85 + query = query_builder.build()
86 +
87 + response = await es_client.search(
88 + index=index_pattern,
89 + body=query,
90 + size=page_size,
91 + scroll="5m",
92 + )
93 +
94 + hits = response["hits"]["hits"]
95 + total = response["hits"]["total"]["value"] if isinstance(response["hits"]["total"], dict) else response["hits"]["total"]
96 + scroll_id = response.get("_scroll_id")
97 +
98 + # If all results fit in one page, clear the scroll context
99 + if len(hits) >= total:
100 + if scroll_id:
101 + await _clear_scroll(es_client, scroll_id)
102 + scroll_id = None
103 +
104 + return EventsQueryResponse(
105 + events=[hit["_source"] for hit in hits],
106 + total=total,
107 + scroll_id=scroll_id,
108 + page_size=page_size,
109 + success=True,
110 + message=f"Retrieved {len(hits)} of {total} events",
111 + )
112 + except Exception as e:
113 + logger.error(f"Error querying events: {e}")
114 + raise HTTPException(status_code=500, detail=f"Error querying events: {e}")
115 + finally:
116 + await es_client.close()
117 +
118 +
119 +async def _scroll_next_page(scroll_id: str) -> EventsQueryResponse:
120 + es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
121 + try:
122 + response = await es_client.scroll(scroll_id=scroll_id, scroll="5m")
123 + hits = response["hits"]["hits"]
124 + total = response["hits"]["total"]["value"] if isinstance(response["hits"]["total"], dict) else response["hits"]["total"]
125 + new_scroll_id = response.get("_scroll_id")
126 +
127 + # If no more results, clear the scroll context
128 + if not hits:
129 + if new_scroll_id:
130 + await _clear_scroll(es_client, new_scroll_id)
131 + return EventsQueryResponse(
132 + events=[],
133 + total=total,
134 + scroll_id=None,
135 + page_size=0,
136 + success=True,
137 + message="No more results",
138 + )
139 +
140 + return EventsQueryResponse(
141 + events=[hit["_source"] for hit in hits],
142 + total=total,
143 + scroll_id=new_scroll_id,
144 + page_size=len(hits),
145 + success=True,
146 + message=f"Retrieved {len(hits)} of {total} events",
147 + )
148 + except Exception as e:
149 + logger.error(f"Error scrolling events: {e}")
150 + raise HTTPException(status_code=500, detail=f"Error scrolling events: {e}")
151 + finally:
152 + await es_client.close()
153 +
154 +
155 +async def _clear_scroll(es_client, scroll_id: str) -> None:
156 + try:
157 + await es_client.clear_scroll(scroll_id=scroll_id)
158 + except Exception as e:
159 + logger.warning(f"Failed to clear scroll context: {e}")
160 +
161 +
162 +async def get_field_mappings(
163 + customer_code: str,
164 + source_name: str,
165 + db: AsyncSession,
166 +) -> FieldMappingsResponse:
167 + """Retrieve index field name mappings for a customer's event source."""
168 + event_source = await get_event_source_by_customer_and_name(customer_code, source_name, db)
169 + es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
170 + try:
171 + mapping_response = await es_client.indices.get_mapping(index=event_source.index_pattern)
172 +
173 + # Flatten nested mappings into dot-notation field list
174 + fields = []
175 + for index_name in mapping_response:
176 + properties = mapping_response[index_name].get("mappings", {}).get("properties", {})
177 + _flatten_properties(properties, "", fields)
178 + break # All indices matching pattern share the same mapping
179 +
180 + # Deduplicate and sort
181 + seen = set()
182 + unique_fields = []
183 + for f in fields:
184 + if f.field not in seen:
185 + seen.add(f.field)
186 + unique_fields.append(f)
187 + unique_fields.sort(key=lambda x: x.field)
188 +
189 + return FieldMappingsResponse(
190 + fields=unique_fields,
191 + total=len(unique_fields),
192 + index_pattern=event_source.index_pattern,
193 + success=True,
194 + message=f"Retrieved {len(unique_fields)} field mappings",
195 + )
196 + except Exception as e:
197 + logger.error(f"Error retrieving field mappings: {e}")
198 + raise HTTPException(status_code=500, detail=f"Error retrieving field mappings: {e}")
199 + finally:
200 + await es_client.close()
201 +
202 +
203 +def _flatten_properties(properties: dict, prefix: str, fields: list) -> None:
204 + """Recursively flatten OpenSearch mapping properties into FieldMapping objects."""
205 + for field_name, field_info in properties.items():
206 + full_name = f"{prefix}{field_name}" if not prefix else f"{prefix}_{field_name}"
207 + field_type = field_info.get("type")
208 + if field_type:
209 + fields.append(FieldMapping(field=full_name, type=field_type))
210 + # Recurse into nested properties
211 + if "properties" in field_info:
212 + _flatten_properties(field_info["properties"], full_name, fields)
backend/app/utils.py
+2
@@ -63,6 +63,7 @@ class ErrorType(str, Enum):
63 NONE_NOT_ALLOWED = "value_error.none.not_allowed"
64 MISSING = "value_error.missing"
65 GENERAL = "value_error"
66 + INVALID_ENUM = "type_error.enum"
67 # Add other types as needed
68
69
@@ -94,6 +95,7 @@ class ValidationErrorItem(BaseModel):
95 ErrorType.NONE_NOT_ALLOWED: "None is not an allowed value.",
96 ErrorType.MISSING: "Missing data for required field.",
97 ErrorType.GENERAL: "Invalid value.",
98 + ErrorType.INVALID_ENUM: "Value is not a valid enumeration member.",
99 }
100
101 return error_messages.get(error_type, value)
backend/app/version/services/version.py
+1 -1
@@ -7,7 +7,7 @@ from loguru import logger
7 from packaging.version import Version
8
9 # Current version - update this with each release
10 -CURRENT_VERSION = "0.1.49"
10 +CURRENT_VERSION = "0.1.50"
11 VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
backend/copilot.py
+2
@@ -74,6 +74,7 @@ from app.routers import sap_siem
74 from app.routers import scheduler
75 from app.routers import scoutsuite
76 from app.routers import shuffle
77 +from app.routers import siem
78 from app.routers import smtp
79 from app.routers import stack_provisioning
80 from app.routers import sublime
@@ -180,6 +181,7 @@ api_router.include_router(portainer.router)
181 api_router.include_router(incidents.router)
182 api_router.include_router(darktrace.router)
183 api_router.include_router(defenderforendpoint.router)
184 +api_router.include_router(siem.router)
185
186 # Include the APIRouter in the FastAPI app
187 app.include_router(api_router)
docs/assets/ui/siem-event-search-detail.png
Binary files /dev/null and b/docs/assets/ui/siem-event-search-detail.png differ
docs/assets/ui/siem-event-search.png
Binary files /dev/null and b/docs/assets/ui/siem-event-search.png differ
docs/assets/ui/siem-event-sources-create.png
Binary files /dev/null and b/docs/assets/ui/siem-event-sources-create.png differ
docs/docs.json
+13
@@ -45,6 +45,13 @@
45 "user/ui/artifacts"
46 ]
47 },
48 + {
49 + "group": "SIEM",
50 + "pages": [
51 + "user/ui/siem-event-search",
52 + "user/ui/alerts-siem"
53 + ]
54 + },
55 {
56 "group": "Agents",
57 "pages": [
@@ -115,6 +122,12 @@
122 "user/ui/indices-snapshots"
123 ]
124 },
125 + {
126 + "group": "SIEM Configuration",
127 + "pages": [
128 + "user/ui/siem-event-sources"
129 + ]
130 + },
131 {
132 "group": "Access Control",
133 "pages": [
docs/user/ui/siem-event-search.md new
+114
@@ -0,0 +1,114 @@
1 +---
2 +title: Event Search
3 +description: Search and explore raw SIEM events across customers and event sources using Lucene queries.
4 +---
5 +
6 +# Event Search
7 +
8 +**Menu:** SIEM → Event Search
9 +
10 +**Best for:** Operators + Analysts
11 +
12 +Event Search lets you query **raw SIEM events** directly from the Wazuh Indexer across any configured [Event Source](/user/ui/siem-event-sources). Use it for investigation, threat hunting, and validating detection coverage.
13 +
14 +![Event Search page](../../assets/ui/siem-event-search.png)
15 +
16 +---
17 +
18 +## Prerequisites
19 +
20 +Before using Event Search, a customer must have at least one **Event Source** configured. If no Event Sources exist for the selected customer, a warning banner will appear with instructions.
21 +
22 +See: [Event Sources](/user/ui/siem-event-sources) for setup instructions.
23 +
24 +---
25 +
26 +## Step 1 — Select a customer and event source
27 +
28 +Use the filter bar at the top to select:
29 +
30 +1. **Customer** — the tenant whose data you want to query
31 +2. **Event Source** — the specific data source (e.g. Wazuh EDR, Office 365 Logs)
32 +3. **Time Range** — how far back to search (1 hour to 30 days)
33 +4. **Page Size** — number of results per page (25–250)
34 +
35 +---
36 +
37 +## Step 2 — Write a Lucene query (optional)
38 +
39 +The search bar supports full **Lucene query syntax**. If left empty, all events in the time range are returned.
40 +
41 +**Example queries:**
42 +
43 +| Query | What it finds |
44 +|---|---|
45 +| `agent_name:web-server-01` | Events from a specific agent |
46 +| `rule_level:>=10` | High-severity alerts (level 10+) |
47 +| `agent_name:dc01 AND rule_level:>=8` | Combined filters |
48 +| `rule_description:"brute force"` | Phrase match in rule description |
49 +| `NOT agent_name:test-*` | Exclude test agents |
50 +
51 +### Field name autocomplete
52 +
53 +As you type a field name, an autocomplete dropdown appears showing matching field names from the selected index. Press **Tab** to accept a suggestion.
54 +
55 +---
56 +
57 +## Step 3 — Review results
58 +
59 +Results appear in a sortable table with these columns:
60 +
61 +| Column | Description |
62 +|---|---|
63 +| **Timestamp** | When the event occurred |
64 +| **Source** | The agent or source that generated the event |
65 +| **Rule** | The rule description or ID that triggered |
66 +| **Level** | Severity level (color-coded: red ≥12, orange ≥8, blue ≥4) |
67 +| **Summary** | The full log message or event data |
68 +
69 +Click any row to open the **Event Detail** drawer.
70 +
71 +### Loading more results
72 +
73 +If more events exist beyond the current page, a **Load More** button appears below the table. Click it to fetch the next batch of results.
74 +
75 +---
76 +
77 +## Step 4 — Inspect event details
78 +
79 +Clicking a row opens a side drawer showing **every field** in the event, sorted alphabetically.
80 +
81 +![Event detail drawer](../../assets/ui/siem-event-search-detail.png)
82 +
83 +### Filter from the detail drawer
84 +
85 +Hover over any field to reveal two action buttons:
86 +
87 +- **Filter (+)** — adds `field:"value"` to your query and re-runs the search
88 +- **Exclude (−)** — adds `NOT field:"value"` and re-runs the search
89 +
90 +This lets you quickly drill down or exclude noise without manually typing queries.
91 +
92 +---
93 +
94 +## Deep-linking from Incident Management
95 +
96 +When viewing an alert asset in **Incident Management → Alerts**, the `alert_linked` field includes a **"View in Event Search"** link. Clicking it opens Event Search in a new tab with the customer, default EDR source, and Lucene query pre-populated to find the specific alert.
97 +
98 +---
99 +
100 +## Tips
101 +
102 +- **Broad first, then narrow:** Start with a wide time range and no query, then use the detail drawer's filter buttons to progressively refine.
103 +- **Use wildcards sparingly:** Lucene supports `*` and `?` wildcards in values, but leading wildcards (e.g. `*server`) are expensive — avoid them on large indexes.
104 +- **Check the time range:** If you're not finding expected events, try expanding the time range — the default is 24 hours.
105 +- **Bookmark queries:** The URL contains query parameters (`customer_code`, `source_name`, `query`), so you can bookmark or share a specific search.
106 +
107 +---
108 +
109 +## Related pages
110 +
111 +- [Event Sources](/user/ui/siem-event-sources) — configure which indexes to search per customer
112 +- [SIEM Alerts](/user/ui/alerts-siem) — high-level alert summaries from Graylog
113 +- [MITRE ATT&CK](/user/ui/alerts-mitre) — technique-centric alert view
114 +- [Incident Alerts](/user/ui/incident-alerts) — the analyst investigation queue
docs/user/ui/siem-event-sources.md new
+69
@@ -0,0 +1,69 @@
1 +---
2 +title: Event Sources
3 +description: Configure the SIEM event sources that define where Event Search queries data from for each customer.
4 +---
5 +
6 +# Event Sources
7 +
8 +**Menu:** Customers → (select customer) → Event Sources tab
9 +
10 +**Best for:** Admin / Engineer
11 +
12 +Event Sources tell CoPilot which **index patterns** to query for each customer when using [Event Search](/user/ui/siem-event-search). You must configure at least one Event Source per customer before Event Search will work for that customer.
13 +
14 +---
15 +
16 +## What is an Event Source?
17 +
18 +An Event Source defines:
19 +
20 +| Field | Description |
21 +|---|---|
22 +| **Name** | A human-readable label (e.g. "Wazuh EDR", "Office 365 Logs") |
23 +| **Index Pattern** | The Wazuh Indexer index pattern to query (e.g. `wazuh-CUSTOMER_CODE_*`) |
24 +| **Event Type** | Category — one of: EDR, EPP, Cloud Integration, Network Security |
25 +| **Time Field** | The field used for time-based filtering (typically `timestamp`) |
26 +| **Enabled** | Whether this source is available for selection in Event Search |
27 +
28 +---
29 +
30 +## Step 1 — Navigate to the customer's Event Sources
31 +
32 +1. Go to **Customers** in the sidebar
33 +2. Select the customer you want to configure
34 +3. Click the **Event Sources** tab
35 +
36 +---
37 +
38 +## Step 2 — Create a new Event Source
39 +
40 +Click the **+ Add** button and fill in the form:
41 +
42 +![Create Event Source form](../../assets/ui/siem-event-sources-create.png)
43 +
44 +**Tips:**
45 +- The **Index Pattern** field auto-suggests patterns based on the customer code (e.g. `wazuh-lab_*`)
46 +- For Wazuh/EDR data, use `timestamp` as the Time Field
47 +- Set **Event Type** to match the data type so analysts can filter sources by category
48 +
49 +---
50 +
51 +## Step 3 — Edit or delete an Event Source
52 +
53 +Each Event Source card shows its configuration at a glance. Use the action buttons to:
54 +- **Edit** — update the index pattern, time field, or toggle enabled/disabled
55 +- **Delete** — permanently remove the source (admin only)
56 +
57 +---
58 +
59 +## Gotchas
60 +
61 +- A customer **must have at least one enabled Event Source** before Event Search will work for them. If none exist, a warning banner will appear on the Event Search page.
62 +- Disabling an Event Source hides it from the Event Search dropdown but does not delete it.
63 +- The index pattern must match actual indices in your Wazuh Indexer — double-check the pattern if searches return zero results.
64 +
65 +---
66 +
67 +## Next step
68 +
69 +Once an Event Source is configured, head to [Event Search](/user/ui/siem-event-search) to start querying events.
frontend/src/api/endpoints/siem.ts new
+60
@@ -0,0 +1,60 @@
1 +import type { FlaskBaseResponse } from "@/types/flask.d"
2 +import type { EventSource } from "@/types/eventSources.d"
3 +import type { EventSearchResult, FieldMapping } from "@/types/events.d"
4 +import { HttpClient } from "../httpClient"
5 +
6 +export interface EventSourceCreatePayload {
7 + customer_code: string
8 + name: string
9 + index_pattern: string
10 + event_type: string
11 + time_field: string
12 + enabled: boolean
13 +}
14 +
15 +export interface EventSourceUpdatePayload {
16 + name?: string
17 + index_pattern?: string
18 + event_type?: string
19 + time_field?: string
20 + enabled?: boolean
21 +}
22 +
23 +export default {
24 + getEventSources(customerCode: string) {
25 + return HttpClient.get<FlaskBaseResponse & { event_sources: EventSource[] }>(
26 + `/siem/event_sources/${customerCode}`
27 + )
28 + },
29 + createEventSource(payload: EventSourceCreatePayload) {
30 + return HttpClient.post<FlaskBaseResponse & { event_source: EventSource }>(`/siem/event_sources`, payload)
31 + },
32 + updateEventSource(eventSourceId: number, payload: EventSourceUpdatePayload) {
33 + return HttpClient.put<FlaskBaseResponse & { event_source: EventSource }>(
34 + `/siem/event_sources/${eventSourceId}`,
35 + payload
36 + )
37 + },
38 + deleteEventSource(eventSourceId: number) {
39 + return HttpClient.delete<FlaskBaseResponse>(`/siem/event_sources/${eventSourceId}`)
40 + },
41 + queryEvents(
42 + customerCode: string,
43 + sourceName: string,
44 + params: { timerange?: string; page_size?: number; scroll_id?: string; query?: string }
45 + ) {
46 + return HttpClient.get<
47 + FlaskBaseResponse & {
48 + events: EventSearchResult[]
49 + total: number
50 + scroll_id: string | null
51 + page_size: number
52 + }
53 + >(`/siem/events/${customerCode}/${sourceName}`, { params })
54 + },
55 + getFieldMappings(customerCode: string, sourceName: string) {
56 + return HttpClient.get<FlaskBaseResponse & { fields: FieldMapping[]; total: number; index_pattern: string }>(
57 + `/siem/events/${customerCode}/${sourceName}/fields`
58 + )
59 + }
60 +}
frontend/src/api/index.ts
+2
@@ -27,6 +27,7 @@ import portainer from "./endpoints/portainer"
27 import reporting from "./endpoints/reporting"
28 import sca from "./endpoints/sca"
29 import scheduler from "./endpoints/scheduler"
30 +import siem from "./endpoints/siem"
31 import shuffle from "./endpoints/shuffle"
32 import sigma from "./endpoints/sigma"
33 import snapshots from "./endpoints/snapshots"
@@ -77,6 +78,7 @@ export default {
78 sysmonConfig,
79 vulnerabilities,
80 sca,
81 + siem,
82 wazuh,
83 patchTuesday,
84 portainer,
frontend/src/app-layouts/common/Navbar/items.tsx
+17 -3
@@ -20,6 +20,7 @@ const ReportCreationIcon = "carbon:report-data"
20 const SchedulerIcon = "material-symbols:autoplay"
21 const CustomerPortalIcon = "streamline-ultimate:coding-apps-website-apps-browser"
22 const ToolsIcon = "carbon:tool-box"
23 +const EventSearchIcon = "carbon:search-locate"
24
25 // TODO-FE: refactor
26 export default function getItems(): MenuMixedOption[] {
@@ -100,8 +101,8 @@ export default function getItems(): MenuMixedOption[] {
101 ]
102 },
103 {
103 - label: "Alerts",
104 - key: "Alerts",
104 + label: "SIEM",
105 + key: "SIEM",
106 icon: renderIcon(AlertsIcon),
107 children: [
108 {
@@ -113,10 +114,23 @@ export default function getItems(): MenuMixedOption[] {
114 name: "Alerts-SIEM"
115 }
116 },
116 - { default: () => "SIEM" }
117 + { default: () => "Alerts" }
118 ),
119 key: "Alerts-SIEM"
120 },
121 + {
122 + label: () =>
123 + h(
124 + RouterLink,
125 + {
126 + to: {
127 + name: "EventSearch"
128 + }
129 + },
130 + { default: () => "Event Search" }
131 + ),
132 + key: "EventSearch"
133 + },
134 {
135 label: () =>
136 h(
frontend/src/components/customers/CustomerItem.vue
+4
@@ -185,6 +185,9 @@
185 >
186 <CustomerNotificationsWorkflows :customer-code="customer.customer_code" />
187 </n-tab-pane>
188 + <n-tab-pane name="Event Sources" tab="Event Sources" display-directive="show:lazy">
189 + <CustomerEventSources :customer-code="customer.customer_code" />
190 + </n-tab-pane>
191
192 <template #suffix>
193 <div
@@ -297,6 +300,7 @@ const CustomerNetworkConnectors = defineAsyncComponent(
300 const CustomerNotificationsWorkflows = defineAsyncComponent(
301 () => import("./notifications/CustomerNotificationsWorkflows.vue")
302 )
303 +const CustomerEventSources = defineAsyncComponent(() => import("./eventSources/CustomerEventSources.vue"))
304 const CustomerWazuhWorker = defineAsyncComponent(() => import("./CustomerWazuhWorker.vue"))
305
306 const { customer, highlight, hideCardActions } = toRefs(props)
frontend/src/components/customers/eventSources/CustomerEventSourceForm.vue new
+138
@@ -0,0 +1,138 @@
1 +<template>
2 + <div class="flex min-h-120 flex-col gap-4 overflow-hidden">
3 + <div class="flex flex-col gap-4 px-7 pt-4">
4 + <div class="text-sm font-semibold">{{ isEditing ? "Edit Event Source" : "New Event Source" }}</div>
5 +
6 + <n-form-item label="Name" required>
7 + <n-input v-model:value="form.name" placeholder="e.g. Wazuh Alerts" clearable />
8 + </n-form-item>
9 +
10 + <n-form-item label="Index Pattern" required>
11 + <n-input
12 + v-model:value="form.index_pattern"
13 + :placeholder="`e.g. wazuh-${props.customerCode}_*`"
14 + clearable
15 + />
16 + </n-form-item>
17 +
18 + <n-form-item label="Event Type" required>
19 + <n-select v-model:value="form.event_type" :options="eventTypeOptions" placeholder="Select type" />
20 + </n-form-item>
21 +
22 + <n-form-item label="Time Field">
23 + <n-input v-model:value="form.time_field" placeholder="e.g. timestamp" clearable />
24 + </n-form-item>
25 +
26 + <n-form-item label="Enabled">
27 + <n-switch v-model:value="form.enabled" />
28 + </n-form-item>
29 + </div>
30 +
31 + <div class="flex justify-between gap-4 px-7 pb-4">
32 + <n-button @click="close()">Close</n-button>
33 + <n-button type="primary" :disabled="!isValid" :loading @click="submit()">
34 + {{ isEditing ? "Update" : "Create" }}
35 + </n-button>
36 + </div>
37 + </div>
38 +</template>
39 +
40 +<script setup lang="ts">
41 +import type { EventSource } from "@/types/eventSources.d"
42 +import { NButton, NFormItem, NInput, NSelect, NSwitch, useMessage } from "naive-ui"
43 +import { computed, reactive, ref } from "vue"
44 +import Api from "@/api"
45 +
46 +const props = defineProps<{
47 + customerCode: string
48 + editingSource?: EventSource | null
49 +}>()
50 +
51 +const emit = defineEmits<{
52 + (e: "close"): void
53 + (e: "submitted"): void
54 +}>()
55 +
56 +const message = useMessage()
57 +const loading = ref(false)
58 +
59 +const isEditing = computed(() => !!props.editingSource)
60 +
61 +const eventTypeOptions = [
62 + { label: "EDR", value: "EDR" },
63 + { label: "EPP", value: "EPP" },
64 + { label: "Cloud Integration", value: "Cloud Integration" },
65 + { label: "Network Security", value: "Network Security" }
66 +]
67 +
68 +const form = reactive({
69 + name: props.editingSource?.name || "",
70 + index_pattern: props.editingSource?.index_pattern || "",
71 + event_type: props.editingSource?.event_type || (null as string | null),
72 + time_field: props.editingSource?.time_field || "timestamp",
73 + enabled: props.editingSource?.enabled ?? true
74 +})
75 +
76 +const isValid = computed(() => {
77 + return !!form.name && !!form.index_pattern && !!form.event_type
78 +})
79 +
80 +function submit() {
81 + if (!isValid.value) return
82 +
83 + loading.value = true
84 +
85 + if (isEditing.value && props.editingSource) {
86 + Api.siem
87 + .updateEventSource(props.editingSource.id, {
88 + name: form.name,
89 + index_pattern: form.index_pattern,
90 + event_type: form.event_type!,
91 + time_field: form.time_field,
92 + enabled: form.enabled
93 + })
94 + .then(res => {
95 + if (res.data.success) {
96 + emit("submitted")
97 + message.success(res.data?.message || "Event source updated successfully.")
98 + } else {
99 + message.warning(res.data?.message || "An error occurred. Please try again later.")
100 + }
101 + })
102 + .catch(err => {
103 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
104 + })
105 + .finally(() => {
106 + loading.value = false
107 + })
108 + } else {
109 + Api.siem
110 + .createEventSource({
111 + customer_code: props.customerCode,
112 + name: form.name,
113 + index_pattern: form.index_pattern,
114 + event_type: form.event_type!,
115 + time_field: form.time_field,
116 + enabled: form.enabled
117 + })
118 + .then(res => {
119 + if (res.data.success) {
120 + emit("submitted")
121 + message.success(res.data?.message || "Event source created successfully.")
122 + } else {
123 + message.warning(res.data?.message || "An error occurred. Please try again later.")
124 + }
125 + })
126 + .catch(err => {
127 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
128 + })
129 + .finally(() => {
130 + loading.value = false
131 + })
132 + }
133 +}
134 +
135 +function close() {
136 + emit("close")
137 +}
138 +</script>
frontend/src/components/customers/eventSources/CustomerEventSourceItem.vue new
+125
@@ -0,0 +1,125 @@
1 +<template>
2 + <div>
3 + <CardEntity hoverable :embedded>
4 + <template #default>
5 + <div class="flex items-center gap-3">
6 + <Icon :name="SourceIcon" :size="18" />
7 + <span class="font-semibold">{{ source.name }}</span>
8 + </div>
9 + </template>
10 +
11 + <template #footerMain>
12 + <div class="flex flex-wrap items-center gap-3">
13 + <Badge type="splitted" color="primary">
14 + <template #iconLeft>
15 + <Icon :name="TypeIcon" :size="13" />
16 + </template>
17 + <template #label>Type</template>
18 + <template #value>{{ source.event_type }}</template>
19 + </Badge>
20 +
21 + <Badge type="splitted" color="primary">
22 + <template #iconLeft>
23 + <Icon :name="IndexIcon" :size="13" />
24 + </template>
25 + <template #label>Index</template>
26 + <template #value>{{ source.index_pattern }}</template>
27 + </Badge>
28 +
29 + <Badge type="splitted" color="primary">
30 + <template #iconLeft>
31 + <Icon :name="TimeIcon" :size="13" />
32 + </template>
33 + <template #label>Time Field</template>
34 + <template #value>{{ source.time_field }}</template>
35 + </Badge>
36 +
37 + <Badge type="splitted" :color="source.enabled ? 'success' : 'danger'" bright>
38 + <template #iconLeft>
39 + <Icon :name="StatusIcon" :size="13" />
40 + </template>
41 + <template #value>{{ source.enabled ? "Enabled" : "Disabled" }}</template>
42 + </Badge>
43 + </div>
44 + </template>
45 +
46 + <template #footerExtra>
47 + <div class="flex flex-wrap gap-3">
48 + <n-button size="small" @click.stop="emit('edit')">
49 + <template #icon>
50 + <Icon :name="EditIcon" />
51 + </template>
52 + Edit
53 + </n-button>
54 + <n-button size="small" type="error" ghost :loading="loadingDelete" @click.stop="handleDelete">
55 + <template #icon>
56 + <Icon :name="DeleteIcon" :size="15" />
57 + </template>
58 + Delete
59 + </n-button>
60 + </div>
61 + </template>
62 + </CardEntity>
63 + </div>
64 +</template>
65 +
66 +<script setup lang="ts">
67 +import type { EventSource } from "@/types/eventSources.d"
68 +import { NButton, useDialog, useMessage } from "naive-ui"
69 +import { ref } from "vue"
70 +import Api from "@/api"
71 +import Badge from "@/components/common/Badge.vue"
72 +import CardEntity from "@/components/common/cards/CardEntity.vue"
73 +import Icon from "@/components/common/Icon.vue"
74 +
75 +const { source, embedded } = defineProps<{
76 + source: EventSource
77 + embedded?: boolean
78 +}>()
79 +
80 +const emit = defineEmits<{
81 + (e: "edit"): void
82 + (e: "deleted"): void
83 +}>()
84 +
85 +const SourceIcon = "carbon:data-base"
86 +const TypeIcon = "carbon:category"
87 +const IndexIcon = "carbon:catalog"
88 +const TimeIcon = "carbon:time"
89 +const StatusIcon = "carbon:circle-dash"
90 +const EditIcon = "carbon:edit"
91 +const DeleteIcon = "ph:trash"
92 +
93 +const dialog = useDialog()
94 +const message = useMessage()
95 +const loadingDelete = ref(false)
96 +
97 +function handleDelete() {
98 + dialog.warning({
99 + title: "Delete Event Source",
100 + content: `Are you sure you want to delete the event source "${source.name}"?`,
101 + positiveText: "Delete",
102 + negativeText: "Cancel",
103 + onPositiveClick: () => {
104 + loadingDelete.value = true
105 +
106 + Api.siem
107 + .deleteEventSource(source.id)
108 + .then(res => {
109 + if (res.data.success) {
110 + emit("deleted")
111 + message.success(res.data?.message || "Event source deleted successfully.")
112 + } else {
113 + message.warning(res.data?.message || "An error occurred. Please try again later.")
114 + }
115 + })
116 + .catch(err => {
117 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
118 + })
119 + .finally(() => {
120 + loadingDelete.value = false
121 + })
122 + }
123 + })
124 +}
125 +</script>
frontend/src/components/customers/eventSources/CustomerEventSources.vue new
+128
@@ -0,0 +1,128 @@
1 +<template>
2 + <div class="customer-event-sources">
3 + <transition name="form-fade" mode="out-in">
4 + <div v-if="showForm">
5 + <CustomerEventSourceForm
6 + :customer-code
7 + :editing-source="editingSource"
8 + @submitted="refreshList()"
9 + @close="closeForm()"
10 + />
11 + </div>
12 + <div v-else>
13 + <div class="flex items-center justify-between gap-4 px-7 pt-2">
14 + <n-button size="small" type="primary" @click="openForm()">
15 + <template #icon>
16 + <Icon :name="AddIcon" :size="14" />
17 + </template>
18 + Add Event Source
19 + </n-button>
20 + </div>
21 +
22 + <n-spin :show="loading">
23 + <div class="min-h-52 p-7 pt-4">
24 + <template v-if="list.length">
25 + <CustomerEventSourceItem
26 + v-for="source of list"
27 + :key="source.id"
28 + :source
29 + embedded
30 + class="item-appear item-appear-bottom item-appear-005 mb-2"
31 + @edit="openEdit(source)"
32 + @deleted="refreshList()"
33 + />
34 + </template>
35 + <template v-else>
36 + <n-empty v-if="!loading" description="No event sources found" class="h-48 justify-center" />
37 + </template>
38 + </div>
39 + </n-spin>
40 + </div>
41 + </transition>
42 + </div>
43 +</template>
44 +
45 +<script setup lang="ts">
46 +import type { EventSource } from "@/types/eventSources.d"
47 +import { NButton, NEmpty, NSpin, useMessage } from "naive-ui"
48 +import { onBeforeMount, ref } from "vue"
49 +import Api from "@/api"
50 +import Icon from "@/components/common/Icon.vue"
51 +import CustomerEventSourceForm from "./CustomerEventSourceForm.vue"
52 +import CustomerEventSourceItem from "./CustomerEventSourceItem.vue"
53 +
54 +const { customerCode } = defineProps<{
55 + customerCode: string
56 +}>()
57 +
58 +const AddIcon = "carbon:add-alt"
59 +
60 +const message = useMessage()
61 +const showForm = ref(false)
62 +const loading = ref(false)
63 +const list = ref<EventSource[]>([])
64 +const editingSource = ref<EventSource | null>(null)
65 +
66 +function getEventSources() {
67 + loading.value = true
68 +
69 + Api.siem
70 + .getEventSources(customerCode)
71 + .then(res => {
72 + if (res.data.success) {
73 + list.value = res.data?.event_sources || []
74 + } else {
75 + message.warning(res.data?.message || "An error occurred. Please try again later.")
76 + }
77 + })
78 + .catch(err => {
79 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
80 + })
81 + .finally(() => {
82 + loading.value = false
83 + })
84 +}
85 +
86 +function openForm() {
87 + editingSource.value = null
88 + showForm.value = true
89 +}
90 +
91 +function openEdit(source: EventSource) {
92 + editingSource.value = source
93 + showForm.value = true
94 +}
95 +
96 +function closeForm() {
97 + editingSource.value = null
98 + showForm.value = false
99 +}
100 +
101 +function refreshList() {
102 + closeForm()
103 + getEventSources()
104 +}
105 +
106 +onBeforeMount(() => {
107 + getEventSources()
108 +})
109 +</script>
110 +
111 +<style lang="scss" scoped>
112 +.customer-event-sources {
113 + .form-fade-enter-active,
114 + .form-fade-leave-active {
115 + transition:
116 + opacity 0.2s ease-in-out,
117 + transform 0.3s ease-in-out;
118 + }
119 + .form-fade-enter-from {
120 + opacity: 0;
121 + transform: translateY(10px);
122 + }
123 + .form-fade-leave-to {
124 + opacity: 0;
125 + transform: translateY(-10px);
126 + }
127 +}
128 +</style>
frontend/src/components/events/EventDetailDrawer.vue new
+86
@@ -0,0 +1,86 @@
1 +<template>
2 + <n-drawer v-model:show="show" display-directive="show" :trap-focus="false" style="max-width: 90vw; width: 600px">
3 + <n-drawer-content title="Event Details" closable :native-scrollbar="false">
4 + <template v-if="event">
5 + <div class="flex flex-col gap-1">
6 + <div
7 + v-for="[key, value] in sortedFields"
8 + :key="key"
9 + class="field-row flex items-start gap-2 rounded px-2 py-1.5 hover:bg-[var(--hover-005-color)]"
10 + >
11 + <span class="min-w-36 shrink-0 font-mono text-xs font-semibold opacity-70">{{ key }}</span>
12 + <span class="min-w-0 flex-1 text-sm break-all">{{ formatValue(value) }}</span>
13 + <div class="actions-container flex shrink-0 gap-1">
14 + <n-button
15 + size="tiny"
16 + quaternary
17 + title="Filter for this value"
18 + @click="$emit('filter-add', key, String(value))"
19 + >
20 + <template #icon>
21 + <Icon :name="FilterAddIcon" :size="14" />
22 + </template>
23 + </n-button>
24 + <n-button
25 + size="tiny"
26 + quaternary
27 + title="Exclude this value"
28 + @click="$emit('filter-exclude', key, String(value))"
29 + >
30 + <template #icon>
31 + <Icon :name="FilterRemoveIcon" :size="14" />
32 + </template>
33 + </n-button>
34 + </div>
35 + </div>
36 + </div>
37 + </template>
38 + <n-empty v-else description="No event selected" class="h-48 justify-center" />
39 + </n-drawer-content>
40 + </n-drawer>
41 +</template>
42 +
43 +<script setup lang="ts">
44 +import type { EventSearchResult } from "@/types/events.d"
45 +import { NButton, NDrawer, NDrawerContent, NEmpty } from "naive-ui"
46 +import { computed } from "vue"
47 +import Icon from "@/components/common/Icon.vue"
48 +
49 +const FilterAddIcon = "carbon:filter"
50 +const FilterRemoveIcon = "carbon:filter-remove"
51 +
52 +const show = defineModel<boolean>("show", { default: false })
53 +
54 +const props = defineProps<{
55 + event: EventSearchResult | null
56 +}>()
57 +
58 +defineEmits<{
59 + "filter-add": [field: string, value: string]
60 + "filter-exclude": [field: string, value: string]
61 +}>()
62 +
63 +const sortedFields = computed(() => {
64 + if (!props.event) return []
65 + return Object.entries(props.event)
66 + .filter(([key]) => !key.startsWith("_"))
67 + .sort(([a], [b]) => a.localeCompare(b))
68 +})
69 +
70 +function formatValue(value: unknown): string {
71 + if (value === null || value === undefined) return "-"
72 + if (typeof value === "object") return JSON.stringify(value)
73 + return String(value)
74 +}
75 +</script>
76 +
77 +<style scoped>
78 +.actions-container {
79 + opacity: 0;
80 + transition: opacity 0.15s;
81 +}
82 +
83 +.field-row:hover .actions-container {
84 + opacity: 1;
85 +}
86 +</style>
frontend/src/components/events/EventSearch.vue new
+550
@@ -0,0 +1,550 @@
1 +<template>
2 + <div class="flex flex-col gap-4">
3 + <!-- No Event Sources Warning -->
4 + <n-alert v-if="showNoSourcesWarning" title="No Event Sources Configured" type="warning" closable>
5 + An Event Source needs to be defined for this customer before events can be searched. Go to the customer's
6 + <strong>Event Sources</strong>
7 + tab to configure one.
8 + </n-alert>
9 +
10 + <!-- Filters Bar -->
11 + <n-card size="small">
12 + <div class="flex flex-col gap-3">
13 + <div class="flex flex-wrap items-end gap-3">
14 + <div class="flex flex-col gap-1">
15 + <span class="text-xs opacity-60">Customer</span>
16 + <n-select
17 + v-model:value="selectedCustomerCode"
18 + :options="customersOptions"
19 + placeholder="Select Customer"
20 + filterable
21 + :loading="loadingCustomers"
22 + style="width: 260px"
23 + @update:value="onCustomerChange"
24 + />
25 + </div>
26 + <div class="flex flex-col gap-1">
27 + <span class="text-xs opacity-60">Event Source</span>
28 + <n-select
29 + v-model:value="selectedSourceName"
30 + :options="eventSourceOptions"
31 + placeholder="Select Source"
32 + filterable
33 + :loading="loadingEventSources"
34 + :disabled="!selectedCustomerCode"
35 + style="width: 220px"
36 + @update:value="onSourceChange"
37 + />
38 + </div>
39 + <div class="flex flex-col gap-1">
40 + <span class="text-xs opacity-60">Time Range</span>
41 + <n-select v-model:value="timerange" :options="timerangeOptions" style="width: 140px" />
42 + </div>
43 + <div class="flex flex-col gap-1">
44 + <span class="text-xs opacity-60">Page Size</span>
45 + <n-select v-model:value="pageSize" :options="pageSizeOptions" style="width: 110px" />
46 + </div>
47 + <n-button
48 + type="primary"
49 + :disabled="!selectedCustomerCode || !selectedSourceName"
50 + :loading="loadingEvents"
51 + @click="searchEvents()"
52 + >
53 + <template #icon>
54 + <Icon :name="SearchIcon" :size="16" />
55 + </template>
56 + Search
57 + </n-button>
58 + </div>
59 +
60 + <!-- Query Bar with Autocomplete -->
61 + <div class="relative">
62 + <n-input
63 + ref="queryInputRef"
64 + v-model:value="query"
65 + placeholder="Lucene query (e.g. agent_name:server01 AND rule_level:>=10)"
66 + clearable
67 + @keydown.enter="searchEvents()"
68 + @keydown.tab.prevent="acceptSuggestion"
69 + @keydown.escape="showSuggestions = false"
70 + @input="onQueryInput"
71 + >
72 + <template #prefix>
73 + <Icon :name="CodeIcon" :size="16" class="opacity-50" />
74 + </template>
75 + </n-input>
76 + <!-- Autocomplete dropdown -->
77 + <div
78 + v-if="showSuggestions && filteredSuggestions.length"
79 + class="suggestions-dropdown bg-default absolute top-full right-0 left-0 z-50 mt-1 max-h-48 overflow-y-auto rounded-lg border shadow-lg"
80 + >
81 + <div
82 + v-for="(suggestion, index) in filteredSuggestions"
83 + :key="suggestion.field"
84 + class="suggestion-item flex cursor-pointer items-center justify-between px-3 py-1.5 text-sm hover:bg-[var(--hover-005-color)]"
85 + :class="{ 'bg-[var(--hover-005-color)]': index === activeSuggestionIndex }"
86 + @mousedown.prevent="applySuggestion(suggestion.field)"
87 + >
88 + <span class="font-mono">{{ suggestion.field }}</span>
89 + <span class="text-xs opacity-50">{{ suggestion.type }}</span>
90 + </div>
91 + </div>
92 + </div>
93 + </div>
94 + </n-card>
95 +
96 + <!-- Results -->
97 + <n-spin :show="loadingEvents">
98 + <n-card v-if="events.length || loadingEvents" size="small">
99 + <div class="mb-2 flex items-center justify-between">
100 + <span class="text-sm opacity-60">
101 + {{ totalEvents }} event{{ totalEvents !== 1 ? "s" : "" }} found
102 + </span>
103 + </div>
104 + <n-data-table
105 + :columns="columns"
106 + :data="events"
107 + :bordered="false"
108 + :single-line="false"
109 + size="small"
110 + :row-key="(row: EventSearchResult) => row._id || JSON.stringify(row)"
111 + :row-props="rowProps"
112 + max-height="calc(100vh - 360px)"
113 + virtual-scroll
114 + />
115 + <div v-if="scrollId && events.length < totalEvents" class="mt-3 flex justify-center">
116 + <n-button :loading="loadingMore" @click="loadMoreEvents">Load More</n-button>
117 + </div>
118 + </n-card>
119 + <n-empty
120 + v-else-if="!loadingEvents && hasSearched"
121 + description="No events found"
122 + class="h-48 justify-center"
123 + />
124 + </n-spin>
125 +
126 + <!-- Event Detail Drawer -->
127 + <EventDetailDrawer
128 + v-model:show="showDetailDrawer"
129 + :event="selectedEvent"
130 + @filter-add="addFilterFromDetail"
131 + @filter-exclude="excludeFilterFromDetail"
132 + />
133 + </div>
134 +</template>
135 +
136 +<script setup lang="ts">
137 +import type { DataTableColumns } from "naive-ui"
138 +import type { EventSearchResult, FieldMapping } from "@/types/events.d"
139 +import type { EventSource } from "@/types/eventSources.d"
140 +import type { Customer } from "@/types/customers.d"
141 +import { NAlert, NButton, NCard, NDataTable, NEmpty, NInput, NSelect, NSpin, useMessage } from "naive-ui"
142 +import { computed, h, nextTick, onBeforeMount, ref } from "vue"
143 +import { useRoute } from "vue-router"
144 +import Api from "@/api"
145 +import Icon from "@/components/common/Icon.vue"
146 +import EventDetailDrawer from "./EventDetailDrawer.vue"
147 +
148 +const route = useRoute()
149 +
150 +const SearchIcon = "carbon:search"
151 +const CodeIcon = "carbon:code"
152 +
153 +const message = useMessage()
154 +
155 +// -- Customer selection --
156 +const loadingCustomers = ref(false)
157 +const customersList = ref<Customer[]>([])
158 +const selectedCustomerCode = ref<string | null>(null)
159 +
160 +const customersOptions = computed(() =>
161 + customersList.value.map(o => ({ label: `#${o.customer_code} - ${o.customer_name}`, value: o.customer_code }))
162 +)
163 +
164 +function getCustomers() {
165 + loadingCustomers.value = true
166 + return Api.customers
167 + .getCustomers()
168 + .then(res => {
169 + if (res.data.success) {
170 + customersList.value = res.data?.customers || []
171 + } else {
172 + message.warning(res.data?.message || "An error occurred. Please try again later.")
173 + }
174 + })
175 + .catch(err => {
176 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
177 + })
178 + .finally(() => {
179 + loadingCustomers.value = false
180 + })
181 +}
182 +
183 +// -- Event Source selection --
184 +const loadingEventSources = ref(false)
185 +const eventSourcesList = ref<EventSource[]>([])
186 +const selectedSourceName = ref<string | null>(null)
187 +
188 +const eventSourceOptions = computed(() =>
189 + eventSourcesList.value.filter(s => s.enabled).map(s => ({ label: `${s.name} (${s.event_type})`, value: s.name }))
190 +)
191 +
192 +const showNoSourcesWarning = computed(
193 + () => selectedCustomerCode.value && !loadingEventSources.value && eventSourcesList.value.length === 0
194 +)
195 +
196 +function getEventSources(customerCode: string) {
197 + loadingEventSources.value = true
198 + eventSourcesList.value = []
199 + selectedSourceName.value = null
200 + fieldMappings.value = []
201 +
202 + Api.siem
203 + .getEventSources(customerCode)
204 + .then(res => {
205 + if (res.data.success) {
206 + eventSourcesList.value = res.data?.event_sources || []
207 + } else {
208 + message.warning(res.data?.message || "An error occurred. Please try again later.")
209 + }
210 + })
211 + .catch(err => {
212 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
213 + })
214 + .finally(() => {
215 + loadingEventSources.value = false
216 + })
217 +}
218 +
219 +function onCustomerChange(code: string) {
220 + resetResults()
221 + if (code) {
222 + getEventSources(code)
223 + }
224 +}
225 +
226 +function onSourceChange() {
227 + resetResults()
228 + if (selectedCustomerCode.value && selectedSourceName.value) {
229 + loadFieldMappings()
230 + }
231 +}
232 +
233 +// -- Search parameters --
234 +const timerange = ref("24h")
235 +const timerangeOptions = [
236 + { label: "1 hour", value: "1h" },
237 + { label: "6 hours", value: "6h" },
238 + { label: "24 hours", value: "24h" },
239 + { label: "3 days", value: "3d" },
240 + { label: "7 days", value: "7d" },
241 + { label: "14 days", value: "14d" },
242 + { label: "30 days", value: "30d" }
243 +]
244 +
245 +const pageSize = ref(50)
246 +const pageSizeOptions = [
247 + { label: "25", value: 25 },
248 + { label: "50", value: 50 },
249 + { label: "100", value: 100 },
250 + { label: "250", value: 250 }
251 +]
252 +
253 +const query = ref("")
254 +
255 +// -- Field mappings / autocomplete --
256 +const fieldMappings = ref<FieldMapping[]>([])
257 +const showSuggestions = ref(false)
258 +const activeSuggestionIndex = ref(0)
259 +
260 +function loadFieldMappings() {
261 + if (!selectedCustomerCode.value || !selectedSourceName.value) return
262 +
263 + Api.siem
264 + .getFieldMappings(selectedCustomerCode.value, selectedSourceName.value)
265 + .then(res => {
266 + if (res.data.success) {
267 + fieldMappings.value = res.data.fields || []
268 + }
269 + })
270 + .catch(() => {
271 + // Silent fail - autocomplete is optional
272 + })
273 +}
274 +
275 +const currentFieldToken = computed(() => {
276 + if (!query.value) return ""
277 + const cursorPos = query.value.length
278 + const before = query.value.substring(0, cursorPos)
279 + // Match the last word being typed (field name token before a colon or standalone)
280 + const match = before.match(/(?:^|[\s(])([a-zA-Z_][\w.]*)$/)
281 + return match ? match[1] : ""
282 +})
283 +
284 +const filteredSuggestions = computed(() => {
285 + const token = currentFieldToken.value.toLowerCase()
286 + if (!token || token.length < 2) return []
287 + return fieldMappings.value.filter(f => f.field.toLowerCase().includes(token)).slice(0, 20)
288 +})
289 +
290 +function onQueryInput() {
291 + showSuggestions.value = currentFieldToken.value.length >= 2 && filteredSuggestions.value.length > 0
292 + activeSuggestionIndex.value = 0
293 +}
294 +
295 +function applySuggestion(fieldName: string) {
296 + const token = currentFieldToken.value
297 + if (token) {
298 + const lastIndex = query.value.lastIndexOf(token)
299 + query.value = query.value.substring(0, lastIndex) + fieldName + ":"
300 + }
301 + showSuggestions.value = false
302 +}
303 +
304 +function acceptSuggestion() {
305 + if (showSuggestions.value && filteredSuggestions.value.length > 0) {
306 + applySuggestion(filteredSuggestions.value[activeSuggestionIndex.value].field)
307 + }
308 +}
309 +
310 +// -- Events data --
311 +const events = ref<EventSearchResult[]>([])
312 +const totalEvents = ref(0)
313 +const scrollId = ref<string | null>(null)
314 +const loadingEvents = ref(false)
315 +const loadingMore = ref(false)
316 +const hasSearched = ref(false)
317 +
318 +function resetResults() {
319 + events.value = []
320 + totalEvents.value = 0
321 + scrollId.value = null
322 + hasSearched.value = false
323 +}
324 +
325 +function searchEvents() {
326 + if (!selectedCustomerCode.value || !selectedSourceName.value) return
327 +
328 + loadingEvents.value = true
329 + hasSearched.value = true
330 + resetResults()
331 +
332 + Api.siem
333 + .queryEvents(selectedCustomerCode.value, selectedSourceName.value, {
334 + timerange: timerange.value,
335 + page_size: pageSize.value,
336 + query: query.value || undefined
337 + })
338 + .then(res => {
339 + if (res.data.success) {
340 + events.value = res.data.events || []
341 + totalEvents.value = res.data.total
342 + scrollId.value = res.data.scroll_id
343 + } else {
344 + message.warning(res.data?.message || "An error occurred. Please try again later.")
345 + }
346 + })
347 + .catch(err => {
348 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
349 + })
350 + .finally(() => {
351 + loadingEvents.value = false
352 + })
353 +}
354 +
355 +function loadMoreEvents() {
356 + if (!selectedCustomerCode.value || !selectedSourceName.value || !scrollId.value) return
357 +
358 + loadingMore.value = true
359 +
360 + Api.siem
361 + .queryEvents(selectedCustomerCode.value, selectedSourceName.value, {
362 + scroll_id: scrollId.value
363 + })
364 + .then(res => {
365 + if (res.data.success) {
366 + events.value.push(...(res.data.events || []))
367 + scrollId.value = res.data.scroll_id
368 + } else {
369 + message.warning(res.data?.message || "An error occurred. Please try again later.")
370 + }
371 + })
372 + .catch(err => {
373 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
374 + })
375 + .finally(() => {
376 + loadingMore.value = false
377 + })
378 +}
379 +
380 +// -- Table columns --
381 +const columns = computed<DataTableColumns<EventSearchResult>>(() => {
382 + const baseColumns: DataTableColumns<EventSearchResult> = [
383 + {
384 + title: "Timestamp",
385 + key: "timestamp",
386 + width: 180,
387 + sorter: (a, b) => {
388 + const timeA = a.timestamp || a["@timestamp"] || ""
389 + const timeB = b.timestamp || b["@timestamp"] || ""
390 + return new Date(timeA).getTime() - new Date(timeB).getTime()
391 + },
392 + render(row) {
393 + const ts = row.timestamp || row["@timestamp"]
394 + if (!ts) return "-"
395 + return new Date(ts).toLocaleString()
396 + }
397 + },
398 + {
399 + title: "Source",
400 + key: "agent_name",
401 + width: 140,
402 + ellipsis: { tooltip: true },
403 + render(row) {
404 + return row.agent_name || row.source || "-"
405 + }
406 + },
407 + {
408 + title: "Rule",
409 + key: "rule_description",
410 + ellipsis: { tooltip: true },
411 + render(row) {
412 + return row.rule_description || row.rule_id || "-"
413 + }
414 + },
415 + {
416 + title: "Level",
417 + key: "rule_level",
418 + width: 80,
419 + sorter: (a, b) => (Number(a.rule_level) || 0) - (Number(b.rule_level) || 0),
420 + render(row) {
421 + if (row.rule_level === undefined || row.rule_level === null) return "-"
422 + const level = Number(row.rule_level)
423 + let type: "default" | "warning" | "error" | "success" | "info" = "default"
424 + if (level >= 12) type = "error"
425 + else if (level >= 8) type = "warning"
426 + else if (level >= 4) type = "info"
427 + return h("span", { class: `level-${type}` }, String(row.rule_level))
428 + }
429 + },
430 + {
431 + title: "Summary",
432 + key: "full_log",
433 + ellipsis: { tooltip: true },
434 + render(row) {
435 + return row.full_log || row.data || row.message || "-"
436 + }
437 + }
438 + ]
439 +
440 + return baseColumns
441 +})
442 +
443 +// -- Event detail --
444 +const showDetailDrawer = ref(false)
445 +const selectedEvent = ref<EventSearchResult | null>(null)
446 +
447 +function rowProps(row: EventSearchResult) {
448 + return {
449 + style: "cursor: pointer",
450 + onClick: () => {
451 + selectedEvent.value = row
452 + showDetailDrawer.value = true
453 + }
454 + }
455 +}
456 +
457 +function addFilterFromDetail(field: string, value: string) {
458 + const filterExpr = `${field}:"${value}"`
459 + if (query.value) {
460 + query.value += ` AND ${filterExpr}`
461 + } else {
462 + query.value = filterExpr
463 + }
464 + showDetailDrawer.value = false
465 + searchEvents()
466 +}
467 +
468 +function excludeFilterFromDetail(field: string, value: string) {
469 + const filterExpr = `NOT ${field}:"${value}"`
470 + if (query.value) {
471 + query.value += ` AND ${filterExpr}`
472 + } else {
473 + query.value = filterExpr
474 + }
475 + showDetailDrawer.value = false
476 + searchEvents()
477 +}
478 +
479 +// -- Lifecycle --
480 +function applyRouteParams() {
481 + const qp = route.query
482 + if (qp.customer_code) {
483 + const code = String(qp.customer_code)
484 + selectedCustomerCode.value = code
485 +
486 + if (qp.query) {
487 + query.value = String(qp.query)
488 + }
489 +
490 + // Load event sources then auto-select source_name if provided
491 + loadingEventSources.value = true
492 + Api.siem
493 + .getEventSources(code)
494 + .then(res => {
495 + if (res.data.success) {
496 + eventSourcesList.value = res.data?.event_sources || []
497 +
498 + const targetSource = qp.source_name ? String(qp.source_name) : null
499 + if (targetSource) {
500 + // Try exact match first
501 + const match = eventSourcesList.value.find(s => s.name === targetSource && s.enabled)
502 + if (match) {
503 + selectedSourceName.value = match.name
504 + }
505 + } else {
506 + // Default to first EDR source if no source_name specified
507 + const edr = eventSourcesList.value.find(s => s.event_type === "EDR" && s.enabled)
508 + if (edr) {
509 + selectedSourceName.value = edr.name
510 + }
511 + }
512 +
513 + if (selectedSourceName.value) {
514 + loadFieldMappings()
515 + nextTick(() => searchEvents())
516 + }
517 + }
518 + })
519 + .finally(() => {
520 + loadingEventSources.value = false
521 + })
522 + }
523 +}
524 +
525 +onBeforeMount(() => {
526 + getCustomers().then(() => {
527 + applyRouteParams()
528 + })
529 +})
530 +</script>
531 +
532 +<style scoped>
533 +.suggestions-dropdown {
534 + border-color: var(--border-color);
535 +}
536 +
537 +.level-error {
538 + color: var(--error-color, #e88080);
539 + font-weight: 600;
540 +}
541 +
542 +.level-warning {
543 + color: var(--warning-color, #f0a020);
544 + font-weight: 600;
545 +}
546 +
547 +.level-info {
548 + color: var(--info-color, #70c0e8);
549 +}
550 +</style>
frontend/src/components/incidentManagement/alerts/AlertAssetInfo.vue
+18 -1
@@ -33,6 +33,15 @@
33 <Icon :name="ViewIcon" :size="14" class="relative top-0.5" />
34 </code>
35 </div>
36 + <div v-else-if="key === 'alert_linked'">
37 + <div class="flex items-center gap-2">
38 + <span>{{ value }}</span>
39 + <code class="text-primary cursor-pointer" @click.stop="openEventSearch()">
40 + View in Event Search
41 + <Icon :name="LinkIcon" :size="14" class="relative top-0.5" />
42 + </code>
43 + </div>
44 + </div>
45 <div v-else>
46 {{ value === "" ? "-" : (value ?? "-") }}
47 </div>
@@ -102,7 +111,7 @@ const { asset } = toRefs(props)
111
112 const LinkIcon = "carbon:launch"
113 const ViewIcon = "iconoir:eye-solid"
105 -const { routeAgent, routeIndex, routeCustomer } = useNavigation()
114 +const { routeAgent, routeIndex, routeCustomer, routeEventSearch } = useNavigation()
115 const message = useMessage()
116 const loading = ref(false)
117 const showAlertDetails = ref(false)
@@ -169,4 +178,12 @@ function openAlertDetails() {
178 function closeAlertDetails() {
179 showAlertDetails.value = false
180 }
181 +
182 +function openEventSearch() {
183 + const url = routeEventSearch({
184 + customer_code: asset.value.customer_code,
185 + query: `alert_id:"${asset.value.alert_linked}"`
186 + }).fullUrl()
187 + window.open(url, "_blank")
188 +}
189 </script>
frontend/src/composables/useNavigation.ts
+10 -1
@@ -89,6 +89,14 @@ export function useNavigation() {
89 return routerConstructor({ name: "IncidentManagement-Cases", query: caseId ? { case_id: caseId } : {} })
90 }
91
92 + function routeEventSearch(params?: { customer_code?: string; source_name?: string; query?: string }) {
93 + const routeQuery: Record<string, string> = {}
94 + if (params?.customer_code) routeQuery.customer_code = params.customer_code
95 + if (params?.source_name) routeQuery.source_name = params.source_name
96 + if (params?.query) routeQuery.query = params.query
97 + return routerConstructor({ name: "EventSearch", query: routeQuery })
98 + }
99 +
100 return {
101 routeCustomer,
102 routeAgent,
@@ -104,6 +112,7 @@ export function useNavigation() {
112 routeAlerts,
113 routeConnectors,
114 routeIncidentManagementAlerts,
107 - routeIncidentManagementCases
115 + routeIncidentManagementCases,
116 + routeEventSearch
117 }
118 }
frontend/src/router/index.ts
+6
@@ -181,6 +181,12 @@ const router = createRouter({
181 }
182 ]
183 },
184 + {
185 + path: "/event-search",
186 + name: "EventSearch",
187 + component: () => import("@/views/EventSearch.vue"),
188 + meta: { title: "Event Search", auth: true, roles: RouteRole.All }
189 + },
190 {
191 path: "/artifacts",
192 name: "Artifacts",
frontend/src/types/eventSources.d.ts new
+13
@@ -0,0 +1,13 @@
1 +export type EventType = "EDR" | "EPP" | "Cloud Integration" | "Network Security"
2 +
3 +export interface EventSource {
4 + id: number
5 + customer_code: string
6 + name: string
7 + index_pattern: string
8 + event_type: EventType
9 + time_field: string
10 + enabled: boolean
11 + created_at: string
12 + updated_at: string
13 +}
frontend/src/types/events.d.ts new
+8
@@ -0,0 +1,8 @@
1 +export interface EventSearchResult {
2 + [key: string]: any
3 +}
4 +
5 +export interface FieldMapping {
6 + field: string
7 + type: string
8 +}
frontend/src/views/EventSearch.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <EventSearch />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import EventSearch from "@/components/events/EventSearch.vue"
9 +</script>
mkdocs.yml
+2
@@ -94,8 +94,10 @@ nav:
94 - Alerts:
95 - Alerts: user/ui/alerts.md
96 - SIEM: user/ui/alerts-siem.md
97 + - Event Search: user/ui/siem-event-search.md
98 - MITRE ATT&CK: user/ui/alerts-mitre.md
99 - Atomic Red Team: user/ui/alerts-atomic-red-team.md
100 + - Event Sources (Admin): user/ui/siem-event-sources.md
101 - Artifacts: user/ui/artifacts.md
102 - Customers: user/ui/customers.md
103 - Agents: