first commit

This commit is contained in:
Первов Артем
2026-06-05 02:16:36 +03:00
commit bb140dcfa3
7 changed files with 1646 additions and 0 deletions

24
.gitignore vendored Normal file
View File

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

147
README.md Normal file
View File

@@ -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
```

12
package.json Normal file
View File

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

365
public/app.js Normal file
View File

@@ -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 = '<div class="empty">No packets yet.</div>'
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 `
<button class="packet${active}" data-id="${event.id}" type="button">
<span class="packet-main">
<span>${formatTime(event.receivedAt)}</span>
<span>${event.count} items</span>
</span>
<span class="packet-meta">
<span>${formatBytes(event.bytes)}</span>
<span>${forward}</span>
</span>
</button>
`
})
.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
? '<tr><td class="empty" colspan="4">No matching values.</td></tr>'
: visibleItems
.map(
(item) => `
<tr>
<td>${item.tag}</td>
<td>${item.edge}</td>
<td class="value">${item.value}</td>
<td>${formatTime(item.timestamp)}</td>
</tr>
`,
)
.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()

137
public/index.html Normal file
View File

@@ -0,0 +1,137 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ingest Monitor</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="brand">
<div class="mark" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<div>
<p class="eyebrow">greact.drill</p>
<h1>Ingest Monitor</h1>
</div>
</div>
<div class="top-actions">
<div class="endpoint">
<span>POST</span>
<code>/api/ingest</code>
</div>
<div class="status" id="connectionStatus">
<span class="dot"></span>
<span>connecting</span>
</div>
</div>
</header>
<main class="layout">
<section class="metrics" aria-label="Summary">
<article>
<span>Packets</span>
<strong id="batchCount">0</strong>
</article>
<article>
<span>Items</span>
<strong id="itemCount">0</strong>
</article>
<article>
<span>Bytes</span>
<strong id="byteCount">0 B</strong>
</article>
<article>
<span>Last batch</span>
<strong id="lastBatch">none</strong>
</article>
</section>
<section class="toolbar">
<label class="search">
<span>Filter</span>
<input id="filterInput" type="search" placeholder="tag, edge, value" />
</label>
<div class="actions">
<button id="followButton" type="button" title="Follow newest packet">Follow latest</button>
<button id="copyButton" type="button" title="Copy selected payload">Copy JSON</button>
<button id="clearButton" type="button" title="Clear monitor memory">Clear</button>
</div>
</section>
<section class="content">
<aside class="packets">
<div class="section-head">
<div>
<p class="section-kicker">Live feed</p>
<h2>Packets</h2>
</div>
<span id="packetRate">0/min</span>
</div>
<div id="packetList" class="packet-list"></div>
</aside>
<section class="details">
<div class="section-head">
<div>
<p class="section-kicker">Selected packet</p>
<h2 id="detailTitle">Waiting for ingest</h2>
</div>
<span id="detailMeta">POST /api/ingest</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Tag</th>
<th>Edge</th>
<th>Value</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody id="itemTable"></tbody>
</table>
</div>
<div class="json-panel">
<div class="json-panel-head">
<div>
<p class="section-kicker">Raw JSON</p>
<strong id="jsonMeta">No payload selected</strong>
</div>
<div class="json-actions">
<button id="expandJsonButton" type="button">Expand</button>
<button id="copyRawButton" type="button">Copy</button>
</div>
</div>
<pre id="rawPayload" class="raw">No payload yet.</pre>
</div>
</section>
</section>
</main>
</div>
<div id="jsonModal" class="modal" aria-hidden="true">
<div class="modal-backdrop" id="jsonModalBackdrop"></div>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="jsonModalTitle">
<header class="modal-head">
<div>
<p class="section-kicker">Expanded JSON</p>
<h2 id="jsonModalTitle">Selected payload</h2>
</div>
<div class="json-actions">
<button id="copyModalJsonButton" type="button">Copy JSON</button>
<button id="closeJsonModalButton" type="button">Close</button>
</div>
</header>
<pre id="jsonModalPayload" class="modal-raw">No payload yet.</pre>
</section>
</div>
<script src="/app.js" type="module"></script>
</body>
</html>

688
public/styles.css Normal file
View File

@@ -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;
}
}

273
server.mjs Normal file
View File

@@ -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,
})
})