From bb140dcfa3267982ea54870850da234582f097aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=BE=D0=B2=20=D0=90=D1=80=D1=82?= =?UTF-8?q?=D0=B5=D0=BC?= Date: Fri, 5 Jun 2026 02:16:36 +0300 Subject: [PATCH] first commit --- .gitignore | 24 ++ README.md | 147 ++++++++++ package.json | 12 + public/app.js | 365 ++++++++++++++++++++++++ public/index.html | 137 +++++++++ public/styles.css | 688 ++++++++++++++++++++++++++++++++++++++++++++++ server.mjs | 273 ++++++++++++++++++ 7 files changed, 1646 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100644 server.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11916ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +runtime-logs/ +*.log + +.env +.env.* +!.env.example + +.DS_Store +Thumbs.db + +coverage/ +dist/ +build/ +.cache/ +.parcel-cache/ +.vite/ + +*.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff7d8ca --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Ingest Monitor + +Local web monitor for inspecting JSON payloads sent to an ingest endpoint. + +The app exposes `POST /api/ingest`, stores recent packets in memory, and streams live updates to the browser with SSE. + +## Requirements + +- Node.js 20+ + +No npm dependencies are required. + +## Run + +From Bash: + +```bash +cd /c/Users/myart/Drill/greact.drill.ingest.monitor +PORT=8090 npm run start +``` + +Open: + +```text +http://localhost:8090 +``` + +Send ingest payloads to: + +```text +POST http://localhost:8090/api/ingest +``` + +## Configure mqtt-ingest + +Point `mqtt-ingest` to the monitor: + +```bash +export EDGE5_MODBUS_POST_URL="http://localhost:8090/api/ingest" +``` + +Then run `mqtt-ingest` from its `app` directory: + +```bash +cd /c/Users/myart/Drill/mqtt-ingest/app +export MQTT_URL="mqtt://localhost:1883" +export HTTP_PORT="8080" +export WS_PORT="9090" +export EDGE5_MODBUS_POST_URL="http://localhost:8090/api/ingest" +npm run start +``` + +Make sure an MQTT broker is listening on `localhost:1883`. + +## Proxy Mode + +The monitor can show payloads and forward the same JSON to a real ingest service: + +```bash +PORT=8090 FORWARD_URL="https://demo.backend.drll.cloud/api/ingest" npm run start +``` + +In this mode: + +```text +mqtt-ingest -> ingest-monitor -> real ingest service + | + +-> browser UI via SSE +``` + +## Environment Variables + +```text +PORT HTTP port. Default: 8090 +HOST Bind address. Default: 0.0.0.0 +FORWARD_URL Optional upstream ingest URL for proxy mode +MAX_EVENTS Recent packets kept in memory. Default: 500 +MAX_BODY_BYTES Max request body size. Default: 5242880 +``` + +For lighter browser memory usage: + +```bash +MAX_EVENTS=100 PORT=8090 npm run start +``` + +## API + +### POST /api/ingest + +Accepts either one JSON object or an array of objects. + +Expected object shape: + +```json +{ + "edge": "edge5", + "timestamp": 1780612349372, + "tag": "modbus.1", + "value": 197.14 +} +``` + +### GET /api/events + +Returns the current in-memory snapshot. + +### DELETE /api/events + +Clears monitor memory. + +### GET /events + +SSE stream used by the browser UI. + +## Data Storage + +Packets are stored only in the Node.js process memory. They are not written to disk or a database. + +After restarting the monitor, history is cleared. + +## UI Notes + +- `Following latest` keeps the selected packet pinned to the newest packet. +- Selecting a packet disables follow mode, so new packets do not steal focus. +- `Expand` opens the raw JSON payload in a large viewer. +- `Copy` copies the selected payload as formatted JSON. + +## Troubleshooting + +If the UI is empty, check that payloads are being posted to: + +```text +http://localhost:8090/api/ingest +``` + +If `mqtt-ingest` cannot connect to MQTT, start Mosquitto: + +```bash +mosquitto -p 1883 -v +``` + +If the monitor port is busy, choose another port: + +```bash +PORT=8091 npm run start +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..5661bbb --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "greact.drill.ingest.monitor", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "dev": "node --watch server.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..35e7304 --- /dev/null +++ b/public/app.js @@ -0,0 +1,365 @@ +const state = { + events: [], + selectedId: undefined, + filter: '', + connected: false, + followLatest: true, + maxEvents: 500, +} + +const elements = { + batchCount: document.querySelector('#batchCount'), + itemCount: document.querySelector('#itemCount'), + byteCount: document.querySelector('#byteCount'), + lastBatch: document.querySelector('#lastBatch'), + packetRate: document.querySelector('#packetRate'), + packetList: document.querySelector('#packetList'), + itemTable: document.querySelector('#itemTable'), + rawPayload: document.querySelector('#rawPayload'), + jsonMeta: document.querySelector('#jsonMeta'), + expandJsonButton: document.querySelector('#expandJsonButton'), + copyRawButton: document.querySelector('#copyRawButton'), + jsonModal: document.querySelector('#jsonModal'), + jsonModalBackdrop: document.querySelector('#jsonModalBackdrop'), + jsonModalTitle: document.querySelector('#jsonModalTitle'), + jsonModalPayload: document.querySelector('#jsonModalPayload'), + copyModalJsonButton: document.querySelector('#copyModalJsonButton'), + closeJsonModalButton: document.querySelector('#closeJsonModalButton'), + detailTitle: document.querySelector('#detailTitle'), + detailMeta: document.querySelector('#detailMeta'), + connectionStatus: document.querySelector('#connectionStatus'), + filterInput: document.querySelector('#filterInput'), + followButton: document.querySelector('#followButton'), + copyButton: document.querySelector('#copyButton'), + clearButton: document.querySelector('#clearButton'), +} + +const numberFormatter = new Intl.NumberFormat('ru-RU') +const byteFormatter = new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: 1, +}) + +function formatBytes(bytes) { + if (bytes < 1024) { + return `${bytes} B` + } + + if (bytes < 1024 * 1024) { + return `${byteFormatter.format(bytes / 1024)} KB` + } + + return `${byteFormatter.format(bytes / 1024 / 1024)} MB` +} + +function formatTime(timestamp) { + return new Intl.DateTimeFormat('ru-RU', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(timestamp)) +} + +function getSelectedEvent() { + return state.events.find((event) => event.id === state.selectedId) +} + +function getSelectedJson() { + const selected = getSelectedEvent() + return selected ? JSON.stringify(selected.payload, null, 2) : '' +} + +async function copySelectedJson(button) { + const json = getSelectedJson() + + if (!json) { + return + } + + const originalText = button.textContent + await navigator.clipboard.writeText(json) + button.textContent = 'Copied' + setTimeout(() => { + button.textContent = originalText + }, 900) +} + +function openJsonModal() { + const selected = getSelectedEvent() + + if (!selected) { + return + } + + elements.jsonModalTitle.textContent = `${selected.count} values - ${formatTime( + selected.receivedAt, + )}` + elements.jsonModalPayload.textContent = getSelectedJson() + elements.jsonModal.classList.add('open') + elements.jsonModal.setAttribute('aria-hidden', 'false') + document.body.classList.add('modal-open') +} + +function closeJsonModal() { + elements.jsonModal.classList.remove('open') + elements.jsonModal.setAttribute('aria-hidden', 'true') + document.body.classList.remove('modal-open') +} + +function setConnectionStatus(status) { + state.connected = status === 'live' + elements.connectionStatus.className = `status ${status}` + elements.connectionStatus.querySelector('span:last-child').textContent = status +} + +function applySnapshot(snapshot) { + const previousSelectedId = state.selectedId + state.events = snapshot.events ?? [] + state.maxEvents = snapshot.maxEvents ?? state.maxEvents + const latest = state.events[state.events.length - 1] + const selectedStillExists = state.events.some( + (event) => event.id === previousSelectedId, + ) + + if (state.followLatest && latest) { + state.selectedId = latest.id + } else if (!selectedStillExists && latest) { + state.selectedId = latest.id + } else if (state.events.length === 0) { + state.selectedId = undefined + } + + elements.batchCount.textContent = numberFormatter.format(snapshot.receivedBatches ?? 0) + elements.itemCount.textContent = numberFormatter.format(snapshot.receivedItems ?? 0) + elements.byteCount.textContent = formatBytes(snapshot.receivedBytes ?? 0) + elements.packetRate.textContent = `${numberFormatter.format( + snapshot.recentBatchesPerMinute ?? 0, + )}/min` + elements.lastBatch.textContent = snapshot.latest + ? `${snapshot.latest.count} items` + : 'none' + + render() +} + +function applySummary(summary) { + state.maxEvents = summary.maxEvents ?? state.maxEvents + elements.batchCount.textContent = numberFormatter.format(summary.receivedBatches ?? 0) + elements.itemCount.textContent = numberFormatter.format(summary.receivedItems ?? 0) + elements.byteCount.textContent = formatBytes(summary.receivedBytes ?? 0) + elements.packetRate.textContent = `${numberFormatter.format( + summary.recentBatchesPerMinute ?? 0, + )}/min` + elements.lastBatch.textContent = summary.latest + ? `${summary.latest.count} items` + : 'none' +} + +function appendEvent(event) { + state.events.push(event) + + while (state.events.length > state.maxEvents) { + const removed = state.events.shift() + + if (removed?.id === state.selectedId) { + state.selectedId = state.events[0]?.id + } + } +} + +function renderPackets() { + if (state.events.length === 0) { + elements.packetList.innerHTML = '
No packets yet.
' + return + } + + elements.packetList.innerHTML = state.events + .slice() + .reverse() + .map((event) => { + const active = event.id === state.selectedId ? ' active' : '' + const forward = event.forward + ? `forward: ${event.forward.status}` + : 'local only' + + return ` + + ` + }) + .join('') +} + +function matchesFilter(item) { + if (!state.filter) { + return true + } + + const haystack = `${item.edge} ${item.tag} ${item.value}`.toLowerCase() + return haystack.includes(state.filter) +} + +function renderDetails() { + const selected = getSelectedEvent() + + if (!selected) { + elements.detailTitle.textContent = 'Waiting for ingest' + elements.detailMeta.textContent = 'POST /api/ingest' + elements.jsonMeta.textContent = 'No payload selected' + elements.itemTable.innerHTML = '' + elements.rawPayload.textContent = 'No payload yet.' + return + } + + const visibleItems = selected.items.filter(matchesFilter) + + elements.detailTitle.textContent = `${selected.count} values received` + elements.detailMeta.textContent = `${formatTime(selected.receivedAt)} · ${formatBytes( + selected.bytes, + )}${selected.forward ? ` · forward: ${selected.forward.status}` : ''}` + elements.jsonMeta.textContent = `${formatBytes(selected.bytes)} - ${selected.count} values` + elements.detailMeta.textContent = `${formatTime(selected.receivedAt)} - ${formatBytes( + selected.bytes, + )}${selected.forward ? ` - forward: ${selected.forward.status}` : ''}` + elements.rawPayload.textContent = getSelectedJson() + + if (elements.jsonModal.classList.contains('open')) { + elements.jsonModalTitle.textContent = `${selected.count} values - ${formatTime( + selected.receivedAt, + )}` + elements.jsonModalPayload.textContent = getSelectedJson() + } + + elements.itemTable.innerHTML = + visibleItems.length === 0 + ? 'No matching values.' + : visibleItems + .map( + (item) => ` + + ${item.tag} + ${item.edge} + ${item.value} + ${formatTime(item.timestamp)} + + `, + ) + .join('') +} + +function render() { + elements.followButton.classList.toggle('active', state.followLatest) + elements.followButton.textContent = state.followLatest + ? 'Following latest' + : 'Follow latest' + renderPackets() + renderDetails() +} + +async function loadSnapshot() { + const response = await fetch('/api/events') + applySnapshot(await response.json()) +} + +function connectEvents() { + const source = new EventSource('/events') + + source.onopen = () => setConnectionStatus('live') + source.onerror = () => setConnectionStatus('offline') + source.onmessage = (message) => { + const data = JSON.parse(message.data) + + if (data.snapshot) { + applySnapshot(data.snapshot) + return + } + + if (data.type === 'ingest') { + appendEvent(data.event) + applySummary(data.summary) + + if (state.followLatest || !state.selectedId) { + state.selectedId = data.event.id + } + + render() + } + } +} + +elements.packetList.addEventListener('click', (event) => { + const packet = event.target.closest('.packet') + + if (!packet) { + return + } + + state.selectedId = packet.dataset.id + state.followLatest = false + render() +}) + +elements.followButton.addEventListener('click', () => { + state.followLatest = !state.followLatest + + if (state.followLatest && state.events.length > 0) { + state.selectedId = state.events[state.events.length - 1].id + } + + render() +}) + +elements.filterInput.addEventListener('input', (event) => { + state.filter = event.target.value.trim().toLowerCase() + renderDetails() +}) + +elements.copyButton.addEventListener('click', async () => { + await copySelectedJson(elements.copyButton) +}) + +elements.copyRawButton.addEventListener('click', async () => { + await copySelectedJson(elements.copyRawButton) +}) + +elements.expandJsonButton.addEventListener('click', () => { + openJsonModal() +}) + +elements.copyModalJsonButton.addEventListener('click', async () => { + await copySelectedJson(elements.copyModalJsonButton) +}) + +elements.closeJsonModalButton.addEventListener('click', () => { + closeJsonModal() +}) + +elements.jsonModalBackdrop.addEventListener('click', () => { + closeJsonModal() +}) + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + closeJsonModal() + } +}) + +elements.clearButton.addEventListener('click', async () => { + await fetch('/api/events', { method: 'DELETE' }) + state.events = [] + state.selectedId = undefined + state.followLatest = true + render() + await loadSnapshot() +}) + +setConnectionStatus('connecting') +await loadSnapshot() +connectEvents() diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..13aa42a --- /dev/null +++ b/public/index.html @@ -0,0 +1,137 @@ + + + + + + Ingest Monitor + + + +
+
+
+ +
+

