Build a security operations dashboard in Lovable with a live event feed via Supabase Realtime, threat severity charts, incident management workflows, and a webhook Edge Function that ingests events from external security tools. Designed for teams that need real-time visibility into security events without managing a separate SIEM.
What you're building
A security monitoring dashboard built in Lovable provides a real-time window into security events from your application and external tools. The data model has two core tables: security_events stores individual events (failed logins, suspicious requests, policy violations) with severity, source, and a raw JSONB payload. Incidents are aggregations of related events, assigned to a team member and tracked through a triage workflow.
The live event feed subscribes to Supabase Realtime INSERT events on security_events. When a new event arrives, it animates in at the top of the feed list. Events are color-coded by severity: critical (red), high (orange), medium (yellow), low (blue), info (gray). A running count badge in the page header shows events in the last 60 minutes.
The webhook Edge Function is the data ingestion point for external security tools. It verifies a shared secret in the Authorization header, validates the payload structure, normalizes the fields to match security_events columns, and inserts the row. The function URL can be registered as a webhook in tools like Datadog, PagerDuty, or your own application's error handler.
Final result
A live security ops dashboard with real-time event ingestion, severity charts, an incident management workflow, and a webhook endpoint — all deployable from Lovable.
Tech stack
Prerequisites
- Lovable Pro account (Edge Functions require Pro or higher)
- Supabase project with URL, anon key, and service role key ready
- VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, and WEBHOOK_SECRET in Cloud tab → Secrets
- A Supabase service role key stored in Secrets (for the Edge Function) as SUPABASE_SERVICE_ROLE_KEY
- Basic understanding of webhook concepts — a URL that receives HTTP POST requests with a JSON body
Build steps
Scaffold the security events and incidents schema
Create the two core tables with appropriate indexes for real-time queries. Security events need fast inserts and the live feed needs an index on created_at for descending order queries.
1Create a security monitoring app with Supabase. Set up these tables:23- security_events: id, org_id, source (text, e.g. 'app'|'firewall'|'auth'), event_type (text), severity ('critical'|'high'|'medium'|'low'|'info'), title (text), description (text), raw_payload (jsonb), ip_address (text), user_id (uuid nullable), incident_id (uuid nullable references incidents), created_at (timestamptz default now())45- incidents: id, org_id, title, severity ('critical'|'high'|'medium'|'low'), status ('open'|'investigating'|'contained'|'resolved'), assignee_id (uuid references auth.users), description (text), resolution_notes (text), created_at, updated_at, resolved_at (nullable)67Create indexes:8- security_events(org_id, created_at DESC)9- security_events(severity, created_at DESC)10- incidents(org_id, status, created_at DESC)1112Enable RLS on both tables. Only users with role = 'security_team' or 'admin' in profiles can access these tables.1314Enable Supabase Realtime on security_events.1516Seed with 50 sample security_events of mixed severities spanning the last 7 days.Pro tip: Add a partial index on security_events(created_at DESC) WHERE severity IN ('critical', 'high') — the live feed and alert threshold checker query high-severity events most frequently and this index makes those queries ~10x faster.
Expected result: Both tables are created with indexes. The Supabase Realtime toggle is enabled for security_events. The seed data populates the table and is visible in the Table Editor.
Build the live event feed with Realtime
Create a scrolling event feed that prepends new events as they arrive via Supabase Realtime. Each event shows severity badge, title, source, IP, and elapsed time.
1import { useState, useEffect, useRef } from 'react'2import { useQuery } from '@tanstack/react-query'3import { supabase } from '@/integrations/supabase/client'4import { Badge } from '@/components/ui/badge'5import { ScrollArea } from '@/components/ui/scroll-area'6import { cn } from '@/lib/utils'78type SecurityEvent = {9 id: string; source: string; event_type: string; severity: string10 title: string; ip_address: string | null; created_at: string11}1213const SEVERITY_COLORS: Record<string, string> = {14 critical: 'bg-red-100 text-red-700 border-red-200',15 high: 'bg-orange-100 text-orange-700 border-orange-200',16 medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',17 low: 'bg-blue-100 text-blue-700 border-blue-200',18 info: 'bg-gray-100 text-gray-600 border-gray-200',19}2021export function LiveEventFeed() {22 const [liveEvents, setLiveEvents] = useState<SecurityEvent[]>([])23 const [newIds, setNewIds] = useState<Set<string>>(new Set())2425 const { data: initial = [] } = useQuery<SecurityEvent[]>({26 queryKey: ['security_events_feed'],27 queryFn: async () => {28 const { data, error } = await supabase29 .from('security_events')30 .select('id, source, event_type, severity, title, ip_address, created_at')31 .order('created_at', { ascending: false })32 .limit(50)33 if (error) throw error34 return data35 },36 })3738 useEffect(() => {39 if (initial.length) setLiveEvents(initial)40 }, [initial])4142 useEffect(() => {43 const channel = supabase44 .channel('security_events_live')45 .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'security_events' },46 (payload) => {47 const ev = payload.new as SecurityEvent48 setLiveEvents((prev) => [ev, ...prev].slice(0, 100))49 setNewIds((prev) => new Set([...prev, ev.id]))50 setTimeout(() => setNewIds((prev) => { const n = new Set(prev); n.delete(ev.id); return n }), 2000)51 })52 .subscribe()53 return () => { supabase.removeChannel(channel) }54 }, [])5556 return (57 <ScrollArea className="h-[600px] rounded-lg border">58 {liveEvents.map((ev) => (59 <div60 key={ev.id}61 className={cn(62 'flex items-start gap-3 border-b p-3 transition-colors',63 newIds.has(ev.id) && 'bg-primary/5',64 )}65 >66 <Badge className={cn('shrink-0 text-xs', SEVERITY_COLORS[ev.severity])} variant="outline">67 {ev.severity}68 </Badge>69 <div className="min-w-0 flex-1">70 <p className="truncate text-sm font-medium">{ev.title}</p>71 <p className="text-xs text-muted-foreground">{ev.source} · {ev.ip_address ?? 'unknown'}</p>72 </div>73 <span className="shrink-0 text-xs text-muted-foreground">74 {new Date(ev.created_at).toLocaleTimeString()}75 </span>76 </div>77 ))}78 </ScrollArea>79 )80}Pro tip: Limit the in-memory event list to 100 entries using .slice(0, 100) when prepending new events — this prevents memory growth during long dashboard sessions.
Expected result: The event feed shows the 50 most recent events. Inserting a new row in the Supabase Table Editor causes it to appear at the top of the feed within one second with a subtle highlight animation.
Build threat volume charts
Create two Recharts charts: a stacked bar chart showing event counts by severity per day, and a pie chart showing the breakdown by event type for the selected time window.
1Build a ThreatCharts component at src/components/security/ThreatCharts.tsx.23Requirements:4- Fetch a daily severity breakdown: query security_events, group by date_trunc('day', created_at) and severity for the last 14 days. Use a Supabase RPC function get_event_counts(start_date, end_date) that returns rows of { day, critical, high, medium, low, info }.5- Fetch event type distribution: query security_events, group by event_type, count, for the same period.6- Left chart (2/3 width on desktop): StackedBarChart with one Bar per severity level, each with its corresponding color. XAxis shows dates formatted as 'MMM d'. Tooltip shows counts per severity.7- Right chart (1/3 width): PieChart with one slice per event_type. Show top 8 types, group the rest as 'Other'. Use a Legend below the chart.8- Add a period selector (7d / 14d / 30d) above the charts that re-fetches both datasets.9- Show Skeleton loaders while fetching.Pro tip: Create the get_event_counts Postgres function that returns the pivoted daily breakdown server-side. Doing this aggregation in a function is much faster than fetching all raw event rows and grouping in JavaScript.
Expected result: Two charts appear side by side. The stacked bar chart shows color-coded event volume by day. The pie chart shows which event types are most common. The period selector re-fetches both charts.
Build the incident management DataTable
Create an incidents DataTable where security team members can create, assign, and resolve incidents. Status transitions are enforced by the UI and logged.
1Build an IncidentsTable component at src/components/security/IncidentsTable.tsx.23Requirements:4- Fetch incidents from Supabase ordered by created_at DESC5- TanStack Table v8 columns: Title, Severity (Badge), Status (Badge: open=red, investigating=orange, contained=yellow, resolved=green), Assignee (Avatar + name), Created, Age (days since created_at)6- Row click opens an IncidentDetailSheet:7 - Title (editable Input)8 - Severity Select9 - Status Select with only valid transitions: open→investigating, investigating→contained, investigating→resolved, contained→resolved10 - Assignee Select (list all security_team profiles)11 - Description Textarea12 - Resolution Notes Textarea (shown only when status is 'resolved' or 'contained')13 - Linked Events section: show security_events where incident_id = this incident's id14 - 'Link Event' Button: opens a Dialog to search and link unassigned events15 - Save Button: calls supabase.from('incidents').update()16- Create Incident Button above table: opens a Dialog with title, severity, description. On submit, inserts a new incident row.Expected result: The incidents table shows all open and resolved incidents. Clicking a row opens the Sheet. Changing the status to 'resolved' reveals the Resolution Notes field. Linked events appear at the bottom of the Sheet.
Build the webhook ingestion Edge Function
Create a Supabase Edge Function that acts as a webhook endpoint for external security tools. It verifies the request signature, normalizes the payload, and inserts a security_events row.
1Create a Supabase Edge Function at supabase/functions/ingest-security-event/index.ts.23Requirements:4- Accept POST requests with Content-Type: application/json5- Verify authentication: check Authorization header matches 'Bearer ' + Deno.env.get('WEBHOOK_SECRET'). Return 401 if invalid.6- Accept a flexible JSON body with these fields (normalize as needed):7 { source, event_type, severity, title, description, ip_address, user_id, raw_payload }8- Validate: severity must be one of critical|high|medium|low|info. Return 400 with error details if invalid.9- Insert into security_events using the Supabase service role client (use Deno.env.get('SUPABASE_URL') and Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'))10- Set org_id from a hard-coded default org or from an X-Org-ID header11- Return { id: insertedId, created_at } on success12- Add CORS headers13- Log errors to console for Supabase Edge Function logs visibility1415Also build a WebhookConfigCard component in Lovable that displays the deployed Edge Function URL and the WEBHOOK_SECRET (masked) so admins know where to point their external tools.Pro tip: Test the webhook locally using Supabase's edge function serve command or by calling the deployed function URL with curl from the Supabase documentation. The function URL format is: https://[project-ref].supabase.co/functions/v1/ingest-security-event.
Expected result: Sending a POST request to the Edge Function URL with a valid Authorization header and JSON body inserts a security_events row and triggers the Realtime feed to update within one second.
Add alert threshold notifications
Add a client-side alert system that watches the event feed and shows a persistent toast notification when critical or high severity events exceed a configurable threshold within a rolling 10-minute window.
1Add alert threshold monitoring to the dashboard.23Requirements:4- Create an alert_thresholds table in Supabase: id, org_id, severity ('critical'|'high'), threshold_count (integer), window_minutes (integer default 10), created_at. RLS: security team members can read/write their org's thresholds.5- Build an AlertThresholdMonitor component:6 - Subscribe to the same security_events Realtime channel7 - Maintain a rolling buffer of events from the last window_minutes8 - On each new event, count events matching the severity in the buffer9 - If count >= threshold_count, call sonner's toast.error() with: 'ALERT: X critical events in the last Y minutes' and a View Events Button that scrolls to the feed10 - Debounce alerts: don't re-fire the same severity alert more than once per minute11- Add an AlertSettings Dialog accessible from a Bell icon in the header:12 - Shows current thresholds for critical and high severity13 - Allows changing threshold_count and window_minutes14 - Saves to the alert_thresholds tableExpected result: When the Realtime feed receives more critical events than the configured threshold within the time window, a prominent error toast appears at the top of the screen. The threshold can be configured from the Bell icon menu.
Complete code
1import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'23const CORS = {4 'Access-Control-Allow-Origin': '*',5 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type, x-org-id',6}78Deno.serve(async (req) => {9 if (req.method === 'OPTIONS') return new Response(null, { headers: CORS })1011 const authHeader = req.headers.get('authorization') ?? ''12 const secret = Deno.env.get('WEBHOOK_SECRET')13 if (!secret || authHeader !== `Bearer ${secret}`) {14 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: CORS })15 }1617 let body: Record<string, unknown>18 try { body = await req.json() }19 catch { return new Response(JSON.stringify({ error: 'Invalid JSON' }), { status: 400, headers: CORS }) }2021 const validSeverities = ['critical', 'high', 'medium', 'low', 'info']22 if (!validSeverities.includes(body.severity as string)) {23 return new Response(JSON.stringify({ error: `severity must be one of: ${validSeverities.join(', ')}` }), { status: 400, headers: CORS })24 }2526 const supabase = createClient(27 Deno.env.get('SUPABASE_URL') ?? '',28 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',29 )3031 const orgId = req.headers.get('x-org-id') ?? Deno.env.get('DEFAULT_ORG_ID')3233 const { data, error } = await supabase.from('security_events').insert({34 org_id: orgId,35 source: body.source ?? 'webhook',36 event_type: body.event_type ?? 'unknown',37 severity: body.severity,38 title: body.title ?? 'Untitled event',39 description: body.description ?? null,40 ip_address: body.ip_address ?? null,41 user_id: body.user_id ?? null,42 raw_payload: body,43 }).select('id, created_at').single()4445 if (error) {46 console.error('Insert error:', error)47 return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: CORS })48 }4950 return new Response(JSON.stringify(data), { status: 201, headers: { ...CORS, 'Content-Type': 'application/json' } })51})Customization ideas
GeoIP threat map
Parse the ip_address from security_events, enrich it with a GeoIP API call in the Edge Function, and store latitude/longitude in the raw_payload. Render a world map using a React mapping library with dots for each event location.
Automated incident creation
Add a Postgres trigger that automatically inserts an incident row when three or more critical events from the same IP address arrive within 5 minutes. The incident is auto-assigned to the on-call user defined in an on_call_schedule table.
Event correlation rules
Create an event_rules table where security engineers define correlation rules (e.g. 'if event_type = failed_login AND count > 10 in 1 minute from same IP, fire alert'). A Supabase Edge Function runs every minute to evaluate rules.
Slack incident notifications
When a new incident is created with severity 'critical', a Postgres trigger calls a Supabase Edge Function that posts a formatted Slack message to a #security-alerts channel with the incident title, severity, and a link to the dashboard.
Event suppression rules
Add a suppression_rules table where known safe IPs or event types can be muted for a defined period. The ingest Edge Function checks suppression rules before inserting events, reducing noise from known infrastructure scanners.
Common pitfalls
Pitfall: Putting the WEBHOOK_SECRET in client-side environment variables
How to avoid: Store WEBHOOK_SECRET only in Supabase Secrets for Edge Functions (accessed via Deno.env). Never use VITE_ prefix for secrets.
Pitfall: Not limiting the in-memory event feed size
How to avoid: Slice the events array to a maximum length (100–200 entries) when prepending each new event.
Pitfall: Querying all security_events without an org_id filter
How to avoid: Always include .eq('org_id', orgId) in queries and enforce this at the RLS level with a policy checking auth.uid()'s org_id.
Pitfall: Forgetting to clean up the Realtime channel subscription on component unmount
How to avoid: Always return () => { supabase.removeChannel(channel) } from the useEffect that creates the subscription.
Best practices
- Verify webhook secrets on the server side in the Edge Function, never in client-side code — client secrets are public.
- Use a partial index on (created_at DESC) WHERE severity IN ('critical', 'high') for fast high-severity event queries.
- Enable Realtime only on the tables that need it — each active subscription counts against Supabase connection limits.
- Store raw_payload as JSONB rather than text so you can query nested fields with Postgres JSON operators when investigating incidents.
- Rate-limit the Realtime alert notifications using a debounce or cooldown — a spike of 100 events should produce one alert, not 100 toasts.
- Add an index on security_events(incident_id) for fast linked-events lookups in the IncidentDetailSheet.
- Use Supabase Edge Function logs (Dashboard → Edge Functions → Logs) to debug webhook ingestion issues before testing with real payloads.
- Test RLS by logging in as a non-security-team user and verifying the security_events and incidents tables return zero rows.
AI prompts to try
Copy these prompts to build this project faster.
I have a Supabase security_events table with columns: id, severity (critical/high/medium/low/info), event_type, created_at, org_id. Help me write a Postgres function get_event_counts(start_date date, end_date date, p_org_id uuid) that returns one row per day with the event count for each severity level as separate columns: day, critical, high, medium, low, info. Use crosstab or conditional aggregation.
Add an event search page to the security monitoring dashboard. It should have a full-text search input, severity multi-select checkboxes, source select, IP address input, and date range picker. Results render in a DataTable with all columns. Each row has a 'Create Incident' Button that opens the incident creation Dialog pre-filled with the event's title and severity. Search state should be persisted in URL parameters.
In Lovable, build a security event detail Sheet that opens when a user clicks any event in the LiveEventFeed. It should show: all event metadata, a collapsible Raw Payload section rendering the raw_payload JSONB as a syntax-highlighted JSON tree using a pre element with CSS, a 'Link to Incident' Select that shows open incidents, and a 'Create New Incident from Event' Button. The Sheet should be accessible from both the feed and the incidents table.
Frequently asked questions
Do I need a paid Supabase plan for Edge Functions?
Edge Functions are available on Supabase's free tier with 500,000 invocations per month. The webhook ingest function for a typical small team will stay well within this limit. For high-volume event ingestion, upgrade to Supabase Pro.
How do I test the webhook without an external tool?
Use a tool like curl or the Supabase Edge Function test panel. Send a POST request to your function URL with the Authorization header set to 'Bearer [your WEBHOOK_SECRET]' and a JSON body matching the expected fields. The event will appear in the live feed immediately.
Can I receive webhooks from tools like Datadog or AWS GuardDuty?
Yes, but each tool sends events in a different format. Extend the Edge Function's normalization logic to handle the specific payload structure of each source. Add a source parameter in the function to identify the format and apply the correct mapping.
What happens if the Edge Function is down when a webhook fires?
The sending tool will receive an error response. Most tools retry webhook deliveries on failure. Ensure your webhook source is configured with retry logic and exponential backoff. Supabase Edge Functions have high availability but brief cold starts may occur.
How do I correlate multiple events into a single incident?
In the IncidentDetailSheet, use the 'Link Event' button to search for related events and set their incident_id to the current incident's id. A Supabase update call updates the security_events row. The linked events section re-fetches and shows them below the incident form.
Can the feed handle very high event volumes (hundreds per minute)?
Supabase Realtime is designed for this. On the client side, limit the in-memory list to 200 events and use React's batched state updates. For very high volumes, consider sampling the feed — show every Nth event in the UI while storing all events in the database.
Why does my Realtime subscription show events from all organizations?
By default, Supabase Realtime broadcasts all changes to subscribed clients. Apply a filter to your channel subscription: .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'security_events', filter: `org_id=eq.${orgId}` }, handler). Also ensure RLS is enabled so the client cannot query events from other orgs.
Can RapidDev help me integrate this with my existing security stack?
Yes. RapidDev can help you write Edge Function adapters for specific security tools, set up automated incident workflows, and configure alert routing for your team's Slack or PagerDuty integration.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation