Skip to content

Commit bf8e7a6

Browse files
committed
Adds route to clear webhook events and refines UI
Introduces an endpoint to delete stored events by webhook path Enhances front-end with advanced event filtering and inspection Improves local environment management through new setup scripts
1 parent 9f86023 commit bf8e7a6

16 files changed

+707
-25
lines changed

server/src/webhook.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,16 @@ export function registerWebhookRoutes(app: express.Application) {
209209
return res.json({ count: list.length, events: list });
210210
});
211211

212+
// Delete (clear) events for a specific webhook path
213+
app.delete('/api/webhook/:userPath/events', (req: Request, res: Response) => {
214+
const userPath = req.params.userPath;
215+
const list = eventStore.get(userPath) || [];
216+
const count = list.length;
217+
eventStore.set(userPath, []);
218+
console.log(`Cleared ${count} events for webhook ${userPath}`);
219+
return res.json({ cleared: count });
220+
});
221+
212222
// Server-Sent Events stream for a webhook path.
213223
app.get('/api/webhook/:userPath/stream', (req: Request, res: Response) => {
214224
const userPath = req.params.userPath;

src/App.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,15 @@ export default function App() {
416416
}
417417
};
418418

419+
// Expose selectedBayObj.id globally for the webhook UI to read (minimal plumbing)
420+
useEffect(() => {
421+
try {
422+
(window as any)._selectedBayIdForWebhook = selectedBayObj?.id ?? null;
423+
} catch (err) {
424+
// ignore in non-browser environments
425+
}
426+
}, [selectedBayObj]);
427+
419428
const handleAddActivity = (activity: Activity) => {
420429
dispatch({ type: 'ADD_ACTIVITY', activity, select: true });
421430
};
@@ -704,7 +713,7 @@ export default function App() {
704713
</div>
705714
) : activeTab === 'webhook' ? (
706715
<div className="documentation-flex">
707-
<WebhookView />
716+
<WebhookView selectedBayDbId={selectedBayObj?.dbId ?? null} />
708717
</div>
709718
) : null}
710719
</div>

src/components/WebhookEventsPanel.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ type EventItem = {
1212

1313
interface Props {
1414
userPath: string;
15+
selectedBayDbId?: number | null;
16+
selectedBayId?: string | null;
1517
}
1618

17-
export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
18-
const [events, setEvents] = React.useState<EventItem[]>([]);
19+
export const WebhookEventsPanel: React.FC<Props> = ({ userPath, selectedBayDbId = null, selectedBayId = null }) => {
20+
// Keep a full list of received events, and derive the filtered list
21+
const [allEvents, setAllEvents] = React.useState<EventItem[]>([]);
1922
const [connected, setConnected] = React.useState(false);
2023

2124
// Fetch initial events
@@ -28,8 +31,8 @@ export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
2831
if (!r.ok) throw new Error(await r.text());
2932
const j = await r.json();
3033
if (!cancelled && Array.isArray(j.events)) {
31-
// ensure newest-first
32-
setEvents(j.events.map((e: any) => ({ id: e.id, eventType: e.eventType, timestamp: e.timestamp, data: e.data, raw: e.raw })));
34+
// ensure newest-first and normalize expanded flag
35+
setAllEvents(j.events.map((e: any) => ({ id: e.id, eventType: e.eventType, timestamp: e.timestamp, data: e.data, raw: e.raw, expanded: false })));
3336
}
3437
} catch (err) {
3538
console.warn('Failed to load events', err);
@@ -59,7 +62,7 @@ export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
5962
try {
6063
const data = JSON.parse(ev.data);
6164
// Prepend newest-first, include data/raw if present
62-
setEvents(prev => [{ id: data.id, eventType: data.eventType, timestamp: data.timestamp, data: data.data, raw: data.raw }, ...prev]);
65+
setAllEvents(prev => [{ id: data.id, eventType: data.eventType, timestamp: data.timestamp, data: data.data, raw: data.raw, expanded: false }, ...prev]);
6366
} catch (err) {
6467
console.warn('Invalid SSE payload', err);
6568
}
@@ -75,18 +78,63 @@ export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
7578
};
7679
}, [userPath]);
7780

