@cryptotaxi247 / CoPilot / commits / 06831a18

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">