767 event search cp (#768)
* Enhance event query endpoints to include customer user access and handle access denial * Add Event Search page and integrate with routing; enhance event source API * precommit-fixes
taylor_socfortress committed
Mar 16, 2026 at 11:44 UTC
06831a18e646b8223e2b20d123a1b26f534df207
9 files changed
+833
-3
backend/app/siem/routes/event_sources.py
+6
-1
@@ -6,9 +6,11 @@ from loguru import logger
6
from sqlalchemy.ext.asyncio import AsyncSession
7
from sqlalchemy.future import select
8
9
+from app.auth.models.users import User
10
from app.auth.utils import AuthHandler
11
from app.db.db_session import get_db
12
from app.db.universal_models import Customers
13
+from app.middleware.customer_access import customer_access_handler
14
from app.siem.schema.event_sources import EventSourceCreate
15
from app.siem.schema.event_sources import EventSourceDeleteResponse
16
from app.siem.schema.event_sources import EventSourceOperationResponse
@@ -38,13 +40,16 @@ async def verify_customer_exists(customer_code: str, db: AsyncSession) -> None:
40
"/{customer_code}",
41
response_model=EventSourcesListResponse,
42
description="Get all event sources for a customer",
41
- dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
43
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
44
)
45
async def get_event_sources_endpoint(
46
customer_code: str,
47
+ current_user: User = Depends(AuthHandler().get_current_user),
48
db: AsyncSession = Depends(get_db),
49
) -> EventSourcesListResponse:
50
logger.info(f"Getting event sources for customer {customer_code}")
51
+ if not await customer_access_handler.check_customer_access(current_user, customer_code, db):
52
+ raise HTTPException(status_code=403, detail=f"Access denied to customer {customer_code}")
53
await verify_customer_exists(customer_code, db)
54
event_sources = await get_event_sources_by_customer(customer_code, db)
55
return EventSourcesListResponse(
backend/app/siem/routes/events.py
+15
-2
@@ -2,13 +2,16 @@ from typing import Optional
2
3
from fastapi import APIRouter
4
from fastapi import Depends
5
+from fastapi import HTTPException
6
from fastapi import Query
7
from fastapi import Security
8
from loguru import logger
9
from sqlalchemy.ext.asyncio import AsyncSession
10
11
+from app.auth.models.users import User
12
from app.auth.utils import AuthHandler
13
from app.db.db_session import get_db
14
+from app.middleware.customer_access import customer_access_handler
15
from app.siem.schema.events import EventsQueryParams
16
from app.siem.schema.events import EventsQueryResponse
17
from app.siem.schema.events import FieldMappingsResponse
@@ -22,7 +25,7 @@ siem_events_router = APIRouter()
25
"/{customer_code}/{source_name}",
26
response_model=EventsQueryResponse,
27
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"))],
28
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
29
)
30
async def query_events_endpoint(
31
customer_code: str,
@@ -31,9 +34,14 @@ async def query_events_endpoint(
34
page_size: int = Query(50, ge=1, le=1000, description="Number of results per page"),
35
scroll_id: Optional[str] = Query(None, description="Scroll ID for fetching the next page"),
36
query: Optional[str] = Query(None, description="Lucene query string (e.g. 'agent_name:piHole AND agent_id:088')"),
37
+ current_user: User = Depends(AuthHandler().get_current_user),
38
db: AsyncSession = Depends(get_db),
39
) -> EventsQueryResponse:
40
logger.info(f"Querying events for customer {customer_code}, source {source_name}")
41
+
42
+ if not await customer_access_handler.check_customer_access(current_user, customer_code, db):
43
+ raise HTTPException(status_code=403, detail=f"Access denied to customer {customer_code}")
44
+
45
params = EventsQueryParams(
46
timerange=timerange,
47
page_size=page_size,
@@ -47,12 +55,17 @@ async def query_events_endpoint(
55
"/{customer_code}/{source_name}/fields",
56
response_model=FieldMappingsResponse,
57
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"))],
58
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst", "customer_user"))],
59
)
60
async def get_field_mappings_endpoint(
61
customer_code: str,
62
source_name: str,
63
+ current_user: User = Depends(AuthHandler().get_current_user),
64
db: AsyncSession = Depends(get_db),
65
) -> FieldMappingsResponse:
66
logger.info(f"Getting field mappings for customer {customer_code}, source {source_name}")
67
+
68
+ if not await customer_access_handler.check_customer_access(current_user, customer_code, db):
69
+ raise HTTPException(status_code=403, detail=f"Access denied to customer {customer_code}")
70
+
71
return await get_field_mappings(customer_code, source_name, db)
customer_portal/src/api/siem.ts
new
+62
@@ -0,0 +1,62 @@
1
+import { httpClient } from "@/utils/httpClient"
2
+
3
+export interface EventSource {
4
+ id: number
5
+ customer_code: string
6
+ name: string
7
+ index_pattern: string
8
+ event_type: string
9
+ time_field: string
10
+ enabled: boolean
11
+ created_at: string
12
+ updated_at: string
13
+}
14
+
15
+export interface EventSearchResult {
16
+ [key: string]: any
17
+}
18
+
19
+export interface FieldMapping {
20
+ field: string
21
+ type: string
22
+}
23
+
24
+export class SiemAPI {
25
+ static async getCustomerCodes(): Promise<{ success: boolean; customer_codes: string[] }> {
26
+ return (await httpClient.get("/auth/me/customers")).data
27
+ }
28
+
29
+ static async getEventSources(
30
+ customerCode: string
31
+ ): Promise<{ success: boolean; message: string; event_sources: EventSource[] }> {
32
+ return (await httpClient.get(`/siem/event_sources/${customerCode}`)).data
33
+ }
34
+
35
+ static async queryEvents(
36
+ customerCode: string,
37
+ sourceName: string,
38
+ params: { timerange?: string; page_size?: number; scroll_id?: string; query?: string }
39
+ ): Promise<{
40
+ success: boolean
41
+ message: string
42
+ events: EventSearchResult[]
43
+ total: number
44
+ scroll_id: string | null
45
+ page_size: number
46
+ }> {
47
+ return (await httpClient.get(`/siem/events/${customerCode}/${sourceName}`, { params })).data
48
+ }
49
+
50
+ static async getFieldMappings(
51
+ customerCode: string,
52
+ sourceName: string
53
+ ): Promise<{
54
+ success: boolean
55
+ message: string
56
+ fields: FieldMapping[]
57
+ total: number
58
+ index_pattern: string
59
+ }> {
60
+ return (await httpClient.get(`/siem/events/${customerCode}/${sourceName}/fields`)).data
61
+ }
62
+}
customer_portal/src/router/index.ts
+7
@@ -5,6 +5,7 @@ import AlertsPage from "@/views/AlertsPage.vue"
5
import CasesPage from "@/views/CasesPage.vue"
6
import CaseDetailsView from "@/views/CaseDetailsView.vue"
7
import AgentsPage from "@/views/AgentsPage.vue"
8
+import EventSearchPage from "@/views/EventSearchPage.vue"
9
10
const NotFound = {
11
template: `
@@ -61,6 +62,12 @@ const routes = [
62
component: AgentsPage,
63
meta: { requiresAuth: true }
64
},
65
+ {
66
+ path: "/event-search",
67
+ name: "EventSearch",
68
+ component: EventSearchPage,
69
+ meta: { requiresAuth: true }
70
+ },
71
{
72
path: "/:pathMatch(.*)*",
73
name: "NotFound",
customer_portal/src/views/AgentsPage.vue
+6
@@ -40,6 +40,12 @@
40
>
41
Agents
42
</router-link>
43
+ <router-link
44
+ to="/event-search"
45
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
46
+ >
47
+ Event Search
48
+ </router-link>
49
</nav>
50
</div>
51
<div class="flex items-center space-x-4">
customer_portal/src/views/AlertsPage.vue
+6
@@ -40,6 +40,12 @@
40
>
41
Agents
42
</router-link>
43
+ <router-link
44
+ to="/event-search"
45
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
46
+ >
47
+ Event Search
48
+ </router-link>
49
</nav>
50
</div>
51
<div class="flex items-center space-x-4">
customer_portal/src/views/CasesPage.vue
+6
@@ -40,6 +40,12 @@
40
>
41
Agents
42
</router-link>
43
+ <router-link
44
+ to="/event-search"
45
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
46
+ >
47
+ Event Search
48
+ </router-link>
49
</nav>
50
</div>
51
<div class="flex items-center space-x-4">
customer_portal/src/views/EventSearchPage.vue
new
+719
@@ -0,0 +1,719 @@
1
+<template>
2
+ <div class="min-h-screen bg-gray-50">
3
+ <!-- Header -->
4
+ <header class="border-b bg-white shadow-sm">
5
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
6
+ <div class="flex h-16 justify-between">
7
+ <div class="flex items-center">
8
+ <div class="mr-3 min-h-8">
9
+ <img
10
+ v-if="portalLogo && showLogo"
11
+ class="h-8 w-auto"
12
+ :src="portalLogo"
13
+ :alt="portalTitle"
14
+ @error="showLogo = false"
15
+ />
16
+ </div>
17
+ <h1 class="text-xl font-semibold text-gray-900">{{ portalTitle }}</h1>
18
+ <nav class="ml-8 flex space-x-8">
19
+ <router-link
20
+ to="/"
21
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
22
+ >
23
+ Overview
24
+ </router-link>
25
+ <router-link
26
+ to="/alerts"
27
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
28
+ >
29
+ Alerts
30
+ </router-link>
31
+ <router-link
32
+ to="/cases"
33
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
34
+ >
35
+ Cases
36
+ </router-link>
37
+ <router-link
38
+ to="/agents"
39
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
40
+ >
41
+ Agents
42
+ </router-link>
43
+ <router-link
44
+ to="/event-search"
45
+ class="rounded-md border-b-2 border-indigo-600 px-3 py-2 text-sm font-medium text-indigo-600"
46
+ >
47
+ Event Search
48
+ </router-link>
49
+ </nav>
50
+ </div>
51
+ <div class="flex items-center space-x-4">
52
+ <div class="text-sm text-gray-700">
53
+ Welcome,
54
+ <span class="font-medium">{{ username }}</span>
55
+ </div>
56
+ <button
57
+ @click="logout"
58
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700"
59
+ >
60
+ Logout
61
+ </button>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ </header>
66
+
67
+ <!-- Main Content -->
68
+ <div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
69
+ <!-- Search Controls -->
70
+ <div class="mb-6 rounded-lg bg-white shadow">
71
+ <div class="px-4 py-5 sm:p-6">
72
+ <div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-4">
73
+ <!-- Customer Code -->
74
+ <div>
75
+ <label for="customer-select" class="block text-sm font-medium text-gray-700">
76
+ Customer
77
+ </label>
78
+ <select
79
+ id="customer-select"
80
+ v-model="selectedCustomerCode"
81
+ @change="onCustomerChange"
82
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
83
+ >
84
+ <option value="">Select a customer</option>
85
+ <option v-for="code in customerCodes" :key="code" :value="code">
86
+ {{ code }}
87
+ </option>
88
+ </select>
89
+ </div>
90
+
91
+ <!-- Event Source -->
92
+ <div>
93
+ <label for="source-select" class="block text-sm font-medium text-gray-700">
94
+ Event Source
95
+ </label>
96
+ <select
97
+ id="source-select"
98
+ v-model="selectedSourceName"
99
+ @change="onSourceChange"
100
+ :disabled="!selectedCustomerCode || loadingEventSources"
101
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 disabled:opacity-50 sm:text-sm"
102
+ >
103
+ <option value="">Select a source</option>
104
+ <option v-for="src in enabledSources" :key="src.name" :value="src.name">
105
+ {{ src.name }} ({{ src.event_type }})
106
+ </option>
107
+ </select>
108
+ </div>
109
+
110
+ <!-- Time Range -->
111
+ <div>
112
+ <label for="timerange-select" class="block text-sm font-medium text-gray-700">
113
+ Time Range
114
+ </label>
115
+ <select
116
+ id="timerange-select"
117
+ v-model="timerange"
118
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
119
+ >
120
+ <option value="1h">Last 1 hour</option>
121
+ <option value="6h">Last 6 hours</option>
122
+ <option value="24h">Last 24 hours</option>
123
+ <option value="2d">Last 2 days</option>
124
+ <option value="7d">Last 7 days</option>
125
+ <option value="14d">Last 14 days</option>
126
+ <option value="30d">Last 30 days</option>
127
+ </select>
128
+ </div>
129
+
130
+ <!-- Page Size -->
131
+ <div>
132
+ <label for="pagesize-select" class="block text-sm font-medium text-gray-700">
133
+ Results per page
134
+ </label>
135
+ <select
136
+ id="pagesize-select"
137
+ v-model="pageSize"
138
+ class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
139
+ >
140
+ <option :value="25">25</option>
141
+ <option :value="50">50</option>
142
+ <option :value="100">100</option>
143
+ <option :value="250">250</option>
144
+ </select>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Query Bar -->
149
+ <div class="relative">
150
+ <label for="query-input" class="block text-sm font-medium text-gray-700">Lucene Query</label>
151
+ <div class="relative mt-1">
152
+ <input
153
+ id="query-input"
154
+ ref="queryInputRef"
155
+ v-model="query"
156
+ type="text"
157
+ placeholder="e.g. agent_name:web-server AND rule_level:>=10"
158
+ class="block w-full rounded-md border-gray-300 pr-24 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
159
+ @input="onQueryInput"
160
+ @keydown.enter="searchEvents"
161
+ @keydown.tab.prevent="acceptSuggestion"
162
+ @keydown.escape="showSuggestions = false"
163
+ />
164
+ <button
165
+ @click="searchEvents"
166
+ :disabled="!selectedCustomerCode || !selectedSourceName || loadingEvents"
167
+ class="absolute inset-y-0 right-0 inline-flex items-center rounded-r-md border border-transparent bg-indigo-600 px-4 text-sm font-medium text-white hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50"
168
+ >
169
+ <svg
170
+ v-if="loadingEvents"
171
+ class="mr-1 h-4 w-4 animate-spin"
172
+ fill="none"
173
+ viewBox="0 0 24 24"
174
+ >
175
+ <circle
176
+ class="opacity-25"
177
+ cx="12"
178
+ cy="12"
179
+ r="10"
180
+ stroke="currentColor"
181
+ stroke-width="4"
182
+ ></circle>
183
+ <path
184
+ class="opacity-75"
185
+ fill="currentColor"
186
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
187
+ ></path>
188
+ </svg>
189
+ Search
190
+ </button>
191
+ </div>
192
+
193
+ <!-- Autocomplete dropdown -->
194
+ <ul
195
+ v-if="showSuggestions && filteredSuggestions.length > 0"
196
+ class="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
197
+ >
198
+ <li
199
+ v-for="(suggestion, idx) in filteredSuggestions"
200
+ :key="suggestion.field"
201
+ class="cursor-pointer px-3 py-2 text-sm hover:bg-indigo-50"
202
+ :class="{ 'bg-indigo-50': idx === activeSuggestionIndex }"
203
+ @mousedown.prevent="applySuggestion(suggestion.field)"
204
+ >
205
+ <span class="font-medium text-gray-900">{{ suggestion.field }}</span>
206
+ <span class="ml-2 text-xs text-gray-400">{{ suggestion.type }}</span>
207
+ </li>
208
+ </ul>
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- No Event Sources Warning -->
214
+ <div
215
+ v-if="selectedCustomerCode && !loadingEventSources && eventSources.length === 0"
216
+ class="mb-6 rounded-md border border-yellow-300 bg-yellow-50 p-4"
217
+ >
218
+ <div class="flex">
219
+ <svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
220
+ <path
221
+ fill-rule="evenodd"
222
+ d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
223
+ clip-rule="evenodd"
224
+ ></path>
225
+ </svg>
226
+ <p class="ml-3 text-sm text-yellow-700">
227
+ No event sources are configured for this customer. Contact your administrator to set up event
228
+ sources.
229
+ </p>
230
+ </div>
231
+ </div>
232
+
233
+ <!-- Error -->
234
+ <div v-if="error" class="mb-6 rounded-md border border-red-300 bg-red-50 p-4">
235
+ <div class="flex">
236
+ <svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
237
+ <path
238
+ fill-rule="evenodd"
239
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
240
+ clip-rule="evenodd"
241
+ ></path>
242
+ </svg>
243
+ <p class="ml-3 text-sm text-red-700">{{ error }}</p>
244
+ </div>
245
+ </div>
246
+
247
+ <!-- Loading -->
248
+ <div v-if="loadingEvents" class="rounded-lg bg-white px-4 py-12 text-center shadow">
249
+ <div class="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-indigo-600"></div>
250
+ <p class="mt-2 text-sm text-gray-500">Searching events...</p>
251
+ </div>
252
+
253
+ <!-- Results -->
254
+ <div v-else-if="hasSearched">
255
+ <!-- Results Summary -->
256
+ <div class="mb-4 flex items-center justify-between">
257
+ <p class="text-sm text-gray-700">
258
+ Showing
259
+ <span class="font-medium">{{ events.length }}</span>
260
+ of
261
+ <span class="font-medium">{{ totalEvents }}</span>
262
+ events
263
+ </p>
264
+ </div>
265
+
266
+ <!-- Events Table -->
267
+ <div v-if="events.length > 0" class="overflow-hidden rounded-lg bg-white shadow">
268
+ <div class="overflow-x-auto">
269
+ <table class="min-w-full divide-y divide-gray-200">
270
+ <thead class="bg-gray-50">
271
+ <tr>
272
+ <th
273
+ class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
274
+ >
275
+ Timestamp
276
+ </th>
277
+ <th
278
+ class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
279
+ >
280
+ Source
281
+ </th>
282
+ <th
283
+ class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
284
+ >
285
+ Rule
286
+ </th>
287
+ <th
288
+ class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
289
+ >
290
+ Level
291
+ </th>
292
+ <th
293
+ class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
294
+ >
295
+ Summary
296
+ </th>
297
+ </tr>
298
+ </thead>
299
+ <tbody class="divide-y divide-gray-200 bg-white">
300
+ <tr
301
+ v-for="(event, idx) in events"
302
+ :key="idx"
303
+ class="cursor-pointer hover:bg-gray-50"
304
+ @click="selectEvent(event)"
305
+ >
306
+ <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-500">
307
+ {{ formatTimestamp(event.timestamp || event["@timestamp"]) }}
308
+ </td>
309
+ <td class="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
310
+ {{ event.agent_name || event.agent?.name || "-" }}
311
+ </td>
312
+ <td class="max-w-xs truncate px-6 py-4 text-sm text-gray-900">
313
+ {{ event.rule_description || event.rule?.description || "-" }}
314
+ </td>
315
+ <td class="px-6 py-4 text-sm whitespace-nowrap">
316
+ <span
317
+ class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium"
318
+ :class="levelClass(event.rule_level ?? event.rule?.level)"
319
+ >
320
+ {{ event.rule_level ?? event.rule?.level ?? "-" }}
321
+ </span>
322
+ </td>
323
+ <td class="max-w-sm truncate px-6 py-4 text-sm text-gray-500">
324
+ {{ event.full_log || event.data || "-" }}
325
+ </td>
326
+ </tr>
327
+ </tbody>
328
+ </table>
329
+ </div>
330
+ </div>
331
+
332
+ <!-- Empty State -->
333
+ <div v-else class="rounded-lg bg-white px-4 py-12 text-center shadow">
334
+ <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
335
+ <path
336
+ stroke-linecap="round"
337
+ stroke-linejoin="round"
338
+ stroke-width="2"
339
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
340
+ ></path>
341
+ </svg>
342
+ <h3 class="mt-2 text-sm font-medium text-gray-900">No events found</h3>
343
+ <p class="mt-1 text-sm text-gray-500">Try adjusting your query or expanding the time range.</p>
344
+ </div>
345
+
346
+ <!-- Load More -->
347
+ <div v-if="scrollId && events.length < totalEvents" class="mt-4 text-center">
348
+ <button
349
+ @click="loadMoreEvents"
350
+ :disabled="loadingMore"
351
+ class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50"
352
+ >
353
+ <svg v-if="loadingMore" class="mr-2 -ml-1 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
354
+ <circle
355
+ class="opacity-25"
356
+ cx="12"
357
+ cy="12"
358
+ r="10"
359
+ stroke="currentColor"
360
+ stroke-width="4"
361
+ ></circle>
362
+ <path
363
+ class="opacity-75"
364
+ fill="currentColor"
365
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
366
+ ></path>
367
+ </svg>
368
+ {{ loadingMore ? "Loading..." : "Load More" }}
369
+ </button>
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- Event Detail Slide-over -->
375
+ <div v-if="selectedEvent" class="fixed inset-0 z-50 overflow-hidden" @click="selectedEvent = null">
376
+ <div class="absolute inset-0 bg-gray-500/50 transition-opacity"></div>
377
+ <div class="fixed inset-y-0 right-0 flex max-w-full pl-10" @click.stop>
378
+ <div class="w-screen max-w-lg">
379
+ <div class="flex h-full flex-col overflow-y-scroll bg-white shadow-xl">
380
+ <!-- Header -->
381
+ <div class="border-b border-gray-200 bg-gray-50 px-4 py-6 sm:px-6">
382
+ <div class="flex items-start justify-between">
383
+ <h2 class="text-lg font-medium text-gray-900">Event Details</h2>
384
+ <button
385
+ @click="selectedEvent = null"
386
+ class="rounded-md text-gray-400 hover:text-gray-500 focus:ring-2 focus:ring-indigo-500 focus:outline-none"
387
+ >
388
+ <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
389
+ <path
390
+ stroke-linecap="round"
391
+ stroke-linejoin="round"
392
+ stroke-width="2"
393
+ d="M6 18L18 6M6 6l12 12"
394
+ ></path>
395
+ </svg>
396
+ </button>
397
+ </div>
398
+ </div>
399
+
400
+ <!-- Fields -->
401
+ <div class="flex-1 px-4 py-4 sm:px-6">
402
+ <dl class="divide-y divide-gray-200">
403
+ <div
404
+ v-for="[key, value] in sortedEventFields"
405
+ :key="key"
406
+ class="group flex items-start justify-between py-3"
407
+ >
408
+ <dt class="min-w-0 flex-shrink-0 font-mono text-xs font-semibold text-gray-500">
409
+ {{ key }}
410
+ </dt>
411
+ <dd class="ml-4 min-w-0 flex-1 text-right text-sm break-all text-gray-900">
412
+ {{ formatValue(value) }}
413
+ </dd>
414
+ <div
415
+ class="ml-2 flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100"
416
+ >
417
+ <button
418
+ @click="addFilter(key, String(value))"
419
+ title="Filter for this value"
420
+ class="rounded p-1 text-gray-400 hover:bg-indigo-50 hover:text-indigo-600"
421
+ >
422
+ <svg
423
+ class="h-3.5 w-3.5"
424
+ fill="none"
425
+ stroke="currentColor"
426
+ viewBox="0 0 24 24"
427
+ >
428
+ <path
429
+ stroke-linecap="round"
430
+ stroke-linejoin="round"
431
+ stroke-width="2"
432
+ d="M12 4v16m8-8H4"
433
+ ></path>
434
+ </svg>
435
+ </button>
436
+ <button
437
+ @click="excludeFilter(key, String(value))"
438
+ title="Exclude this value"
439
+ class="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
440
+ >
441
+ <svg
442
+ class="h-3.5 w-3.5"
443
+ fill="none"
444
+ stroke="currentColor"
445
+ viewBox="0 0 24 24"
446
+ >
447
+ <path
448
+ stroke-linecap="round"
449
+ stroke-linejoin="round"
450
+ stroke-width="2"
451
+ d="M20 12H4"
452
+ ></path>
453
+ </svg>
454
+ </button>
455
+ </div>
456
+ </div>
457
+ </dl>
458
+ </div>
459
+ </div>
460
+ </div>
461
+ </div>
462
+ </div>
463
+ </div>
464
+</template>
465
+
466
+<script setup lang="ts">
467
+import { ref, computed, onBeforeMount } from "vue"
468
+import { useRouter } from "vue-router"
469
+import { usePortalSettingsStore } from "@/stores/portalSettings"
470
+import { SiemAPI, type EventSource, type EventSearchResult, type FieldMapping } from "@/api/siem"
471
+
472
+const router = useRouter()
473
+const portalSettingsStore = usePortalSettingsStore()
474
+
475
+const showLogo = ref(true)
476
+const error = ref("")
477
+
478
+// Portal info
479
+const username = computed(() => {
480
+ try {
481
+ const user = JSON.parse(localStorage.getItem("customer-portal-user") || "{}")
482
+ return user.username || "User"
483
+ } catch {
484
+ return "User"
485
+ }
486
+})
487
+const portalTitle = computed(() => portalSettingsStore.portalTitle || "Customer Portal")
488
+const portalLogo = computed(() => portalSettingsStore.portalLogo)
489
+
490
+function logout() {
491
+ localStorage.removeItem("customer-portal-auth-token")
492
+ localStorage.removeItem("customer-portal-user")
493
+ router.push("/login")
494
+}
495
+
496
+// -- Customer selection --
497
+const customerCodes = ref<string[]>([])
498
+const selectedCustomerCode = ref("")
499
+
500
+// -- Event Source selection --
501
+const eventSources = ref<EventSource[]>([])
502
+const loadingEventSources = ref(false)
503
+const selectedSourceName = ref("")
504
+
505
+const enabledSources = computed(() => eventSources.value.filter(s => s.enabled))
506
+
507
+async function loadCustomerCodes() {
508
+ try {
509
+ const response = await SiemAPI.getCustomerCodes()
510
+ customerCodes.value = response.customer_codes.filter(c => c !== "*")
511
+ } catch (err: any) {
512
+ error.value = err.response?.data?.detail || err.message || "Failed to load customer codes"
513
+ }
514
+}
515
+
516
+async function loadEventSources(customerCode: string) {
517
+ loadingEventSources.value = true
518
+ eventSources.value = []
519
+ selectedSourceName.value = ""
520
+ fieldMappings.value = []
521
+ try {
522
+ const response = await SiemAPI.getEventSources(customerCode)
523
+ eventSources.value = response.event_sources
524
+ } catch (err: any) {
525
+ error.value = err.response?.data?.detail || err.message || "Failed to load event sources"
526
+ } finally {
527
+ loadingEventSources.value = false
528
+ }
529
+}
530
+
531
+function onCustomerChange() {
532
+ resetResults()
533
+ error.value = ""
534
+ if (selectedCustomerCode.value) {
535
+ loadEventSources(selectedCustomerCode.value)
536
+ } else {
537
+ eventSources.value = []
538
+ selectedSourceName.value = ""
539
+ }
540
+}
541
+
542
+function onSourceChange() {
543
+ resetResults()
544
+ if (selectedSourceName.value) {
545
+ loadFieldMappings()
546
+ }
547
+}
548
+
549
+// -- Search parameters --
550
+const timerange = ref("24h")
551
+const pageSize = ref(50)
552
+const query = ref("")
553
+
554
+// -- Field mappings / autocomplete --
555
+const fieldMappings = ref<FieldMapping[]>([])
556
+const showSuggestions = ref(false)
557
+const activeSuggestionIndex = ref(0)
558
+const queryInputRef = ref<HTMLInputElement | null>(null)
559
+
560
+async function loadFieldMappings() {
561
+ if (!selectedCustomerCode.value || !selectedSourceName.value) return
562
+ try {
563
+ const response = await SiemAPI.getFieldMappings(selectedCustomerCode.value, selectedSourceName.value)
564
+ fieldMappings.value = response.fields
565
+ } catch {
566
+ // Non-critical, autocomplete just won't work
567
+ }
568
+}
569
+
570
+const currentFieldToken = computed(() => {
571
+ const text = query.value
572
+ const match = text.match(/(?:^|[\s(])(\w[\w.]*)$/)
573
+ return match ? match[1] : ""
574
+})
575
+
576
+const filteredSuggestions = computed(() => {
577
+ const token = currentFieldToken.value.toLowerCase()
578
+ if (!token || token.length < 2) return []
579
+ return fieldMappings.value.filter(f => f.field.toLowerCase().includes(token)).slice(0, 15)
580
+})
581
+
582
+function onQueryInput() {
583
+ showSuggestions.value = currentFieldToken.value.length >= 2
584
+ activeSuggestionIndex.value = 0
585
+}
586
+
587
+function applySuggestion(fieldName: string) {
588
+ const token = currentFieldToken.value
589
+ if (token) {
590
+ const lastIndex = query.value.lastIndexOf(token)
591
+ query.value = query.value.substring(0, lastIndex) + fieldName + ":"
592
+ }
593
+ showSuggestions.value = false
594
+ queryInputRef.value?.focus()
595
+}
596
+
597
+function acceptSuggestion() {
598
+ if (showSuggestions.value && filteredSuggestions.value.length > 0) {
599
+ applySuggestion(filteredSuggestions.value[activeSuggestionIndex.value].field)
600
+ }
601
+}
602
+
603
+// -- Events data --
604
+const events = ref<EventSearchResult[]>([])
605
+const totalEvents = ref(0)
606
+const scrollId = ref<string | null>(null)
607
+const loadingEvents = ref(false)
608
+const loadingMore = ref(false)
609
+const hasSearched = ref(false)
610
+
611
+function resetResults() {
612
+ events.value = []
613
+ totalEvents.value = 0
614
+ scrollId.value = null
615
+ hasSearched.value = false
616
+}
617
+
618
+async function searchEvents() {
619
+ if (!selectedCustomerCode.value || !selectedSourceName.value) return
620
+
621
+ loadingEvents.value = true
622
+ error.value = ""
623
+ resetResults()
624
+
625
+ try {
626
+ const response = await SiemAPI.queryEvents(selectedCustomerCode.value, selectedSourceName.value, {
627
+ timerange: timerange.value,
628
+ page_size: pageSize.value,
629
+ query: query.value || undefined
630
+ })
631
+ events.value = response.events
632
+ totalEvents.value = response.total
633
+ scrollId.value = response.scroll_id
634
+ hasSearched.value = true
635
+ } catch (err: any) {
636
+ error.value = err.response?.data?.detail || err.message || "Failed to search events"
637
+ } finally {
638
+ loadingEvents.value = false
639
+ }
640
+}
641
+
642
+async function loadMoreEvents() {
643
+ if (!scrollId.value) return
644
+
645
+ loadingMore.value = true
646
+ try {
647
+ const response = await SiemAPI.queryEvents(selectedCustomerCode.value, selectedSourceName.value, {
648
+ scroll_id: scrollId.value
649
+ })
650
+ events.value.push(...response.events)
651
+ scrollId.value = response.scroll_id
652
+ } catch (err: any) {
653
+ error.value = err.response?.data?.detail || err.message || "Failed to load more events"
654
+ } finally {
655
+ loadingMore.value = false
656
+ }
657
+}
658
+
659
+// -- Event detail --
660
+const selectedEvent = ref<EventSearchResult | null>(null)
661
+
662
+function selectEvent(event: EventSearchResult) {
663
+ selectedEvent.value = event
664
+}
665
+
666
+const sortedEventFields = computed(() => {
667
+ if (!selectedEvent.value) return []
668
+ return Object.entries(selectedEvent.value)
669
+ .filter(([key]) => !key.startsWith("_"))
670
+ .sort(([a], [b]) => a.localeCompare(b))
671
+})
672
+
673
+function addFilter(field: string, value: string) {
674
+ const clause = `${field}:"${value}"`
675
+ query.value = query.value ? `${query.value} AND ${clause}` : clause
676
+ selectedEvent.value = null
677
+ searchEvents()
678
+}
679
+
680
+function excludeFilter(field: string, value: string) {
681
+ const clause = `NOT ${field}:"${value}"`
682
+ query.value = query.value ? `${query.value} AND ${clause}` : clause
683
+ selectedEvent.value = null
684
+ searchEvents()
685
+}
686
+
687
+// -- Formatting helpers --
688
+function formatTimestamp(ts: string | undefined): string {
689
+ if (!ts) return "-"
690
+ try {
691
+ return new Date(ts).toLocaleString()
692
+ } catch {
693
+ return ts
694
+ }
695
+}
696
+
697
+function formatValue(value: unknown): string {
698
+ if (value === null || value === undefined) return "-"
699
+ if (typeof value === "object") return JSON.stringify(value)
700
+ return String(value)
701
+}
702
+
703
+function levelClass(level: number | undefined): string {
704
+ if (level === undefined || level === null) return "bg-gray-100 text-gray-800"
705
+ if (level >= 12) return "bg-red-100 text-red-800"
706
+ if (level >= 8) return "bg-yellow-100 text-yellow-800"
707
+ if (level >= 4) return "bg-blue-100 text-blue-800"
708
+ return "bg-gray-100 text-gray-800"
709
+}
710
+
711
+// -- Lifecycle --
712
+onBeforeMount(() => {
713
+ loadCustomerCodes()
714
+})
715
+</script>
716
+
717
+<style scoped>
718
+/* Tailwind handles all styles */
719
+</style>
customer_portal/src/views/OverviewPage.vue
+6
@@ -40,6 +40,12 @@
40
>
41
Agents
42
</router-link>
43
+ <router-link
44
+ to="/event-search"
45
+ class="rounded-md px-3 py-2 text-sm font-medium text-gray-500 hover:text-gray-900"
46
+ >
47
+ Event Search
48
+ </router-link>
49
</nav>
50
</div>
51
<div class="flex items-center space-x-4">