81+
// Helper to extract Bay.Id (which in fixtures corresponds to bay.dbId)
82+
const getBayIdFromEvent = (e: EventItem) => {
83+
// Look for Bay id in multiple possible locations produced by different envelopes
84+
try {
85+
const raw = e.raw as any;
86+
const data = e.data as any;
87+
88+
// 1) classifier-produced common metadata at top-level (normalize may attach common)
89+
if (raw && raw.common && raw.common.Bay && (raw.common.Bay.Id || raw.common.Bay.id)) return raw.common.Bay.Id ?? raw.common.Bay.id;
90+
if (raw && raw.common && raw.common.BayId) return raw.common.BayId;
91+
92+
// 2) normalized payload: raw.data.Bay
93+
if (raw && raw.data && raw.data.Bay && (raw.data.Bay.Id || raw.data.Bay.id)) return raw.data.Bay.Id ?? raw.data.Bay.id;
94+
if (raw && raw.data && (raw.data.BayId || raw.data.bayId)) return raw.data.BayId ?? raw.data.bayId;
95+
96+
// 3) direct top-level Bay (some fixtures might be structured differently)
97+
if (raw && raw.Bay && (raw.Bay.Id || raw.Bay.id)) return raw.Bay.Id ?? raw.Bay.id;
98+
99+
// 4) the simplified data field that the frontend stores (data is normalize(data.data))
100+
if (data && data.Bay && (data.Bay.Id || data.Bay.id)) return data.Bay.Id ?? data.Bay.id;
101+
if (data && (data.BayId || data.bayId)) return data.BayId ?? data.bayId;
102+
103+
// 5) fallback: check common inside data
104+
if (data && data.common && data.common.Bay && (data.common.Bay.Id || data.common.Bay.id)) return data.common.Bay.Id ?? data.common.Bay.id;
105+
106+
return null;
107+
} catch (err) {
108+
return null;
109+
}
110+
};
111+
112+
// Derive filtered events based on selectedBayDbId prop
113+
const filteredEvents = React.useMemo(() => {
114+
if (selectedBayDbId === null || selectedBayDbId === undefined) return allEvents;
115+
return allEvents.filter(e => {
116+
const bayId = getBayIdFromEvent(e);
117+
if (bayId === null || bayId === undefined) return false;
118+
try {
119+
return String(bayId) === String(selectedBayDbId);
120+
} catch (_) {
121+
return false;
122+
}
123+
});
124+
}, [allEvents, selectedBayDbId]);
125+
78126
return (
79127
<div className="webhook-events">
80128
<div className="webhook-events-header">
81129
<strong>Events</strong>
82130
<span className={`webhook-events-status ${connected ? 'live' : ''}`}>{connected ? 'live' : 'disconnected'}</span>
83131
</div>
84132
<div className="webhook-events-list">
85-
{events.length === 0 ? (
133+
{filteredEvents.length === 0 ? (
86134
<div>No events yet.</div>
87135
) : (
88136
<ul className="webhook-events-ul">
89-
{events.map((e, idx) => (
137+
{filteredEvents.map((e, idx) => (
90138
<li key={e.id || idx} className="webhook-event-item">
91139
<div className="webhook-event-header">
92140
<div>
@@ -95,7 +143,8 @@ export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
95143
</div>
96144
<div>
97145
<button onClick={() => {
98-
setEvents(prev => prev.map((it, i) => i === idx ? { ...it, expanded: !it.expanded } : it));
146+
// Toggle expanded state on the underlying allEvents array by id
147+
setAllEvents(prev => prev.map(it => (it.id === e.id ? { ...it, expanded: !it.expanded } : it)));
99148
}}>{e.expanded ? 'Hide' : 'Show'}</button>
100149
</div>
101150
</div>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.webhook-inspector{display:flex;height:100%;width:100%}
2+
.webhook-inspector{box-sizing:border-box}
3+
.webhook-inspector-list{width:280px;flex:0 0 280px;border-right:1px solid #ddd;display:flex;flex-direction:column}
4+
.webhook-events-header{padding:8px 12px;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;background:#fff;z-index:2;border-bottom:1px solid #f0f0f0}
5+
.webhook-events-status{font-size:12px;color:#999}
6+
.webhook-events-status.live{color:green}
7+
.webhook-events-ul{list-style:none;margin:0;padding:0;overflow:auto;flex:1}
8+
.webhook-event-item{padding:10px;border-bottom:1px solid #f0f0f0;cursor:pointer}
9+
.webhook-event-item.selected{background:#f7fbff}
10+
.event-type{font-size:13px;color:#333}
11+
.event-meta{font-size:12px;color:#777}
12+
.event-bay{font-size:11px;color:#666;margin-top:6px}
13+
.no-events{padding:12px;color:#666}
14+
.webhook-inspector-preview{flex:1;padding:12px;overflow:auto;min-width:0}
15+
.preview-title{margin-top:0}
16+
.preview-time{margin-bottom:8px;color:#666}
17+
.preview-json{background:#fafafa;padding:12px;border-radius:6px;overflow:auto}
18+
.preview-empty{color:#666}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import React from 'react';
2+
import './WebhookEventsPanel.css';
3+
import './WebhookInspector.css';
4+
5+
type EventItem = {
6+
id?: string;
7+
eventType: string;
8+
timestamp: string;
9+
data?: any;
10+
raw?: any;
11+
expanded?: boolean;
12+
};
13+
14+
interface Props {
15+
userPath: string;
16+
selectedBayDbId?: number | null;
17+
selectedBayId?: string | null;
18+
}
19+
20+
const getBayIdFromEvent = (e: EventItem) => {
21+
try {
22+
const raw = e.raw as any;
23+
const data = e.data as any;
24+
if (raw && raw.common && raw.common.Bay && (raw.common.Bay.Id || raw.common.Bay.id)) return raw.common.Bay.Id ?? raw.common.Bay.id;
25+
if (raw && raw.common && raw.common.BayId) return raw.common.BayId;
26+
if (raw && raw.data && raw.data.Bay && (raw.data.Bay.Id || raw.data.Bay.id)) return raw.data.Bay.Id ?? raw.data.Bay.id;
27+
if (raw && raw.data && (raw.data.BayId || raw.data.bayId)) return raw.data.BayId ?? raw.data.bayId;
28+
if (raw && raw.Bay && (raw.Bay.Id || raw.Bay.id)) return raw.Bay.Id ?? raw.Bay.id;
29+
if (data && data.Bay && (data.Bay.Id || data.Bay.id)) return data.Bay.Id ?? data.Bay.id;
30+
if (data && (data.BayId || data.bayId)) return data.BayId ?? data.bayId;
31+
if (data && data.common && data.common.Bay && (data.common.Bay.Id || data.common.Bay.id)) return data.common.Bay.Id ?? data.common.Bay.id;
32+
return null;
33+
} catch (err) {
34+
return null;
35+
}
36+
};
37+
38+
const WebhookInspector: React.FC<Props> = ({ userPath, selectedBayDbId = null, selectedBayId = null }) => {
39+
const [allEvents, setAllEvents] = React.useState<EventItem[]>([]);
40+
const [connected, setConnected] = React.useState(false);
41+
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
42+
const listRef = React.useRef<HTMLUListElement | null>(null);
43+
44+
// Fetch initial events
45+
React.useEffect(() => {
46+
if (!userPath) return;
47+
let cancelled = false;
48+
(async () => {
49+
try {
50+
const r = await fetch(`/api/webhook/${encodeURIComponent(userPath)}/events`);
51+
if (!r.ok) throw new Error(await r.text());
52+
const j = await r.json();
53+
if (!cancelled && Array.isArray(j.events)) {
54+
setAllEvents(j.events.map((e: any) => ({ id: e.id, eventType: e.eventType, timestamp: e.timestamp, data: e.data, raw: e.raw, expanded: false })));
55+
}
56+
} catch (err) {
57+
console.warn('Failed to load events', err);
58+
}
59+
})();
60+
return () => { cancelled = true; };
61+
}, [userPath]);
62+
63+
// SSE subscription
64+
React.useEffect(() => {
65+
if (!userPath) return;
66+
let es: EventSource | null = null;
67+
let reconnectTimer: number | null = null;
68+
69+
const connect = () => {
70+
es = new EventSource(`/api/webhook/${encodeURIComponent(userPath)}/stream`);
71+
es.onopen = () => setConnected(true);
72+
es.onerror = () => {
73+
setConnected(false);
74+
if (es) es.close();
75+
reconnectTimer = window.setTimeout(() => connect(), 3000);
76+
};
77+
es.onmessage = (ev) => {
78+
try {
79+
const data = JSON.parse(ev.data);
80+
setAllEvents(prev => [{ id: data.id, eventType: data.eventType, timestamp: data.timestamp, data: data.data, raw: data.raw, expanded: false }, ...prev]);
81+
} catch (err) {
82+
console.warn('Invalid SSE payload', err);
83+
}
84+
};
85+
};
86+
87+
connect();
88+
89+
return () => {
90+
if (reconnectTimer) window.clearTimeout(reconnectTimer);
91+
if (es) es.close();
92+
setConnected(false);
93+
};
94+
}, [userPath]);
95+
96+
const filtered = React.useMemo(() => {
97+
if (!selectedBayDbId && !selectedBayId) return allEvents;
98+
return allEvents.filter(e => {
99+
const bayId = getBayIdFromEvent(e);
100+
if (!bayId) return false;
101+
if (selectedBayId && String(bayId) === String(selectedBayId)) return true;
102+
if (selectedBayDbId && String(bayId) === String(selectedBayDbId)) return true;
103+
return false;
104+
});
105+
}, [allEvents, selectedBayDbId, selectedBayId]);
106+
107+
// ensure selected item is visible
108+
React.useEffect(() => {
109+
if (selectedIndex === null) return;
110+
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
111+
if (el && typeof el.scrollIntoView === 'function') {
112+
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
113+
}
114+
}, [selectedIndex, filtered]);
115+
116+
const select = (idx: number) => {
117+
setSelectedIndex(idx);
118+
};
119+
120+
const onListKeyDown = (ev: React.KeyboardEvent) => {
121+
if (filtered.length === 0) return;
122+
if (ev.key === 'ArrowDown') {
123+
ev.preventDefault();
124+
if (selectedIndex === null) setSelectedIndex(0);
125+
else setSelectedIndex(Math.min(filtered.length - 1, selectedIndex + 1));
126+
} else if (ev.key === 'ArrowUp') {
127+
ev.preventDefault();
128+
if (selectedIndex === null) setSelectedIndex(filtered.length - 1);
129+
else setSelectedIndex(Math.max(0, selectedIndex - 1));
130+
}
131+
};
132+
133+
const selectedEvent = selectedIndex === null ? null : filtered[selectedIndex];
134+
135+
return (
136+
<div className="webhook-inspector">
137+
<div className="webhook-inspector-list" tabIndex={0} onKeyDown={onListKeyDown}>
138+
<div className="webhook-events-header">
139+
<strong>Events</strong>
140+
<span className={`webhook-events-status ${connected ? 'live' : ''}`}>{connected ? 'live' : 'disconnected'}</span>
141+
</div>
142+
<ul className="webhook-events-ul" ref={listRef}>
143+
{filtered.length === 0 ? (
144+
<li className="no-events">No events yet.</li>
145+
) : (
146+
filtered.map((e, idx) => (
147+
<li key={e.id || idx} className={`webhook-event-item ${selectedIndex === idx ? 'selected' : ''}`} onClick={() => select(idx)}>
148+
<div className="event-type">{e.eventType}</div>
149+
<div className="event-meta">{new Date(e.timestamp).toLocaleString()}</div>
150+
<div className="event-bay">{getBayIdFromEvent(e) ? `Bay: ${getBayIdFromEvent(e)}` : ''}</div>
151+
</li>
152+
))
153+
)}
154+
</ul>
155+
</div>
156+
<div className="webhook-inspector-preview">
157+
{selectedEvent ? (
158+
<div>
159+
<h4 className="preview-title">{selectedEvent.eventType}</h4>
160+
<div className="preview-time">{new Date(selectedEvent.timestamp).toLocaleString()}</div>
161+
{/* Version 1: render JSON fallback of event.data or raw */}
162+
<pre className="preview-json">{JSON.stringify(selectedEvent.data || selectedEvent.raw || {}, null, 2)}</pre>
163+
</div>
164+
) : (
165+
<div className="preview-empty">Select an event to preview</div>
166+
)}
167+
</div>
168+
</div>
169+
);
170+
};
171+
172+
export default WebhookInspector;

src/components/WebhookView.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
.webhook-view {
22
padding: 24px;
3+
width: 100%;
4+
box-sizing: border-box;
35
}
46
.webhook-title {
57
margin: 0 0 8px 0;
@@ -30,3 +32,8 @@
3032
border-radius: 4px;
3133
cursor: pointer;
3234
}
35+
36+
.webhook-topbar{display:flex;gap:8px;align-items:center}
37+
.webhook-url-input{flex:1;padding:6px 8px;border:1px solid #ccc;border-radius:4px}
38+
.webhook-inspector-wrap{margin-top:8px;height:calc(100vh - 160px)}
39+
.webhook-copy-button,.webhook-clear-button{padding:6px 10px;border-radius:4px;border:1px solid #888;background:#f2f2f2;cursor:pointer}

0 commit comments

Comments
 (0)