UNPKG

serverless-spy

Version:

CDK-based library for writing elegant integration tests on AWS serverless architecture and an additional web console to monitor events in real time.

500 lines (449 loc) 15.6 kB
import { Modal, Tooltip } from 'bootstrap'; import formatHighlight from 'json-format-highlight'; // import { sampleData } from './sampleData'; // Leave this for testing import { EventBridgeBaseSpyEvent } from '../common/spyEvents/EventBridgeBaseSpyEvent'; import { FunctionBaseSpyEvent } from '../common/spyEvents/FunctionBaseSpyEvent'; import { SnsSpyEventBase } from '../common/spyEvents/SnsSpyEventBase'; import { SpyEvent } from '../common/spyEvents/SpyEvent'; import { SpyMessage } from '../common/spyEvents/SpyMessage'; import { getTopic } from '../listener/topic'; //needed because of strange bug in Bootstrap Tooltip (window as any).process = { env: {} }; document.addEventListener('DOMContentLoaded', function () { run(); }); function run() { // ************* variables ************* const spyMessages: (SpyMessageExt | SpyMessageGroup)[] = []; const snsEventsByMessageId: Record<string, SpyMessageExt<SnsSpyEventBase>[]> = {}; const functionEventsByRequestId: Record< string, SpyMessageExt<FunctionBaseSpyEvent>[] > = {}; const eventBridgeById: Record< string, SpyMessageExt<EventBridgeBaseSpyEvent>[] > = {}; let uiNeedsRefresh = false; let stackList: string[] | undefined; let stackNameToTopicMappings: Record<string, string> | undefined; const selectedStackLocalStorageKey = 'selectedStack'; let ws: WebSocket; let selectedStack: string | undefined; // ************* HTML elements ************* const tableBodyElement = document.getElementById('tableBody')!; const modalTimeElement = document.getElementById('time')!; const modalServiceKeyElement = document.getElementById('serviceKey')!; const modalDataElement = document.getElementById('data')!; const errorContentElement = document.getElementById('errorContent')!; const stackListContainerElement = document.getElementById('stackListContainer')!; const stackListElement = document.getElementById( 'stackList' ) as HTMLSelectElement; const serviceKeyFilterInputElement = document.getElementById( 'serviceKeyFilter' ) as HTMLInputElement; const dataFilterInputElement = document.getElementById( 'dataFilter' ) as HTMLInputElement; const detailsModal = new Modal('#detailsModal', { backdrop: true, keyboard: true, }); const errorModal = new Modal('#errorModal', { backdrop: true, keyboard: true, }); // ************* events ************* serviceKeyFilterInputElement.addEventListener('input', () => { uiNeedsRefresh = true; }); dataFilterInputElement.addEventListener('input', () => { uiNeedsRefresh = true; }); tableBodyElement.addEventListener('click', openDetails()); stackListElement.addEventListener('change', () => { switchStack(); }); void (async () => { await fillStacks(); await connectToWebSocket(); await loadStackNameToTopicMappings(); })(); window.requestAnimationFrame(render); setupTooltip(); // Do not remove! // sample data for testing purposes // for (const sm of sampleData) { // addSpyMessage(sm as any); // } function updateSelectedStack() { selectedStack = stackListElement.value; console.log(`Updating selected stack to ${selectedStack}`); } function switchStack() { updateSelectedStack(); uiNeedsRefresh = true; } async function fillStacks() { const response = await fetch('/stackList'); try { stackList = await response.json(); } catch { stackList = []; } selectedStack = localStorage.getItem(selectedStackLocalStorageKey); stackListElement.innerHTML = stackList ?.map( (s) => `<option value="${s}" ${ selectedStack === s ? 'selected' : '' }>${s}</option>` ) .join('') ?? ''; updateSelectedStack(); console.log(stackListContainerElement.style.display); stackListContainerElement.style.display = stackList && stackList.length > 1 ? '' : 'none'; } function canFilterOnTopic() { return ( stackList && stackList?.length > 1 && stackNameToTopicMappings && Object.values(stackNameToTopicMappings).length ); } async function loadStackNameToTopicMappings() { const response = await fetch('/stackTopicMappings'); try { stackNameToTopicMappings = await response.json(); } catch { stackNameToTopicMappings = {}; } } async function connectToWebSocket() { try { const response = await fetch(`/wsUrl`); if (response.ok) { const url = await response.text(); ws = new WebSocket(url); ws.addEventListener('open', () => { console.log(`Connected ${new Date().toISOString()} to IoT`); }); ws.addEventListener('message', receiveSpyMessage()); ws.addEventListener('close', () => { console.log(`Disconnected ${new Date().toISOString()}, reconnecting`); void connectToWebSocket(); }); } else { showError(await response.text()); } } catch (err) { showError(err.message); } } function showError(messsage: string) { errorContentElement.innerHTML = messsage; errorModal.show(); } function render() { if (uiNeedsRefresh) { tableBodyElement.innerHTML = spyMessages .map((sm) => renderSpyMessage(sm)) .join(''); uiNeedsRefresh = false; } window.requestAnimationFrame(render); } function receiveSpyMessage(): ( this: WebSocket, ev: MessageEvent<any> ) => any { return ({ data }) => { let parsed; try { parsed = JSON.parse(data); } catch (err) { console.error('Can not parse ' + data); } addSpyMessage(parsed); }; } function openDetails(): (this: HTMLElement, ev: MouseEvent) => any { return (e) => { const row = (e.target as HTMLElement).closest('tr'); const dataElements = row?.getElementsByClassName('data'); const timeElements = row?.getElementsByClassName('time'); const serviceKeyElements = row?.getElementsByClassName('serviceKey'); if ( !dataElements || dataElements.length === 0 || !timeElements || timeElements.length === 0 || !serviceKeyElements || serviceKeyElements.length === 0 ) { return; } modalTimeElement.innerHTML = timeElements[0].textContent || ''; modalServiceKeyElement.innerHTML = serviceKeyElements[0].textContent || ''; modalDataElement.innerHTML = formatDataJson( dataElements[0].textContent || '' ); detailsModal.show(); }; } function formatDataJson(dataJson: string) { return formatHighlight(JSON.stringify(JSON.parse(dataJson), null, 2), { keyColor: 'black', numberColor: 'blue', stringColor: '#0B7500', trueColor: '#00cc00', falseColor: '#ff8080', nullColor: 'cornflowerblue', }); } function addSpyMessage(spyMessage: SpyMessageExt) { const spyMessageExt = spyMessage as SpyMessageExt; spyMessageExt.dataJsonLowerCase = JSON.stringify( spyMessageExt.data ).toLocaleLowerCase(); spyMessageExt.serviceKeyLowerCase = spyMessageExt.serviceKey.toLocaleLowerCase(); const service = getServiceNameFromServiceKey(spyMessageExt.serviceKey); let spyMessageToAdd: SpyMessageExt | SpyMessageGroup | undefined = spyMessageExt; if (service === 'Function') { const spyMessageFunction = spyMessageExt as SpyMessageExt<FunctionBaseSpyEvent>; const awsRequestId = spyMessageFunction.data.context.awsRequestId; let functionEvents = functionEventsByRequestId[awsRequestId]; if (!functionEvents) { functionEvents = []; functionEventsByRequestId[awsRequestId] = functionEvents; } const step = getFunctionStepFromServiceKey(spyMessageExt.serviceKey); if (step === 'Request') { spyMessageToAdd = <SpyMessageGroup>{ timestamp: spyMessageExt.timestamp, serviceKey: spyMessageExt.serviceKey, messages: functionEvents, topic: spyMessageExt.topic, }; functionEvents.unshift(spyMessageFunction); } else { addSpyMessageToArraySorted(spyMessageFunction, functionEvents); spyMessageToAdd = undefined; } } else if (service === 'SnsSubscription' || service === 'SnsTopic') { const spyMessageSns = spyMessageExt as SpyMessageExt<SnsSpyEventBase>; const messageId = spyMessageSns.data.messageId; let snsEvents = snsEventsByMessageId[messageId]; if (!snsEvents) { snsEvents = []; snsEventsByMessageId[messageId] = snsEvents; } if (service === 'SnsTopic') { spyMessageToAdd = <SpyMessageGroup>{ timestamp: spyMessageExt.timestamp, serviceKey: spyMessageExt.serviceKey, messages: snsEvents, topic: spyMessageExt.topic, }; snsEvents.unshift(spyMessageSns); } else { addSpyMessageToArraySorted(spyMessageSns, snsEvents); spyMessageToAdd = undefined; } } else if (service === 'EventBridge' || service === 'EventBridgeRule') { const spyMessageEventBridge = spyMessageExt as SpyMessageExt<EventBridgeBaseSpyEvent>; const eventBridgeId = spyMessageEventBridge.data.eventBridgeId; let eventBridgeEvents = eventBridgeById[eventBridgeId]; if (!eventBridgeEvents) { eventBridgeEvents = []; eventBridgeById[eventBridgeId] = eventBridgeEvents; } if (service === 'EventBridge') { spyMessageToAdd = <SpyMessageGroup>{ timestamp: spyMessageExt.timestamp, serviceKey: spyMessageExt.serviceKey, messages: eventBridgeEvents, topic: spyMessageExt.topic, }; eventBridgeEvents.unshift(spyMessageEventBridge); } else { addSpyMessageToArraySorted(spyMessageEventBridge, eventBridgeEvents); spyMessageToAdd = undefined; } } if (spyMessageToAdd) { //add in correct order addSpyMessageToArraySorted(spyMessageToAdd, spyMessages); uiNeedsRefresh = true; } } function matchFilter( spyMessage: SpyMessageExt, filter: { serviceKey: string; data: string; } ) { let testServiceKey = false; try { testServiceKey = !filter.serviceKey || new RegExp(filter.serviceKey).test(spyMessage.serviceKeyLowerCase); } catch {} let testData = false; try { testData = !filter.data || new RegExp(filter.data).test(spyMessage.dataJsonLowerCase); } catch {} return testServiceKey && testData; } function getFunctionStepFromServiceKey(serviceKey: string) { const serviceKeyParts = serviceKey.split('#'); const step = serviceKeyParts.length > 0 ? serviceKeyParts[serviceKeyParts.length - 1] : undefined; return step; } function resolveTopic(selectedStack: string | undefined) { if (!selectedStack || !stackNameToTopicMappings) return '#'; return getTopic(stackNameToTopicMappings[selectedStack]); } function renderSpyMessage(spyMessage: SpyMessageExt | SpyMessageGroup) { const serviceKey = serviceKeyFilterInputElement.value?.toLocaleLowerCase(); const data = dataFilterInputElement.value?.toLocaleLowerCase(); let messages: SpyMessageExt[]; if ((spyMessage as SpyMessageGroup).messages) { messages = (spyMessage as SpyMessageGroup).messages; } else { messages = [spyMessage as SpyMessageExt]; } const html = messages .filter( (sm) => !canFilterOnTopic() || sm.topic === resolveTopic(selectedStack) ) .filter((sm) => matchFilter(sm, { serviceKey, data })) .map((sm, i) => { const service = getServiceNameFromServiceKey(sm.serviceKey); let icon: string | undefined; let iconLinked = '<i class="icon-linked bi bi-arrow-return-right"></i>'; switch (service) { case 'Function': const step = getFunctionStepFromServiceKey(sm.serviceKey); if (i === 0) { icon = `<img class="aws-icon" src="icons/Arch_AWS-Lambda_16.svg" ></img>`; } else { icon = iconLinked; } break; case 'S3': icon = '<img class="aws-icon" src="icons/Arch_Amazon-Simple-Storage-Service_16.svg"/>'; break; case 'SnsTopic': icon = '<img class="aws-icon" src="icons/Arch_Amazon-Simple-Notification-Service_16.svg" />'; break; case 'EventBridge': icon = '<img class="aws-icon" src="icons/Arch_Amazon-EventBridge_16.svg" />'; break; case 'DynamoDB': icon = '<img class="aws-icon" src="icons/Arch_Amazon-DynamoDB_16.svg" />'; break; case 'Sqs': icon = '<img class="aws-icon" src="icons/Arch_Amazon-Simple-Queue-Service_16.svg" />'; break; case 'EventBridgeRule': if (i === 0) { icon = '<img class="aws-icon" src="icons/Arch_Amazon-EventBridge_16.svg" />'; } else { icon = iconLinked; } break; case 'SnsSubscription': if (i === 0) { icon = '<img class="aws-icon" src="icons/Arch_Amazon-Simple-Notification-Service_16.svg" />'; } else { icon = iconLinked; } break; default: break; } let timeStamp; try { timeStamp = new Date(sm.timestamp).toISOString(); } catch (e) { console.log(`Failed to render ${JSON.stringify(sm)}: ${e}`); throw e; } return `<tr> <td class="col-time"><span class="time">${timeStamp}</span></td> <td class="col-servicekey">${icon}<span class="serviceKey">${ sm.serviceKey }</span></td> <td class="col-data"><div class="data">${JSON.stringify( sm.data )}</div></td></tr> `; }) .join(''); return html; } function getServiceNameFromServiceKey(serviceKey: string) { const serviceKeyParts = serviceKey.split('#'); const service = serviceKeyParts.length > 0 ? serviceKeyParts[0] : undefined; return service; } function setupTooltip() { var tooltipTriggerList = [].slice.call( document.querySelectorAll('[data-bs-toggle="tooltip"]') ); tooltipTriggerList.map(function (tooltipTriggerEl) { return new Tooltip(tooltipTriggerEl); }); } function addSpyMessageToArraySorted( spyMessageToAdd: SpyMessageExt | SpyMessageGroup, spyMessages: (SpyMessageExt | SpyMessageGroup)[] ) { // console.log('Adding message'); let index = 0; for (let i = spyMessages.length - 1; i >= 0; i--) { const currentSpyMessages = spyMessages[i]; index = i + 1; if (i === 0 || currentSpyMessages.timestamp < spyMessageToAdd.timestamp) { break; } } spyMessages.splice(index, 0, spyMessageToAdd); } } type SpyMessageExt<T extends SpyEvent = SpyEvent> = SpyMessage<T> & { dataJsonLowerCase: string; serviceKeyLowerCase: string; topic: string; }; type SpyMessageGroup<T extends SpyEvent = SpyEvent> = { timestamp: string; serviceKey: string; messages: SpyMessageExt<T>[]; topic: string; };