greact.drill

+

Ingest Monitor

+
+
+
+
+ POST + /api/ingest +
+
+ + connecting +
+
+
+ +
+
+
+ Packets + 0 +
+
+ Items + 0 +
+
+ Bytes + 0 B +
+
+ Last batch + none +
+
+ +
+ +
+ + + +
+
+ +
+ + +
+
+
+

Selected packet

+

Waiting for ingest

+
+ POST /api/ingest +
+
+ + + + + + + + + + +
TagEdgeValueTimestamp
+
+
+
+
+

Raw JSON

+ No payload selected +
+
+ + +
+
+
No payload yet.
+
+
+
+
+
+ + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..25eed25 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,688 @@ +:root { + color-scheme: dark; + --page: #090b0f; + --page-2: #0d1117; + --surface: #121720; + --surface-2: #171e29; + --surface-3: #1d2633; + --glass: rgba(18, 23, 32, 0.84); + --ink: #f3f7fb; + --muted: #9ba8b7; + --faint: #697586; + --line: rgba(154, 169, 186, 0.16); + --line-strong: rgba(154, 169, 186, 0.28); + --green: #4ee3a0; + --green-strong: #18c47f; + --green-soft: rgba(78, 227, 160, 0.13); + --blue: #66c2ff; + --blue-strong: #2f8cff; + --blue-soft: rgba(102, 194, 255, 0.13); + --amber: #ffd166; + --amber-soft: rgba(255, 209, 102, 0.14); + --red: #ff6b86; + --red-soft: rgba(255, 107, 134, 0.14); + --violet: #b9a4ff; + --shadow: 0 28px 80px rgba(0, 0, 0, 0.46); + --shadow-soft: 0 16px 40px rgba(0, 0, 0, 0.28); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: + linear-gradient(180deg, rgba(102, 194, 255, 0.08), transparent 280px), + linear-gradient(135deg, rgba(78, 227, 160, 0.06), transparent 42%), + var(--page); + color: var(--ink); +} + +body::before { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background-image: + linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px); + background-size: 48px 48px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.8), transparent 76%); + content: ""; +} + +button, +input { + font: inherit; +} + +.shell { + width: min(1480px, 100%); + margin: 0 auto; + padding: 24px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 18px; +} + +.brand, +.top-actions { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.mark { + display: grid; + grid-template-columns: repeat(3, 8px); + align-items: end; + gap: 4px; + width: 48px; + height: 48px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent), + var(--surface); + box-shadow: var(--shadow-soft); +} + +.mark span { + display: block; + width: 8px; + border-radius: 999px; + background: var(--green); + box-shadow: 0 0 18px rgba(78, 227, 160, 0.4); +} + +.mark span:nth-child(1) { + height: 14px; +} + +.mark span:nth-child(2) { + height: 25px; + background: var(--blue); + box-shadow: 0 0 18px rgba(102, 194, 255, 0.42); +} + +.mark span:nth-child(3) { + height: 18px; + background: var(--amber); + box-shadow: 0 0 18px rgba(255, 209, 102, 0.36); +} + +.eyebrow, +.section-kicker { + margin: 0; + color: var(--green); + font-size: 11px; + font-weight: 850; + letter-spacing: 0; + text-transform: uppercase; +} + +.section-kicker { + color: var(--muted); +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + overflow: hidden; + font-size: clamp(28px, 4vw, 42px); + line-height: 1.02; + text-overflow: ellipsis; + white-space: nowrap; +} + +h2 { + margin-top: 2px; + font-size: 17px; +} + +.endpoint, +.status { + display: inline-flex; + align-items: center; + min-height: 44px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--glass); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(14px); +} + +.endpoint { + gap: 10px; + padding: 0 12px; +} + +.endpoint span { + border: 1px solid rgba(102, 194, 255, 0.2); + border-radius: 999px; + background: var(--blue-soft); + color: var(--blue); + font-size: 11px; + font-weight: 850; + padding: 4px 7px; +} + +.endpoint code { + color: var(--ink); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 13px; +} + +.status { + gap: 10px; + min-width: 132px; + padding: 0 14px; + color: var(--muted); + font-weight: 750; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 0 4px var(--amber-soft), 0 0 18px rgba(255, 209, 102, 0.42); +} + +.status.live .dot { + background: var(--green); + box-shadow: 0 0 0 4px var(--green-soft), 0 0 20px rgba(78, 227, 160, 0.5); +} + +.status.offline .dot { + background: var(--red); + box-shadow: 0 0 0 4px var(--red-soft), 0 0 20px rgba(255, 107, 134, 0.45); +} + +.layout { + display: grid; + gap: 14px; +} + +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.metrics article, +.toolbar, +.packets, +.details { + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent), + var(--glass); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(14px); +} + +.metrics article { + position: relative; + min-height: 108px; + overflow: hidden; + padding: 17px; +} + +.metrics article::before { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 2px; + background: linear-gradient(90deg, var(--green), var(--blue), var(--violet)); + content: ""; +} + +.metrics article::after { + position: absolute; + right: 14px; + bottom: 12px; + width: 56px; + height: 28px; + border-right: 1px solid rgba(255, 255, 255, 0.09); + border-bottom: 1px solid rgba(255, 255, 255, 0.09); + content: ""; +} + +.metrics span { + display: block; + margin-bottom: 14px; + color: var(--muted); + font-size: 13px; + font-weight: 750; +} + +.metrics strong { + display: block; + overflow: hidden; + font-size: clamp(24px, 3vw, 34px); + line-height: 1.05; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + padding: 14px; +} + +.search { + display: grid; + flex: 1; + gap: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 800; +} + +.search input { + width: 100%; + min-height: 44px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(9, 11, 15, 0.45); + color: var(--ink); + outline: none; + padding: 0 13px; +} + +.search input::placeholder { + color: var(--faint); +} + +.search input:focus { + border-color: rgba(102, 194, 255, 0.78); + background: rgba(9, 11, 15, 0.62); + box-shadow: 0 0 0 4px var(--blue-soft); +} + +.actions { + display: flex; + gap: 8px; +} + +button { + min-height: 44px; + border: 1px solid var(--line); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent), + var(--surface-2); + color: var(--ink); + cursor: pointer; + font-weight: 800; + padding: 0 14px; + transition: + border-color 140ms ease, + background 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +button:hover { + border-color: rgba(102, 194, 255, 0.7); + box-shadow: 0 0 0 4px var(--blue-soft); + transform: translateY(-1px); +} + +button.active { + border-color: rgba(78, 227, 160, 0.5); + background: + linear-gradient(180deg, rgba(78, 227, 160, 0.18), transparent), + var(--surface-2); + color: var(--green); +} + +.content { + display: grid; + grid-template-columns: minmax(300px, 390px) minmax(0, 1fr); + gap: 14px; + min-height: 630px; +} + +.packets, +.details { + min-width: 0; + overflow: hidden; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 66px; + padding: 0 16px; + border-bottom: 1px solid var(--line); + background: rgba(255, 255, 255, 0.025); +} + +.section-head span { + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(9, 11, 15, 0.42); + color: var(--muted); + font-size: 12px; + font-weight: 800; + padding: 7px 10px; + white-space: nowrap; +} + +.packet-list { + height: calc(100vh - 326px); + min-height: 560px; + overflow: auto; + padding: 10px; + scrollbar-color: var(--surface-3) transparent; +} + +.packet { + width: 100%; + margin: 0 0 8px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(13, 17, 23, 0.72); + color: var(--ink); + text-align: left; + transition: + border-color 140ms ease, + background 140ms ease, + transform 140ms ease, + box-shadow 140ms ease; +} + +.packet:hover { + transform: translateY(-1px); +} + +.packet.active { + border-color: rgba(78, 227, 160, 0.72); + background: + linear-gradient(135deg, rgba(78, 227, 160, 0.17), rgba(102, 194, 255, 0.06)), + rgba(13, 17, 23, 0.9); + box-shadow: 0 0 0 4px rgba(78, 227, 160, 0.08); +} + +.packet-main { + display: flex; + justify-content: space-between; + gap: 8px; + font-weight: 900; +} + +.packet-meta { + display: flex; + justify-content: space-between; + gap: 8px; + margin-top: 8px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.table-wrap { + max-height: 410px; + overflow: auto; + scrollbar-color: var(--surface-3) transparent; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 12px 14px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} + +th { + position: sticky; + top: 0; + z-index: 1; + background: #121720; + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +td { + color: #dce6ef; + font-size: 13px; +} + +td:first-child { + color: var(--ink); + font-weight: 850; +} + +td.value { + color: var(--green); + font-variant-numeric: tabular-nums; + font-weight: 900; +} + +tr:hover td { + background: rgba(255, 255, 255, 0.025); +} + +.json-panel { + margin: 14px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(7, 9, 13, 0.72); +} + +.json-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 58px; + padding: 10px 12px; + border-bottom: 1px solid var(--line); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent), + rgba(18, 23, 32, 0.92); +} + +.json-panel-head strong { + display: block; + margin-top: 3px; + color: var(--ink); + font-size: 13px; +} + +.json-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.json-actions button { + min-height: 36px; + padding: 0 11px; +} + +.raw { + height: 250px; + margin: 0; + overflow: auto; + border: 0; + border-radius: 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), + #07090d; + color: #d7e2ef; + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 12px; + line-height: 1.55; + padding: 14px; + white-space: pre-wrap; + scrollbar-color: var(--surface-3) transparent; +} + +.modal-open { + overflow: hidden; +} + +.modal { + position: fixed; + inset: 0; + z-index: 30; + display: none; + padding: 28px; +} + +.modal.open { + display: grid; + place-items: center; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: + radial-gradient(circle at 50% 0%, rgba(102, 194, 255, 0.14), transparent 34rem), + rgba(2, 4, 8, 0.78); + backdrop-filter: blur(10px); +} + +.modal-card { + position: relative; + z-index: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(1120px, 100%); + height: min(820px, calc(100vh - 56px)); + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent), + var(--surface); + box-shadow: var(--shadow); +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 70px; + padding: 0 16px; + border-bottom: 1px solid var(--line); + background: rgba(9, 11, 15, 0.42); +} + +.modal-raw { + min-height: 0; + margin: 0; + overflow: auto; + background: #07090d; + color: #d7e2ef; + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 12px; + line-height: 1.6; + padding: 18px; + white-space: pre-wrap; + scrollbar-color: var(--surface-3) transparent; +} + +.empty { + padding: 18px; + color: var(--muted); +} + +@media (max-width: 980px) { + .shell { + padding: 16px; + } + + .topbar, + .toolbar { + align-items: stretch; + flex-direction: column; + } + + .top-actions { + align-items: stretch; + flex-wrap: wrap; + } + + .metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .content { + grid-template-columns: 1fr; + } + + .packet-list { + height: 300px; + min-height: 300px; + } +} + +@media (max-width: 580px) { + .brand { + align-items: flex-start; + } + + .metrics { + grid-template-columns: 1fr; + } + + .actions, + .top-actions, + .json-actions { + flex-direction: column; + } + + .modal { + padding: 12px; + } + + .modal-head { + align-items: stretch; + flex-direction: column; + padding: 14px; + } + + th, + td { + padding: 10px; + } +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..bc751f7 --- /dev/null +++ b/server.mjs @@ -0,0 +1,273 @@ +import { createServer } from 'node:http' +import { readFile } from 'node:fs/promises' +import { extname, join, normalize } from 'node:path' +import { fileURLToPath } from 'node:url' + +const port = Number(process.env.PORT ?? 8090) +const host = process.env.HOST ?? '0.0.0.0' +const forwardUrl = process.env.FORWARD_URL +const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 5 * 1024 * 1024) +const maxEvents = Number(process.env.MAX_EVENTS ?? 500) +const publicDir = join(fileURLToPath(new URL('.', import.meta.url)), 'public') + +const events = [] +const clients = new Set() + +let receivedBatches = 0 +let receivedItems = 0 +let receivedBytes = 0 +let startedAt = Date.now() + +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', +} + +function sendJson(res, status, body) { + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'access-control-allow-origin': '*', + }) + res.end(JSON.stringify(body)) +} + +function readRequestBody(req) { + return new Promise((resolve, reject) => { + const chunks = [] + let total = 0 + + req.on('data', (chunk) => { + total += chunk.length + + if (total > maxBodyBytes) { + reject(new Error(`Request body is larger than ${maxBodyBytes} bytes`)) + req.destroy() + return + } + + chunks.push(chunk) + }) + + req.on('end', () => resolve(Buffer.concat(chunks))) + req.on('error', reject) + }) +} + +function normalizePayload(payload) { + const items = Array.isArray(payload) ? payload : [payload] + return items.map((item, index) => ({ + edge: typeof item?.edge === 'string' ? item.edge : '', + timestamp: + typeof item?.timestamp === 'number' && Number.isFinite(item.timestamp) + ? item.timestamp + : Date.now(), + tag: typeof item?.tag === 'string' ? item.tag : `item.${index + 1}`, + value: + typeof item?.value === 'number' && Number.isFinite(item.value) + ? item.value + : Number(item?.value ?? 0), + raw: item, + })) +} + +function buildSnapshot() { + return { + ...buildSummary(), + events, + } +} + +function buildSummary() { + const latest = events[events.length - 1] + const now = Date.now() + const uptimeSeconds = Math.round((now - startedAt) / 1000) + const recent = events.filter((event) => now - event.receivedAt <= 60_000) + + return { + startedAt, + uptimeSeconds, + receivedBatches, + receivedItems, + receivedBytes, + recentBatchesPerMinute: recent.length, + maxEvents, + latest, + } +} + +function broadcast(message) { + const data = `data: ${JSON.stringify(message)}\n\n` + + for (const client of clients) { + client.write(data) + } +} + +async function handleIngest(req, res) { + let body + + try { + body = await readRequestBody(req) + } catch (err) { + sendJson(res, 413, { ok: false, error: err.message }) + return + } + + let payload + + try { + payload = JSON.parse(body.toString('utf8')) + } catch { + sendJson(res, 400, { ok: false, error: 'Expected JSON payload' }) + return + } + + const items = normalizePayload(payload) + const event = { + id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + receivedAt: Date.now(), + bytes: body.length, + count: items.length, + forward: forwardUrl ? { status: 'pending', url: forwardUrl } : undefined, + items, + payload, + } + + events.push(event) + while (events.length > maxEvents) { + events.shift() + } + + receivedBatches += 1 + receivedItems += items.length + receivedBytes += body.length + + console.log('ingest.received', { + id: event.id, + bytes: event.bytes, + count: event.count, + }) + + if (forwardUrl) { + try { + const forwardResponse = await fetch(forwardUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body, + }) + + event.forward = { + status: forwardResponse.ok ? 'ok' : 'error', + url: forwardUrl, + httpStatus: forwardResponse.status, + statusText: forwardResponse.statusText, + } + } catch (err) { + event.forward = { + status: 'error', + url: forwardUrl, + error: err.message, + } + } + } + + broadcast({ type: 'ingest', event, summary: buildSummary() }) + sendJson(res, 202, { ok: true, id: event.id, count: event.count }) +} + +function handleEvents(req, res) { + res.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'access-control-allow-origin': '*', + }) + + res.write(`data: ${JSON.stringify({ type: 'snapshot', snapshot: buildSnapshot() })}\n\n`) + clients.add(res) + + req.on('close', () => { + clients.delete(res) + }) +} + +function resetMonitor(res) { + events.length = 0 + receivedBatches = 0 + receivedItems = 0 + receivedBytes = 0 + startedAt = Date.now() + broadcast({ type: 'snapshot', snapshot: buildSnapshot() }) + sendJson(res, 200, { ok: true }) +} + +async function serveStatic(req, res) { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`) + const requestedPath = url.pathname === '/' ? '/index.html' : url.pathname + const normalized = normalize(requestedPath) + .replace(/^([/\\])+/, '') + .replace(/^(\.\.[/\\])+/, '') + const filePath = join(publicDir, normalized) + + try { + const file = await readFile(filePath) + res.writeHead(200, { + 'content-type': contentTypes[extname(filePath)] ?? 'application/octet-stream', + }) + res.end(file) + } catch { + sendJson(res, 404, { ok: false, error: 'Not found' }) + } +} + +const server = createServer(async (req, res) => { + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', + 'access-control-allow-headers': 'content-type', + }) + res.end() + return + } + + if (req.method === 'POST' && req.url?.startsWith('/api/ingest')) { + await handleIngest(req, res) + return + } + + if (req.method === 'GET' && req.url?.startsWith('/api/events')) { + sendJson(res, 200, buildSnapshot()) + return + } + + if (req.method === 'DELETE' && req.url?.startsWith('/api/events')) { + resetMonitor(res) + return + } + + if (req.method === 'GET' && req.url?.startsWith('/events')) { + handleEvents(req, res) + return + } + + if (req.method === 'GET') { + await serveStatic(req, res) + return + } + + sendJson(res, 405, { ok: false, error: 'Method not allowed' }) +}) + +server.listen(port, host, () => { + console.log('ingest-monitor.started', { + url: `http://localhost:${port}`, + ingestUrl: `http://localhost:${port}/api/ingest`, + forwardUrl, + }) +})