Files
ingest-monitor/public/app.js
Первов Артем bb140dcfa3 first commit
2026-06-05 02:16:36 +03:00

366 lines
10 KiB
JavaScript

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()