Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b6e5711a | ||
|
|
ead9aaa713 | ||
|
|
46bd6e8be6 |
37
README.md
37
README.md
@@ -678,3 +678,40 @@ DB-IP City Lite загружается сначала из вложения ре
|
|||||||
задать `GITEA_URL` в окружении сервиса Dashboard. База хранится отдельно от Git-истории
|
задать `GITEA_URL` в окружении сервиса Dashboard. База хранится отдельно от Git-истории
|
||||||
и бинарника приложения; контрольная сумма проверяется независимо от источника.
|
и бинарника приложения; контрольная сумма проверяется независимо от источника.
|
||||||
Официальный файл: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
|
Официальный файл: https://download.db-ip.com/free/dbip-city-lite-2026-10.mmdb.gz
|
||||||
|
|
||||||
|
## Города на карте (v0.26.0)
|
||||||
|
|
||||||
|
При установленной базе городов и масштабе от 200% на карте появляются города
|
||||||
|
с HTTP/CrowdSec-активностью. Маркеры используют приблизительные координаты DB-IP,
|
||||||
|
подписи не увеличиваются вместе с картой. Наведение показывает статистику,
|
||||||
|
нажатие открывает карточку города с итогами и ссылкой на всю историю страны.
|
||||||
|
HTTP/CrowdSec-импульсы также начинаются в городе, если он определён.
|
||||||
|
|
||||||
|
Счётчики городов учитывают выбранного агента и фильтры за 24 часа. На карте
|
||||||
|
показываются до 500 наиболее активных городов; остальные события и IP без города
|
||||||
|
остаются в точках стран. TCP/UDP/ICMP пока отображаются на уровне стран.
|
||||||
|
Город может относиться к провайдеру/VPN, а не фактическому местоположению человека.
|
||||||
|
|
||||||
|
## Подробности UPS (v0.27.0)
|
||||||
|
|
||||||
|
Карточка UPS показывает питание, батарею, пороги и диагностику, а также исходные
|
||||||
|
параметры `upsc` / `apcaccess`. Расчётная мощность помечается ≈ и вычисляется как
|
||||||
|
номинальная активная мощность в ваттах × процент нагрузки / 100. Если UPS передаёт
|
||||||
|
`ups.realpower`, используется измеренное значение. Без номинала оценка не строится.
|
||||||
|
|
||||||
|
Для NUT/CyberPower (CPS / vendor 0764) частота в диапазоне 450–650 Гц
|
||||||
|
корректируется делением на 10 с явной пометкой и сохранением исходного значения.
|
||||||
|
Корректные 49,8/50/60 Гц повторно не преобразуются. Настройки NUT не меняются.
|
||||||
|
Невалидные даты батареи вроде `CPS` остаются только в исходных параметрах.
|
||||||
|
|
||||||
|
История 1 час / 24 часа / 7 дней: заряд и нагрузка, мощность, входное/выходное
|
||||||
|
напряжение, напряжение батареи, автономность и частота. Запись раз в минуту,
|
||||||
|
новые ряды заполняются после обновления; при потере связи сохраняются пропуски.
|
||||||
|
Пороги UPS/NUT показаны отдельно от сценария выключения Dashboard.
|
||||||
|
|
||||||
|
## Компактная карточка UPS (v0.27.1)
|
||||||
|
|
||||||
|
Окно UPS разделено на вкладки «Обзор», «Батарея», «Диагностика», «Графики»
|
||||||
|
и «Исходные данные». В обзоре — компактные карточки основных показателей.
|
||||||
|
На графиках выбирается один показатель и период; технические пояснения
|
||||||
|
коррекции частоты и расчёта мощности находятся в исходных данных.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ html[data-theme="light"] dialog,html[data-theme="light"] .tooltip,html[data-them
|
|||||||
<div class="toolbar"><div class="segments"><button class="active" data-kind="">Весь трафик</button><button data-kind="request">HTTP</button><button data-kind="alert">Срабатывания</button><button data-kind="decision">Решения</button></div><select id="domain" aria-label="Фильтр по домену"><option value="">Все домены</option></select><a id="networkLink" href="/network" style="font-size:10px;white-space:nowrap" hidden>TCP / UDP ↗</a><label id="externalControl"><input type="checkbox" id="external"> Только внешние IP</label><label id="monitoringControl"><input type="checkbox" id="hideMonitoring"> Скрыть мониторинг</label><label id="networkOverlayControl" hidden><input type="checkbox" id="networkOverlay" checked> Сеть на карте</label><button id="pause">Пауза</button></div>
|
<div class="toolbar"><div class="segments"><button class="active" data-kind="">Весь трафик</button><button data-kind="request">HTTP</button><button data-kind="alert">Срабатывания</button><button data-kind="decision">Решения</button></div><select id="domain" aria-label="Фильтр по домену"><option value="">Все домены</option></select><a id="networkLink" href="/network" style="font-size:10px;white-space:nowrap" hidden>TCP / UDP ↗</a><label id="externalControl"><input type="checkbox" id="external"> Только внешние IP</label><label id="monitoringControl"><input type="checkbox" id="hideMonitoring"> Скрыть мониторинг</label><label id="networkOverlayControl" hidden><input type="checkbox" id="networkOverlay" checked> Сеть на карте</label><button id="pause">Пауза</button></div>
|
||||||
<section class="metrics" aria-label="Статистика за последние 24 часа"><button class="metric" data-metric="unique"><div><small>Уникальные IP · 24 ч</small><b id="unique">—</b></div><i aria-hidden="true">⌁</i></button><button class="metric request" data-metric="requests"><div><small>HTTP-запросы · 24 ч</small><b id="requests">—</b></div><i aria-hidden="true">↗</i></button><button class="metric alert" data-metric="alerts"><div><small>Срабатывания · 24 ч</small><b id="alerts">—</b></div><i aria-hidden="true">ϟ</i></button><button class="metric decision" data-metric="decisions"><div><small>Решения CrowdSec · 24 ч</small><b id="decisions">—</b></div><i aria-hidden="true">◇</i></button></section>
|
<section class="metrics" aria-label="Статистика за последние 24 часа"><button class="metric" data-metric="unique"><div><small>Уникальные IP · 24 ч</small><b id="unique">—</b></div><i aria-hidden="true">⌁</i></button><button class="metric request" data-metric="requests"><div><small>HTTP-запросы · 24 ч</small><b id="requests">—</b></div><i aria-hidden="true">↗</i></button><button class="metric alert" data-metric="alerts"><div><small>Срабатывания · 24 ч</small><b id="alerts">—</b></div><i aria-hidden="true">ϟ</i></button><button class="metric decision" data-metric="decisions"><div><small>Решения CrowdSec · 24 ч</small><b id="decisions">—</b></div><i aria-hidden="true">◇</i></button></section>
|
||||||
<nav id="mobileTabs" aria-label="Раздел карты"><button class="active" data-mobile-tab="map">Карта</button><button data-mobile-tab="feed">События</button><button data-mobile-tab="stats">Статистика</button></nav>
|
<nav id="mobileTabs" aria-label="Раздел карты"><button class="active" data-mobile-tab="map">Карта</button><button data-mobile-tab="feed">События</button><button data-mobile-tab="stats">Статистика</button></nav>
|
||||||
<section class="dashboard"><div class="panel map-panel"><div class="map-heading"><span class="section-label">Глобальная активность</span><button class="source-status" id="sourceDetails" aria-label="Состояние источника"><span id="sourceName">Источник…</span><span id="status" class="status-box" role="status">Подключение</span><span>ⓘ</span></button></div><div class="map-stage"><button id="networkSummary" hidden aria-label="Сетевая активность на карте"></button><div class="map-controls"><button id="zoomOut" aria-label="Отдалить карту">−</button><output id="zoomLevel">100%</output><button id="zoomIn" aria-label="Приблизить карту">+</button><button id="zoomReset" aria-label="Сбросить масштаб карты">↺</button></div><canvas tabindex="0" id="world" role="img" aria-label="Карта стран источников; данные доступны также в статистике и ленте событий"></canvas></div><div class="map-foot"><div class="legend"><span><i class="dot"></i>HTTP</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="unmapped">Без геолокации: —</span></div></div>
|
<section class="dashboard"><div class="panel map-panel"><div class="map-heading"><span class="section-label">Глобальная активность</span><button class="source-status" id="sourceDetails" aria-label="Состояние источника"><span id="sourceName">Источник…</span><span id="status" class="status-box" role="status">Подключение</span><span>ⓘ</span></button></div><div class="map-stage"><button id="networkSummary" hidden aria-label="Сетевая активность на карте"></button><div class="map-controls"><button id="zoomOut" aria-label="Отдалить карту">−</button><output id="zoomLevel">100%</output><button id="zoomIn" aria-label="Приблизить карту">+</button><button id="zoomReset" aria-label="Сбросить масштаб карты">↺</button></div><canvas tabindex="0" id="world" role="img" aria-label="Карта стран источников; данные доступны также в статистике и ленте событий"></canvas></div><div class="map-foot"><div class="legend"><span><i class="dot"></i>HTTP</span><span><i class="dot alert"></i>Срабатывание</span><span><i class="dot decision"></i>Решение</span></div><span id="cityLayerNote" title="Города по приблизительным координатам GeoIP"></span><span id="unmapped">Без геолокации: —</span></div></div>
|
||||||
<aside class="panel feed-panel"><div class="feed-heading"><h2>Поток событий</h2><span class="note" id="feedMode">LIVE / 100</span></div><div class="feed" id="feed"><div class="empty">Ожидание первого события…</div></div><div class="pager"><button id="feedPrev" aria-label="Предыдущая страница событий">←</button><span id="feedPage">—</span><button id="feedNext" aria-label="Следующая страница событий">→</button><button id="latest">К новым ↗</button></div></aside></section>
|
<aside class="panel feed-panel"><div class="feed-heading"><h2>Поток событий</h2><span class="note" id="feedMode">LIVE / 100</span></div><div class="feed" id="feed"><div class="empty">Ожидание первого события…</div></div><div class="pager"><button id="feedPrev" aria-label="Предыдущая страница событий">←</button><span id="feedPage">—</span><button id="feedNext" aria-label="Следующая страница событий">→</button><button id="latest">К новым ↗</button></div></aside></section>
|
||||||
<section class="bottom"><div class="panel chart-panel"><div class="bottom-head"><h2>Ритм запросов <span class="note">/ 60 мин</span></h2><span class="note"><b id="qps" style="color:var(--blue)">—</b> req/s</span></div><canvas class="chart" id="chart" role="img" aria-label="HTTP-запросы в минуту за последний час; req/s — среднее за последние 60 секунд"></canvas></div><div class="panel"><div class="bottom-head"><h2>География</h2><span class="note">ТОП 7 / события</span></div><div id="countries"></div></div><div class="panel"><div class="bottom-head"><h2>Источники</h2><span class="note">ТОП 7 / IP</span></div><div id="ips"></div></div></section>
|
<section class="bottom"><div class="panel chart-panel"><div class="bottom-head"><h2>Ритм запросов <span class="note">/ 60 мин</span></h2><span class="note"><b id="qps" style="color:var(--blue)">—</b> req/s</span></div><canvas class="chart" id="chart" role="img" aria-label="HTTP-запросы в минуту за последний час; req/s — среднее за последние 60 секунд"></canvas></div><div class="panel"><div class="bottom-head"><h2>География</h2><span class="note">ТОП 7 / события</span></div><div id="countries"></div></div><div class="panel"><div class="bottom-head"><h2>Источники</h2><span class="note">ТОП 7 / IP</span></div><div id="ips"></div></div></section>
|
||||||
<footer><div class="credits"><span><a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> · <span id="geoDate"></span></span><a class="license-credit" href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a><a class="natural-credit" href="https://www.naturalearthdata.com/">Natural Earth</a></div><button class="footer-help" id="about">Как читать данные ⓘ</button><a href="/#traffic-settings">Настройки ↗</a></footer>
|
<footer><div class="credits"><span><a href="https://db-ip.com" target="_blank" rel="noopener">IP Geolocation by DB-IP</a> · <span id="geoDate"></span></span><a class="license-credit" href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a><a class="natural-credit" href="https://www.naturalearthdata.com/">Natural Earth</a></div><button class="footer-help" id="about">Как читать данные ⓘ</button><a href="/#traffic-settings">Настройки ↗</a></footer>
|
||||||
@@ -52,9 +52,11 @@ function networkVisible(){return !!view?.network&&el('networkOverlay').checked&&
|
|||||||
function networkFresh(){return networkVisible()&&view.network.receivedAt>0&&view.now+(Date.now()-lastPoll)/1000-view.network.receivedAt<25}
|
function networkFresh(){return networkVisible()&&view.network.receivedAt>0&&view.now+(Date.now()-lastPoll)/1000-view.network.receivedAt<25}
|
||||||
function drawMap(now){const canvas=el('world'),w=Math.max(1,Math.floor(canvas.clientWidth)),h=Math.max(1,Math.floor(canvas.clientHeight)),dpr=Math.min(devicePixelRatio||1,2);if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);baseMap=mapBackground(w,h)}const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const cacheKey=[w,h,dpr,mapZoom,mapX,mapY,isLight(),world.length].join(':');if(!baseMap||baseMapKey!==cacheKey){baseMap=mapBackground(w,h);baseMapKey=cacheKey}ctx.drawImage(baseMap,0,0,w,h);ctx.save();ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);hitPoints=[];drawCountryHighlight(ctx,w,h);
|
function drawMap(now){const canvas=el('world'),w=Math.max(1,Math.floor(canvas.clientWidth)),h=Math.max(1,Math.floor(canvas.clientHeight)),dpr=Math.min(devicePixelRatio||1,2);if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);baseMap=mapBackground(w,h)}const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const cacheKey=[w,h,dpr,mapZoom,mapX,mapY,isLight(),world.length].join(':');if(!baseMap||baseMapKey!==cacheKey){baseMap=mapBackground(w,h);baseMapKey=cacheKey}ctx.drawImage(baseMap,0,0,w,h);ctx.save();ctx.translate(mapX,mapY);ctx.scale(mapZoom,mapZoom);hitPoints=[];drawCountryHighlight(ctx,w,h);
|
||||||
if(view){const destination=countryMap.get(view.settings.serverCountry),end=destination?project(destination.lon,destination.lat,w,h):[w*.82,h*.88];
|
if(view){const destination=countryMap.get(view.settings.serverCountry),end=destination?project(destination.lon,destination.lat,w,h):[w*.82,h*.88];
|
||||||
for(const c of view.countries){const country=countryMap.get(c.name);if(!country)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle=colors.request;ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,(2+Math.min(5,Math.log2(c.count+1)))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(c.count)+' событий'})}ctx.globalAlpha=1;
|
const cityLayer=mapZoom>=2&&view.cityReady,cityCounts=new Map();el("cityLayerNote").textContent=view.cityReady?(cityLayer?"Города: "+(view.cities||[]).length+(view.citiesLimited?" (топ 500)":""):"Города — от 200%") : "Города: нужна GeoIP-база";
|
||||||
|
if(cityLayer){const labels=[];for(const c of view.cities||[]){const total=c.requests+c.alerts+c.decisions;cityCounts.set(c.country,(cityCounts.get(c.country)||0)+total);const [x,y]=project(c.longitude,c.latitude,w,h),sx=x*mapZoom+mapX,sy=y*mapZoom+mapY;if(sx<0||sx>w||sy<0||sy>h)continue;ctx.fillStyle=c.requests?colors.request:c.alerts?colors.alert:colors.decision;ctx.beginPath();ctx.arc(x,y,(3+Math.min(3,Math.log2(total+1)/2))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,city:c});ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='left';const width=ctx.measureText(c.city).width*mapZoom;if(!labels.some(p=>Math.abs(sx-p.x)<Math.max(width,p.width)+12&&Math.abs(sy-p.y)<18)){ctx.fillStyle=isLight()?'#203d4c':'#e1edf4';ctx.fillText(c.city,x+9/mapZoom,y-5/mapZoom);labels.push({x:sx,y:sy,width})}}}
|
||||||
|
for(const c of view.countries){const country=countryMap.get(c.name),count=Math.max(0,c.count-(cityCounts.get(c.name)||0));if(!country||!count)continue;const [x,y]=project(country.lon,country.lat,w,h);ctx.fillStyle=colors.request;ctx.globalAlpha=.75;ctx.beginPath();ctx.arc(x,y,(2+Math.min(5,Math.log2(count+1)))/mapZoom,0,Math.PI*2);ctx.fill();hitPoints.push({x,y,text:country.name+' · '+num(count)+' событий'})}ctx.globalAlpha=1;
|
||||||
if(networkFresh()){for(const p of view.network.points){const c=countryMap.get(p.country);if(!c)continue;const [x,y]=project(c.lon,c.lat,w,h),radius=(8+['tcp','udp','icmp'].indexOf(p.protocol)*3)/mapZoom;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4/mapZoom;ctx.globalAlpha=.8;ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.stroke();const text=p.protocol.toUpperCase()+' '+(p.direction==='out'?'← исходящие':'→ входящие')+': '+num(p.count)+' потоков';const existing=hitPoints.find(h=>Math.hypot(h.x-x,h.y-y)<1);if(existing)existing.text+=' · '+text;else hitPoints.push({x,y,text:c.name+' · '+text})}ctx.globalAlpha=1}
|
if(networkFresh()){for(const p of view.network.points){const c=countryMap.get(p.country);if(!c)continue;const [x,y]=project(c.lon,c.lat,w,h),radius=(8+['tcp','udp','icmp'].indexOf(p.protocol)*3)/mapZoom;ctx.strokeStyle=colors[p.protocol];ctx.lineWidth=1.4/mapZoom;ctx.globalAlpha=.8;ctx.beginPath();ctx.arc(x,y,radius,0,Math.PI*2);ctx.stroke();const text=p.protocol.toUpperCase()+' '+(p.direction==='out'?'← исходящие':'→ входящие')+': '+num(p.count)+' потоков';const existing=hitPoints.find(h=>Math.hypot(h.x-x,h.y-y)<1);if(existing)existing.text+=' · '+text;else hitPoints.push({x,y,text:c.name+' · '+text})}ctx.globalAlpha=1}
|
||||||
pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=project(c.lon,c.lat,w,h),start=p.direction==='out'?end:remote,finish=p.direction==='out'?remote:end,t=Math.min(1,(now-p.start)/2400),control=[(start[0]+finish[0])/2,(start[1]+finish[1])/2-Math.min(h*.3,Math.abs(start[0]-finish[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.4*fade;ctx.lineWidth=1/mapZoom;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...finish);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*finish[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*finish[1];ctx.fillStyle=colors[p.kind];ctx.globalAlpha=fade;ctx.shadowColor=colors[p.kind];ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x,y,2.5/mapZoom,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}
|
pulses=pulses.filter(p=>now-p.start<3200&&(!p.network||networkFresh()));if(matchMedia('(prefers-reduced-motion: reduce)').matches)pulses=[];for(const p of pulses){const c=countryMap.get(p.country);if(!c)continue;const remote=cityLayer&&p.location?.located?project(p.location.longitude,p.location.latitude,w,h):project(c.lon,c.lat,w,h),start=p.direction==='out'?end:remote,finish=p.direction==='out'?remote:end,t=Math.min(1,(now-p.start)/2400),control=[(start[0]+finish[0])/2,(start[1]+finish[1])/2-Math.min(h*.3,Math.abs(start[0]-finish[0])*.3+30)],fade=Math.min(1,(3200-(now-p.start))/700);ctx.strokeStyle=colors[p.kind];ctx.globalAlpha=.4*fade;ctx.lineWidth=1/mapZoom;ctx.beginPath();ctx.moveTo(...start);ctx.quadraticCurveTo(...control,...finish);ctx.stroke();const x=(1-t)**2*start[0]+2*(1-t)*t*control[0]+t*t*finish[0],y=(1-t)**2*start[1]+2*(1-t)*t*control[1]+t*t*finish[1];ctx.fillStyle=colors[p.kind];ctx.globalAlpha=fade;ctx.shadowColor=colors[p.kind];ctx.shadowBlur=12;ctx.beginPath();ctx.arc(x,y,2.5/mapZoom,0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}
|
||||||
ctx.globalAlpha=1;ctx.strokeStyle=colors.decision;ctx.lineWidth=1.5/mapZoom;ctx.beginPath();ctx.arc(...end,7/mapZoom,0,Math.PI*2);ctx.stroke();ctx.fillStyle=isLight()?'#554496':'#e7dcff';ctx.beginPath();ctx.arc(...end,3/mapZoom,0,Math.PI*2);ctx.fill();ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22/mapZoom);
|
ctx.globalAlpha=1;ctx.strokeStyle=colors.decision;ctx.lineWidth=1.5/mapZoom;ctx.beginPath();ctx.arc(...end,7/mapZoom,0,Math.PI*2);ctx.stroke();ctx.fillStyle=isLight()?'#554496':'#e7dcff';ctx.beginPath();ctx.arc(...end,3/mapZoom,0,Math.PI*2);ctx.fill();ctx.font=(11/mapZoom)+'px system-ui';ctx.textAlign='center';ctx.fillText(destination?'NPM · '+destination.name:'NPM · геопозиция не задана',end[0],end[1]+22/mapZoom);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
@@ -70,7 +72,7 @@ function render(data){view=data;el('networkLink').hidden=!data.settings.networkE
|
|||||||
const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped);
|
const unmapped=data.countries.filter(c=>!countryMap.has(c.name)).reduce((n,c)=>n+c.count,0);el('unmapped').textContent='Локальные / без геолокации: '+num(unmapped);
|
||||||
renderFeed();
|
renderFeed();
|
||||||
if(networkFresh()&&data.network.receivedAt!==lastNetworkSnapshot){lastNetworkSnapshot=data.network.receivedAt;for(const p of data.network.points.slice(0,24)){pulses.push({country:p.country,kind:p.protocol,direction:p.direction,network:true,start:performance.now()})}}
|
if(networkFresh()&&data.network.receivedAt!==lastNetworkSnapshot){lastNetworkSnapshot=data.network.receivedAt;for(const p of data.network.points.slice(0,24)){pulses.push({country:p.country,kind:p.protocol,direction:p.direction,network:true,start:performance.now()})}}
|
||||||
for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();if(paused)requestAnimationFrame(drawMap);
|
for(const e of data.events){if(!seen.has(e.sequence)&&e.time>=data.now-15&&pulses.length<70)pulses.push({country:e.country,location:e.location,kind:e.kind,start:performance.now()});seen.add(e.sequence)}if(seen.size>5000)seen=new Set(data.events.map(e=>e.sequence));drawChart();if(paused)requestAnimationFrame(drawMap);
|
||||||
}
|
}
|
||||||
function renderNetworkSummary(){el('networkSummary').hidden=!networkVisible();if(!networkVisible())return;const n=view.network,fresh=networkFresh();el('networkSummary').innerHTML=(fresh?'Сеть · ':'Сеть · нет свежего снимка · ')+['tcp','udp','icmp'].map(p=>'<span style="color:'+colors[p]+'">'+p.toUpperCase()+' '+num(n[p])+'</span>').join(' · ')+(n.error||n.limited?' · ⚠':'')+' ⓘ';el('networkSummary').title='Текущие потоки conntrack. Фильтры домена и HTTP/CrowdSec на них не влияют.'}
|
function renderNetworkSummary(){el('networkSummary').hidden=!networkVisible();if(!networkVisible())return;const n=view.network,fresh=networkFresh();el('networkSummary').innerHTML=(fresh?'Сеть · ':'Сеть · нет свежего снимка · ')+['tcp','udp','icmp'].map(p=>'<span style="color:'+colors[p]+'">'+p.toUpperCase()+' '+num(n[p])+'</span>').join(' · ')+(n.error||n.limited?' · ⚠':'')+' ⓘ';el('networkSummary').title='Текущие потоки conntrack. Фильтры домена и HTTP/CrowdSec на них не влияют.'}
|
||||||
el('networkSummary').onclick=()=>{const n=view.network;showDetails('Сетевая активность на карте','<p>Зелёный — TCP, розовый — UDP, жёлтый — ICMP/ICMPv6. Кольца показывают страны текущих потоков, импульс означает получение нового снимка (~5 с), а не новый запрос или пакет.</p><p>Входящий поток идёт к NPM, исходящий — от NPM. Если оба конца публичные, направление определяется по инициатору; это не трассировка маршрута.</p><p>HTTP-фильтры домена и типа события относятся только к HTTP/CrowdSec. «Только внешние IP» и «Скрыть мониторинг» применяются также к сетевому слою. Для сети используются только правила без домена/пути.</p><p>Снимок: '+(n.at?new Date(n.at*1000).toLocaleString('ru-RU'):'ещё не получен')+'. Локальные / без страны: '+num(n.unmapped)+'. Скрыто потоков мониторинга: '+num(n.monitoringHidden)+'.</p><p>'+safe(n.error||(n.limited?'Достигнут лимит 5 000 потоков':'При устаревании снимка сетевые кольца и импульсы скрываются.'))+'</p><p><a href="/network">Открыть таблицу TCP/UDP/ICMP ↗</a></p>')};
|
el('networkSummary').onclick=()=>{const n=view.network;showDetails('Сетевая активность на карте','<p>Зелёный — TCP, розовый — UDP, жёлтый — ICMP/ICMPv6. Кольца показывают страны текущих потоков, импульс означает получение нового снимка (~5 с), а не новый запрос или пакет.</p><p>Входящий поток идёт к NPM, исходящий — от NPM. Если оба конца публичные, направление определяется по инициатору; это не трассировка маршрута.</p><p>HTTP-фильтры домена и типа события относятся только к HTTP/CrowdSec. «Только внешние IP» и «Скрыть мониторинг» применяются также к сетевому слою. Для сети используются только правила без домена/пути.</p><p>Снимок: '+(n.at?new Date(n.at*1000).toLocaleString('ru-RU'):'ещё не получен')+'. Локальные / без страны: '+num(n.unmapped)+'. Скрыто потоков мониторинга: '+num(n.monitoringHidden)+'.</p><p>'+safe(n.error||(n.limited?'Достигнут лимит 5 000 потоков':'При устаревании снимка сетевые кольца и импульсы скрываются.'))+'</p><p><a href="/network">Открыть таблицу TCP/UDP/ICMP ↗</a></p>')};
|
||||||
@@ -84,7 +86,7 @@ el('feedNext').onclick=()=>{if(!frozenEvents)frozenEvents=view.events.slice();fe
|
|||||||
function openEvent(target){const row=target.closest('[data-event]');if(!row)return;const e=feedVisible[Number(row.dataset.event)];if(!e)return;const fields=[['Источник',[e.ip,countryName(e.country),e.region,e.city].filter(Boolean).join(' · ')],['Время',new Date(e.time*1000).toLocaleString('ru-RU')],['Тип',kindLabels[e.kind]],['Мониторинг',e.monitoring?'Да — совпало с правилом':'Нет'],['Домен',e.domain||'Не указан'],['Детали',eventText(e)]];showDetails('Событие трафика',fields.map(([k,v])=>'<div class="detail-row"><small>'+safe(k)+'</small>'+safe(v)+'</div>').join(''))}
|
function openEvent(target){const row=target.closest('[data-event]');if(!row)return;const e=feedVisible[Number(row.dataset.event)];if(!e)return;const fields=[['Источник',[e.ip,countryName(e.country),e.region,e.city].filter(Boolean).join(' · ')],['Время',new Date(e.time*1000).toLocaleString('ru-RU')],['Тип',kindLabels[e.kind]],['Мониторинг',e.monitoring?'Да — совпало с правилом':'Нет'],['Домен',e.domain||'Не указан'],['Детали',eventText(e)]];showDetails('Событие трафика',fields.map(([k,v])=>'<div class="detail-row"><small>'+safe(k)+'</small>'+safe(v)+'</div>').join(''))}
|
||||||
el('feed').onclick=e=>openEvent(e.target);el('feed').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openEvent(e.target)}};
|
el('feed').onclick=e=>openEvent(e.target);el('feed').onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();openEvent(e.target)}};
|
||||||
el('sourceDetails').onclick=()=>showDetails('Состояние источника',statusMessages.map(m=>'<p>'+safe(m)+'</p>').join(''));
|
el('sourceDetails').onclick=()=>showDetails('Состояние источника',statusMessages.map(m=>'<p>'+safe(m)+'</p>').join(''));
|
||||||
el('about').onclick=()=>showDetails('Как читать данные','<p>Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.</p><p>Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.</p><p>Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.</p><p>Точки на карте обозначают страны, а не точное местоположение людей. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.</p>');
|
el('about').onclick=()=>showDetails('Как читать данные','<p>Голубой — HTTP-запросы, оранжевый — срабатывания CrowdSec, сиреневый — решения о ban или captcha.</p><p>Решение о бане не подтверждает конкретный отброшенный запрос. HTTP 401/403/502 сами по себе не означают блокировку CrowdSec.</p><p>Счётчики за 24 часа следуют выбранным фильтрам. Уникальный IP не равен человеку. Req/s — среднее за 60 секунд; график показывает запросы в минуту за последний час.</p><p>При масштабе от 200% и установленной базе городов появляются города с HTTP/CrowdSec-активностью. Геолокация приблизительная. Сетевые потоки и события без города остаются на уровне стран. Показываются до 500 наиболее активных городов; остальные события учитываются в точках стран. Локальные адреса остаются в ленте без страны. «Скрыть мониторинг» исключает из отображения HTTP-проверки по правилам в настройках; события CrowdSec сохраняются на экране. Исходная история не удаляется. В ленте показаны последние 100 событий; полная сохранённая история за 24 часа доступна в карточках и окнах стран; нажмите строку, чтобы увидеть детали целиком.</p>');
|
||||||
el('closeDetails').onclick=()=>el('details').close();el('details').onclick=e=>{if(e.target===el('details'))el('details').close()};
|
el('closeDetails').onclick=()=>el('details').close();el('details').onclick=e=>{if(e.target===el('details'))el('details').close()};
|
||||||
document.querySelectorAll('[data-mobile-tab]').forEach(b=>b.onclick=()=>{document.body.dataset.mobile=b.dataset.mobileTab;document.querySelectorAll('[data-mobile-tab]').forEach(t=>t.classList.toggle('active',t===b));drawChart();renderFeed()});
|
document.querySelectorAll('[data-mobile-tab]').forEach(b=>b.onclick=()=>{document.body.dataset.mobile=b.dataset.mobileTab;document.querySelectorAll('[data-mobile-tab]').forEach(t=>t.classList.toggle('active',t===b));drawChart();renderFeed()});
|
||||||
async function poll(force=false){if((paused&&!force)||busy||document.hidden)return;busy=true;try{const q=new URLSearchParams({domain:el('domain').value,kind,hideMonitoring:el('hideMonitoring').checked?'1':'0',external:el('external').checked?'1':'0'}),r=await fetch('/api/traffic?'+q);if(!r.ok){if(r.status===404){view=null;pulses=[];location.replace('/');return;}throw Error(r.status===404?'Карта отключена':'Не удалось обновить данные')}const data=await r.json();lastPoll=Date.now();render(data)}catch(e){el('live').textContent='НЕТ ОБНОВЛЕНИЙ';el('live').classList.add('stale');statusMessages=[e.message];el('status').textContent=e.message;el('status').classList.add('error')}finally{busy=false}}
|
async function poll(force=false){if((paused&&!force)||busy||document.hidden)return;busy=true;try{const q=new URLSearchParams({domain:el('domain').value,kind,hideMonitoring:el('hideMonitoring').checked?'1':'0',external:el('external').checked?'1':'0'}),r=await fetch('/api/traffic?'+q);if(!r.ok){if(r.status===404){view=null;pulses=[];location.replace('/');return;}throw Error(r.status===404?'Карта отключена':'Не удалось обновить данные')}const data=await r.json();lastPoll=Date.now();render(data)}catch(e){el('live').textContent='НЕТ ОБНОВЛЕНИЙ';el('live').classList.add('stale');statusMessages=[e.message];el('status').textContent=e.message;el('status').classList.add('error')}finally{busy=false}}
|
||||||
@@ -110,12 +112,14 @@ function countryNetwork(code){if(!view?.network)return '';const points=view.netw
|
|||||||
function filterNote(){return '<p class="modal-note">Последние 24 часа · текущие фильтры · снимок на '+new Date(view.now*1000).toLocaleTimeString('ru-RU')+'. '+(view.limited?'История ограничена лимитом хранения.':'')+'</p>'}
|
function filterNote(){return '<p class="modal-note">Последние 24 часа · текущие фильтры · снимок на '+new Date(view.now*1000).toLocaleTimeString('ru-RU')+'. '+(view.limited?'История ограничена лимитом хранения.':'')+'</p>'}
|
||||||
function eventList(events){return events.length?'<ul class="detail-list">'+events.map(e=>'<li>'+safe(e.ip)+' · '+safe(kindLabels[e.kind]||e.kind)+' · '+safe(eventText(e))+(e.monitoring?' · мониторинг':'')+'<small>'+safe([countryName(e.country),e.region,e.city].filter(Boolean).join(' · '))+' · '+new Date(e.time*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>В выбранном периоде записей нет.</p>'}
|
function eventList(events){return events.length?'<ul class="detail-list">'+events.map(e=>'<li>'+safe(e.ip)+' · '+safe(kindLabels[e.kind]||e.kind)+' · '+safe(eventText(e))+(e.monitoring?' · мониторинг':'')+'<small>'+safe([countryName(e.country),e.region,e.city].filter(Boolean).join(' · '))+' · '+new Date(e.time*1000).toLocaleString('ru-RU')+'</small></li>').join('')+'</ul>':'<p>В выбранном периоде записей нет.</p>'}
|
||||||
function openCountry(code){if(!view)return;const c=countryStats(code);showDetails(countryName(code),filterNote()+statGrid(c)+countryNetwork(code)+'<p>Последнее событие: '+(c.lastSeen?new Date(c.lastSeen*1000).toLocaleString('ru-RU'):'нет за выбранный период')+'</p>'+historyShell());startHistory({country:code})}
|
function openCountry(code){if(!view)return;const c=countryStats(code);showDetails(countryName(code),filterNote()+statGrid(c)+countryNetwork(code)+'<p>Последнее событие: '+(c.lastSeen?new Date(c.lastSeen*1000).toLocaleString('ru-RU'):'нет за выбранный период')+'</p>'+historyShell());startHistory({country:code})}
|
||||||
|
function cityAt(x,y){if(mapZoom<2)return null;let nearest=null,distance=12;for(const p of hitPoints){if(!p.city)continue;const d=Math.hypot(x-(p.x*mapZoom+mapX),y-(p.y*mapZoom+mapY));if(d<distance){nearest=p.city;distance=d}}return nearest}
|
||||||
|
function openCity(c){showDetails(c.city,filterNote()+'<p>'+safe([countryName(c.country),c.region].filter(Boolean).join(' · '))+'</p>'+statGrid(c)+'<p class="modal-note">Приблизительное местоположение IP по DB-IP. Это может быть город провайдера или VPN, а не местоположение посетителя.</p><p>Последнее событие: '+new Date(c.lastSeen*1000).toLocaleString('ru-RU')+'</p><button class="country-choice" data-country="'+safe(c.country)+'">Вся история страны: '+safe(countryName(c.country))+'</button>')}
|
||||||
function hideCountryTip(){el('tooltip').style.display='none';hoverCountry=null}
|
function hideCountryTip(){el('tooltip').style.display='none';hoverCountry=null}
|
||||||
function showCountryTip(e){const r=el('world').getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);hoverCountry=c||null;const tip=el('tooltip');if(!c){tip.style.display='none';redrawMap();return}const s=countryStats(c.code);tip.innerHTML='<strong>'+safe(c.name)+'</strong>HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'<br>Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+countryNetwork(c.code)+'<small>24 ч · текущие фильтры · нажмите для деталей</small>';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()}
|
function showCountryTip(e){const r=el('world').getBoundingClientRect(),city=cityAt(e.clientX-r.left,e.clientY-r.top),c=city?countryMap.get(city.country):countryAt(e.clientX-r.left,e.clientY-r.top);hoverCountry=c||null;const tip=el('tooltip');if(!c){tip.style.display='none';redrawMap();return}const s=city||countryStats(c.code);tip.innerHTML='<strong>'+safe(city?city.city+' · '+c.name:c.name)+'</strong>HTTP '+num(s.requests)+' · IP '+num(s.uniqueIps)+'<br>Срабатывания '+num(s.alerts)+' · решения '+num(s.decisions)+(city?'<p class="modal-note">Геолокация IP приблизительная</p>':countryNetwork(c.code))+'<small>24 ч · текущие фильтры · нажмите для деталей</small>';tip.style.display='block';tip.style.left=Math.max(8,Math.min(innerWidth-tip.offsetWidth-8,e.clientX+14))+'px';tip.style.top=Math.max(8,Math.min(innerHeight-tip.offsetHeight-8,e.clientY+14))+'px';redrawMap()}
|
||||||
const mapCanvas=el('world');
|
const mapCanvas=el('world');
|
||||||
mapCanvas.onpointerdown=e=>{mapCanvas.setPointerCapture(e.pointerId);pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});drag={x:e.clientX,y:e.clientY,moved:pointers.size>1};hideCountryTip()};
|
mapCanvas.onpointerdown=e=>{mapCanvas.setPointerCapture(e.pointerId);pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});drag={x:e.clientX,y:e.clientY,moved:pointers.size>1};hideCountryTip()};
|
||||||
mapCanvas.onpointermove=e=>{if(!pointers.has(e.pointerId)){showCountryTip(e);return}const old=pointers.get(e.pointerId),next={x:e.clientX,y:e.clientY};if(pointers.size===2){const other=[...pointers.entries()].find(([id])=>id!==e.pointerId)[1],before=Math.hypot(old.x-other.x,old.y-other.y),after=Math.hypot(next.x-other.x,next.y-other.y),r=mapCanvas.getBoundingClientRect();if(before>0)zoomMap(after/before,(next.x+other.x)/2-r.left,(next.y+other.y)/2-r.top);drag.moved=true}else{mapX+=next.x-old.x;mapY+=next.y-old.y;if(Math.hypot(next.x-drag.x,next.y-drag.y)>4)drag.moved=true;clampMap();redrawMap()}pointers.set(e.pointerId,next)};
|
mapCanvas.onpointermove=e=>{if(!pointers.has(e.pointerId)){showCountryTip(e);return}const old=pointers.get(e.pointerId),next={x:e.clientX,y:e.clientY};if(pointers.size===2){const other=[...pointers.entries()].find(([id])=>id!==e.pointerId)[1],before=Math.hypot(old.x-other.x,old.y-other.y),after=Math.hypot(next.x-other.x,next.y-other.y),r=mapCanvas.getBoundingClientRect();if(before>0)zoomMap(after/before,(next.x+other.x)/2-r.left,(next.y+other.y)/2-r.top);drag.moved=true}else{mapX+=next.x-old.x;mapY+=next.y-old.y;if(Math.hypot(next.x-drag.x,next.y-drag.y)>4)drag.moved=true;clampMap();redrawMap()}pointers.set(e.pointerId,next)};
|
||||||
mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);if(c)openCountry(c.code)}if(!pointers.size)drag=null};
|
mapCanvas.onpointerup=e=>{const moved=drag?.moved; pointers.delete(e.pointerId);if(!moved&&pointers.size===0){const r=mapCanvas.getBoundingClientRect(),c=countryAt(e.clientX-r.left,e.clientY-r.top);const city=cityAt(e.clientX-r.left,e.clientY-r.top);if(city)openCity(city);else if(c)openCountry(c.code)}if(!pointers.size)drag=null};
|
||||||
mapCanvas.onpointercancel=e=>{pointers.clear();drag=null;hideCountryTip()};mapCanvas.onpointerleave=()=>{hideCountryTip();redrawMap()};
|
mapCanvas.onpointercancel=e=>{pointers.clear();drag=null;hideCountryTip()};mapCanvas.onpointerleave=()=>{hideCountryTip();redrawMap()};
|
||||||
window.addEventListener('resize',clampMap);
|
window.addEventListener('resize',clampMap);
|
||||||
document.querySelectorAll('[data-metric]').forEach(b=>b.onclick=()=>{if(!view)return;const key=b.dataset.metric,title={unique:'Уникальные HTTP IP',requests:'HTTP-запросы',alerts:'Срабатывания CrowdSec',decisions:'Решения CrowdSec'}[key],field=key==='unique'?'uniqueIps':key,type={unique:'request',requests:'request',alerts:'alert',decisions:'decision'}[key],rows=(view.countryDetails||[]).filter(c=>c[field]>0).sort((a,b)=>b[field]-a[field]);const explanation={unique:'Уникальный IP не равен человеку. Учитываются IP HTTP-запросов.',requests:'Количество HTTP-запросов. Ошибки HTTP сами по себе не доказывают атаку.',alerts:'Срабатывания сценариев CrowdSec. Одно срабатывание может объединять несколько запросов.',decisions:'Решения CrowdSec, например ban или captcha. Это не число отброшенных пакетов и не список активных банов.'}[key];showDetails(title,filterNote()+'<div class="detail-stat"><small>'+title+'</small><b>'+num(view[field])+'</b></div><p>'+explanation+'</p><h3>По странам</h3>'+rows.map(c=>'<button class="country-choice" data-country="'+safe(c.country)+'">'+safe(countryName(c.country))+' · '+num(c[field])+'</button>').join('')+historyShell());startHistory({type,group:key==='unique'?'ip':''})});
|
document.querySelectorAll('[data-metric]').forEach(b=>b.onclick=()=>{if(!view)return;const key=b.dataset.metric,title={unique:'Уникальные HTTP IP',requests:'HTTP-запросы',alerts:'Срабатывания CrowdSec',decisions:'Решения CrowdSec'}[key],field=key==='unique'?'uniqueIps':key,type={unique:'request',requests:'request',alerts:'alert',decisions:'decision'}[key],rows=(view.countryDetails||[]).filter(c=>c[field]>0).sort((a,b)=>b[field]-a[field]);const explanation={unique:'Уникальный IP не равен человеку. Учитываются IP HTTP-запросов.',requests:'Количество HTTP-запросов. Ошибки HTTP сами по себе не доказывают атаку.',alerts:'Срабатывания сценариев CrowdSec. Одно срабатывание может объединять несколько запросов.',decisions:'Решения CrowdSec, например ban или captcha. Это не число отброшенных пакетов и не список активных банов.'}[key];showDetails(title,filterNote()+'<div class="detail-stat"><small>'+title+'</small><b>'+num(view[field])+'</b></div><p>'+explanation+'</p><h3>По странам</h3>'+rows.map(c=>'<button class="country-choice" data-country="'+safe(c.country)+'">'+safe(countryName(c.country))+' · '+num(c[field])+'</button>').join('')+historyShell());startHistory({type,group:key==='unique'?'ip':''})});
|
||||||
|
|||||||
29
history.go
29
history.go
@@ -28,6 +28,31 @@ func runHistoryCollector(collect func() (dashboardMetrics, error), store *Store)
|
|||||||
|
|
||||||
func (s *Store) AddEntityMetrics(metrics dashboardMetrics) {
|
func (s *Store) AddEntityMetrics(metrics dashboardMetrics) {
|
||||||
ts := time.Now().Unix()
|
ts := time.Now().Unix()
|
||||||
|
u := metrics.UPS
|
||||||
|
usable := func(v *float64) *float64 {
|
||||||
|
if !u.Available || !u.CommunicationOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
for _, item := range []struct {
|
||||||
|
kind, name string
|
||||||
|
value *float64
|
||||||
|
}{
|
||||||
|
{"ups-voltage", "Вход", u.InputVoltage}, {"ups-voltage", "Выход", u.OutputVoltage},
|
||||||
|
{"ups-battery", "Батарея", u.BatteryVoltage}, {"ups-runtime", "Автономность", u.RuntimeSeconds},
|
||||||
|
{"ups-frequency", "Частота", u.InputFrequency},
|
||||||
|
} {
|
||||||
|
s.AddEntityHistory(item.kind, item.name, ts, usable(item.value), nil)
|
||||||
|
}
|
||||||
|
var measured, estimated *float64
|
||||||
|
if u.PowerEstimated {
|
||||||
|
estimated = u.Power
|
||||||
|
} else {
|
||||||
|
measured = u.Power
|
||||||
|
}
|
||||||
|
s.AddEntityHistory("ups-power", "Измеренная", ts, usable(measured), nil)
|
||||||
|
s.AddEntityHistory("ups-power", "Расчётная ≈", ts, usable(estimated), nil)
|
||||||
for _, p := range metrics.ZFS.Pools {
|
for _, p := range metrics.ZFS.Pools {
|
||||||
v := p.CapacityPercent
|
v := p.CapacityPercent
|
||||||
s.AddEntityHistory("zfs", p.Name, ts, &v, nil)
|
s.AddEntityHistory("zfs", p.Name, ts, &v, nil)
|
||||||
@@ -99,6 +124,10 @@ func historyPointFromMetrics(metrics dashboardMetrics) HistoryPoint {
|
|||||||
NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent,
|
NetworkTransmit: metrics.Network.TransmitPerSec, UPSCharge: metrics.UPS.ChargePercent,
|
||||||
UPSLoad: metrics.UPS.LoadPercent,
|
UPSLoad: metrics.UPS.LoadPercent,
|
||||||
}
|
}
|
||||||
|
if !metrics.UPS.Available || !metrics.UPS.CommunicationOK {
|
||||||
|
point.UPSCharge = nil
|
||||||
|
point.UPSLoad = nil
|
||||||
|
}
|
||||||
if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil {
|
if temperature := cpuTemperature(metrics.CPU.Temperatures); temperature != nil {
|
||||||
point.CPUTemperature = *temperature
|
point.CPUTemperature = *temperature
|
||||||
}
|
}
|
||||||
|
|||||||
48
traffic.go
48
traffic.go
@@ -12,21 +12,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TrafficEvent struct {
|
type TrafficEvent struct {
|
||||||
City string `json:"city,omitempty"`
|
Location *TrafficCityLocation `json:"location,omitempty"`
|
||||||
Region string `json:"region,omitempty"`
|
City string `json:"city,omitempty"`
|
||||||
Monitoring bool `json:"monitoring"`
|
Region string `json:"region,omitempty"`
|
||||||
ID string `json:"id"`
|
Monitoring bool `json:"monitoring"`
|
||||||
Sequence int64 `json:"sequence,omitempty"`
|
ID string `json:"id"`
|
||||||
Time int64 `json:"time"`
|
Sequence int64 `json:"sequence,omitempty"`
|
||||||
Kind string `json:"kind"`
|
Time int64 `json:"time"`
|
||||||
IP string `json:"ip"`
|
Kind string `json:"kind"`
|
||||||
Domain string `json:"domain,omitempty"`
|
IP string `json:"ip"`
|
||||||
Method string `json:"method,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
Path string `json:"path,omitempty"`
|
Method string `json:"method,omitempty"`
|
||||||
Status int `json:"status,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Status int `json:"status,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Agent string `json:"agent,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
|
Agent string `json:"agent,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrafficBatch struct {
|
type TrafficBatch struct {
|
||||||
@@ -62,6 +63,9 @@ type TrafficCountryDetail struct {
|
|||||||
LastSeen int64 `json:"lastSeen"`
|
LastSeen int64 `json:"lastSeen"`
|
||||||
}
|
}
|
||||||
type TrafficView struct {
|
type TrafficView struct {
|
||||||
|
Cities []TrafficMapCity `json:"cities"`
|
||||||
|
CitiesLimited bool `json:"citiesLimited"`
|
||||||
|
CityReady bool `json:"cityReady"`
|
||||||
MaxSequence int64 `json:"maxSequence"`
|
MaxSequence int64 `json:"maxSequence"`
|
||||||
CountryDetails []TrafficCountryDetail `json:"countryDetails"`
|
CountryDetails []TrafficCountryDetail `json:"countryDetails"`
|
||||||
|
|
||||||
@@ -209,7 +213,11 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
|
|||||||
return v, err
|
return v, err
|
||||||
}
|
}
|
||||||
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
|
e.Monitoring = isMonitoringRequest(e, v.Settings.MonitoringRules)
|
||||||
e.City, e.Region = lookupTrafficCity(e.IP)
|
g := lookupTrafficCityLocation(e.IP)
|
||||||
|
e.City, e.Region = g.City, g.Region
|
||||||
|
if g.Located {
|
||||||
|
e.Location = &g
|
||||||
|
}
|
||||||
v.Events = append(v.Events, e)
|
v.Events = append(v.Events, e)
|
||||||
}
|
}
|
||||||
err = rows.Err()
|
err = rows.Err()
|
||||||
@@ -257,6 +265,14 @@ func (s *Store) TrafficView(domain, kind string, external bool, hideMonitoring .
|
|||||||
if detailErr != nil {
|
if detailErr != nil {
|
||||||
return v, detailErr
|
return v, detailErr
|
||||||
}
|
}
|
||||||
|
v.CityReady = trafficCityReady()
|
||||||
|
if v.CityReady {
|
||||||
|
var cityErr error
|
||||||
|
v.Cities, v.CitiesLimited, cityErr = s.trafficMapCities(where, args)
|
||||||
|
if cityErr != nil {
|
||||||
|
return v, cityErr
|
||||||
|
}
|
||||||
|
}
|
||||||
r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...)
|
r, err := s.db.Query(`SELECT CAST((ts/60)*60 AS TEXT),COUNT(*) FROM traffic_events`+where+` AND kind='request' AND ts>=? GROUP BY ts/60 ORDER BY ts/60`, append(args, now-3600)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v, err
|
return v, err
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -26,6 +27,7 @@ var trafficCity struct {
|
|||||||
db *maxminddb.Reader
|
db *maxminddb.Reader
|
||||||
running bool
|
running bool
|
||||||
message string
|
message string
|
||||||
|
cache map[string]TrafficCityLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
func trafficCityPath() string {
|
func trafficCityPath() string {
|
||||||
@@ -49,14 +51,34 @@ func trafficCityReady() bool {
|
|||||||
defer trafficCity.Unlock()
|
defer trafficCity.Unlock()
|
||||||
return trafficCity.db != nil
|
return trafficCity.db != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrafficCityLocation struct {
|
||||||
|
City string `json:"city"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Located bool `json:"located"`
|
||||||
|
}
|
||||||
|
|
||||||
func lookupTrafficCity(text string) (string, string) {
|
func lookupTrafficCity(text string) (string, string) {
|
||||||
|
g := lookupTrafficCityLocation(text)
|
||||||
|
return g.City, g.Region
|
||||||
|
}
|
||||||
|
func lookupTrafficCityLocation(text string) TrafficCityLocation {
|
||||||
ip := net.ParseIP(text)
|
ip := net.ParseIP(text)
|
||||||
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() {
|
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || !ip.IsGlobalUnicast() || !trafficCityReady() {
|
||||||
return "", ""
|
return TrafficCityLocation{}
|
||||||
}
|
}
|
||||||
trafficCity.Lock()
|
trafficCity.Lock()
|
||||||
defer trafficCity.Unlock()
|
defer trafficCity.Unlock()
|
||||||
|
if g, ok := trafficCity.cache[text]; ok {
|
||||||
|
return g
|
||||||
|
}
|
||||||
var r struct {
|
var r struct {
|
||||||
|
Location struct {
|
||||||
|
Latitude *float64 `maxminddb:"latitude"`
|
||||||
|
Longitude *float64 `maxminddb:"longitude"`
|
||||||
|
} `maxminddb:"location"`
|
||||||
City struct {
|
City struct {
|
||||||
Names map[string]string `maxminddb:"names"`
|
Names map[string]string `maxminddb:"names"`
|
||||||
} `maxminddb:"city"`
|
} `maxminddb:"city"`
|
||||||
@@ -65,13 +87,24 @@ func lookupTrafficCity(text string) (string, string) {
|
|||||||
} `maxminddb:"subdivisions"`
|
} `maxminddb:"subdivisions"`
|
||||||
}
|
}
|
||||||
if trafficCity.db.Lookup(ip, &r) != nil {
|
if trafficCity.db.Lookup(ip, &r) != nil {
|
||||||
return "", ""
|
return TrafficCityLocation{}
|
||||||
}
|
}
|
||||||
region := ""
|
region := ""
|
||||||
if len(r.Subdivisions) > 0 {
|
if len(r.Subdivisions) > 0 {
|
||||||
region = cityName(r.Subdivisions[0].Names)
|
region = cityName(r.Subdivisions[0].Names)
|
||||||
}
|
}
|
||||||
return cityName(r.City.Names), region
|
g := TrafficCityLocation{City: cityName(r.City.Names), Region: region}
|
||||||
|
if g.City != "" && r.Location.Latitude != nil && r.Location.Longitude != nil {
|
||||||
|
g.Latitude, g.Longitude = *r.Location.Latitude, *r.Location.Longitude
|
||||||
|
g.Located = !math.IsNaN(g.Latitude) && !math.IsNaN(g.Longitude) && math.Abs(g.Latitude) <= 90 && math.Abs(g.Longitude) <= 180
|
||||||
|
}
|
||||||
|
if trafficCity.cache == nil {
|
||||||
|
trafficCity.cache = map[string]TrafficCityLocation{}
|
||||||
|
}
|
||||||
|
if len(trafficCity.cache) < 32768 {
|
||||||
|
trafficCity.cache[text] = g
|
||||||
|
}
|
||||||
|
return g
|
||||||
}
|
}
|
||||||
func trafficCityRoutes(mux *http.ServeMux) {
|
func trafficCityRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/settings/traffic/city-database", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -178,5 +211,6 @@ func installTrafficCityFrom(source, expectedHash string) error {
|
|||||||
trafficCity.db.Close()
|
trafficCity.db.Close()
|
||||||
}
|
}
|
||||||
trafficCity.db = db
|
trafficCity.db = db
|
||||||
|
trafficCity.cache = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCityInstallRejectsBadDownload(t *testing.T) {
|
func TestCityInstallRejectsBadDownload(t *testing.T) {
|
||||||
@@ -46,6 +47,33 @@ func TestCityDatabaseDownloaded(t *testing.T) {
|
|||||||
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil {
|
if err := installTrafficCityFrom(server.URL, cityDatabaseSHA1); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
g := lookupTrafficCityLocation("20.194.96.114")
|
||||||
|
if !g.Located || g.Latitude < 30 || g.Latitude > 40 || g.Longitude < 120 || g.Longitude > 130 {
|
||||||
|
t.Fatalf("invalid Seoul coordinates: %+v", g)
|
||||||
|
}
|
||||||
|
s, a := trafficFixture(t)
|
||||||
|
s.SaveTrafficSettings(TrafficSettings{Enabled: true, AgentID: a.ID})
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if err := s.SaveTraffic(a.ID, TrafficBatch{Events: []TrafficEvent{
|
||||||
|
{ID: "1", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
|
||||||
|
{ID: "2", Time: now, Kind: "request", IP: "20.194.96.114", Domain: "a.test"},
|
||||||
|
{ID: "3", Time: now, Kind: "alert", IP: "20.194.96.114", Domain: "b.test"},
|
||||||
|
{ID: "4", Time: now, Kind: "request", IP: "192.168.0.10"},
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
view, err := s.TrafficView("", "", false)
|
||||||
|
if err != nil || len(view.Cities) != 1 {
|
||||||
|
t.Fatalf("cities %+v err %v", view.Cities, err)
|
||||||
|
}
|
||||||
|
c := view.Cities[0]
|
||||||
|
if c.Requests != 2 || c.UniqueIPs != 1 || c.Alerts != 1 || c.City != g.City {
|
||||||
|
t.Fatalf("bad aggregation %+v", c)
|
||||||
|
}
|
||||||
|
filtered, err := s.TrafficView("b.test", "alert", false)
|
||||||
|
if err != nil || len(filtered.Cities) != 1 || filtered.Cities[0].Requests != 0 || filtered.Cities[0].Alerts != 1 {
|
||||||
|
t.Fatalf("city filter %+v %v", filtered.Cities, err)
|
||||||
|
}
|
||||||
city, region := lookupTrafficCity("20.194.96.114")
|
city, region := lookupTrafficCity("20.194.96.114")
|
||||||
t.Logf("city=%q region=%q", city, region)
|
t.Logf("city=%q region=%q", city, region)
|
||||||
if city == "" {
|
if city == "" {
|
||||||
|
|||||||
71
traffic_map_cities.go
Normal file
71
traffic_map_cities.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrafficMapCity struct {
|
||||||
|
TrafficCityLocation
|
||||||
|
Country string `json:"country"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
UniqueIPs int64 `json:"uniqueIps"`
|
||||||
|
Alerts int64 `json:"alerts"`
|
||||||
|
Decisions int64 `json:"decisions"`
|
||||||
|
LastSeen int64 `json:"lastSeen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) trafficMapCities(where string, args []any) ([]TrafficMapCity, bool, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT ip,country,SUM(kind='request'),SUM(kind='alert'),SUM(kind='decision'),MAX(ts) FROM traffic_events`+where+` GROUP BY ip,country`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
groups := map[string]*TrafficMapCity{}
|
||||||
|
for rows.Next() {
|
||||||
|
var ip, country string
|
||||||
|
var requests, alerts, decisions, last int64
|
||||||
|
if err = rows.Scan(&ip, &country, &requests, &alerts, &decisions, &last); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
g := lookupTrafficCityLocation(ip)
|
||||||
|
if !g.Located {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%s/%s/%s/%.5f/%.5f", country, g.Region, g.City, g.Latitude, g.Longitude)
|
||||||
|
c := groups[key]
|
||||||
|
if c == nil {
|
||||||
|
c = &TrafficMapCity{TrafficCityLocation: g, Country: country}
|
||||||
|
groups[key] = c
|
||||||
|
}
|
||||||
|
c.Requests += requests
|
||||||
|
c.Alerts += alerts
|
||||||
|
c.Decisions += decisions
|
||||||
|
if requests > 0 {
|
||||||
|
c.UniqueIPs++
|
||||||
|
}
|
||||||
|
if last > c.LastSeen {
|
||||||
|
c.LastSeen = last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
result := make([]TrafficMapCity, 0, len(groups))
|
||||||
|
for _, c := range groups {
|
||||||
|
result = append(result, *c)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
a, b := result[i], result[j]
|
||||||
|
x, y := a.Requests+a.Alerts+a.Decisions, b.Requests+b.Alerts+b.Decisions
|
||||||
|
if x != y {
|
||||||
|
return x > y
|
||||||
|
}
|
||||||
|
return fmt.Sprint(a.Country, a.Region, a.City, a.Latitude, a.Longitude) < fmt.Sprint(b.Country, b.Region, b.City, b.Latitude, b.Longitude)
|
||||||
|
})
|
||||||
|
limited := len(result) > 500
|
||||||
|
if limited {
|
||||||
|
result = result[:500]
|
||||||
|
}
|
||||||
|
return result, limited, nil
|
||||||
|
}
|
||||||
49
ups.go
49
ups.go
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,6 +11,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UPSMetrics struct {
|
type UPSMetrics struct {
|
||||||
|
Raw map[string]string `json:"raw"`
|
||||||
|
NominalPower *float64 `json:"nominalPowerWatts"`
|
||||||
|
Power *float64 `json:"powerWatts"`
|
||||||
|
PowerEstimated bool `json:"powerEstimated"`
|
||||||
|
InputFrequencyRaw *float64 `json:"inputFrequencyRaw"`
|
||||||
|
FrequencyCorrected bool `json:"frequencyCorrected"`
|
||||||
|
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -92,14 +100,16 @@ func readAPCUPSD() (UPSMetrics, bool) {
|
|||||||
}
|
}
|
||||||
raw := parseColonValues(string(output))
|
raw := parseColonValues(string(output))
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"device.model": raw["MODEL"], "ups.status": raw["STATUS"],
|
"device.model": raw["MODEL"], "ups.realpower.nominal": raw["NOMPOWER"], "ups.status": raw["STATUS"],
|
||||||
"battery.charge": raw["BCHARGE"], "battery.runtime": minutesToSeconds(raw["TIMELEFT"]),
|
"battery.charge": raw["BCHARGE"], "battery.runtime": minutesToSeconds(raw["TIMELEFT"]),
|
||||||
"ups.load": raw["LOADPCT"], "input.voltage": raw["LINEV"],
|
"ups.load": raw["LOADPCT"], "input.voltage": raw["LINEV"],
|
||||||
"output.voltage": raw["OUTPUTV"], "input.frequency": raw["LINEFREQ"],
|
"output.voltage": raw["OUTPUTV"], "input.frequency": raw["LINEFREQ"],
|
||||||
"battery.voltage": raw["BATTV"], "ups.temperature": raw["ITEMP"],
|
"battery.voltage": raw["BATTV"], "ups.temperature": raw["ITEMP"],
|
||||||
"battery.date": raw["BATTDATE"], "battery.charge.low": raw["MBATTCHG"], "battery.runtime.low": minutesToSeconds(raw["MINTIMEL"]),
|
"battery.date": raw["BATTDATE"], "battery.charge.low": raw["MBATTCHG"], "battery.runtime.low": minutesToSeconds(raw["MINTIMEL"]),
|
||||||
}
|
}
|
||||||
return upsFromValues("apcupsd", raw["UPSNAME"], values), true
|
m := upsFromValues("apcupsd", raw["UPSNAME"], values)
|
||||||
|
m.Raw = raw
|
||||||
|
return m, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseColonValues(output string) map[string]string {
|
func parseColonValues(output string) map[string]string {
|
||||||
@@ -116,7 +126,7 @@ func parseColonValues(output string) map[string]string {
|
|||||||
func upsFromValues(source, name string, values map[string]string) UPSMetrics {
|
func upsFromValues(source, name string, values map[string]string) UPSMetrics {
|
||||||
status := values["ups.status"]
|
status := values["ups.status"]
|
||||||
metrics := UPSMetrics{
|
metrics := UPSMetrics{
|
||||||
Available: true, CommunicationOK: true, Source: source, Name: name, Model: values["device.model"],
|
Available: true, CommunicationOK: true, Source: source, Name: name, Model: firstNonEmpty(values["device.model"], values["ups.model"]), Raw: values,
|
||||||
Status: status, StatusLabel: upsStatusLabel(status),
|
Status: status, StatusLabel: upsStatusLabel(status),
|
||||||
ChargePercent: numberPointer(values["battery.charge"]), RuntimeSeconds: numberPointer(values["battery.runtime"]),
|
ChargePercent: numberPointer(values["battery.charge"]), RuntimeSeconds: numberPointer(values["battery.runtime"]),
|
||||||
LoadPercent: numberPointer(values["ups.load"]), InputVoltage: numberPointer(values["input.voltage"]),
|
LoadPercent: numberPointer(values["ups.load"]), InputVoltage: numberPointer(values["input.voltage"]),
|
||||||
@@ -124,13 +134,44 @@ func upsFromValues(source, name string, values map[string]string) UPSMetrics {
|
|||||||
BatteryVoltage: numberPointer(values["battery.voltage"]), Temperature: numberPointer(firstNonEmpty(values["ups.temperature"], values["battery.temperature"])),
|
BatteryVoltage: numberPointer(values["battery.voltage"]), Temperature: numberPointer(firstNonEmpty(values["ups.temperature"], values["battery.temperature"])),
|
||||||
BatteryDate: firstNonEmpty(values["battery.date"], values["battery.mfr.date"]), ReplaceBattery: strings.Contains(status, "RB"), LowBatteryCharge: numberPointer(values["battery.charge.low"]), LowBatteryRuntime: numberPointer(values["battery.runtime.low"]),
|
BatteryDate: firstNonEmpty(values["battery.date"], values["battery.mfr.date"]), ReplaceBattery: strings.Contains(status, "RB"), LowBatteryCharge: numberPointer(values["battery.charge.low"]), LowBatteryRuntime: numberPointer(values["battery.runtime.low"]),
|
||||||
}
|
}
|
||||||
|
metrics.NominalPower = numberPointer(values["ups.realpower.nominal"])
|
||||||
|
metrics.Power = numberPointer(values["ups.realpower"])
|
||||||
|
if metrics.Power != nil && *metrics.Power < 0 {
|
||||||
|
metrics.Power = nil
|
||||||
|
}
|
||||||
|
if metrics.NominalPower != nil && *metrics.NominalPower <= 0 {
|
||||||
|
metrics.NominalPower = nil
|
||||||
|
}
|
||||||
|
if metrics.Power == nil && metrics.NominalPower != nil && metrics.LoadPercent != nil && *metrics.LoadPercent >= 0 {
|
||||||
|
watts := *metrics.NominalPower * *metrics.LoadPercent / 100
|
||||||
|
if !math.IsInf(watts, 0) {
|
||||||
|
metrics.Power = &watts
|
||||||
|
metrics.PowerEstimated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metrics.InputFrequencyRaw = metrics.InputFrequency
|
||||||
|
mfr := strings.ToLower(firstNonEmpty(values["device.mfr"], values["ups.mfr"]))
|
||||||
|
cyberpower := mfr == "cps" || strings.Contains(mfr, "cyberpower") || values["ups.vendorid"] == "0764"
|
||||||
|
if source == "NUT" && cyberpower && metrics.InputFrequency != nil && *metrics.InputFrequency >= 450 && *metrics.InputFrequency <= 650 {
|
||||||
|
hz := *metrics.InputFrequency / 10
|
||||||
|
metrics.InputFrequency = &hz
|
||||||
|
metrics.FrequencyCorrected = true
|
||||||
|
}
|
||||||
if metrics.BatteryDate != "" {
|
if metrics.BatteryDate != "" {
|
||||||
|
validDate := false
|
||||||
for _, layout := range []string{"2006/01/02", "2006-01-02", "01/02/2006", "02/01/2006"} {
|
for _, layout := range []string{"2006/01/02", "2006-01-02", "01/02/2006", "02/01/2006"} {
|
||||||
if date, err := time.Parse(layout, metrics.BatteryDate); err == nil {
|
if date, err := time.Parse(layout, metrics.BatteryDate); err == nil {
|
||||||
|
if date.After(time.Now()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validDate = true
|
||||||
metrics.BatteryAgeDays = int(time.Since(date).Hours() / 24)
|
metrics.BatteryAgeDays = int(time.Since(date).Hours() / 24)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !validDate {
|
||||||
|
metrics.BatteryDate = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return metrics
|
return metrics
|
||||||
}
|
}
|
||||||
@@ -141,7 +182,7 @@ func numberPointer(value string) *float64 {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
number, err := strconv.ParseFloat(strings.TrimSuffix(field[0], "%"), 64)
|
number, err := strconv.ParseFloat(strings.TrimSuffix(field[0], "%"), 64)
|
||||||
if err != nil {
|
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &number
|
return &number
|
||||||
|
|||||||
68
ups_preview_test.go
Normal file
68
ups_preview_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUPSPreview(t *testing.T) {
|
||||||
|
if os.Getenv("DASHBOARD_UPS_PREVIEW") != "1" {
|
||||||
|
t.Skip("manual visual fixture")
|
||||||
|
}
|
||||||
|
s, a := trafficFixture(t)
|
||||||
|
_ = a
|
||||||
|
u := upsFromValues("NUT", "cyberpower", parseColonValues(cyberPowerSample))
|
||||||
|
m := dashboardMetrics{UPS: u, CPU: CPUMetrics{CPUStatic: CPUStatic{Model: "DEMO / искусственные данные"}, Temperatures: []Temperature{}}, Timestamp: time.Now()}
|
||||||
|
s.AddEntityMetrics(m)
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
ts := time.Now().Add(time.Duration(i-30) * time.Minute).Unix()
|
||||||
|
v := 220 + float64(i%6)
|
||||||
|
out := 225.0
|
||||||
|
power := 150 + float64(i)
|
||||||
|
runtime := 450.0
|
||||||
|
freq := 49.8
|
||||||
|
bat := 13.4
|
||||||
|
s.AddEntityHistory("ups-voltage", "Вход", ts, &v, nil)
|
||||||
|
s.AddEntityHistory("ups-voltage", "Выход", ts, &out, nil)
|
||||||
|
s.AddEntityHistory("ups-power", "Расчётная ≈", ts, &power, nil)
|
||||||
|
s.AddEntityHistory("ups-runtime", "Автономность", ts, &runtime, nil)
|
||||||
|
s.AddEntityHistory("ups-frequency", "Частота", ts, &freq, nil)
|
||||||
|
s.AddEntityHistory("ups-battery", "Батарея", ts, &bat, nil)
|
||||||
|
p := historyPointFromMetrics(m)
|
||||||
|
p.Timestamp = ts
|
||||||
|
s.AddHistory(p)
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
writeJSON(w, map[string]any{}, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write([]byte(strings.Replace(dashboardHTML, "<title>", "<title>DEMO / ", 1)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
b, _ := json.Marshal(m)
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
<-r.Context().Done()
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/history", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
||||||
|
v, e := s.History(h)
|
||||||
|
writeJSON(w, v, e)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/entity-history", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h, _ := strconv.Atoi(r.URL.Query().Get("hours"))
|
||||||
|
v, e := s.EntityHistory(r.URL.Query().Get("kind"), h)
|
||||||
|
writeJSON(w, v, e)
|
||||||
|
})
|
||||||
|
t.Fatal(http.ListenAndServe("127.0.0.1:19106", mux))
|
||||||
|
}
|
||||||
101
ups_test.go
101
ups_test.go
@@ -12,3 +12,104 @@ func TestUPSParsing(t *testing.T) {
|
|||||||
t.Fatalf("неожиданный статус: %s", metrics.StatusLabel)
|
t.Fatalf("неожиданный статус: %s", metrics.StatusLabel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cyberPowerSample = `device.mfr: CPS
|
||||||
|
device.model: VP700ELCD
|
||||||
|
battery.charge: 100
|
||||||
|
battery.charge.low: 10
|
||||||
|
battery.charge.warning: 20
|
||||||
|
battery.mfr.date: CPS
|
||||||
|
battery.runtime: 450
|
||||||
|
battery.runtime.low: 300
|
||||||
|
battery.type: PbAcid
|
||||||
|
battery.voltage: 13.4
|
||||||
|
battery.voltage.nominal: 12
|
||||||
|
input.frequency: 498.0
|
||||||
|
input.transfer.high: 295
|
||||||
|
input.transfer.low: 167
|
||||||
|
input.voltage: 224.0
|
||||||
|
input.voltage.nominal: 230
|
||||||
|
output.voltage: 225.0
|
||||||
|
ups.beeper.status: disabled
|
||||||
|
ups.delay.shutdown: 20
|
||||||
|
ups.delay.start: 30
|
||||||
|
ups.load: 46
|
||||||
|
ups.realpower.nominal: 390
|
||||||
|
ups.status: OL
|
||||||
|
ups.test.result: No test initiated
|
||||||
|
driver.name: usbhid-ups
|
||||||
|
driver.version: 2.8.1
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestCyberPowerFrequencyPowerAndRaw(t *testing.T) {
|
||||||
|
raw := parseColonValues(cyberPowerSample)
|
||||||
|
m := upsFromValues("NUT", "cyberpower", raw)
|
||||||
|
if !m.FrequencyCorrected || *m.InputFrequency != 49.8 || *m.InputFrequencyRaw != 498 || m.Raw["input.frequency"] != "498.0" {
|
||||||
|
t.Fatalf("frequency %+v", m)
|
||||||
|
}
|
||||||
|
if m.Power == nil || *m.Power != 179.4 || !m.PowerEstimated {
|
||||||
|
t.Fatalf("power %+v", m)
|
||||||
|
}
|
||||||
|
if m.BatteryDate != "" || m.BatteryAgeDays != 0 || m.Raw["battery.mfr.date"] != "CPS" {
|
||||||
|
t.Fatal("invalid date not preserved as raw only")
|
||||||
|
}
|
||||||
|
raw["input.frequency"] = "49.8"
|
||||||
|
raw["ups.realpower"] = "165"
|
||||||
|
m = upsFromValues("NUT", "ups", raw)
|
||||||
|
if m.FrequencyCorrected || *m.InputFrequency != 49.8 || m.PowerEstimated || *m.Power != 165 {
|
||||||
|
t.Fatal("double correction or ignored measured power")
|
||||||
|
}
|
||||||
|
raw["device.mfr"] = "Other"
|
||||||
|
raw["input.frequency"] = "498"
|
||||||
|
if m = upsFromValues("NUT", "ups", raw); m.FrequencyCorrected || *m.InputFrequency != 498 {
|
||||||
|
t.Fatal("other vendor corrected")
|
||||||
|
}
|
||||||
|
for _, v := range []string{"NaN", "Inf", "-Inf", ""} {
|
||||||
|
if numberPointer(v) != nil {
|
||||||
|
t.Fatal("nonfinite accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m = upsFromValues("NUT", "ups", map[string]string{"ups.load": "46"}); m.Power != nil {
|
||||||
|
t.Fatal("invented power without nominal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestUPSHistoryRecordsCorrectedValuesAndGaps(t *testing.T) {
|
||||||
|
s, a := trafficFixture(t)
|
||||||
|
_ = a
|
||||||
|
u := upsFromValues("NUT", "cyberpower", parseColonValues(cyberPowerSample))
|
||||||
|
s.AddEntityMetrics(dashboardMetrics{UPS: u})
|
||||||
|
for _, tc := range []struct {
|
||||||
|
kind, name string
|
||||||
|
want float64
|
||||||
|
}{{"ups-frequency", "Частота", 49.8}, {"ups-power", "Расчётная ≈", 179.4}, {"ups-runtime", "Автономность", 450}} {
|
||||||
|
rows, err := s.EntityHistory(tc.kind, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Name == tc.name {
|
||||||
|
found = true
|
||||||
|
if r.Points[0].Value1 == nil || *r.Points[0].Value1 != tc.want {
|
||||||
|
t.Fatalf("bad %s: %+v", tc.kind, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("missing history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.Available = false
|
||||||
|
u.CommunicationOK = false
|
||||||
|
s.AddEntityMetrics(dashboardMetrics{UPS: u})
|
||||||
|
rows, _ := s.EntityHistory("ups-frequency", 1)
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Points[len(r.Points)-1].Value1 != nil {
|
||||||
|
t.Fatal("stale reading stored as live")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := historyPointFromMetrics(dashboardMetrics{UPS: u})
|
||||||
|
if p.UPSCharge != nil || p.UPSLoad != nil {
|
||||||
|
t.Fatal("stale charge stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user